Compare commits
98
Commits
stable
...
0a07fade40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a07fade40 | ||
|
|
0e8a999e41 | ||
|
|
2e02f60601 | ||
|
|
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 | ||
|
|
e4f7a35f0f | ||
|
|
8a6768b316 | ||
|
|
3892a8beab | ||
|
|
d39bc55be3 | ||
|
|
e0debf0394 |
@@ -19,7 +19,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"test:api-dossiers": "node scripts/test-api-dossiers.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.1.6",
|
"@nestjs/common": "^11.1.6",
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
|
||||||
|
*
|
||||||
|
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-api-dossiers.js
|
||||||
|
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||||
|
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||||
|
*
|
||||||
|
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
|
||||||
|
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||||
|
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
|
||||||
|
|
||||||
|
async function request(method, path, body = null, token = null) {
|
||||||
|
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
};
|
||||||
|
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (_) {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Base URL:', BASE_URL);
|
||||||
|
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||||
|
console.log('1. Login...');
|
||||||
|
const loginRes = await request('POST', '/auth/login', {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||||
|
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||||
|
if (!token) {
|
||||||
|
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' OK, token reçu.');
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let numeroDossier = NUMERO_DOSSIER;
|
||||||
|
if (!numeroDossier && token) {
|
||||||
|
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
|
||||||
|
const pendingRes = await request('GET', '/parents/pending-families', null, token);
|
||||||
|
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
|
||||||
|
numeroDossier = pendingRes.data[0].numero_dossier || null;
|
||||||
|
console.log(' Premier numero_dossier:', numeroDossier);
|
||||||
|
} else {
|
||||||
|
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numeroDossier) {
|
||||||
|
numeroDossier = '2026-000001';
|
||||||
|
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
|
||||||
|
} else {
|
||||||
|
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dossierRes = await request(
|
||||||
|
'GET',
|
||||||
|
'/dossiers/' + encodeURIComponent(numeroDossier),
|
||||||
|
null,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(' Status:', dossierRes.status);
|
||||||
|
if (dossierRes.status === 200 && dossierRes.data) {
|
||||||
|
const d = dossierRes.data;
|
||||||
|
console.log(' type:', d.type);
|
||||||
|
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
|
||||||
|
if (d.dossier && Array.isArray(d.dossier.enfants)) {
|
||||||
|
console.log(' enfants:', d.dossier.enfants.length);
|
||||||
|
d.dossier.enfants.forEach((e, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
console.log('Réponse brute (dossier):');
|
||||||
|
console.log(JSON.stringify(d.dossier, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Fin du test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Erreur:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Test des endpoints "comptes en attente" (ticket #107).
|
||||||
|
*
|
||||||
|
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/test-pending-api.js
|
||||||
|
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||||
|
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||||
|
*
|
||||||
|
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
|
||||||
|
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||||
|
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||||
|
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||||
|
|
||||||
|
async function request(method, path, body = null, token = null) {
|
||||||
|
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
};
|
||||||
|
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch (_) {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Base URL:', BASE_URL);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
let token = null;
|
||||||
|
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||||
|
console.log('1. Login...');
|
||||||
|
const loginRes = await request('POST', '/auth/login', {
|
||||||
|
email: TEST_EMAIL,
|
||||||
|
password: TEST_PASSWORD,
|
||||||
|
});
|
||||||
|
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||||
|
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||||
|
if (!token) {
|
||||||
|
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(' OK, token reçu.');
|
||||||
|
console.log('');
|
||||||
|
} else {
|
||||||
|
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
|
||||||
|
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('2. GET /users/pending?role=assistante_maternelle');
|
||||||
|
const pendingUsersRes = await request(
|
||||||
|
'GET',
|
||||||
|
'/users/pending?role=assistante_maternelle',
|
||||||
|
null,
|
||||||
|
token
|
||||||
|
);
|
||||||
|
console.log(' Status:', pendingUsersRes.status);
|
||||||
|
if (pendingUsersRes.status === 200) {
|
||||||
|
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
|
||||||
|
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
|
||||||
|
list.forEach((u, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('3. GET /parents/pending-families');
|
||||||
|
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
|
||||||
|
console.log(' Status:', pendingFamiliesRes.status);
|
||||||
|
if (pendingFamiliesRes.status === 200) {
|
||||||
|
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
|
||||||
|
console.log(' Nombre de familles en attente:', list.length);
|
||||||
|
list.forEach((f, i) => {
|
||||||
|
console.log(
|
||||||
|
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Fin du test.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Erreur:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
|
||||||
|
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = `## Besoin
|
||||||
|
|
||||||
|
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
|
||||||
|
|
||||||
|
**Option A – Endpoint unifié**
|
||||||
|
|
||||||
|
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
|
||||||
|
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
|
||||||
|
- **Réponse** avec discriminent :
|
||||||
|
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
|
||||||
|
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
|
||||||
|
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
|
||||||
|
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
|
||||||
|
|
||||||
|
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
|
||||||
|
|
||||||
|
**Labels suggérés** : backend, api, dossiers, gestionnaire
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implémentation
|
||||||
|
|
||||||
|
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
|
||||||
|
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
|
||||||
|
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
|
||||||
|
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opts = {
|
||||||
|
hostname: 'git.ptits-pas.fr',
|
||||||
|
path: '/api/v1/repos/jmartin/petitspas/issues/119',
|
||||||
|
method: 'PATCH',
|
||||||
|
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 || o.id) {
|
||||||
|
console.log('Issue #119 mise à jour.');
|
||||||
|
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
|
||||||
|
} 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();
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,24 +1,83 @@
|
|||||||
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@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, parents)',
|
||||||
|
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)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||||
@Get()
|
@Get()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ 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 { AuthModule } from 'src/routes/auth/auth.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Users]),
|
TypeOrmModule.forFeature([Users]),
|
||||||
AuthModule,
|
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,6 +40,26 @@ 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, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||||
@@ -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' })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
|||||||
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 { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||||
|
import { MailModule } from 'src/modules/mail/mail.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature(
|
imports: [TypeOrmModule.forFeature(
|
||||||
@@ -22,6 +23,7 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
|||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
GestionnairesModule,
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
||||||
-- Aligné sur utilisateurs-test-complet.json
|
-- Aligné sur utilisateurs-test-complet.json
|
||||||
-- Mot de passe universel : password (bcrypt)
|
-- 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)
|
-- À exécuter après BDD.sql (init DB)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
@@ -36,10 +39,12 @@ VALUES
|
|||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ASSISTANTES MATERNELLES ==========
|
-- ========== 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)
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
||||||
VALUES
|
VALUES
|
||||||
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
('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', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
('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;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
|
|||||||
+242
-33
@@ -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.7
|
||||||
**Date** : 9 Février 2026
|
**Date** : 25 Mars 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 mars 2026 ; #106, #107, #109, #119 fermés via API).
|
||||||
|
|
||||||
| Gitea # | Titre (dépôt) | Statut |
|
| Gitea # | Titre (dépôt) | Statut |
|
||||||
|--------|----------------|--------|
|
|--------|----------------|--------|
|
||||||
@@ -25,13 +25,87 @@
|
|||||||
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
||||||
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
| 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 ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
|
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
||||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
|
| 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 | ✅ Fermé |
|
||||||
|
| 107 | Onglet « À valider » + listes | ✅ Fermé |
|
||||||
|
| 108 | Validation dossier famille | Ouvert |
|
||||||
|
| 109 | Modale de validation | ✅ Fermé |
|
||||||
|
| 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 |
|
||||||
|
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
|
||||||
|
|
||||||
*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
|
||||||
@@ -316,21 +390,22 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
|
|||||||
|
|
||||||
## 🟢 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)
|
||||||
|
|
||||||
@@ -567,17 +642,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
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -641,6 +717,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
|
||||||
@@ -798,7 +907,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`
|
||||||
|
|
||||||
@@ -806,9 +915,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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -894,9 +1004,10 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
|
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API ✅
|
||||||
**Estimation** : 8h
|
**Estimation** : 8h
|
||||||
**Labels** : `frontend`, `p3`, `admin`
|
**Labels** : `frontend`, `p3`, `admin`
|
||||||
|
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
|
||||||
|
|
||||||
**Description** :
|
**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).
|
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
||||||
@@ -1018,6 +1129,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
|
||||||
@@ -1133,6 +1327,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`
|
||||||
@@ -1235,28 +1443,29 @@ 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.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).
|
- ✅ **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
|
||||||
@@ -1270,7 +1479,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,210 @@
|
|||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
|
||||||
|
/// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
|
class DossierUnifie {
|
||||||
|
final String type; // 'am' | 'family'
|
||||||
|
final dynamic dossier; // DossierAM | DossierFamille
|
||||||
|
|
||||||
|
DossierUnifie({required this.type, required this.dossier});
|
||||||
|
|
||||||
|
bool get isAm => type == 'am';
|
||||||
|
bool get isFamily => type == 'family';
|
||||||
|
|
||||||
|
DossierAM get asAm => dossier as DossierAM;
|
||||||
|
DossierFamille get asFamily => dossier as DossierFamille;
|
||||||
|
|
||||||
|
factory DossierUnifie.fromJson(Map<String, dynamic> json) {
|
||||||
|
final t = json['type'];
|
||||||
|
final raw = t is String ? t : 'family';
|
||||||
|
final typeStr = raw.toLowerCase();
|
||||||
|
final d = json['dossier'];
|
||||||
|
if (d == null || d is! Map<String, dynamic>) {
|
||||||
|
throw FormatException('dossier manquant ou invalide');
|
||||||
|
}
|
||||||
|
final dossierMap = Map<String, dynamic>.from(d as Map);
|
||||||
|
// Seul `am` (casse tolérée) charge le dossier AM ; le reste = famille (API : type "family").
|
||||||
|
final isAm = typeStr == 'am';
|
||||||
|
final dossier = isAm ? DossierAM.fromJson(dossierMap) : DossierFamille.fromJson(dossierMap);
|
||||||
|
return DossierUnifie(type: isAm ? 'am' : 'family', dossier: dossier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier AM (type: 'am'). Champs alignés API.
|
||||||
|
class DossierAM {
|
||||||
|
final String? numeroDossier;
|
||||||
|
final AppUser user;
|
||||||
|
final String? numeroAgrement;
|
||||||
|
final String? nir;
|
||||||
|
final String? presentation;
|
||||||
|
final String? dateAgrement;
|
||||||
|
final int? nbMaxEnfants;
|
||||||
|
final int? placesDisponibles;
|
||||||
|
final String? villeResidence;
|
||||||
|
|
||||||
|
DossierAM({
|
||||||
|
this.numeroDossier,
|
||||||
|
required this.user,
|
||||||
|
this.numeroAgrement,
|
||||||
|
this.nir,
|
||||||
|
this.presentation,
|
||||||
|
this.dateAgrement,
|
||||||
|
this.nbMaxEnfants,
|
||||||
|
this.placesDisponibles,
|
||||||
|
this.villeResidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DossierAM.fromJson(Map<String, dynamic> json) {
|
||||||
|
final userJson = json['user'];
|
||||||
|
final userMap = userJson is Map<String, dynamic>
|
||||||
|
? userJson
|
||||||
|
: <String, dynamic>{};
|
||||||
|
final nbMax = json['nb_max_enfants'];
|
||||||
|
final places = json['place_disponible'];
|
||||||
|
return DossierAM(
|
||||||
|
numeroDossier: json['numero_dossier']?.toString(),
|
||||||
|
user: AppUser.fromJson(Map<String, dynamic>.from(userMap)),
|
||||||
|
numeroAgrement: json['numero_agrement']?.toString(),
|
||||||
|
nir: json['nir']?.toString(),
|
||||||
|
presentation: (json['biographie'] ?? json['presentation'])?.toString(),
|
||||||
|
dateAgrement: json['date_agrement']?.toString(),
|
||||||
|
nbMaxEnfants: nbMax is int ? nbMax : (nbMax is num ? nbMax.toInt() : null),
|
||||||
|
placesDisponibles: places is int ? places : (places is num ? places.toInt() : null),
|
||||||
|
villeResidence: json['ville_residence']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier famille (type: 'family'). Champs alignés API.
|
||||||
|
class DossierFamille {
|
||||||
|
final String? numeroDossier;
|
||||||
|
final List<ParentDossier> parents;
|
||||||
|
final List<EnfantDossier> enfants;
|
||||||
|
final String? presentation;
|
||||||
|
|
||||||
|
DossierFamille({
|
||||||
|
this.numeroDossier,
|
||||||
|
required this.parents,
|
||||||
|
required this.enfants,
|
||||||
|
this.presentation,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DossierFamille.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parentsRaw = json['parents'];
|
||||||
|
final parentsList = parentsRaw is List
|
||||||
|
? (parentsRaw)
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <ParentDossier>[];
|
||||||
|
final enfantsRaw = json['enfants'];
|
||||||
|
final enfantsList = enfantsRaw is List
|
||||||
|
? (enfantsRaw)
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList()
|
||||||
|
: <EnfantDossier>[];
|
||||||
|
return DossierFamille(
|
||||||
|
numeroDossier: json['numero_dossier']?.toString(),
|
||||||
|
parents: parentsList,
|
||||||
|
enfants: enfantsList,
|
||||||
|
presentation: (json['texte_motivation'] ?? json['presentation'])?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEnAttente =>
|
||||||
|
parents.any((p) => p.statut == 'en_attente');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parent dans un dossier famille (champs user exposés).
|
||||||
|
class ParentDossier {
|
||||||
|
final String id;
|
||||||
|
final String email;
|
||||||
|
final String? prenom;
|
||||||
|
final String? nom;
|
||||||
|
final String? telephone;
|
||||||
|
final String? adresse;
|
||||||
|
final String? ville;
|
||||||
|
final String? codePostal;
|
||||||
|
final String? dateNaissance;
|
||||||
|
final String? genre;
|
||||||
|
final String? situationFamiliale;
|
||||||
|
final String? creeLe;
|
||||||
|
final String? statut;
|
||||||
|
|
||||||
|
ParentDossier({
|
||||||
|
required this.id,
|
||||||
|
required this.email,
|
||||||
|
this.prenom,
|
||||||
|
this.nom,
|
||||||
|
this.telephone,
|
||||||
|
this.adresse,
|
||||||
|
this.ville,
|
||||||
|
this.codePostal,
|
||||||
|
this.dateNaissance,
|
||||||
|
this.genre,
|
||||||
|
this.situationFamiliale,
|
||||||
|
this.creeLe,
|
||||||
|
this.statut,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '${prenom ?? ''} ${nom ?? ''}'.trim();
|
||||||
|
|
||||||
|
factory ParentDossier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ParentDossier(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
email: json['email']?.toString() ?? '',
|
||||||
|
prenom: json['prenom']?.toString(),
|
||||||
|
nom: json['nom']?.toString(),
|
||||||
|
telephone: json['telephone']?.toString(),
|
||||||
|
adresse: json['adresse']?.toString(),
|
||||||
|
ville: json['ville']?.toString(),
|
||||||
|
codePostal: json['code_postal']?.toString(),
|
||||||
|
dateNaissance: json['date_naissance']?.toString(),
|
||||||
|
genre: json['genre']?.toString(),
|
||||||
|
situationFamiliale: json['situation_familiale']?.toString(),
|
||||||
|
creeLe: json['cree_le']?.toString(),
|
||||||
|
statut: json['statut']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enfant dans un dossier famille.
|
||||||
|
class EnfantDossier {
|
||||||
|
final String id;
|
||||||
|
final String? firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String? birthDate;
|
||||||
|
final String? gender;
|
||||||
|
final String? status;
|
||||||
|
final String? dueDate;
|
||||||
|
final String? photoUrl;
|
||||||
|
final bool consentPhoto;
|
||||||
|
|
||||||
|
EnfantDossier({
|
||||||
|
required this.id,
|
||||||
|
this.firstName,
|
||||||
|
this.lastName,
|
||||||
|
this.birthDate,
|
||||||
|
this.gender,
|
||||||
|
this.status,
|
||||||
|
this.dueDate,
|
||||||
|
this.photoUrl,
|
||||||
|
this.consentPhoto = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||||
|
|
||||||
|
factory EnfantDossier.fromJson(Map<String, dynamic> json) {
|
||||||
|
return EnfantDossier(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
firstName: (json['first_name'] ?? json['prenom'])?.toString(),
|
||||||
|
lastName: (json['last_name'] ?? json['nom'])?.toString(),
|
||||||
|
birthDate: json['birth_date']?.toString(),
|
||||||
|
gender: (json['gender'] ?? json['genre'])?.toString(),
|
||||||
|
status: json['status']?.toString(),
|
||||||
|
dueDate: json['due_date']?.toString(),
|
||||||
|
photoUrl: json['photo_url']?.toString(),
|
||||||
|
consentPhoto: json['consent_photo'] == true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/// Résumé affichable pour un parent (liste pending-families).
|
||||||
|
class PendingParentLine {
|
||||||
|
final String? email;
|
||||||
|
final String? telephone;
|
||||||
|
final String? codePostal;
|
||||||
|
final String? ville;
|
||||||
|
|
||||||
|
const PendingParentLine({
|
||||||
|
this.email,
|
||||||
|
this.telephone,
|
||||||
|
this.codePostal,
|
||||||
|
this.ville,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty {
|
||||||
|
final e = email?.trim();
|
||||||
|
final t = telephone?.trim();
|
||||||
|
final loc = _locationTrimmed;
|
||||||
|
return (e == null || e.isEmpty) &&
|
||||||
|
(t == null || t.isEmpty) &&
|
||||||
|
(loc == null || loc.isEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get _locationTrimmed {
|
||||||
|
final cp = codePostal?.trim();
|
||||||
|
final v = ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
return loc.isEmpty ? null : loc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Famille en attente de validation (GET /parents/pending-families). Ticket #107.
|
||||||
|
///
|
||||||
|
/// Contrat API : `libelle`, `parentIds`, `numero_dossier`, `date_soumission`,
|
||||||
|
/// `nombre_enfants`, `emails`, éventuellement `parents` / tableaux parallèles.
|
||||||
|
class PendingFamily {
|
||||||
|
final String libelle;
|
||||||
|
final List<String> parentIds;
|
||||||
|
final String? numeroDossier;
|
||||||
|
|
||||||
|
/// Date affichée : `date_soumission` (ISO), sinon alias `cree_le` / etc.
|
||||||
|
final DateTime? dateSoumission;
|
||||||
|
|
||||||
|
/// Emails seuls (API) — le sous-titre utilise de préférence [parentLines].
|
||||||
|
final List<String> emails;
|
||||||
|
|
||||||
|
/// Une entrée par parent : email, tél., CP ville (si fournis par l’API).
|
||||||
|
final List<PendingParentLine> parentLines;
|
||||||
|
|
||||||
|
final int nombreEnfants;
|
||||||
|
|
||||||
|
/// Compat : premier email.
|
||||||
|
final String? email;
|
||||||
|
|
||||||
|
PendingFamily({
|
||||||
|
required this.libelle,
|
||||||
|
required this.parentIds,
|
||||||
|
this.numeroDossier,
|
||||||
|
this.dateSoumission,
|
||||||
|
this.emails = const [],
|
||||||
|
this.parentLines = const [],
|
||||||
|
this.nombreEnfants = 0,
|
||||||
|
this.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic v) {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (v is DateTime) return v;
|
||||||
|
if (v is String) {
|
||||||
|
return DateTime.tryParse(v);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _parseStringList(dynamic raw) {
|
||||||
|
if (raw is! List) return [];
|
||||||
|
return raw
|
||||||
|
.map((e) => e?.toString().trim() ?? '')
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<PendingParentLine> _parseParentLinesFromMaps(dynamic raw) {
|
||||||
|
if (raw is! List) return [];
|
||||||
|
final out = <PendingParentLine>[];
|
||||||
|
for (final e in raw) {
|
||||||
|
if (e is! Map) continue;
|
||||||
|
final m = Map<String, dynamic>.from(e);
|
||||||
|
final em = m['email']?.toString().trim();
|
||||||
|
final tel = m['telephone']?.toString().trim();
|
||||||
|
final cp = (m['code_postal'] ?? m['codePostal'])?.toString().trim();
|
||||||
|
final ville = m['ville']?.toString().trim();
|
||||||
|
out.add(PendingParentLine(
|
||||||
|
email: em != null && em.isNotEmpty ? em : null,
|
||||||
|
telephone: tel != null && tel.isNotEmpty ? tel : null,
|
||||||
|
codePostal: cp != null && cp.isNotEmpty ? cp : null,
|
||||||
|
ville: ville != null && ville.isNotEmpty ? ville : null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construit [parentLines] : objets `parents`, tableaux parallèles, ou emails + champs racine.
|
||||||
|
static List<PendingParentLine> _buildParentLines(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
List<String> emails,
|
||||||
|
) {
|
||||||
|
final fromMaps = _parseParentLinesFromMaps(
|
||||||
|
json['parents'] ?? json['resume_parents'] ?? json['parent_summaries'] ?? json['parent_lines'],
|
||||||
|
);
|
||||||
|
if (fromMaps.isNotEmpty) {
|
||||||
|
return fromMaps;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String>? parallel(dynamic keySingular, dynamic keyPlural) {
|
||||||
|
final pl = json[keyPlural];
|
||||||
|
if (pl is List) return _parseStringList(pl);
|
||||||
|
final s = json[keySingular];
|
||||||
|
if (s is String && s.trim().isNotEmpty) return [s.trim()];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tels = parallel('telephone', 'telephones');
|
||||||
|
final cps = parallel('code_postal', 'code_postaux') ?? parallel('codePostal', 'codes_postaux');
|
||||||
|
final villes = parallel('ville', 'villes');
|
||||||
|
|
||||||
|
if (emails.isNotEmpty &&
|
||||||
|
((tels?.isNotEmpty ?? false) ||
|
||||||
|
(cps?.isNotEmpty ?? false) ||
|
||||||
|
(villes?.isNotEmpty ?? false))) {
|
||||||
|
return List.generate(emails.length, (i) {
|
||||||
|
return PendingParentLine(
|
||||||
|
email: emails[i],
|
||||||
|
telephone: tels != null && i < tels.length ? tels[i] : null,
|
||||||
|
codePostal: cps != null && i < cps.length ? cps[i] : null,
|
||||||
|
ville: villes != null && i < villes.length ? villes[i] : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final rootTel = json['telephone']?.toString().trim();
|
||||||
|
final rootTelOk = rootTel != null && rootTel.isNotEmpty ? rootTel : null;
|
||||||
|
final rootCp = (json['code_postal'] ?? json['codePostal'])?.toString().trim();
|
||||||
|
final rootCpOk = rootCp != null && rootCp.isNotEmpty ? rootCp : null;
|
||||||
|
final rootVille = json['ville']?.toString().trim();
|
||||||
|
final rootVilleOk = rootVille != null && rootVille.isNotEmpty ? rootVille : null;
|
||||||
|
|
||||||
|
if (emails.isNotEmpty) {
|
||||||
|
return List.generate(emails.length, (i) {
|
||||||
|
return PendingParentLine(
|
||||||
|
email: emails[i],
|
||||||
|
telephone: i == 0 ? rootTelOk : null,
|
||||||
|
codePostal: i == 0 ? rootCpOk : null,
|
||||||
|
ville: i == 0 ? rootVilleOk : null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootTelOk != null || rootCpOk != null || rootVilleOk != null) {
|
||||||
|
final em = json['email']?.toString().trim();
|
||||||
|
return [
|
||||||
|
PendingParentLine(
|
||||||
|
email: em != null && em.isNotEmpty ? em : null,
|
||||||
|
telephone: rootTelOk,
|
||||||
|
codePostal: rootCpOk,
|
||||||
|
ville: rootVilleOk,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PendingFamily.fromJson(Map<String, dynamic> json) {
|
||||||
|
final parentIdsRaw = json['parentIds'] ?? json['parent_ids'];
|
||||||
|
final List<String> ids = parentIdsRaw is List
|
||||||
|
? (parentIdsRaw).map((e) => e?.toString() ?? '').where((s) => s.isNotEmpty).toList()
|
||||||
|
: [];
|
||||||
|
final libelle = json['libelle'];
|
||||||
|
final libelleStr = libelle is String ? libelle : (libelle?.toString() ?? 'Famille');
|
||||||
|
final nd = json['numero_dossier'] ?? json['numeroDossier'];
|
||||||
|
final numeroDossier = (nd is String && nd.isNotEmpty) ? nd : null;
|
||||||
|
|
||||||
|
DateTime? dateSoumission = _parseDate(
|
||||||
|
json['date_soumission'] ?? json['dateSoumission'],
|
||||||
|
);
|
||||||
|
dateSoumission ??= _parseDate(
|
||||||
|
json['cree_le'] ?? json['creeLe'] ?? json['date_inscription'],
|
||||||
|
);
|
||||||
|
|
||||||
|
List<String> emails = _parseStringList(json['emails']);
|
||||||
|
if (emails.isEmpty) {
|
||||||
|
final emailRaw = json['email'];
|
||||||
|
if (emailRaw is String && emailRaw.trim().isNotEmpty) {
|
||||||
|
emails = [emailRaw.trim()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final String? emailCompat = emails.isNotEmpty
|
||||||
|
? emails.first
|
||||||
|
: (json['email'] is String && (json['email'] as String).trim().isNotEmpty
|
||||||
|
? (json['email'] as String).trim()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
final nbRaw = json['nombre_enfants'] ?? json['nombreEnfants'];
|
||||||
|
int nombreEnfants = 0;
|
||||||
|
if (nbRaw is int) {
|
||||||
|
nombreEnfants = nbRaw;
|
||||||
|
} else if (nbRaw is num) {
|
||||||
|
nombreEnfants = nbRaw.toInt();
|
||||||
|
} else if (nbRaw != null) {
|
||||||
|
nombreEnfants = int.tryParse(nbRaw.toString()) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parentLines = _buildParentLines(json, emails);
|
||||||
|
if (parentLines.isEmpty && emails.isNotEmpty) {
|
||||||
|
parentLines = emails.map((e) => PendingParentLine(email: e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return PendingFamily(
|
||||||
|
libelle: libelleStr.isEmpty ? 'Famille' : libelleStr,
|
||||||
|
parentIds: ids,
|
||||||
|
numeroDossier: numeroDossier,
|
||||||
|
dateSoumission: dateSoumission,
|
||||||
|
emails: emails,
|
||||||
|
parentLines: parentLines,
|
||||||
|
nombreEnfants: nombreEnfants,
|
||||||
|
email: emailCompat,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ class AppUser {
|
|||||||
final String? adresse;
|
final String? adresse;
|
||||||
final String? ville;
|
final String? ville;
|
||||||
final String? codePostal;
|
final String? codePostal;
|
||||||
|
final String? relaisId;
|
||||||
|
final String? relaisNom;
|
||||||
|
final String? numeroDossier;
|
||||||
|
|
||||||
AppUser({
|
AppUser({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -29,33 +32,52 @@ class AppUser {
|
|||||||
this.adresse,
|
this.adresse,
|
||||||
this.ville,
|
this.ville,
|
||||||
this.codePostal,
|
this.codePostal,
|
||||||
|
this.relaisId,
|
||||||
|
this.relaisNom,
|
||||||
|
this.numeroDossier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static String _str(dynamic v) {
|
||||||
|
if (v == null) return '';
|
||||||
|
if (v is String) return v;
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime _date(dynamic v) {
|
||||||
|
if (v == null) return DateTime.now();
|
||||||
|
if (v is DateTime) return v;
|
||||||
|
try {
|
||||||
|
return DateTime.parse(v.toString());
|
||||||
|
} catch (_) {
|
||||||
|
return DateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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: _str(json['id']),
|
||||||
email: json['email'] as String,
|
email: _str(json['email']),
|
||||||
role: json['role'] as String,
|
role: _str(json['role']),
|
||||||
createdAt: json['cree_le'] != null
|
createdAt: _date(json['cree_le'] ?? json['createdAt']),
|
||||||
? DateTime.parse(json['cree_le'] as String)
|
updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
|
||||||
: (json['createdAt'] != null
|
|
||||||
? DateTime.parse(json['createdAt'] as String)
|
|
||||||
: DateTime.now()),
|
|
||||||
updatedAt: json['modifie_le'] != null
|
|
||||||
? DateTime.parse(json['modifie_le'] as String)
|
|
||||||
: (json['updatedAt'] != null
|
|
||||||
? DateTime.parse(json['updatedAt'] as String)
|
|
||||||
: DateTime.now()),
|
|
||||||
changementMdpObligatoire:
|
changementMdpObligatoire:
|
||||||
json['changement_mdp_obligatoire'] as bool? ?? false,
|
json['changement_mdp_obligatoire'] == true,
|
||||||
nom: json['nom'] as String?,
|
nom: json['nom'] is String ? json['nom'] as String : null,
|
||||||
prenom: json['prenom'] as String?,
|
prenom: json['prenom'] is String ? json['prenom'] as String : null,
|
||||||
statut: json['statut'] as String?,
|
statut: json['statut'] is String ? json['statut'] as String : null,
|
||||||
telephone: json['telephone'] as String?,
|
telephone: json['telephone'] is String ? json['telephone'] as String : null,
|
||||||
photoUrl: json['photo_url'] as String?,
|
photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
|
||||||
adresse: json['adresse'] as String?,
|
adresse: json['adresse'] is String ? json['adresse'] as String : null,
|
||||||
ville: json['ville'] as String?,
|
ville: json['ville'] is String ? json['ville'] as String : null,
|
||||||
codePostal: json['code_postal'] as String?,
|
codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
|
||||||
|
relaisId: (json['relaisId'] ?? json['relais_id'] ?? relaisMap['id'])
|
||||||
|
?.toString(),
|
||||||
|
relaisNom: relaisMap['nom']?.toString(),
|
||||||
|
numeroDossier: json['numero_dossier'] is String ? json['numero_dossier'] as String : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +97,9 @@ class AppUser {
|
|||||||
'adresse': adresse,
|
'adresse': adresse,
|
||||||
'ville': ville,
|
'ville': ville,
|
||||||
'code_postal': codePostal,
|
'code_postal': codePostal,
|
||||||
|
'relais_id': relaisId,
|
||||||
|
'relais_nom': relaisNom,
|
||||||
|
'numero_dossier': numeroDossier,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +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/admin_management_widget.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/parametres_panel.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
import 'package:p_tits_pas/widgets/admin/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});
|
||||||
@@ -17,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() {
|
||||||
@@ -26,21 +27,30 @@ 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) {
|
||||||
|
setState(() {
|
||||||
_setupCompleted = false;
|
_setupCompleted = false;
|
||||||
mainTabIndex = 1;
|
mainTabIndex = 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void onMainTabChange(int index) {
|
void onMainTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -48,9 +58,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSubTabChange(int index) {
|
void onSettingsSubTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
subIndex = index;
|
settingsSubIndex = index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,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!,
|
||||||
),
|
|
||||||
child: DashboardAppBarAdmin(
|
|
||||||
selectedIndex: mainTabIndex,
|
|
||||||
onTabChange: onMainTabChange,
|
|
||||||
setupCompleted: _setupCompleted!,
|
|
||||||
),
|
),
|
||||||
|
const DashboardTabItem(label: 'Paramètres'),
|
||||||
|
],
|
||||||
|
selectedTabIndex: mainTabIndex,
|
||||||
|
onTabSelected: onMainTabChange,
|
||||||
|
userDisplayName: _user?.fullName.isNotEmpty == true
|
||||||
|
? _user!.fullName
|
||||||
|
: 'Admin',
|
||||||
|
userEmail: _user?.email,
|
||||||
|
userRole: _user?.role,
|
||||||
|
onProfileTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Modification du profil – à venir')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSettingsTap: () => onMainTabChange(1),
|
||||||
|
onLogout: () {},
|
||||||
|
showLogoutConfirmation: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Column(
|
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(),
|
||||||
@@ -95,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 AdminManagementWidget();
|
|
||||||
default:
|
|
||||||
return const Center(child: Text('Page non trouvée'));
|
|
||||||
}
|
}
|
||||||
|
return const UserManagementPanel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.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 = formatPhoneForDisplay(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: normalizePhone(_telephoneController.text),
|
||||||
|
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: normalizePhone(_telephoneController.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
FrenchPhoneNumberFormatter(),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (v) => _required(v, 'Téléphone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,649 @@
|
|||||||
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/utils/phone_utils.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
|
@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 _toTitleCase(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty) return trimmed;
|
||||||
|
final words = trimmed.split(RegExp(r'\s+'));
|
||||||
|
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
||||||
|
return normalizedWords.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capitalizeComposedWord(String word) {
|
||||||
|
if (word.isEmpty) return word;
|
||||||
|
final lower = word.toLowerCase();
|
||||||
|
final separators = <String>{"-", "'", "’"};
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
var capitalizeNext = true;
|
||||||
|
|
||||||
|
for (var i = 0; i < lower.length; i++) {
|
||||||
|
final char = lower[i];
|
||||||
|
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
||||||
|
buffer.write(char.toUpperCase());
|
||||||
|
capitalizeNext = false;
|
||||||
|
} else {
|
||||||
|
buffer.write(char);
|
||||||
|
capitalizeNext = separators.contains(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final normalizedNom = _toTitleCase(_nomController.text);
|
||||||
|
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||||
|
final normalizedPhone = normalizePhone(_telephoneController.text);
|
||||||
|
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||||
|
|
||||||
|
if (_isEditMode) {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
||||||
|
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
||||||
|
await UserService.updateAdministrateur(
|
||||||
|
adminId: widget.initialUser!.id,
|
||||||
|
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
||||||
|
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty
|
||||||
|
? normalizePhone(widget.initialUser!.telephone ?? '')
|
||||||
|
: normalizedPhone,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final currentUser = widget.initialUser!;
|
||||||
|
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||||
|
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||||
|
final initialEmail = currentUser.email.trim();
|
||||||
|
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
||||||
|
|
||||||
|
final onlyRelaisChanged =
|
||||||
|
normalizedNom == initialNom &&
|
||||||
|
normalizedPrenom == initialPrenom &&
|
||||||
|
_emailController.text.trim() == initialEmail &&
|
||||||
|
normalizedPhone == initialPhone &&
|
||||||
|
!passwordProvided;
|
||||||
|
|
||||||
|
if (onlyRelaisChanged) {
|
||||||
|
await UserService.updateGestionnaireRelais(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.updateGestionnaire(
|
||||||
|
gestionnaireId: currentUser.id,
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
password: passwordProvided ? _passwordController.text : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (widget.adminMode) {
|
||||||
|
await UserService.createAdministrateur(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await UserService.createGestionnaire(
|
||||||
|
nom: normalizedNom,
|
||||||
|
prenom: normalizedPrenom,
|
||||||
|
email: _emailController.text.trim(),
|
||||||
|
password: _passwordController.text,
|
||||||
|
telephone: normalizePhone(_telephoneController.text),
|
||||||
|
relaisId: _selectedRelaisId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
_isEditMode
|
||||||
|
? (widget.adminMode
|
||||||
|
? 'Administrateur modifié avec succès.'
|
||||||
|
: 'Gestionnaire modifié avec succès.')
|
||||||
|
: (widget.adminMode
|
||||||
|
? 'Administrateur créé avec succès.'
|
||||||
|
: 'Gestionnaire créé avec succès.'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
e.toString().replaceFirst('Exception: ', ''),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete() async {
|
||||||
|
if (widget.readOnly) return;
|
||||||
|
if (_isSuperAdminTarget) return;
|
||||||
|
if (!_isEditMode || _isSubmitting) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer la suppression'),
|
||||||
|
content: Text(
|
||||||
|
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await UserService.deleteUser(widget.initialUser!.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
Widget build(BuildContext context) {
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 620,
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPrenomField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildNomField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildEmailField(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildPasswordField()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildTelephoneField()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (widget.withRelais) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildRelaisField(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (widget.readOnly) ...[
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Fermer'),
|
||||||
|
),
|
||||||
|
] else if (_isEditMode) ...[
|
||||||
|
if (!_isSuperAdminTarget)
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _isSubmitting ? null : _delete,
|
||||||
|
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.edit),
|
||||||
|
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed:
|
||||||
|
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _isSubmitting ? null : _submit,
|
||||||
|
icon: _isSubmitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _nomController,
|
||||||
|
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||||||
|
? null
|
||||||
|
: (v) => _required(v, 'Nom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPrenomField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _prenomController,
|
||||||
|
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
||||||
|
textCapitalization: TextCapitalization.words,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Prénom',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: (widget.readOnly || _isLockedAdminIdentity)
|
||||||
|
? null
|
||||||
|
: (v) => _required(v, 'Prénom'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmailField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _emailController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Email',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validateEmail,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPasswordField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
obscureText: _obscurePassword,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: _isEditMode
|
||||||
|
? const <String>[]
|
||||||
|
: const [AutofillHints.newPassword],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _isEditMode
|
||||||
|
? 'Nouveau mot de passe'
|
||||||
|
: 'Mot de passe',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixIcon: widget.readOnly
|
||||||
|
? null
|
||||||
|
: ExcludeFocus(
|
||||||
|
child: IconButton(
|
||||||
|
focusNode: _passwordToggleFocusNode,
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validatePassword,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTelephoneField() {
|
||||||
|
return TextFormField(
|
||||||
|
controller: _telephoneController,
|
||||||
|
readOnly: widget.readOnly,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
inputFormatters: widget.readOnly
|
||||||
|
? null
|
||||||
|
: [
|
||||||
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
|
LengthLimitingTextInputFormatter(10),
|
||||||
|
FrenchPhoneNumberFormatter(),
|
||||||
|
],
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
validator: widget.readOnly ? null : _validatePhone,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRelaisField() {
|
||||||
|
final selectedValue = _selectedRelaisId != null &&
|
||||||
|
_relais.any((relais) => relais.id == _selectedRelaisId)
|
||||||
|
? _selectedRelaisId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
value: selectedValue,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Relais principal',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('Aucun relais'),
|
||||||
|
),
|
||||||
|
..._relais.map(
|
||||||
|
(relais) => DropdownMenuItem<String?>(
|
||||||
|
value: relais.id,
|
||||||
|
child: Text(relais.nom),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: const Center(
|
],
|
||||||
child: Text('Formulaire de création de gestionnaire'),
|
onChanged: (_isLoadingRelais || widget.readOnly)
|
||||||
|
? null
|
||||||
|
: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedRelaisId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
if (_isLoadingRelais) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const LinearProgressIndicator(minHeight: 2),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -108,15 +128,12 @@ 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';
|
||||||
@@ -16,7 +17,7 @@ class LoginScreen extends StatefulWidget {
|
|||||||
State<LoginScreen> createState() => _LoginPageState();
|
State<LoginScreen> createState() => _LoginPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
class _LoginPageState extends State<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
@@ -26,31 +27,30 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
static const double _mobileBreakpoint = 900.0;
|
static const double _mobileBreakpoint = 900.0;
|
||||||
|
|
||||||
|
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
|
||||||
|
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
|
||||||
|
/// ou des comportements bizarres pendant la saisie dans les champs).
|
||||||
|
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
_passwordController.dispose();
|
_passwordController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeMetrics() {
|
|
||||||
super.didChangeMetrics();
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? value) {
|
String? _validateEmail(String? value) {
|
||||||
if (value == null || value.isEmpty) {
|
final v = value ?? '';
|
||||||
|
if (v.isEmpty) {
|
||||||
return 'Veuillez entrer votre email';
|
return 'Veuillez entrer votre email';
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
|
||||||
return 'Veuillez entrer un email valide';
|
return 'Veuillez entrer un email valide';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -63,6 +63,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,8 +111,11 @@ 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.isEmpty ? null : user.role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -117,14 +125,17 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||||||
void _redirectUserByRole(String role) {
|
void _redirectUserByRole(String? role) {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
switch (role.toLowerCase()) {
|
final r = (role ?? '').toLowerCase();
|
||||||
|
switch (r) {
|
||||||
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;
|
||||||
@@ -151,8 +162,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
return FutureBuilder(
|
return FutureBuilder<ImageDimensions>(
|
||||||
future: _getImageDimensions(),
|
future: _desktopRiverLogoDimensionsFuture,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@@ -160,7 +171,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
final imageDimensions = snapshot.data!;
|
final imageDimensions = snapshot.data!;
|
||||||
final imageHeight = h;
|
final imageHeight = h;
|
||||||
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height);
|
final imageWidth = imageHeight *
|
||||||
|
(imageDimensions.width / imageDimensions.height);
|
||||||
final remainingWidth = w - imageWidth;
|
final remainingWidth = w - imageWidth;
|
||||||
final leftMargin = remainingWidth / 4;
|
final leftMargin = remainingWidth / 4;
|
||||||
|
|
||||||
@@ -193,6 +205,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||||
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -207,6 +220,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
hintText: 'Votre adresse email',
|
hintText: 'Votre adresse email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
validator: _validateEmail,
|
validator: _validateEmail,
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -220,6 +239,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
labelText: 'Mot de passe',
|
labelText: 'Mot de passe',
|
||||||
hintText: 'Votre mot de passe',
|
hintText: 'Votre mot de passe',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.password,
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted:
|
||||||
|
_handlePasswordSubmitted,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
fieldHeight: 53,
|
fieldHeight: 53,
|
||||||
@@ -242,7 +267,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red[700], size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -309,6 +335,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
// Pied de page (Wrap pour éviter overflow sur petite largeur)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0,
|
left: 0,
|
||||||
@@ -378,6 +405,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
/// Dimensions de river_logo_mobile.png (à mettre à jour si l'asset change).
|
||||||
static const int _riverLogoMobileWidth = 600;
|
static const int _riverLogoMobileWidth = 600;
|
||||||
static const int _riverLogoMobileHeight = 1080;
|
static const int _riverLogoMobileHeight = 1080;
|
||||||
|
|
||||||
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
/// Fraction de la hauteur de l'image où se termine visuellement le slogan (0 = haut, 1 = bas).
|
||||||
static const double _sloganEndFraction = 0.42;
|
static const double _sloganEndFraction = 0.42;
|
||||||
static const double _gapBelowSlogan = 12.0;
|
static const double _gapBelowSlogan = 12.0;
|
||||||
@@ -388,7 +416,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
final imageAspectRatio = _riverLogoMobileHeight / _riverLogoMobileWidth;
|
||||||
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
final formTop =
|
||||||
|
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@@ -428,9 +457,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24, vertical: 20),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: AutofillGroup(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -442,6 +473,12 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
hintText: 'Votre adresse email',
|
hintText: 'Votre adresse email',
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
validator: _validateEmail,
|
validator: _validateEmail,
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
fieldHeight: 48,
|
fieldHeight: 48,
|
||||||
@@ -454,6 +491,11 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
showLabel: false,
|
showLabel: false,
|
||||||
hintText: 'Votre mot de passe',
|
hintText: 'Votre mot de passe',
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.password
|
||||||
|
],
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
onFieldSubmitted: _handlePasswordSubmitted,
|
||||||
validator: _validatePassword,
|
validator: _validatePassword,
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
fieldHeight: 48,
|
fieldHeight: 48,
|
||||||
@@ -466,16 +508,21 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.red.shade50,
|
color: Colors.red.shade50,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: Colors.red.shade300),
|
border: Border.all(
|
||||||
|
color: Colors.red.shade300),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
Icon(Icons.error_outline,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
size: 20),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.red.shade700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -506,7 +553,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => context.go('/register-choice'),
|
onPressed: () =>
|
||||||
|
context.go('/register-choice'),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Créer un compte',
|
'Créer un compte',
|
||||||
style: GoogleFonts.merienda(
|
style: GoogleFonts.merienda(
|
||||||
@@ -522,6 +570,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
padding: const EdgeInsets.only(bottom: 12, top: 8),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -533,12 +582,17 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
|||||||
text: 'Contact support',
|
text: 'Contact support',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
|
final uri = Uri(
|
||||||
|
scheme: 'mailto',
|
||||||
|
path: 'support@supernounou.local');
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
await launchUrl(uri);
|
await launchUrl(uri);
|
||||||
} else if (context.mounted) {
|
} else if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Impossible d\'ouvrir le client mail',
|
||||||
|
style: GoogleFonts.merienda())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,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(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ class ApiConfig {
|
|||||||
static const String gestionnaires = '/gestionnaires';
|
static const String gestionnaires = '/gestionnaires';
|
||||||
static const String parents = '/parents';
|
static const String parents = '/parents';
|
||||||
static const String assistantesMaternelles = '/assistantes-maternelles';
|
static const String assistantesMaternelles = '/assistantes-maternelles';
|
||||||
|
static const String relais = '/relais';
|
||||||
|
static const String dossiers = '/dossiers';
|
||||||
|
|
||||||
// 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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,73 @@ 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) {
|
||||||
|
final err = decoded['error'];
|
||||||
|
return (err is String ? err : err?.toString()) ?? '$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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.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/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
|
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||||
import 'package:p_tits_pas/services/api/tokenService.dart';
|
import 'package:p_tits_pas/services/api/tokenService.dart';
|
||||||
|
|
||||||
@@ -20,6 +22,145 @@ class UserService {
|
|||||||
return v.toString();
|
return v.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _errMessage(dynamic err) {
|
||||||
|
if (err == null) return 'Erreur inconnue';
|
||||||
|
if (err is String) return err;
|
||||||
|
if (err is Map) {
|
||||||
|
final m = err['message'];
|
||||||
|
if (m is String) return m;
|
||||||
|
if (m is Map && m['message'] is String) return m['message'] as String;
|
||||||
|
if (m != null) return _toStr(m) ?? 'Erreur inconnue';
|
||||||
|
}
|
||||||
|
return _toStr(err) ?? 'Erreur inconnue';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Utilisateurs en attente de validation (GET /users/pending). Ticket #107.
|
||||||
|
static Future<List<AppUser>> getPendingUsers({String? role}) async {
|
||||||
|
final query = role != null ? '?role=$role' : '';
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/pending$query'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement comptes en attente (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
|
||||||
|
return data
|
||||||
|
.where((e) => e is Map<String, dynamic>)
|
||||||
|
.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Réponse invalide (comptes en attente): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Familles en attente (une entrée par famille). GET /parents/pending-families. Ticket #107.
|
||||||
|
static Future<List<PendingFamily>> getPendingFamilies() async {
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/pending-families'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement familles en attente (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
final data = decoded is List ? decoded as List<dynamic> : <dynamic>[];
|
||||||
|
return data
|
||||||
|
.where((e) => e is Map)
|
||||||
|
.map((e) => PendingFamily.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Réponse invalide (familles en attente): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dossier unifié par numéro (AM ou famille). GET /dossiers/:numeroDossier. Ticket #119, #107.
|
||||||
|
static Future<DossierUnifie> getDossier(String numeroDossier) async {
|
||||||
|
final encoded = Uri.encodeComponent(numeroDossier);
|
||||||
|
final response = await http.get(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
|
||||||
|
headers: await _headers(),
|
||||||
|
);
|
||||||
|
if (response.statusCode == 404) {
|
||||||
|
throw Exception('Aucun dossier avec ce numéro.');
|
||||||
|
}
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur chargement dossier (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw FormatException('Réponse invalide');
|
||||||
|
}
|
||||||
|
return DossierUnifie.fromJson(Map<String, dynamic>.from(decoded));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is FormatException) rethrow;
|
||||||
|
throw Exception('Réponse invalide (dossier): ${e is Exception ? e.toString() : "format inattendu"}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valider un utilisateur (AM). PATCH /users/:id/valider. Ticket #108.
|
||||||
|
static Future<AppUser> validateUser(String userId, {String? comment}) async {
|
||||||
|
final response = await http.patch(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId/valider'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(comment != null ? {'comment': comment} : {}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur validation (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return AppUser.fromJson(Map<String, dynamic>.from(data is Map ? data : {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valider tout le dossier famille. POST /parents/:parentId/valider-dossier. Ticket #108.
|
||||||
|
static Future<List<AppUser>> validerDossierFamille(String parentId, {String? comment}) async {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.parents}/$parentId/valider-dossier'),
|
||||||
|
headers: await _headers(),
|
||||||
|
body: jsonEncode(comment != null ? {'comment': comment} : {}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
try {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception(_errMessage(err is Map ? err['message'] : err));
|
||||||
|
} catch (e) {
|
||||||
|
if (e is Exception) rethrow;
|
||||||
|
throw Exception('Erreur validation dossier famille (${response.statusCode})');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
final list = data is List ? data : [];
|
||||||
|
return list.map((e) => AppUser.fromJson(Map<String, dynamic>.from(e as Map))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
// Récupérer la liste des gestionnaires (endpoint dédié)
|
// Récupérer la liste des gestionnaires (endpoint dédié)
|
||||||
static Future<List<AppUser>> getGestionnaires() async {
|
static Future<List<AppUser>> getGestionnaires() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -29,13 +170,87 @@ class UserService {
|
|||||||
|
|
||||||
if (response.statusCode != 200) {
|
if (response.statusCode != 200) {
|
||||||
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
final err = jsonDecode(response.body) as Map<String, dynamic>?;
|
||||||
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
throw Exception(
|
||||||
|
_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> data = jsonDecode(response.body);
|
final List<dynamic> data = jsonDecode(response.body);
|
||||||
return data.map((e) => AppUser.fromJson(e)).toList();
|
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
|
// Récupérer la liste des parents
|
||||||
static Future<List<ParentModel>> getParents() async {
|
static Future<List<ParentModel>> getParents() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
@@ -53,7 +268,8 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
|
static Future<List<AssistanteMaternelleModel>>
|
||||||
|
getAssistantesMaternelles() async {
|
||||||
final response = await http.get(
|
final response = await http.get(
|
||||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.assistantesMaternelles}'),
|
||||||
headers: await _headers(),
|
headers: await _headers(),
|
||||||
@@ -87,8 +303,212 @@ class UserService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur chargement admins: $e');
|
// On garde un fallback vide pour ne pas bloquer l'UI admin.
|
||||||
}
|
}
|
||||||
return [];
|
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,57 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78).
|
||||||
|
|
||||||
|
/// Ne garde que les chiffres (max 10).
|
||||||
|
String normalizePhone(String raw) {
|
||||||
|
final digits = raw.replaceAll(RegExp(r'\D'), '');
|
||||||
|
return digits.length > 10 ? digits.substring(0, 10) : digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
|
||||||
|
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "–" etc.).
|
||||||
|
String formatPhoneForDisplay(String raw) {
|
||||||
|
if (raw.trim().isEmpty) return raw;
|
||||||
|
final normalized = normalizePhone(raw);
|
||||||
|
if (normalized.isEmpty) return raw;
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < normalized.length; i++) {
|
||||||
|
if (i > 0 && i.isEven) buffer.write(' ');
|
||||||
|
buffer.write(normalized[i]);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Conserver la position du curseur : compter les chiffres avant la sélection
|
||||||
|
final sel = newValue.selection;
|
||||||
|
final digitsBeforeCursor = newValue.text
|
||||||
|
.substring(0, sel.start.clamp(0, newValue.text.length))
|
||||||
|
.replaceAll(RegExp(r'\D'), '')
|
||||||
|
.length;
|
||||||
|
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
|
||||||
|
final clampedOffset = newOffset.clamp(0, formatted.length);
|
||||||
|
|
||||||
|
return TextEditingValue(
|
||||||
|
text: formatted,
|
||||||
|
selection: TextSelection.collapsed(offset: clampedOffset),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.dart';
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.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/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 {
|
class AdminManagementWidget extends StatefulWidget {
|
||||||
const AdminManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
|
||||||
|
const AdminManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
||||||
@@ -13,21 +23,17 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
List<AppUser> _filteredAdmins = [];
|
String? _currentUserRole;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_loadCurrentUserRole();
|
||||||
_loadAdmins();
|
_loadAdmins();
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAdmins() async {
|
Future<void> _loadAdmins() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -39,7 +45,6 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_admins = list;
|
_admins = list;
|
||||||
_filteredAdmins = list;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -51,91 +56,100 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged() {
|
Future<void> _loadCurrentUserRole() async {
|
||||||
final query = _searchController.text.toLowerCase();
|
final cached = await AuthService.getCurrentUser();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (cached != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_filteredAdmins = _admins.where((u) {
|
_currentUserRole = (cached.role).toLowerCase();
|
||||||
final name = u.fullName.toLowerCase();
|
|
||||||
final email = u.email.toLowerCase();
|
|
||||||
return name.contains(query) || email.contains(query);
|
|
||||||
}).toList();
|
|
||||||
});
|
});
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredAdmins = _admins.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);
|
||||||
Row(
|
}).toList();
|
||||||
children: [
|
|
||||||
Expanded(
|
return UserList(
|
||||||
child: TextField(
|
isLoading: _isLoading,
|
||||||
controller: _searchController,
|
error: _error,
|
||||||
decoration: const InputDecoration(
|
isEmpty: filteredAdmins.isEmpty,
|
||||||
hintText: "Rechercher un administrateur...",
|
emptyMessage: 'Aucun administrateur trouvé.',
|
||||||
prefixIcon: Icon(Icons.search),
|
itemCount: filteredAdmins.length,
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Créer admin
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Créer un admin"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_isLoading)
|
|
||||||
const Center(child: CircularProgressIndicator())
|
|
||||||
else if (_error != null)
|
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
|
||||||
else if (_filteredAdmins.isEmpty)
|
|
||||||
const Center(child: Text("Aucun administrateur trouvé."))
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filteredAdmins.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = _filteredAdmins[index];
|
final user = filteredAdmins[index];
|
||||||
return Card(
|
final isSuperAdmin = _isSuperAdmin(user);
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
final canEdit = _canEditAdmin(user);
|
||||||
child: ListTile(
|
return AdminUserCard(
|
||||||
leading: CircleAvatar(
|
title: user.fullName,
|
||||||
child: Text(user.fullName.isNotEmpty
|
fallbackIcon: isSuperAdmin
|
||||||
? user.fullName[0].toUpperCase()
|
? Icons.verified_user_outlined
|
||||||
: 'A'),
|
: Icons.manage_accounts_outlined,
|
||||||
),
|
subtitleLines: [
|
||||||
title: Text(user.fullName.isNotEmpty
|
user.email,
|
||||||
? user.fullName
|
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(user.email),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
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,9 +1,20 @@
|
|||||||
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/models/assistante_maternelle_model.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
class AssistanteMaternelleManagementWidget extends StatefulWidget {
|
||||||
const AssistanteMaternelleManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final int? capacityMin;
|
||||||
|
|
||||||
|
const AssistanteMaternelleManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.capacityMin,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssistanteMaternelleManagementWidget> createState() =>
|
State<AssistanteMaternelleManagementWidget> createState() =>
|
||||||
@@ -15,25 +26,15 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AssistanteMaternelleModel> _assistantes = [];
|
List<AssistanteMaternelleModel> _assistantes = [];
|
||||||
List<AssistanteMaternelleModel> _filteredAssistantes = [];
|
|
||||||
|
|
||||||
final TextEditingController _zoneController = TextEditingController();
|
|
||||||
final TextEditingController _capacityController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadAssistantes();
|
_loadAssistantes();
|
||||||
_zoneController.addListener(_filter);
|
|
||||||
_capacityController.addListener(_filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_zoneController.dispose();
|
|
||||||
_capacityController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadAssistantes() async {
|
Future<void> _loadAssistantes() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -45,7 +46,6 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_assistantes = list;
|
_assistantes = list;
|
||||||
_filter();
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -57,117 +57,100 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filter() {
|
|
||||||
final zoneQuery = _zoneController.text.toLowerCase();
|
|
||||||
final capacityQuery = int.tryParse(_capacityController.text);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_filteredAssistantes = _assistantes.where((am) {
|
|
||||||
final matchesZone = zoneQuery.isEmpty ||
|
|
||||||
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
|
|
||||||
final matchesCapacity = capacityQuery == null ||
|
|
||||||
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
|
|
||||||
return matchesZone && matchesCapacity;
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredAssistantes = _assistantes.where((am) {
|
||||||
child: Column(
|
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
am.user.email.toLowerCase().contains(query) ||
|
||||||
children: [
|
(am.residenceCity?.toLowerCase().contains(query) ?? false);
|
||||||
// 🔎 Zone de filtre
|
final matchesCapacity = widget.capacityMin == null ||
|
||||||
_buildFilterSection(),
|
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
|
||||||
|
return matchesName && matchesCapacity;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
return UserList(
|
||||||
|
isLoading: _isLoading,
|
||||||
// 📋 Liste des assistantes
|
error: _error,
|
||||||
if (_isLoading)
|
isEmpty: filteredAssistantes.isEmpty,
|
||||||
const Center(child: CircularProgressIndicator())
|
emptyMessage: 'Aucune assistante maternelle trouvée.',
|
||||||
else if (_error != null)
|
itemCount: filteredAssistantes.length,
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
|
||||||
else if (_filteredAssistantes.isEmpty)
|
|
||||||
const Center(child: Text("Aucune assistante maternelle trouvée."))
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filteredAssistantes.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final assistante = _filteredAssistantes[index];
|
final assistante = filteredAssistantes[index];
|
||||||
return Card(
|
return AdminUserCard(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
title: assistante.user.fullName,
|
||||||
child: ListTile(
|
avatarUrl: assistante.user.photoUrl,
|
||||||
leading: CircleAvatar(
|
fallbackIcon: Icons.face,
|
||||||
backgroundImage: assistante.user.photoUrl != null
|
subtitleLines: [
|
||||||
? NetworkImage(assistante.user.photoUrl!)
|
assistante.user.email,
|
||||||
: null,
|
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
||||||
child: assistante.user.photoUrl == null
|
],
|
||||||
? const Icon(Icons.face)
|
actions: [
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(assistante.user.fullName.isNotEmpty
|
|
||||||
? assistante.user.fullName
|
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(
|
|
||||||
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: 'Modifier',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Ajouter modification
|
_openAssistanteDetails(assistante);
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Ajouter suppression
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AdminDetailModal(
|
||||||
|
title: assistante.user.fullName.isEmpty
|
||||||
|
? 'Assistante maternelle'
|
||||||
|
: assistante.user.fullName,
|
||||||
|
subtitle: assistante.user.email,
|
||||||
|
fields: [
|
||||||
|
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Numero agrement',
|
||||||
|
value: _v(assistante.approvalNumber),
|
||||||
),
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Ville residence',
|
||||||
|
value: _v(assistante.residenceCity),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Capacite max',
|
||||||
|
value: assistante.maxChildren?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Places disponibles',
|
||||||
|
value: assistante.placesAvailable?.toString() ?? '-',
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Telephone',
|
||||||
|
value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
||||||
|
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(assistante.user.codePostal),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFilterSection() {
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
return Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 200,
|
|
||||||
child: TextField(
|
|
||||||
controller: _zoneController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Zone géographique",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.location_on),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 200,
|
|
||||||
child: TextField(
|
|
||||||
controller: _capacityController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Capacité minimum",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'admin_detail_modal.dart';
|
||||||
|
|
||||||
|
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
|
||||||
|
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
|
||||||
|
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
|
||||||
|
class ValidationDetailSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<AdminDetailField> fields;
|
||||||
|
|
||||||
|
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
|
||||||
|
final List<int>? rowLayout;
|
||||||
|
|
||||||
|
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
|
||||||
|
final Map<int, List<int>>? rowFlex;
|
||||||
|
|
||||||
|
const ValidationDetailSection({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.fields,
|
||||||
|
this.rowLayout,
|
||||||
|
this.rowFlex,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final layout = rowLayout ?? List.filled(fields.length, 1);
|
||||||
|
int index = 0;
|
||||||
|
int rowIndex = 0;
|
||||||
|
final rows = <Widget>[];
|
||||||
|
for (final count in layout) {
|
||||||
|
if (index >= fields.length) break;
|
||||||
|
final rowFields = fields.skip(index).take(count).toList();
|
||||||
|
index += count;
|
||||||
|
if (rowFields.isEmpty) continue;
|
||||||
|
final flexForRow = rowFlex?[rowIndex];
|
||||||
|
rowIndex++;
|
||||||
|
if (count == 1) {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: _buildFieldCell(rowFields.first),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
rows.add(Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < rowFields.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
flex: (flexForRow != null && i < flexForRow.length)
|
||||||
|
? flexForRow[i]
|
||||||
|
: 1,
|
||||||
|
child: _buildFieldCell(rowFields[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...rows,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFieldCell(AdminDetailField field) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
field.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ValidationReadOnlyField(value: field.value),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
|
||||||
|
class ValidationReadOnlyField extends StatelessWidget {
|
||||||
|
final String value;
|
||||||
|
final int? maxLines;
|
||||||
|
|
||||||
|
const ValidationReadOnlyField({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
this.maxLines = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: Colors.black87, fontSize: 14),
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,140 +1,138 @@
|
|||||||
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 : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
||||||
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
|
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
|
||||||
final int selectedIndex;
|
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
|
||||||
final ValueChanged<int> onTabChange;
|
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||||
final bool setupCompleted;
|
final int selectedSubIndex;
|
||||||
|
final ValueChanged<int> onSubTabChange;
|
||||||
|
final TextEditingController searchController;
|
||||||
|
final String searchHint;
|
||||||
|
final Widget? filterControl;
|
||||||
|
final VoidCallback? onAddPressed;
|
||||||
|
final String addLabel;
|
||||||
|
final int subTabCount;
|
||||||
|
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
|
||||||
|
final List<String>? tabLabels;
|
||||||
|
|
||||||
const DashboardAppBarAdmin({
|
static const List<String> _defaultTabLabels = [
|
||||||
|
'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,
|
||||||
|
this.tabLabels,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppBar(
|
final labels = _labels;
|
||||||
elevation: 0,
|
return Container(
|
||||||
automaticallyImplyLeading: false,
|
height: 56,
|
||||||
title: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: Colors.grey.shade100,
|
||||||
const SizedBox(width: 24),
|
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
||||||
Image.asset(
|
|
||||||
'assets/images/logo.png',
|
|
||||||
height: 40,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
),
|
||||||
Expanded(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
||||||
child: Center(
|
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
|
for (int i = 0; i < labels.length; i++) ...[
|
||||||
const SizedBox(width: 24),
|
if (i > 0) const SizedBox(width: 12),
|
||||||
_buildNavItem(context, 'Paramètres', 1, enabled: true),
|
_buildSubNavItem(context, labels[i], i),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 36),
|
||||||
|
_pillField(
|
||||||
|
width: 320,
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: searchHint,
|
||||||
|
prefixIcon: const Icon(Icons.search, size: 18),
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (filterControl != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
_pillField(width: 150, child: filterControl!),
|
||||||
|
],
|
||||||
|
const Spacer(),
|
||||||
|
if (onAddPressed != null) _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(
|
|
||||||
opacity: enabled ? 1.0 : 0.5,
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: isActive ? null : Border.all(color: Colors.black26),
|
border: isActive ? null : Border.all(color: Colors.black26),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive ? Colors.white : Colors.black,
|
color: isActive ? Colors.white : Colors.black87,
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLogout(BuildContext context) {
|
|
||||||
showDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Déconnexion'),
|
|
||||||
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await AuthService.logout();
|
|
||||||
if (context.mounted) context.go('/login');
|
|
||||||
},
|
|
||||||
child: const Text('Déconnecter'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
|
/// 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 +151,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,10 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/user.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/services/user_service.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatefulWidget {
|
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
|
@override
|
||||||
State<GestionnaireManagementWidget> createState() =>
|
State<GestionnaireManagementWidget> createState() =>
|
||||||
@@ -16,21 +23,15 @@ class _GestionnaireManagementWidgetState
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _gestionnaires = [];
|
List<AppUser> _gestionnaires = [];
|
||||||
List<AppUser> _filteredGestionnaires = [];
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadGestionnaires();
|
_loadGestionnaires();
|
||||||
_searchController.addListener(_onSearchChanged);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadGestionnaires() async {
|
Future<void> _loadGestionnaires() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -38,11 +39,10 @@ class _GestionnaireManagementWidgetState
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final list = await UserService.getGestionnaires();
|
final gestionnaires = await UserService.getGestionnaires();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_gestionnaires = list;
|
_gestionnaires = gestionnaires;
|
||||||
_filteredGestionnaires = list;
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -54,71 +54,56 @@ class _GestionnaireManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged() {
|
Future<void> _openGestionnaireEditDialog(AppUser user) async {
|
||||||
final query = _searchController.text.toLowerCase();
|
final changed = await showDialog<bool>(
|
||||||
setState(() {
|
context: context,
|
||||||
_filteredGestionnaires = _gestionnaires.where((u) {
|
barrierDismissible: false,
|
||||||
final name = u.fullName.toLowerCase();
|
builder: (dialogContext) {
|
||||||
final email = u.email.toLowerCase();
|
return AdminUserFormDialog(initialUser: user);
|
||||||
return name.contains(query) || email.contains(query);
|
},
|
||||||
}).toList();
|
);
|
||||||
});
|
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: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: "Rechercher un gestionnaire...",
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Rediriger vers la page de création
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text("Créer un gestionnaire"),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 🔹 Liste des gestionnaires
|
return UserList(
|
||||||
if (_isLoading)
|
isLoading: _isLoading,
|
||||||
const Center(child: CircularProgressIndicator())
|
error: _error,
|
||||||
else if (_error != null)
|
isEmpty: filteredGestionnaires.isEmpty,
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
emptyMessage: 'Aucun gestionnaire trouvé.',
|
||||||
else if (_filteredGestionnaires.isEmpty)
|
itemCount: filteredGestionnaires.length,
|
||||||
const Center(child: Text("Aucun gestionnaire trouvé."))
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filteredGestionnaires.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final user = _filteredGestionnaires[index];
|
final user = filteredGestionnaires[index];
|
||||||
return GestionnaireCard(
|
return AdminUserCard(
|
||||||
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
|
title: user.fullName,
|
||||||
email: user.email,
|
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,
|
||||||
@@ -237,9 +267,18 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
|
_buildField(
|
||||||
|
'smtp_host',
|
||||||
|
'Serveur SMTP',
|
||||||
|
hint: 'mail.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
|
_buildField(
|
||||||
|
'smtp_port',
|
||||||
|
'Port SMTP',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
hint: '25, 465, 587',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
@@ -247,14 +286,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpSecure,
|
value: _smtpSecure,
|
||||||
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
|
onChanged: (v) =>
|
||||||
|
setState(() => _smtpSecure = v ?? false),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('SSL/TLS (secure)'),
|
const Text('SSL/TLS (secure)'),
|
||||||
const SizedBox(width: 24),
|
const SizedBox(width: 24),
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpAuthRequired,
|
value: _smtpAuthRequired,
|
||||||
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
|
onChanged: (v) => setState(
|
||||||
|
() => _smtpAuthRequired = v ?? false,
|
||||||
|
),
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('Authentification requise'),
|
const Text('Authentification requise'),
|
||||||
@@ -263,11 +305,19 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
),
|
),
|
||||||
_buildField('smtp_user', 'Utilisateur SMTP'),
|
_buildField('smtp_user', 'Utilisateur SMTP'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
|
_buildField(
|
||||||
|
'smtp_password',
|
||||||
|
'Mot de passe SMTP',
|
||||||
|
obscure: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_name', 'Nom expéditeur'),
|
_buildField('email_from_name', 'Nom expéditeur'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
|
_buildField(
|
||||||
|
'email_from_address',
|
||||||
|
'Email expéditeur',
|
||||||
|
hint: 'no-reply@example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
@@ -277,8 +327,13 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
label: const Text('Tester la connexion SMTP'),
|
label: const Text('Tester la connexion SMTP'),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF2D6A4F),
|
foregroundColor: const Color(0xFF2D6A4F),
|
||||||
side: const BorderSide(color: Color(0xFF9CC5C0)),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
color: Color(0xFF9CC5C0),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -295,9 +350,17 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
children: [
|
children: [
|
||||||
_buildField('app_name', 'Nom de l\'application'),
|
_buildField('app_name', 'Nom de l\'application'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
|
_buildField(
|
||||||
|
'app_url',
|
||||||
|
'URL de l\'application',
|
||||||
|
hint: 'https://app.example.com',
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
|
_buildField(
|
||||||
|
'app_logo_url',
|
||||||
|
'URL du logo',
|
||||||
|
hint: '/assets/logo.png',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -309,11 +372,23 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'password_reset_token_expiry_days',
|
||||||
|
'Validité token MDP (jours)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'jwt_expiry_hours',
|
||||||
|
'Validité session JWT (heures)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
|
_buildField(
|
||||||
|
'max_upload_size_mb',
|
||||||
|
'Taille max upload (MB)',
|
||||||
|
keyboard: TextInputType.number,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -327,7 +402,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
: const Text('Sauvegarder la configuration'),
|
: const Text('Sauvegarder la configuration'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -339,7 +421,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
|
Widget _buildSectionCard(BuildContext context,
|
||||||
|
{required IconData icon, required String title, required Widget child}) {
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
@@ -369,7 +452,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
|
Widget _buildField(String key, String label,
|
||||||
|
{bool obscure = false, TextInputType? keyboard, String? hint}) {
|
||||||
final c = _controllers[key];
|
final c = _controllers[key];
|
||||||
if (c == null) return const SizedBox.shrink();
|
if (c == null) return const SizedBox.shrink();
|
||||||
return TextFormField(
|
return TextFormField(
|
||||||
@@ -381,7 +465,8 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/models/parent_model.dart';
|
import 'package:p_tits_pas/models/parent_model.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.dart';
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
||||||
|
|
||||||
class ParentManagementWidget extends StatefulWidget {
|
class ParentManagementWidget extends StatefulWidget {
|
||||||
const ParentManagementWidget({super.key});
|
final String searchQuery;
|
||||||
|
final String? statusFilter;
|
||||||
|
|
||||||
|
const ParentManagementWidget({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
this.statusFilter,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
||||||
@@ -13,23 +24,15 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<ParentModel> _parents = [];
|
List<ParentModel> _parents = [];
|
||||||
List<ParentModel> _filteredParents = [];
|
|
||||||
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
|
||||||
String? _selectedStatus;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadParents();
|
_loadParents();
|
||||||
_searchController.addListener(_filter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() => super.dispose();
|
||||||
_searchController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadParents() async {
|
Future<void> _loadParents() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -41,7 +44,6 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_parents = list;
|
_parents = list;
|
||||||
_filter(); // Apply initial filter (if any)
|
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -53,139 +55,102 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filter() {
|
|
||||||
final query = _searchController.text.toLowerCase();
|
|
||||||
setState(() {
|
|
||||||
_filteredParents = _parents.where((p) {
|
|
||||||
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
|
||||||
p.user.email.toLowerCase().contains(query);
|
|
||||||
final matchesStatus = _selectedStatus == null ||
|
|
||||||
_selectedStatus == 'Tous' ||
|
|
||||||
(p.user.statut?.toLowerCase() == _selectedStatus?.toLowerCase());
|
|
||||||
|
|
||||||
// Mapping simple pour le statut affiché vs backend
|
|
||||||
// Backend: en_attente, actif, suspendu
|
|
||||||
// Dropdown: En attente, Actif, Suspendu
|
|
||||||
|
|
||||||
return matchesName && matchesStatus;
|
|
||||||
}).toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
final query = widget.searchQuery.toLowerCase();
|
||||||
padding: const EdgeInsets.all(16),
|
final filteredParents = _parents.where((p) {
|
||||||
child: Column(
|
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
p.user.email.toLowerCase().contains(query);
|
||||||
children: [
|
final matchesStatus =
|
||||||
_buildSearchSection(),
|
widget.statusFilter == null || p.user.statut == widget.statusFilter;
|
||||||
const SizedBox(height: 16),
|
return matchesName && matchesStatus;
|
||||||
if (_isLoading)
|
}).toList();
|
||||||
const Center(child: CircularProgressIndicator())
|
|
||||||
else if (_error != null)
|
return UserList(
|
||||||
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
isLoading: _isLoading,
|
||||||
else if (_filteredParents.isEmpty)
|
error: _error,
|
||||||
const Center(child: Text("Aucun parent trouvé."))
|
isEmpty: filteredParents.isEmpty,
|
||||||
else
|
emptyMessage: 'Aucun parent trouvé.',
|
||||||
Expanded(
|
itemCount: filteredParents.length,
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filteredParents.length,
|
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final parent = _filteredParents[index];
|
final parent = filteredParents[index];
|
||||||
return Card(
|
return AdminUserCard(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
title: parent.user.fullName,
|
||||||
child: ListTile(
|
fallbackIcon: Icons.supervisor_account_outlined,
|
||||||
leading: CircleAvatar(
|
avatarUrl: parent.user.photoUrl,
|
||||||
backgroundImage: parent.user.photoUrl != null
|
subtitleLines: [
|
||||||
? NetworkImage(parent.user.photoUrl!)
|
parent.user.email,
|
||||||
: null,
|
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
|
||||||
child: parent.user.photoUrl == null
|
],
|
||||||
? const Icon(Icons.person)
|
actions: [
|
||||||
: null,
|
|
||||||
),
|
|
||||||
title: Text(parent.user.fullName.isNotEmpty
|
|
||||||
? parent.user.fullName
|
|
||||||
: 'Sans nom'),
|
|
||||||
subtitle: Text(
|
|
||||||
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
|
|
||||||
),
|
|
||||||
isThreeLine: true,
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.visibility),
|
|
||||||
tooltip: "Voir dossier",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Voir le statut du dossier
|
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
tooltip: "Modifier",
|
tooltip: 'Modifier',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Modifier parent
|
_openParentDetails(parent);
|
||||||
},
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
tooltip: "Supprimer",
|
|
||||||
onPressed: () {
|
|
||||||
// TODO: Supprimer compte
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _displayStatus(String? status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'actif':
|
||||||
|
return 'Actif';
|
||||||
|
case 'en_attente':
|
||||||
|
return 'En attente';
|
||||||
|
case 'suspendu':
|
||||||
|
return 'Suspendu';
|
||||||
|
default:
|
||||||
|
return 'Inconnu';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openParentDetails(ParentModel parent) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AdminDetailModal(
|
||||||
|
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
|
||||||
|
subtitle: parent.user.email,
|
||||||
|
fields: [
|
||||||
|
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Statut',
|
||||||
|
value: _displayStatus(parent.user.statut),
|
||||||
),
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Telephone',
|
||||||
|
value: _v(parent.user.telephone) != '–' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '–',
|
||||||
|
),
|
||||||
|
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
|
||||||
|
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Code postal',
|
||||||
|
value: _v(parent.user.codePostal),
|
||||||
|
),
|
||||||
|
AdminDetailField(
|
||||||
|
label: 'Nombre d\'enfants',
|
||||||
|
value: parent.childrenCount.toString(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Modifier a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onDelete: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Action Supprimer a implementer')),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchSection() {
|
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
||||||
return Wrap(
|
|
||||||
spacing: 16,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Nom du parent",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.search),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(
|
|
||||||
width: 220,
|
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: "Statut",
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
value: _selectedStatus,
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(value: null, child: Text("Tous")),
|
|
||||||
DropdownMenuItem(value: "actif", child: Text("Actif")),
|
|
||||||
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
|
|
||||||
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
|
|
||||||
],
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedStatus = value;
|
|
||||||
_filter();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:p_tits_pas/models/user.dart';
|
||||||
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
|
import 'package:p_tits_pas/services/user_service.dart';
|
||||||
|
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||||
|
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
|
||||||
|
|
||||||
|
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
|
||||||
|
class PendingValidationWidget extends StatefulWidget {
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
const PendingValidationWidget({super.key, this.onRefresh});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||||
|
bool _isLoading = true;
|
||||||
|
String? _error;
|
||||||
|
List<AppUser> _pendingAM = [];
|
||||||
|
List<PendingFamily> _pendingFamilies = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||||
|
final families = await UserService.getPendingFamilies();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_pendingAM = am;
|
||||||
|
_pendingFamilies = families;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
|
||||||
|
final num = numeroDossier?.trim();
|
||||||
|
if (num == null || num.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Numéro de dossier manquant.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ValidationDossierModal(
|
||||||
|
numeroDossier: num,
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
onSuccess: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_load();
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (_error != null && _error!.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _load,
|
||||||
|
child: const Text('Réessayer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final hasAM = _pendingAM.isNotEmpty;
|
||||||
|
final hasFamilies = _pendingFamilies.isNotEmpty;
|
||||||
|
if (!hasAM && !hasFamilies) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun dossier en attente de validation',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await _load();
|
||||||
|
widget.onRefresh?.call();
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (hasAM) ...[
|
||||||
|
_sectionTitle('Assistantes maternelles en attente'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._pendingAM.map((u) => _buildAMCard(u)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
if (hasFamilies) ...[
|
||||||
|
_sectionTitle('Familles en attente'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionTitle(String title) {
|
||||||
|
return Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
|
||||||
|
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
|
||||||
|
Widget _buildPendingRow({
|
||||||
|
required IconData icon,
|
||||||
|
String? title,
|
||||||
|
Widget? titleWidget,
|
||||||
|
String? subtitle,
|
||||||
|
TextStyle? subtitleStyle,
|
||||||
|
required VoidCallback onOpen,
|
||||||
|
}) {
|
||||||
|
assert(title != null || titleWidget != null);
|
||||||
|
final titleChild = titleWidget ??
|
||||||
|
Text(
|
||||||
|
title!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final subStyle = subtitleStyle ??
|
||||||
|
TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
);
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Colors.grey.shade600, size: 28),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
titleChild,
|
||||||
|
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: subStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onOpen,
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
|
label: const Text('Ouvrir'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
|
||||||
|
String _amSubtitleLine(AppUser user) {
|
||||||
|
final email = user.email.trim();
|
||||||
|
final bits = <String>[];
|
||||||
|
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
|
||||||
|
final tel = user.telephone?.trim();
|
||||||
|
if (tel != null && tel.isNotEmpty) {
|
||||||
|
bits.add(formatPhoneForDisplay(tel));
|
||||||
|
}
|
||||||
|
final cp = user.codePostal?.trim();
|
||||||
|
final ville = user.ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
if (loc.isNotEmpty) bits.add(loc);
|
||||||
|
final infos = bits.join(' • ');
|
||||||
|
if (email.isEmpty) return infos;
|
||||||
|
return '$email - $infos';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAMCard(AppUser user) {
|
||||||
|
final numDossier = user.numeroDossier ?? '–';
|
||||||
|
final nameBold =
|
||||||
|
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
||||||
|
return _buildPendingRow(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
titleWidget: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: nameBold,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' - $numDossier',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: _amSubtitleLine(user),
|
||||||
|
subtitleStyle: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
onOpen: () => _onOpenValidation(
|
||||||
|
type: 'AM',
|
||||||
|
id: user.id,
|
||||||
|
numeroDossier: user.numeroDossier,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
|
||||||
|
String _familyParentSegment(PendingParentLine p) {
|
||||||
|
final parts = <String>[];
|
||||||
|
final e = p.email?.trim();
|
||||||
|
if (e != null && e.isNotEmpty) parts.add(e);
|
||||||
|
final t = p.telephone?.trim();
|
||||||
|
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
|
||||||
|
final cp = p.codePostal?.trim();
|
||||||
|
final v = p.ville?.trim();
|
||||||
|
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
if (loc.isNotEmpty) parts.add(loc);
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _familySubtitleLine(PendingFamily family) {
|
||||||
|
final blocks = family.parentLines
|
||||||
|
.map(_familyParentSegment)
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.join(' - ');
|
||||||
|
|
||||||
|
final tail = <String>[];
|
||||||
|
final date = family.dateSoumission;
|
||||||
|
if (date != null) {
|
||||||
|
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
|
||||||
|
}
|
||||||
|
if (family.nombreEnfants > 0) {
|
||||||
|
tail.add(
|
||||||
|
family.nombreEnfants > 1
|
||||||
|
? '${family.nombreEnfants} enfants'
|
||||||
|
: '1 enfant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final right = tail.join(' - ');
|
||||||
|
|
||||||
|
if (blocks.isEmpty && right.isEmpty) return '';
|
||||||
|
if (blocks.isEmpty) return right;
|
||||||
|
if (right.isEmpty) return blocks;
|
||||||
|
return '$blocks - $right';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFamilyCard(PendingFamily family) {
|
||||||
|
final numDossier = family.numeroDossier ?? '–';
|
||||||
|
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
||||||
|
return _buildPendingRow(
|
||||||
|
icon: Icons.family_restroom_outlined,
|
||||||
|
titleWidget: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: nameBold,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: ' - $numDossier',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w400),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: _familySubtitleLine(family),
|
||||||
|
subtitleStyle: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
onOpen: () => _onOpenValidation(
|
||||||
|
type: 'famille',
|
||||||
|
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
|
||||||
|
numeroDossier: family.numeroDossier,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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