Compare commits
No commits in common. "master" and "stable" have entirely different histories.
@ -19,8 +19,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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); });
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
});
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
#!/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);
|
|
||||||
});
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,8 +16,6 @@ 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: [
|
||||||
@ -55,8 +53,6 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
AppConfigModule,
|
AppConfigModule,
|
||||||
DocumentsLegauxModule,
|
DocumentsLegauxModule,
|
||||||
RelaisModule,
|
|
||||||
DossiersModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 };
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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,7 +48,4 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +0,0 @@
|
|||||||
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, Column, OneToOne, JoinColumn,
|
Entity, PrimaryColumn, OneToOne, JoinColumn,
|
||||||
ManyToOne, OneToMany
|
ManyToOne, OneToMany
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Users } from './users.entity';
|
import { Users } from './users.entity';
|
||||||
@ -21,10 +21,6 @@ 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[];
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
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,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Entity, PrimaryGeneratedColumn, Column,
|
Entity, PrimaryGeneratedColumn, Column,
|
||||||
CreateDateColumn, UpdateDateColumn,
|
CreateDateColumn, UpdateDateColumn,
|
||||||
OneToOne, OneToMany, ManyToOne, JoinColumn
|
OneToOne, OneToMany
|
||||||
} 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 {
|
||||||
@ -29,7 +28,6 @@ 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 {
|
||||||
@ -82,7 +80,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.ACTIF,
|
default: StatutUtilisateurType.EN_ATTENTE,
|
||||||
name: 'statut'
|
name: 'statut'
|
||||||
})
|
})
|
||||||
statut: StatutUtilisateurType;
|
statut: StatutUtilisateurType;
|
||||||
@ -119,13 +117,6 @@ 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;
|
||||||
|
|
||||||
@ -156,15 +147,4 @@ 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,137 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { NumeroDossierService } from './numero-dossier.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
providers: [NumeroDossierService],
|
|
||||||
exports: [NumeroDossierService],
|
|
||||||
})
|
|
||||||
export class NumeroDossierModule {}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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, Patch, Post, Query, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, 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,17 +6,14 @@ 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, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, 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')
|
||||||
@ -68,35 +65,6 @@ 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,14 +10,12 @@ 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,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -23,11 +22,7 @@ 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 {
|
||||||
@ -36,15 +31,12 @@ 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>,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,12 +93,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,11 +177,6 @@ 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é');
|
||||||
@ -212,8 +193,6 @@ 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,
|
||||||
@ -226,7 +205,6 @@ 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);
|
||||||
@ -251,7 +229,6 @@ 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);
|
||||||
@ -259,7 +236,6 @@ 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) {
|
||||||
@ -271,7 +247,6 @@ 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;
|
||||||
@ -350,44 +325,11 @@ 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,
|
||||||
@ -405,8 +347,6 @@ 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,
|
||||||
@ -423,7 +363,6 @@ 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);
|
||||||
|
|
||||||
@ -431,13 +370,12 @@ 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: nirNormalized,
|
nir: dto.nir,
|
||||||
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);
|
||||||
|
|
||||||
@ -532,47 +470,4 @@ 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,12 +103,10 @@ export class RegisterAMCompletDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lieu_naissance_pays?: string;
|
lieu_naissance_pays?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Le NIR est requis' })
|
@IsNotEmpty({ message: 'Le NIR est requis' })
|
||||||
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
|
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
|
||||||
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" })
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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,28 +1,4 @@
|
|||||||
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 {}
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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,83 +1,24 @@
|
|||||||
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, StatutUtilisateurType } from 'src/entities/users.entity';
|
import { RoleType } from 'src/entities/users.entity';
|
||||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, 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(
|
constructor(private readonly parentsService: ParentsService) {}
|
||||||
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,27 +1,12 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module } 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: [
|
imports: [TypeOrmModule.forFeature([Parents, Users])],
|
||||||
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,18 +5,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, Repository } from 'typeorm';
|
import { 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 {
|
||||||
@ -25,8 +18,6 @@ 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
|
||||||
@ -80,263 +71,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateRelaisDto } from './create-relais.dto';
|
|
||||||
|
|
||||||
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Relais } from 'src/entities/relais.entity';
|
|
||||||
import { CreateRelaisDto } from './dto/create-relais.dto';
|
|
||||||
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RelaisService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(Relais)
|
|
||||||
private readonly relaisRepository: Repository<Relais>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
create(createRelaisDto: CreateRelaisDto) {
|
|
||||||
const relais = this.relaisRepository.create(createRelaisDto);
|
|
||||||
return this.relaisRepository.save(relais);
|
|
||||||
}
|
|
||||||
|
|
||||||
findAll() {
|
|
||||||
return this.relaisRepository.find({ order: { nom: 'ASC' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: string) {
|
|
||||||
const relais = await this.relaisRepository.findOne({ where: { id } });
|
|
||||||
if (!relais) {
|
|
||||||
throw new NotFoundException(`Relais #${id} not found`);
|
|
||||||
}
|
|
||||||
return relais;
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
|
|
||||||
const relais = await this.findOne(id);
|
|
||||||
Object.assign(relais, updateRelaisDto);
|
|
||||||
return this.relaisRepository.save(relais);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string) {
|
|
||||||
const relais = await this.findOne(id);
|
|
||||||
return this.relaisRepository.remove(relais);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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,10 +1,4 @@
|
|||||||
import { PickType } from "@nestjs/swagger";
|
import { OmitType } from "@nestjs/swagger";
|
||||||
import { CreateUserDto } from "./create_user.dto";
|
import { CreateUserDto } from "./create_user.dto";
|
||||||
|
|
||||||
export class CreateAdminDto extends PickType(CreateUserDto, [
|
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
||||||
'nom',
|
|
||||||
'prenom',
|
|
||||||
'email',
|
|
||||||
'password',
|
|
||||||
'telephone'
|
|
||||||
] as const) {}
|
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
import { 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', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
|
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] 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 })
|
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(GenreType)
|
@IsEnum(GenreType)
|
||||||
genre?: GenreType;
|
genre?: GenreType = GenreType.AUTRE;
|
||||||
|
|
||||||
@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;
|
consentement_photo?: boolean = false;
|
||||||
|
|
||||||
@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;
|
changement_mdp_obligatoire?: boolean = false;
|
||||||
|
|
||||||
@ApiProperty({ example: true })
|
@ApiProperty({ example: true })
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@ -4,13 +4,11 @@ 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,18 +5,16 @@ 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, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
import { RoleType, 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
|
||||||
@ -32,51 +30,30 @@ export class GestionnairesService {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
prenom: dto.prenom,
|
prenom: dto.prenom,
|
||||||
nom: dto.nom,
|
nom: dto.nom,
|
||||||
// genre: dto.genre, // Retiré
|
genre: dto.genre,
|
||||||
// statut: dto.statut, // Retiré
|
statut: dto.statut,
|
||||||
statut: StatutUtilisateurType.ACTIF,
|
|
||||||
telephone: dto.telephone,
|
telephone: dto.telephone,
|
||||||
// adresse: dto.adresse, // Retiré
|
adresse: dto.adresse,
|
||||||
// photo_url: dto.photo_url, // Retiré
|
photo_url: dto.photo_url,
|
||||||
// consentement_photo: dto.consentement_photo ?? false, // Retiré
|
consentement_photo: dto.consentement_photo ?? false,
|
||||||
// date_consentement_photo: dto.date_consentement_photo // Retiré
|
date_consentement_photo: dto.date_consentement_photo
|
||||||
// ? new Date(dto.date_consentement_photo)
|
? new Date(dto.date_consentement_photo)
|
||||||
// : undefined,
|
: undefined,
|
||||||
changement_mdp_obligatoire: true,
|
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
|
||||||
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({
|
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
|
||||||
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;
|
||||||
@ -91,7 +68,13 @@ export class GestionnairesService {
|
|||||||
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { password, ...rest } = dto;
|
if (dto.date_consentement_photo !== undefined) {
|
||||||
|
gestionnaire.date_consentement_photo = dto.date_consentement_photo
|
||||||
|
? new Date(dto.date_consentement_photo)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { password, date_consentement_photo, ...rest } = dto;
|
||||||
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,34 +1,20 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, 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, RolesGuard)
|
@UseGuards(AuthGuard)
|
||||||
@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)
|
||||||
@ -40,26 +26,6 @@ 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)
|
||||||
@ -77,9 +43,9 @@ export class UserController {
|
|||||||
return this.userService.findOne(id);
|
return this.userService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modifier un utilisateur (réservé super_admin et admin)
|
// Modifier un utilisateur (réservé super_admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
@Roles(RoleType.SUPER_ADMIN)
|
||||||
@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(
|
||||||
@ -90,23 +56,6 @@ 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' })
|
||||||
@ -122,18 +71,6 @@ 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,7 +10,6 @@ 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(
|
||||||
@ -23,7 +22,6 @@ import { MailModule } from 'src/modules/mail/mail.module';
|
|||||||
ParentsModule,
|
ParentsModule,
|
||||||
AssistantesMaternellesModule,
|
AssistantesMaternellesModule,
|
||||||
GestionnairesModule,
|
GestionnairesModule,
|
||||||
MailModule,
|
|
||||||
],
|
],
|
||||||
controllers: [UserController],
|
controllers: [UserController],
|
||||||
providers: [UserService],
|
providers: [UserService],
|
||||||
|
|||||||
@ -1,21 +1,16 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, 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, MoreThan, Repository } from "typeorm";
|
import { In, Repository } from "typeorm";
|
||||||
import { CreateUserDto } from "./dto/create_user.dto";
|
import { CreateUserDto } from "./dto/create_user.dto";
|
||||||
import { CreateAdminDto } from "./dto/create_admin.dto";
|
|
||||||
import { UpdateUserDto } from "./dto/update_user.dto";
|
import { UpdateUserDto } from "./dto/update_user.dto";
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||||
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>,
|
||||||
@ -27,9 +22,7 @@ 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> {
|
||||||
@ -113,48 +106,6 @@ 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();
|
||||||
}
|
}
|
||||||
@ -178,26 +129,11 @@ 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) &&
|
||||||
@ -229,7 +165,7 @@ export class UserService {
|
|||||||
return this.usersRepository.save(user);
|
return this.usersRepository.save(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Valider un compte utilisateur (en_attente ou refuse -> actif)
|
// Valider un compte utilisateur
|
||||||
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');
|
||||||
@ -238,10 +174,6 @@ 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) {
|
||||||
@ -289,165 +221,10 @@ 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');
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
const pass = '!Bezons2014';
|
|
||||||
|
|
||||||
bcrypt.hash(pass, 10).then(hash => {
|
|
||||||
console.log('New Hash:', hash);
|
|
||||||
}).catch(err => console.error(err));
|
|
||||||
@ -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','refuse');
|
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu');
|
||||||
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) NOT NULL,
|
nir_chiffre CHAR(15),
|
||||||
nb_max_enfants INT,
|
nb_max_enfants INT,
|
||||||
biographie TEXT,
|
biographie TEXT,
|
||||||
disponible BOOLEAN DEFAULT true,
|
disponible BOOLEAN DEFAULT true,
|
||||||
@ -331,50 +331,13 @@ 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);
|
||||||
|
|
||||||
-- ==========================================================
|
-- ==========================================================
|
||||||
-- Table : relais
|
-- Modification Table : utilisateurs (ajout colonnes documents)
|
||||||
-- ==========================================================
|
|
||||||
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
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
-- 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 $$;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- 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';
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- 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 (nir_chiffre NOT NULL depuis ticket #102)
|
-- assistantes_maternelles
|
||||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence)
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
|
||||||
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
|
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@ -2,9 +2,6 @@
|
|||||||
-- 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)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
@ -39,12 +36,10 @@ 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', '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),
|
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||||
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '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)
|
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||||
|
|
||||||
**Version** : 1.6
|
**Version** : 1.4
|
||||||
**Date** : 25 Février 2026
|
**Date** : 9 Février 2026
|
||||||
**Auteur** : Équipe PtitsPas
|
**Auteur** : Équipe PtitsPas
|
||||||
**Estimation totale** : ~208h
|
**Estimation totale** : ~184h
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 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 25 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 9 février 2026).
|
||||||
|
|
||||||
| Gitea # | Titre (dépôt) | Statut |
|
| Gitea # | Titre (dépôt) | Statut |
|
||||||
|--------|----------------|--------|
|
|--------|----------------|--------|
|
||||||
@ -25,86 +25,13 @@
|
|||||||
| 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) / Intégration panneau | Ouvert |
|
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
||||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||||
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
|
||||||
| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé |
|
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
|
||||||
| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé |
|
|
||||||
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
|
|
||||||
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
|
||||||
| 24 | [Backend] API Création mot de passe | Ouvert |
|
|
||||||
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
|
||||||
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
|
||||||
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
|
||||||
| 33 | [Backend] API Documents Légaux | Ouvert |
|
|
||||||
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
|
||||||
| 35 | [Frontend] Écran Création Gestionnaire | Ouvert |
|
|
||||||
| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé |
|
|
||||||
| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert |
|
|
||||||
| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé |
|
|
||||||
| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé |
|
|
||||||
| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé |
|
|
||||||
| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé |
|
|
||||||
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
|
||||||
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
|
||||||
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
|
||||||
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
|
||||||
| 47 | [Frontend] Écran Changement MDP Obligatoire | Ouvert |
|
|
||||||
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
|
|
||||||
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
|
|
||||||
| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert |
|
|
||||||
| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert |
|
|
||||||
| 52 | [Tests] Tests unitaires Backend | Ouvert |
|
|
||||||
| 53 | [Tests] Tests intégration Backend | Ouvert |
|
|
||||||
| 54 | [Tests] Tests E2E Frontend | Ouvert |
|
|
||||||
| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert |
|
|
||||||
| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert |
|
|
||||||
| 58 | [Backend] Service Logging (Winston) | Ouvert |
|
|
||||||
| 59 | [Infra] Volume Docker pour uploads | Ouvert |
|
|
||||||
| 60 | [Infra] Volume Docker pour documents légaux | Ouvert |
|
|
||||||
| 61 | [Doc] Guide installation & configuration | Ouvert |
|
|
||||||
| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert |
|
|
||||||
| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert |
|
|
||||||
| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé |
|
|
||||||
| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé |
|
|
||||||
| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé |
|
|
||||||
| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé |
|
|
||||||
| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé |
|
|
||||||
| 89 | Log des appels API en mode debug | Ouvert |
|
|
||||||
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
|
|
||||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
|
|
||||||
| 93 | [Frontend] Panneau Admin - Homogénéisation des onglets | ✅ Fermé |
|
|
||||||
| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé |
|
|
||||||
| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé |
|
|
||||||
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
|
|
||||||
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
|
|
||||||
| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert |
|
|
||||||
| 103 | Numéro de dossier – backend | Ouvert |
|
|
||||||
| 104 | Numéro de dossier – frontend | Ouvert |
|
|
||||||
| 105 | Statut « refusé » | Ouvert |
|
|
||||||
| 106 | Liste familles en attente | Ouvert |
|
|
||||||
| 107 | Onglet « À valider » + listes | Ouvert |
|
|
||||||
| 108 | Validation dossier famille | Ouvert |
|
|
||||||
| 109 | Modale de validation | Ouvert |
|
|
||||||
| 110 | Refus sans suppression | Ouvert |
|
|
||||||
| 111 | Reprise après refus – backend | Ouvert |
|
|
||||||
| 112 | Reprise après refus – frontend | Ouvert |
|
|
||||||
| 113 | Doublons à l'inscription | Ouvert |
|
|
||||||
| 114 | Doublons – alerte gestionnaire | Ouvert |
|
|
||||||
| 115 | Rattachement parent – backend | Ouvert |
|
|
||||||
| 116 | Rattachement parent – frontend | Ouvert |
|
|
||||||
| 117 | Évolution du cahier des charges | Ouvert |
|
|
||||||
|
|
||||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||||
|
|
||||||
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📊 Vue d'ensemble
|
## 📊 Vue d'ensemble
|
||||||
@ -389,22 +316,21 @@ 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** :
|
||||||
- [x] Endpoint `POST /api/v1/gestionnaires`
|
- [ ] Endpoint `POST /api/v1/gestionnaires`
|
||||||
- [x] Validation DTO
|
- [ ] Validation DTO
|
||||||
- [x] Hash bcrypt
|
- [ ] Hash bcrypt
|
||||||
- [x] Flag `changement_mdp_obligatoire = TRUE`
|
- [ ] Flag `changement_mdp_obligatoire = TRUE`
|
||||||
- [x] Guards (super_admin only)
|
- [ ] Guards (super_admin only)
|
||||||
- [x] Email de notification (utiliser MailService avec config dynamique)
|
- [ ] Email de notification (utiliser MailService avec config dynamique)
|
||||||
- [x] Tests unitaires
|
- [ ] 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)
|
||||||
|
|
||||||
@ -641,18 +567,17 @@ 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** :
|
||||||
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
||||||
- [x] Vérification flag `changement_mdp_obligatoire`
|
- [ ] Vérification flag `changement_mdp_obligatoire`
|
||||||
- [x] Mise à jour flag après changement
|
- [ ] Mise à jour flag après changement
|
||||||
- [ ] Tests unitaires
|
- [ ] Tests unitaires
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -716,39 +641,6 @@ 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
|
||||||
@ -906,7 +798,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`
|
||||||
|
|
||||||
@ -914,10 +806,9 @@ 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** :
|
||||||
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
|
- [ ] Layout avec 2 onglets (Parents / AM)
|
||||||
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
|
- [ ] Navigation entre onglets
|
||||||
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
|
- [ ] État vide ("Aucune demande")
|
||||||
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1003,10 +894,9 @@ 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).
|
||||||
@ -1128,89 +1018,6 @@ 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
|
||||||
@ -1326,20 +1133,6 @@ 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`
|
||||||
@ -1442,29 +1235,28 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
## 📊 Résumé final
|
## 📊 Résumé final
|
||||||
|
|
||||||
**Total** : 72 tickets
|
**Total** : 65 tickets
|
||||||
**Estimation** : ~208h de développement
|
**Estimation** : ~184h 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)** : 19 tickets (~54h)
|
- **P2 (Backend)** : 18 tickets (~50h)
|
||||||
- **P3 (Frontend)** : 25 tickets (~83h)
|
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
|
||||||
- **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** : 24 tickets
|
- **Backend** : 23 tickets
|
||||||
- **Frontend** : 25 tickets
|
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
|
||||||
- **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
|
||||||
@ -1478,7 +1270,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Dernière mise à jour** : 25 Février 2026
|
**Dernière mise à jour** : 9 Février 2026
|
||||||
**Version** : 1.6
|
**Version** : 1.4
|
||||||
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés)
|
**Statut** : ✅ Aligné avec le dépôt Gitea
|
||||||
|
|
||||||
|
|||||||
@ -256,23 +256,3 @@ 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 | closed | [Frontend] Dashboard Gestionnaire - Structure
|
44 | open | [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.3 – 27 janvier 2026_
|
_Version 0.2 – 24 avril 2025_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -62,13 +62,6 @@ 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)*
|
||||||
@ -113,4 +106,3 @@ 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,11 +20,7 @@ import '../screens/auth/am_register_step3_screen.dart';
|
|||||||
import '../screens/auth/am_register_step4_screen.dart';
|
import '../screens/auth/am_register_step4_screen.dart';
|
||||||
import '../screens/home/home_screen.dart';
|
import '../screens/home/home_screen.dart';
|
||||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||||
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
|
||||||
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
import '../screens/home/parent_screen/ParentDashboardScreen.dart';
|
||||||
import '../screens/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 ---
|
||||||
@ -57,26 +53,13 @@ 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) =>
|
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||||
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 ---
|
||||||
|
|||||||
@ -1,210 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
/// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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,9 +13,6 @@ 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,
|
||||||
@ -32,52 +29,33 @@ 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: _str(json['id']),
|
id: json['id'] as String,
|
||||||
email: _str(json['email']),
|
email: json['email'] as String,
|
||||||
role: _str(json['role']),
|
role: json['role'] as String,
|
||||||
createdAt: _date(json['cree_le'] ?? json['createdAt']),
|
createdAt: json['cree_le'] != null
|
||||||
updatedAt: _date(json['modifie_le'] ?? json['updatedAt']),
|
? DateTime.parse(json['cree_le'] as String)
|
||||||
|
: (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'] == true,
|
json['changement_mdp_obligatoire'] as bool? ?? false,
|
||||||
nom: json['nom'] is String ? json['nom'] as String : null,
|
nom: json['nom'] as String?,
|
||||||
prenom: json['prenom'] is String ? json['prenom'] as String : null,
|
prenom: json['prenom'] as String?,
|
||||||
statut: json['statut'] is String ? json['statut'] as String : null,
|
statut: json['statut'] as String?,
|
||||||
telephone: json['telephone'] is String ? json['telephone'] as String : null,
|
telephone: json['telephone'] as String?,
|
||||||
photoUrl: json['photo_url'] is String ? json['photo_url'] as String : null,
|
photoUrl: json['photo_url'] as String?,
|
||||||
adresse: json['adresse'] is String ? json['adresse'] as String : null,
|
adresse: json['adresse'] as String?,
|
||||||
ville: json['ville'] is String ? json['ville'] as String : null,
|
ville: json['ville'] as String?,
|
||||||
codePostal: json['code_postal'] is String ? json['code_postal'] as String : null,
|
codePostal: json['code_postal'] as String?,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,9 +75,6 @@ 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/dashboard_admin.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/admin/parametres_panel.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/user_management_panel.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/app_footer.dart';
|
import 'package:p_tits_pas/widgets/app_footer.dart';
|
||||||
import 'package:p_tits_pas/widgets/dashboard/dashboard_bandeau.dart';
|
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
|
||||||
|
|
||||||
class AdminDashboardScreen extends StatefulWidget {
|
class AdminDashboardScreen extends StatefulWidget {
|
||||||
const AdminDashboardScreen({super.key});
|
const AdminDashboardScreen({super.key});
|
||||||
@ -17,9 +17,8 @@ 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 settingsSubIndex = 0;
|
int subIndex = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -27,28 +26,19 @@ 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) {
|
if (mounted) setState(() {
|
||||||
setState(() {
|
_setupCompleted = false;
|
||||||
_setupCompleted = false;
|
mainTabIndex = 1;
|
||||||
mainTabIndex = 1;
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,9 +48,9 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSettingsSubTabChange(int index) {
|
void onSubTabChange(int index) {
|
||||||
setState(() {
|
setState(() {
|
||||||
settingsSubIndex = index;
|
subIndex = index;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,39 +64,25 @@ 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: DashboardBandeau(
|
child: Container(
|
||||||
tabItems: [
|
decoration: BoxDecoration(
|
||||||
DashboardTabItem(
|
border: Border(
|
||||||
label: 'Gestion des utilisateurs',
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
enabled: _setupCompleted!,
|
|
||||||
),
|
),
|
||||||
const DashboardTabItem(label: 'Paramètres'),
|
),
|
||||||
],
|
child: DashboardAppBarAdmin(
|
||||||
selectedTabIndex: mainTabIndex,
|
selectedIndex: mainTabIndex,
|
||||||
onTabSelected: onMainTabChange,
|
onTabChange: onMainTabChange,
|
||||||
userDisplayName: _user?.fullName.isNotEmpty == true
|
setupCompleted: _setupCompleted!,
|
||||||
? _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)
|
||||||
const SizedBox.shrink()
|
DashboardUserManagementSubBar(
|
||||||
else
|
selectedSubIndex: subIndex,
|
||||||
DashboardSettingsSubBar(
|
onSubTabChange: onSubTabChange,
|
||||||
selectedSubIndex: settingsSubIndex,
|
|
||||||
onSubTabChange: onSettingsSubTabChange,
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _getBody(),
|
child: _getBody(),
|
||||||
@ -119,11 +95,19 @@ class _AdminDashboardScreenState extends State<AdminDashboardScreen> {
|
|||||||
|
|
||||||
Widget _getBody() {
|
Widget _getBody() {
|
||||||
if (mainTabIndex == 1) {
|
if (mainTabIndex == 1) {
|
||||||
return ParametresPanel(
|
return ParametresPanel(redirectToLoginAfterSave: !_setupCompleted!);
|
||||||
redirectToLoginAfterSave: !_setupCompleted!,
|
}
|
||||||
selectedSettingsTabIndex: settingsSubIndex,
|
switch (subIndex) {
|
||||||
);
|
case 0:
|
||||||
|
return const GestionnaireManagementWidget();
|
||||||
|
case 1:
|
||||||
|
return const ParentManagementWidget();
|
||||||
|
case 2:
|
||||||
|
return const AssistanteMaternelleManagementWidget();
|
||||||
|
case 3:
|
||||||
|
return const AdminManagementWidget();
|
||||||
|
default:
|
||||||
|
return const Center(child: Text('Page non trouvée'));
|
||||||
}
|
}
|
||||||
return const UserManagementPanel();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,364 +0,0 @@
|
|||||||
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,649 +1,17 @@
|
|||||||
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 AdminUserFormDialog extends StatefulWidget {
|
class GestionnairesCreate extends StatelessWidget {
|
||||||
final AppUser? initialUser;
|
const GestionnairesCreate({super.key});
|
||||||
final bool withRelais;
|
|
||||||
final bool adminMode;
|
|
||||||
final bool readOnly;
|
|
||||||
|
|
||||||
const AdminUserFormDialog({
|
|
||||||
super.key,
|
|
||||||
this.initialUser,
|
|
||||||
this.withRelais = true,
|
|
||||||
this.adminMode = false,
|
|
||||||
this.readOnly = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<AdminUserFormDialog> createState() => _AdminUserFormDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _nomController = TextEditingController();
|
|
||||||
final _prenomController = TextEditingController();
|
|
||||||
final _emailController = TextEditingController();
|
|
||||||
final _passwordController = TextEditingController();
|
|
||||||
final _telephoneController = TextEditingController();
|
|
||||||
final _passwordToggleFocusNode =
|
|
||||||
FocusNode(skipTraversal: true, canRequestFocus: false);
|
|
||||||
|
|
||||||
bool _isSubmitting = false;
|
|
||||||
bool _obscurePassword = true;
|
|
||||||
bool _isLoadingRelais = true;
|
|
||||||
List<RelaisModel> _relais = [];
|
|
||||||
String? _selectedRelaisId;
|
|
||||||
bool get _isEditMode => widget.initialUser != null;
|
|
||||||
bool get _isSuperAdminTarget =>
|
|
||||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
|
||||||
bool get _isLockedAdminIdentity =>
|
|
||||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
|
||||||
String get _targetRoleKey {
|
|
||||||
if (widget.initialUser != null) {
|
|
||||||
return (widget.initialUser!.role).toLowerCase();
|
|
||||||
}
|
|
||||||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
|
||||||
}
|
|
||||||
|
|
||||||
String get _targetRoleLabel {
|
|
||||||
switch (_targetRoleKey) {
|
|
||||||
case 'super_admin':
|
|
||||||
return 'Super administrateur';
|
|
||||||
case 'administrateur':
|
|
||||||
return 'Administrateur';
|
|
||||||
case 'gestionnaire':
|
|
||||||
return 'Gestionnaire';
|
|
||||||
case 'assistante_maternelle':
|
|
||||||
return 'Assistante maternelle';
|
|
||||||
case 'parent':
|
|
||||||
return 'Parent';
|
|
||||||
default:
|
|
||||||
return 'Utilisateur';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconData get _targetRoleIcon {
|
|
||||||
switch (_targetRoleKey) {
|
|
||||||
case 'super_admin':
|
|
||||||
return Icons.verified_user_outlined;
|
|
||||||
case 'administrateur':
|
|
||||||
return Icons.admin_panel_settings_outlined;
|
|
||||||
case 'gestionnaire':
|
|
||||||
return Icons.assignment_ind_outlined;
|
|
||||||
case 'assistante_maternelle':
|
|
||||||
return Icons.child_care_outlined;
|
|
||||||
case 'parent':
|
|
||||||
return Icons.supervisor_account_outlined;
|
|
||||||
default:
|
|
||||||
return Icons.person_outline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final user = widget.initialUser;
|
|
||||||
if (user != null) {
|
|
||||||
_nomController.text = user.nom ?? '';
|
|
||||||
_prenomController.text = user.prenom ?? '';
|
|
||||||
_emailController.text = user.email;
|
|
||||||
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
|
||||||
// En édition, on ne préremplit jamais le mot de passe.
|
|
||||||
_passwordController.clear();
|
|
||||||
final initialRelaisId = user.relaisId?.trim();
|
|
||||||
_selectedRelaisId =
|
|
||||||
(initialRelaisId == null || initialRelaisId.isEmpty)
|
|
||||||
? null
|
|
||||||
: initialRelaisId;
|
|
||||||
}
|
|
||||||
if (widget.withRelais) {
|
|
||||||
_loadRelais();
|
|
||||||
} else {
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nomController.dispose();
|
|
||||||
_prenomController.dispose();
|
|
||||||
_emailController.dispose();
|
|
||||||
_passwordController.dispose();
|
|
||||||
_telephoneController.dispose();
|
|
||||||
_passwordToggleFocusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadRelais() async {
|
|
||||||
try {
|
|
||||||
final list = await RelaisService.getRelais();
|
|
||||||
if (!mounted) return;
|
|
||||||
final uniqueById = <String, RelaisModel>{};
|
|
||||||
for (final relais in list) {
|
|
||||||
uniqueById[relais.id] = relais;
|
|
||||||
}
|
|
||||||
|
|
||||||
final filtered = uniqueById.values.where((r) => r.actif).toList();
|
|
||||||
if (_selectedRelaisId != null &&
|
|
||||||
!filtered.any((r) => r.id == _selectedRelaisId)) {
|
|
||||||
final selected = uniqueById[_selectedRelaisId!];
|
|
||||||
if (selected != null) {
|
|
||||||
filtered.add(selected);
|
|
||||||
} else {
|
|
||||||
_selectedRelaisId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_relais = filtered;
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
});
|
|
||||||
} catch (_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_selectedRelaisId = null;
|
|
||||||
_relais = [];
|
|
||||||
_isLoadingRelais = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _required(String? value, String field) {
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
|
||||||
return '$field est requis';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validateEmail(String? value) {
|
|
||||||
final base = _required(value, 'Email');
|
|
||||||
if (base != null) return base;
|
|
||||||
final email = value!.trim();
|
|
||||||
final ok = RegExp(r'^[^@]+@[^@]+\.[^@]+$').hasMatch(email);
|
|
||||||
if (!ok) return 'Format email invalide';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validatePassword(String? value) {
|
|
||||||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final base = _required(value, 'Mot de passe');
|
|
||||||
if (base != null) return base;
|
|
||||||
if (value!.trim().length < 6) return 'Minimum 6 caractères';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _validatePhone(String? value) {
|
|
||||||
if (_isEditMode && (value == null || value.trim().isEmpty)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final base = _required(value, 'Téléphone');
|
|
||||||
if (base != null) return base;
|
|
||||||
final digits = normalizePhone(value!);
|
|
||||||
if (digits.length != 10) {
|
|
||||||
return 'Le téléphone doit contenir 10 chiffres';
|
|
||||||
}
|
|
||||||
if (!digits.startsWith('0')) {
|
|
||||||
return 'Le téléphone doit commencer par 0';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _toTitleCase(String raw) {
|
|
||||||
final trimmed = raw.trim();
|
|
||||||
if (trimmed.isEmpty) return trimmed;
|
|
||||||
final words = trimmed.split(RegExp(r'\s+'));
|
|
||||||
final normalizedWords = words.map(_capitalizeComposedWord).toList();
|
|
||||||
return normalizedWords.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capitalizeComposedWord(String word) {
|
|
||||||
if (word.isEmpty) return word;
|
|
||||||
final lower = word.toLowerCase();
|
|
||||||
final separators = <String>{"-", "'", "’"};
|
|
||||||
final buffer = StringBuffer();
|
|
||||||
var capitalizeNext = true;
|
|
||||||
|
|
||||||
for (var i = 0; i < lower.length; i++) {
|
|
||||||
final char = lower[i];
|
|
||||||
if (capitalizeNext && RegExp(r'[a-zà-öø-ÿ]').hasMatch(char)) {
|
|
||||||
buffer.write(char.toUpperCase());
|
|
||||||
capitalizeNext = false;
|
|
||||||
} else {
|
|
||||||
buffer.write(char);
|
|
||||||
capitalizeNext = separators.contains(char);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return buffer.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
if (widget.readOnly) return;
|
|
||||||
if (_isSubmitting) return;
|
|
||||||
if (!_formKey.currentState!.validate()) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final normalizedNom = _toTitleCase(_nomController.text);
|
|
||||||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
|
||||||
final normalizedPhone = normalizePhone(_telephoneController.text);
|
|
||||||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
|
||||||
|
|
||||||
if (_isEditMode) {
|
|
||||||
if (widget.adminMode) {
|
|
||||||
final lockedNom = _toTitleCase(widget.initialUser!.nom ?? '');
|
|
||||||
final lockedPrenom = _toTitleCase(widget.initialUser!.prenom ?? '');
|
|
||||||
await UserService.updateAdministrateur(
|
|
||||||
adminId: widget.initialUser!.id,
|
|
||||||
nom: _isLockedAdminIdentity ? lockedNom : normalizedNom,
|
|
||||||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
telephone: normalizedPhone.isEmpty
|
|
||||||
? normalizePhone(widget.initialUser!.telephone ?? '')
|
|
||||||
: normalizedPhone,
|
|
||||||
password: passwordProvided ? _passwordController.text : null,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
final currentUser = widget.initialUser!;
|
|
||||||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
|
||||||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
|
||||||
final initialEmail = currentUser.email.trim();
|
|
||||||
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
|
||||||
|
|
||||||
final onlyRelaisChanged =
|
|
||||||
normalizedNom == initialNom &&
|
|
||||||
normalizedPrenom == initialPrenom &&
|
|
||||||
_emailController.text.trim() == initialEmail &&
|
|
||||||
normalizedPhone == initialPhone &&
|
|
||||||
!passwordProvided;
|
|
||||||
|
|
||||||
if (onlyRelaisChanged) {
|
|
||||||
await UserService.updateGestionnaireRelais(
|
|
||||||
gestionnaireId: currentUser.id,
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await UserService.updateGestionnaire(
|
|
||||||
gestionnaireId: currentUser.id,
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
telephone: normalizedPhone.isEmpty ? initialPhone : normalizedPhone,
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
password: passwordProvided ? _passwordController.text : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (widget.adminMode) {
|
|
||||||
await UserService.createAdministrateur(
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
password: _passwordController.text,
|
|
||||||
telephone: normalizePhone(_telephoneController.text),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await UserService.createGestionnaire(
|
|
||||||
nom: normalizedNom,
|
|
||||||
prenom: normalizedPrenom,
|
|
||||||
email: _emailController.text.trim(),
|
|
||||||
password: _passwordController.text,
|
|
||||||
telephone: normalizePhone(_telephoneController.text),
|
|
||||||
relaisId: _selectedRelaisId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
_isEditMode
|
|
||||||
? (widget.adminMode
|
|
||||||
? 'Administrateur modifié avec succès.'
|
|
||||||
: 'Gestionnaire modifié avec succès.')
|
|
||||||
: (widget.adminMode
|
|
||||||
? 'Administrateur créé avec succès.'
|
|
||||||
: 'Gestionnaire créé avec succès.'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
e.toString().replaceFirst('Exception: ', ''),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _delete() async {
|
|
||||||
if (widget.readOnly) return;
|
|
||||||
if (_isSuperAdminTarget) return;
|
|
||||||
if (!_isEditMode || _isSubmitting) return;
|
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) {
|
|
||||||
return AlertDialog(
|
|
||||||
title: const Text('Confirmer la suppression'),
|
|
||||||
content: Text(
|
|
||||||
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton(
|
|
||||||
onPressed: () => Navigator.of(ctx).pop(true),
|
|
||||||
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed != true) return;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = true;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await UserService.deleteUser(widget.initialUser!.id);
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Gestionnaire supprimé.')),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(e.toString().replaceFirst('Exception: ', '')),
|
|
||||||
backgroundColor: Colors.red.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setState(() {
|
|
||||||
_isSubmitting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AlertDialog(
|
return Scaffold(
|
||||||
title: Row(
|
appBar: AppBar(
|
||||||
children: [
|
title: const Text('Créer un gestionnaire'),
|
||||||
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(
|
body: const Center(
|
||||||
width: 620,
|
child: Text('Formulaire de création de gestionnaire'),
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildPrenomField()),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _buildNomField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildEmailField(),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(child: _buildPasswordField()),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _buildTelephoneField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (widget.withRelais) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_buildRelaisField(),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
|
||||||
if (widget.readOnly) ...[
|
|
||||||
FilledButton(
|
|
||||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Fermer'),
|
|
||||||
),
|
|
||||||
] else if (_isEditMode) ...[
|
|
||||||
if (!_isSuperAdminTarget)
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed: _isSubmitting ? null : _delete,
|
|
||||||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700),
|
|
||||||
child: const Text('Supprimer'),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
icon: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.edit),
|
|
||||||
label: Text(_isSubmitting ? 'Modification...' : 'Modifier'),
|
|
||||||
),
|
|
||||||
] else ...[
|
|
||||||
OutlinedButton(
|
|
||||||
onPressed:
|
|
||||||
_isSubmitting ? null : () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _isSubmitting ? null : _submit,
|
|
||||||
icon: _isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.person_add_alt_1),
|
|
||||||
label: Text(_isSubmitting ? 'Création...' : 'Créer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildNomField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _nomController,
|
|
||||||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Nom',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
|
||||||
? null
|
|
||||||
: (v) => _required(v, 'Nom'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPrenomField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _prenomController,
|
|
||||||
readOnly: widget.readOnly || _isLockedAdminIdentity,
|
|
||||||
textCapitalization: TextCapitalization.words,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Prénom',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: (widget.readOnly || _isLockedAdminIdentity)
|
|
||||||
? null
|
|
||||||
: (v) => _required(v, 'Prénom'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildEmailField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _emailController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
keyboardType: TextInputType.emailAddress,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Email',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: widget.readOnly ? null : _validateEmail,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildPasswordField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _passwordController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
obscureText: _obscurePassword,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: _isEditMode
|
|
||||||
? const <String>[]
|
|
||||||
: const [AutofillHints.newPassword],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: _isEditMode
|
|
||||||
? 'Nouveau mot de passe'
|
|
||||||
: 'Mot de passe',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
suffixIcon: widget.readOnly
|
|
||||||
? null
|
|
||||||
: ExcludeFocus(
|
|
||||||
child: IconButton(
|
|
||||||
focusNode: _passwordToggleFocusNode,
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_obscurePassword = !_obscurePassword;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: widget.readOnly ? null : _validatePassword,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTelephoneField() {
|
|
||||||
return TextFormField(
|
|
||||||
controller: _telephoneController,
|
|
||||||
readOnly: widget.readOnly,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
inputFormatters: widget.readOnly
|
|
||||||
? null
|
|
||||||
: [
|
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
|
||||||
LengthLimitingTextInputFormatter(10),
|
|
||||||
FrenchPhoneNumberFormatter(),
|
|
||||||
],
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
validator: widget.readOnly ? null : _validatePhone,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildRelaisField() {
|
|
||||||
final selectedValue = _selectedRelaisId != null &&
|
|
||||||
_relais.any((relais) => relais.id == _selectedRelaisId)
|
|
||||||
? _selectedRelaisId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
DropdownButtonFormField<String?>(
|
|
||||||
isExpanded: true,
|
|
||||||
value: selectedValue,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Relais principal',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
const DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('Aucun relais'),
|
|
||||||
),
|
|
||||||
..._relais.map(
|
|
||||||
(relais) => DropdownMenuItem<String?>(
|
|
||||||
value: relais.id,
|
|
||||||
child: Text(relais.nom),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (_isLoadingRelais || widget.readOnly)
|
|
||||||
? null
|
|
||||||
: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedRelaisId = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (_isLoadingRelais) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const LinearProgressIndicator(minHeight: 2),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,78 +0,0 @@
|
|||||||
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,6 +3,7 @@ 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';
|
||||||
|
|
||||||
@ -13,17 +14,19 @@ 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);
|
||||||
|
|
||||||
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
// Générer des données de test si vide
|
||||||
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: 'Marie',
|
firstName: genFirstName,
|
||||||
lastName: 'DUBOIS',
|
lastName: genLastName,
|
||||||
phone: '0696345678',
|
phone: DataGenerator.phone(),
|
||||||
email: 'marie.dubois@ptits-pas.fr',
|
email: DataGenerator.email(genFirstName, genLastName),
|
||||||
address: '25 Rue de la République',
|
address: DataGenerator.address(),
|
||||||
postalCode: '95870',
|
postalCode: DataGenerator.postalCode(),
|
||||||
city: 'Bezons',
|
city: DataGenerator.city(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
initialData = PersonalInfoData(
|
initialData = PersonalInfoData(
|
||||||
|
|||||||
@ -6,6 +6,7 @@ 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 {
|
||||||
@ -53,17 +54,17 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
capacity: registrationData.capacity,
|
capacity: registrationData.capacity,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
// Générer des données de test si les champs sont vides
|
||||||
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(1980, 6, 8),
|
dateOfBirth: DateTime(1985, 3, 15),
|
||||||
birthCity: 'Bezons',
|
birthCity: DataGenerator.city(),
|
||||||
birthCountry: 'France',
|
birthCountry: 'France',
|
||||||
nir: '280062A00100191',
|
nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}',
|
||||||
agrementNumber: 'AGR-2019-095001',
|
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
||||||
capacity: 4,
|
capacity: DataGenerator.randomIntInRange(1, 5),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
// Données de test : Marie DUBOIS (jeu de test 03_seed_test_data.sql / docs/test-data)
|
// Générer un texte de test si vide
|
||||||
String initialText = data.presentationText;
|
String initialText = data.presentationText;
|
||||||
bool initialCgu = data.cguAccepted;
|
bool initialCgu = data.cguAccepted;
|
||||||
|
|
||||||
if (initialText.isEmpty) {
|
if (initialText.isEmpty) {
|
||||||
initialText = 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.';
|
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é.';
|
||||||
initialCgu = true;
|
initialCgu = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ 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';
|
||||||
@ -23,28 +22,6 @@ 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);
|
||||||
@ -113,9 +90,12 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: HoverReliefWidget(
|
child: HoverReliefWidget(
|
||||||
child: CustomNavigationButton(
|
child: CustomNavigationButton(
|
||||||
text: _isSubmitting ? 'Envoi...' : 'Soumettre',
|
text: 'Soumettre',
|
||||||
style: NavigationButtonStyle.green,
|
style: NavigationButtonStyle.green,
|
||||||
onPressed: () => _submitAMRegistration(registrationData),
|
onPressed: () {
|
||||||
|
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,
|
||||||
@ -126,14 +106,17 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ImageButton(
|
ImageButton(
|
||||||
bg: 'assets/images/bg_green.png',
|
bg: 'assets/images/bg_green.png',
|
||||||
text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande',
|
text: 'Soumettre ma demande',
|
||||||
textColor: const Color(0xFF2D6A4F),
|
textColor: const Color(0xFF2D6A4F),
|
||||||
width: 350,
|
width: 350,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
onPressed: () => _submitAMRegistration(registrationData),
|
onPressed: () {
|
||||||
|
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
||||||
|
_showConfirmationModal(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
@ -17,7 +16,7 @@ class LoginScreen extends StatefulWidget {
|
|||||||
State<LoginScreen> createState() => _LoginPageState();
|
State<LoginScreen> createState() => _LoginPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginScreen> {
|
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController();
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController();
|
final _passwordController = TextEditingController();
|
||||||
@ -27,30 +26,31 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
|
|
||||||
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();
|
||||||
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
final v = value ?? '';
|
if (value == null || value.isEmpty) {
|
||||||
if (v.isEmpty) {
|
|
||||||
return 'Veuillez entrer votre email';
|
return 'Veuillez entrer votre email';
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||||
return 'Veuillez entrer un email valide';
|
return 'Veuillez entrer un email valide';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -63,11 +63,6 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
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
|
||||||
@ -111,11 +106,8 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
|
|
||||||
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.isEmpty ? null : user.role);
|
_redirectUserByRole(user.role);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@ -125,16 +117,13 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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);
|
||||||
final r = (role ?? '').toLowerCase();
|
switch (role.toLowerCase()) {
|
||||||
switch (r) {
|
|
||||||
case 'super_admin':
|
case 'super_admin':
|
||||||
case 'administrateur':
|
case 'administrateur':
|
||||||
context.go('/admin-dashboard');
|
|
||||||
break;
|
|
||||||
case 'gestionnaire':
|
case 'gestionnaire':
|
||||||
context.go('/gestionnaire-dashboard');
|
context.go('/admin-dashboard');
|
||||||
break;
|
break;
|
||||||
case 'parent':
|
case 'parent':
|
||||||
context.go('/parent-dashboard');
|
context.go('/parent-dashboard');
|
||||||
@ -162,50 +151,48 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final w = constraints.maxWidth;
|
final w = constraints.maxWidth;
|
||||||
final h = constraints.maxHeight;
|
final h = constraints.maxHeight;
|
||||||
return FutureBuilder<ImageDimensions>(
|
return FutureBuilder(
|
||||||
future: _desktopRiverLogoDimensionsFuture,
|
future: _getImageDimensions(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageDimensions = snapshot.data!;
|
final imageDimensions = snapshot.data!;
|
||||||
final imageHeight = h;
|
final imageHeight = h;
|
||||||
final imageWidth = imageHeight *
|
final imageWidth = imageHeight * (imageDimensions.width / imageDimensions.height);
|
||||||
(imageDimensions.width / imageDimensions.height);
|
final remainingWidth = w - imageWidth;
|
||||||
final remainingWidth = w - imageWidth;
|
final leftMargin = remainingWidth / 4;
|
||||||
final leftMargin = remainingWidth / 4;
|
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// Fond en papier
|
// Fond en papier
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/images/paper2.png',
|
'assets/images/paper2.png',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
repeat: ImageRepeat.repeat,
|
repeat: ImageRepeat.repeat,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// Image principale
|
||||||
// Image principale
|
Positioned(
|
||||||
Positioned(
|
left: leftMargin,
|
||||||
left: leftMargin,
|
top: 0,
|
||||||
top: 0,
|
height: imageHeight,
|
||||||
height: imageHeight,
|
width: imageWidth,
|
||||||
width: imageWidth,
|
child: Image.asset(
|
||||||
child: Image.asset(
|
'assets/images/river_logo_desktop.png',
|
||||||
'assets/images/river_logo_desktop.png',
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.contain,
|
),
|
||||||
),
|
),
|
||||||
),
|
// Formulaire dans le cadran en bas à droite
|
||||||
// Formulaire dans le cadran en bas à droite
|
Positioned(
|
||||||
Positioned(
|
right: 0,
|
||||||
right: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
width: w * 0.6, // 60% de la largeur de l'écran
|
||||||
width: w * 0.6, // 60% de la largeur de l'écran
|
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(
|
||||||
@ -220,12 +207,6 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
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,
|
||||||
@ -239,12 +220,6 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
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,
|
||||||
@ -267,8 +242,7 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.error_outline,
|
Icon(Icons.error_outline, color: Colors.red[700], size: 20),
|
||||||
color: Colors.red[700], size: 20),
|
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -335,68 +309,67 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 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,
|
right: 0,
|
||||||
right: 0,
|
bottom: 0,
|
||||||
bottom: 0,
|
child: Container(
|
||||||
child: Container(
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
decoration: const BoxDecoration(
|
||||||
decoration: const BoxDecoration(
|
color: Colors.transparent,
|
||||||
color: Colors.transparent,
|
),
|
||||||
),
|
child: Wrap(
|
||||||
child: Wrap(
|
alignment: WrapAlignment.center,
|
||||||
alignment: WrapAlignment.center,
|
runSpacing: 8,
|
||||||
runSpacing: 8,
|
children: [
|
||||||
children: [
|
_FooterLink(
|
||||||
_FooterLink(
|
text: 'Contact support',
|
||||||
text: 'Contact support',
|
onTap: () async {
|
||||||
onTap: () async {
|
final Uri emailLaunchUri = Uri(
|
||||||
final Uri emailLaunchUri = Uri(
|
scheme: 'mailto',
|
||||||
scheme: 'mailto',
|
path: 'support@supernounou.local',
|
||||||
path: 'support@supernounou.local',
|
|
||||||
);
|
|
||||||
if (await canLaunchUrl(emailLaunchUri)) {
|
|
||||||
await launchUrl(emailLaunchUri);
|
|
||||||
} else {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Impossible d\'ouvrir le client mail',
|
|
||||||
style: GoogleFonts.merienda(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
if (await canLaunchUrl(emailLaunchUri)) {
|
||||||
},
|
await launchUrl(emailLaunchUri);
|
||||||
),
|
} else {
|
||||||
_FooterLink(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
text: 'Signaler un bug',
|
SnackBar(
|
||||||
onTap: () {
|
content: Text(
|
||||||
_showBugReportDialog(context);
|
'Impossible d\'ouvrir le client mail',
|
||||||
},
|
style: GoogleFonts.merienda(),
|
||||||
),
|
),
|
||||||
_FooterLink(
|
),
|
||||||
text: 'Mentions légales',
|
);
|
||||||
onTap: () {
|
}
|
||||||
context.go('/legal');
|
},
|
||||||
},
|
),
|
||||||
),
|
_FooterLink(
|
||||||
_FooterLink(
|
text: 'Signaler un bug',
|
||||||
text: 'Politique de confidentialité',
|
onTap: () {
|
||||||
onTap: () {
|
_showBugReportDialog(context);
|
||||||
context.go('/privacy');
|
},
|
||||||
},
|
),
|
||||||
),
|
_FooterLink(
|
||||||
],
|
text: 'Mentions légales',
|
||||||
|
onTap: () {
|
||||||
|
context.go('/legal');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_FooterLink(
|
||||||
|
text: 'Politique de confidentialité',
|
||||||
|
onTap: () {
|
||||||
|
context.go('/privacy');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
);
|
||||||
);
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -405,7 +378,6 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
/// 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;
|
||||||
@ -416,8 +388,7 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
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 =
|
final formTop = w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
||||||
w * imageAspectRatio * _sloganEndFraction + _gapBelowSlogan;
|
|
||||||
return Stack(
|
return Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@ -457,115 +428,95 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||||
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(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
CustomAppTextField(
|
||||||
CustomAppTextField(
|
controller: _emailController,
|
||||||
controller: _emailController,
|
labelText: 'Email',
|
||||||
labelText: 'Email',
|
showLabel: false,
|
||||||
showLabel: false,
|
hintText: 'Votre adresse email',
|
||||||
hintText: 'Votre adresse email',
|
validator: _validateEmail,
|
||||||
keyboardType: TextInputType.emailAddress,
|
style: CustomAppTextFieldStyle.lavande,
|
||||||
autofillHints: const [
|
fieldHeight: 48,
|
||||||
AutofillHints.username,
|
fieldWidth: double.infinity,
|
||||||
AutofillHints.email,
|
),
|
||||||
],
|
const SizedBox(height: 12),
|
||||||
textInputAction: TextInputAction.next,
|
CustomAppTextField(
|
||||||
validator: _validateEmail,
|
controller: _passwordController,
|
||||||
style: CustomAppTextFieldStyle.lavande,
|
labelText: 'Mot de passe',
|
||||||
fieldHeight: 48,
|
showLabel: false,
|
||||||
fieldWidth: double.infinity,
|
hintText: 'Votre mot de passe',
|
||||||
),
|
obscureText: true,
|
||||||
const SizedBox(height: 12),
|
validator: _validatePassword,
|
||||||
CustomAppTextField(
|
style: CustomAppTextFieldStyle.jaune,
|
||||||
controller: _passwordController,
|
fieldHeight: 48,
|
||||||
labelText: 'Mot de passe',
|
fieldWidth: double.infinity,
|
||||||
showLabel: false,
|
),
|
||||||
hintText: 'Votre mot de passe',
|
if (_errorMessage != null) ...[
|
||||||
obscureText: true,
|
const SizedBox(height: 12),
|
||||||
autofillHints: const [
|
Container(
|
||||||
AutofillHints.password
|
padding: const EdgeInsets.all(12),
|
||||||
],
|
decoration: BoxDecoration(
|
||||||
textInputAction: TextInputAction.done,
|
color: Colors.red.shade50,
|
||||||
onFieldSubmitted: _handlePasswordSubmitted,
|
borderRadius: BorderRadius.circular(10),
|
||||||
validator: _validatePassword,
|
border: Border.all(color: Colors.red.shade300),
|
||||||
style: CustomAppTextFieldStyle.jaune,
|
),
|
||||||
fieldHeight: 48,
|
child: Row(
|
||||||
fieldWidth: double.infinity,
|
children: [
|
||||||
),
|
Icon(Icons.error_outline, color: Colors.red.shade700, size: 20),
|
||||||
if (_errorMessage != null) ...[
|
const SizedBox(width: 10),
|
||||||
const SizedBox(height: 12),
|
Expanded(
|
||||||
Container(
|
child: Text(
|
||||||
padding: const EdgeInsets.all(12),
|
_errorMessage!,
|
||||||
decoration: BoxDecoration(
|
style: GoogleFonts.merienda(fontSize: 12, color: Colors.red.shade700),
|
||||||
color: Colors.red.shade50,
|
),
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.red.shade300),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.error_outline,
|
|
||||||
color: Colors.red.shade700,
|
|
||||||
size: 20),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
_errorMessage!,
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.red.shade700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_isLoading
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: ImageButton(
|
|
||||||
bg: 'assets/images/bg_green.png',
|
|
||||||
width: double.infinity,
|
|
||||||
height: 44,
|
|
||||||
text: 'Se connecter',
|
|
||||||
textColor: const Color(0xFF2D6A4F),
|
|
||||||
onPressed: _handleLogin,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {/* TODO */},
|
|
||||||
child: Text(
|
|
||||||
'Mot de passe oublié ?',
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 14,
|
|
||||||
color: const Color(0xFF2D6A4F),
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () =>
|
|
||||||
context.go('/register-choice'),
|
|
||||||
child: Text(
|
|
||||||
'Créer un compte',
|
|
||||||
style: GoogleFonts.merienda(
|
|
||||||
fontSize: 16,
|
|
||||||
color: const Color(0xFF2D6A4F),
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_isLoading
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: ImageButton(
|
||||||
|
bg: 'assets/images/bg_green.png',
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
text: 'Se connecter',
|
||||||
|
textColor: const Color(0xFF2D6A4F),
|
||||||
|
onPressed: _handleLogin,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () { /* TODO */ },
|
||||||
|
child: Text(
|
||||||
|
'Mot de passe oublié ?',
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 14,
|
||||||
|
color: const Color(0xFF2D6A4F),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go('/register-choice'),
|
||||||
|
child: Text(
|
||||||
|
'Créer un compte',
|
||||||
|
style: GoogleFonts.merienda(
|
||||||
|
fontSize: 16,
|
||||||
|
color: const Color(0xFF2D6A4F),
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -582,17 +533,12 @@ class _LoginPageState extends State<LoginScreen> {
|
|||||||
text: 'Contact support',
|
text: 'Contact support',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final uri = Uri(
|
final uri = Uri(scheme: 'mailto', path: 'support@supernounou.local');
|
||||||
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(
|
SnackBar(content: Text('Impossible d\'ouvrir le client mail', style: GoogleFonts.merienda())),
|
||||||
content: Text(
|
|
||||||
'Impossible d\'ouvrir le client mail',
|
|
||||||
style: GoogleFonts.merienda())),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,72 +0,0 @@
|
|||||||
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,12 +1,11 @@
|
|||||||
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';
|
||||||
@ -20,7 +19,6 @@ 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(() {
|
||||||
@ -31,18 +29,12 @@ 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:
|
||||||
@ -61,43 +53,29 @@ 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(
|
appBar: PreferredSize(preferredSize: const Size.fromHeight(60.0),
|
||||||
preferredSize: const Size.fromHeight(60.0),
|
child: Container(
|
||||||
child: DashboardBandeau(
|
decoration: BoxDecoration(
|
||||||
tabItems: const [
|
border: Border(
|
||||||
DashboardTabItem(label: 'Mon tableau de bord'),
|
bottom: BorderSide(color: Colors.grey.shade300),
|
||||||
DashboardTabItem(label: 'Trouver une nounou'),
|
),
|
||||||
DashboardTabItem(label: 'Paramètres'),
|
),
|
||||||
],
|
child: DashboardAppBar(
|
||||||
selectedTabIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
onTabSelected: onTabChange,
|
onTabChange: 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,14 +18,11 @@ 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 =
|
static const String configurationSetupComplete = '/configuration/setup/complete';
|
||||||
'/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';
|
||||||
|
|
||||||
@ -36,14 +33,14 @@ class ApiConfig {
|
|||||||
static const String conversations = '/conversations';
|
static const String conversations = '/conversations';
|
||||||
static const String notifications = '/notifications';
|
static const String notifications = '/notifications';
|
||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
static Map<String, String> get headers => {
|
static Map<String, String> get headers => {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
static Map<String, String> authHeaders(String token) => {
|
static Map<String, String> authHeaders(String token) => {
|
||||||
...headers,
|
...headers,
|
||||||
'Authorization': 'Bearer $token',
|
'Authorization': 'Bearer $token',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1,12 +1,9 @@
|
|||||||
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';
|
||||||
@ -136,73 +133,6 @@ 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();
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
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,8 +3,6 @@ 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';
|
||||||
|
|
||||||
@ -22,145 +20,6 @@ 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(
|
||||||
@ -170,87 +29,13 @@ 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(
|
throw Exception(_toStr(err?['message']) ?? 'Erreur chargement gestionnaires');
|
||||||
_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(
|
||||||
@ -268,8 +53,7 @@ class UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer la liste des assistantes maternelles
|
// Récupérer la liste des assistantes maternelles
|
||||||
static Future<List<AssistanteMaternelleModel>>
|
static Future<List<AssistanteMaternelleModel>> getAssistantesMaternelles() async {
|
||||||
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(),
|
||||||
@ -303,212 +87,8 @@ class UserService {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// On garde un fallback vide pour ne pas bloquer l'UI admin.
|
print('Erreur chargement admins: $e');
|
||||||
}
|
}
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
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),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
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,19 +1,9 @@
|
|||||||
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 {
|
||||||
final String searchQuery;
|
const AdminManagementWidget({super.key});
|
||||||
|
|
||||||
const AdminManagementWidget({
|
|
||||||
super.key,
|
|
||||||
required this.searchQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
State<AdminManagementWidget> createState() => _AdminManagementWidgetState();
|
||||||
@ -23,17 +13,21 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _admins = [];
|
List<AppUser> _admins = [];
|
||||||
String? _currentUserRole;
|
List<AppUser> _filteredAdmins = [];
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadCurrentUserRole();
|
|
||||||
_loadAdmins();
|
_loadAdmins();
|
||||||
|
_searchController.addListener(_onSearchChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() => super.dispose();
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadAdmins() async {
|
Future<void> _loadAdmins() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -45,6 +39,7 @@ 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) {
|
||||||
@ -56,100 +51,91 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCurrentUserRole() async {
|
void _onSearchChanged() {
|
||||||
final cached = await AuthService.getCurrentUser();
|
final query = _searchController.text.toLowerCase();
|
||||||
if (!mounted) return;
|
|
||||||
if (cached != null) {
|
|
||||||
setState(() {
|
|
||||||
_currentUserRole = (cached.role).toLowerCase();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final refreshed = await AuthService.refreshCurrentUser();
|
|
||||||
if (!mounted || refreshed == null) return;
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentUserRole = (refreshed.role).toLowerCase();
|
_filteredAdmins = _admins.where((u) {
|
||||||
|
final name = u.fullName.toLowerCase();
|
||||||
|
final email = u.email.toLowerCase();
|
||||||
|
return name.contains(query) || email.contains(query);
|
||||||
|
}).toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
return Padding(
|
||||||
final filteredAdmins = _admins.where((u) {
|
padding: const EdgeInsets.all(16),
|
||||||
final name = u.fullName.toLowerCase();
|
child: Column(
|
||||||
final email = u.email.toLowerCase();
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
return name.contains(query) || email.contains(query);
|
children: [
|
||||||
}).toList();
|
Row(
|
||||||
|
children: [
|
||||||
return UserList(
|
Expanded(
|
||||||
isLoading: _isLoading,
|
child: TextField(
|
||||||
error: _error,
|
controller: _searchController,
|
||||||
isEmpty: filteredAdmins.isEmpty,
|
decoration: const InputDecoration(
|
||||||
emptyMessage: 'Aucun administrateur trouvé.',
|
hintText: "Rechercher un administrateur...",
|
||||||
itemCount: filteredAdmins.length,
|
prefixIcon: Icon(Icons.search),
|
||||||
itemBuilder: (context, index) {
|
border: OutlineInputBorder(),
|
||||||
final user = filteredAdmins[index];
|
),
|
||||||
final isSuperAdmin = _isSuperAdmin(user);
|
),
|
||||||
final canEdit = _canEditAdmin(user);
|
|
||||||
return AdminUserCard(
|
|
||||||
title: user.fullName,
|
|
||||||
fallbackIcon: isSuperAdmin
|
|
||||||
? Icons.verified_user_outlined
|
|
||||||
: Icons.manage_accounts_outlined,
|
|
||||||
subtitleLines: [
|
|
||||||
user.email,
|
|
||||||
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
|
|
||||||
],
|
|
||||||
avatarUrl: user.photoUrl,
|
|
||||||
borderColor: isSuperAdmin
|
|
||||||
? const Color(0xFF8E6AC8)
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
backgroundColor: isSuperAdmin
|
|
||||||
? const Color(0xFFF4EEFF)
|
|
||||||
: Colors.white,
|
|
||||||
titleColor: isSuperAdmin ? const Color(0xFF5D2F99) : null,
|
|
||||||
infoColor: isSuperAdmin
|
|
||||||
? const Color(0xFF6D4EA1)
|
|
||||||
: Colors.black54,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
canEdit ? Icons.edit_outlined : Icons.visibility_outlined,
|
|
||||||
),
|
),
|
||||||
tooltip: canEdit ? 'Modifier' : 'Consulter',
|
const SizedBox(width: 16),
|
||||||
onPressed: () {
|
ElevatedButton.icon(
|
||||||
_openAdminEditDialog(user);
|
onPressed: () {
|
||||||
},
|
// TODO: Créer admin
|
||||||
),
|
},
|
||||||
],
|
icon: const Icon(Icons.add),
|
||||||
);
|
label: const Text("Créer un admin"),
|
||||||
},
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else if (_error != null)
|
||||||
|
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||||
|
else if (_filteredAdmins.isEmpty)
|
||||||
|
const Center(child: Text("Aucun administrateur trouvé."))
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: _filteredAdmins.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final user = _filteredAdmins[index];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
child: Text(user.fullName.isNotEmpty
|
||||||
|
? user.fullName[0].toUpperCase()
|
||||||
|
: 'A'),
|
||||||
|
),
|
||||||
|
title: Text(user.fullName.isNotEmpty
|
||||||
|
? user.fullName
|
||||||
|
: 'Sans nom'),
|
||||||
|
subtitle: Text(user.email),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
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 {
|
||||||
final String searchQuery;
|
const AssistanteMaternelleManagementWidget({super.key});
|
||||||
final int? capacityMin;
|
|
||||||
|
|
||||||
const AssistanteMaternelleManagementWidget({
|
|
||||||
super.key,
|
|
||||||
required this.searchQuery,
|
|
||||||
this.capacityMin,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AssistanteMaternelleManagementWidget> createState() =>
|
State<AssistanteMaternelleManagementWidget> createState() =>
|
||||||
@ -26,15 +15,25 @@ 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() => super.dispose();
|
void dispose() {
|
||||||
|
_zoneController.dispose();
|
||||||
|
_capacityController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadAssistantes() async {
|
Future<void> _loadAssistantes() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -46,6 +45,7 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_assistantes = list;
|
_assistantes = list;
|
||||||
|
_filter();
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -57,100 +57,117 @@ class _AssistanteMaternelleManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _filter() {
|
||||||
Widget build(BuildContext context) {
|
final zoneQuery = _zoneController.text.toLowerCase();
|
||||||
final query = widget.searchQuery.toLowerCase();
|
final capacityQuery = int.tryParse(_capacityController.text);
|
||||||
final filteredAssistantes = _assistantes.where((am) {
|
|
||||||
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
|
|
||||||
am.user.email.toLowerCase().contains(query) ||
|
|
||||||
(am.residenceCity?.toLowerCase().contains(query) ?? false);
|
|
||||||
final matchesCapacity = widget.capacityMin == null ||
|
|
||||||
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
|
|
||||||
return matchesName && matchesCapacity;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return UserList(
|
setState(() {
|
||||||
isLoading: _isLoading,
|
_filteredAssistantes = _assistantes.where((am) {
|
||||||
error: _error,
|
final matchesZone = zoneQuery.isEmpty ||
|
||||||
isEmpty: filteredAssistantes.isEmpty,
|
(am.residenceCity?.toLowerCase().contains(zoneQuery) ?? false);
|
||||||
emptyMessage: 'Aucune assistante maternelle trouvée.',
|
final matchesCapacity = capacityQuery == null ||
|
||||||
itemCount: filteredAssistantes.length,
|
(am.maxChildren != null && am.maxChildren! >= capacityQuery);
|
||||||
itemBuilder: (context, index) {
|
return matchesZone && matchesCapacity;
|
||||||
final assistante = filteredAssistantes[index];
|
}).toList();
|
||||||
return AdminUserCard(
|
});
|
||||||
title: assistante.user.fullName,
|
|
||||||
avatarUrl: assistante.user.photoUrl,
|
|
||||||
fallbackIcon: Icons.face,
|
|
||||||
subtitleLines: [
|
|
||||||
assistante.user.email,
|
|
||||||
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
tooltip: 'Modifier',
|
|
||||||
onPressed: () {
|
|
||||||
_openAssistanteDetails(assistante);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
@override
|
||||||
showDialog<void>(
|
Widget build(BuildContext context) {
|
||||||
context: context,
|
return Padding(
|
||||||
builder: (context) => AdminDetailModal(
|
padding: const EdgeInsets.all(16),
|
||||||
title: assistante.user.fullName.isEmpty
|
child: Column(
|
||||||
? 'Assistante maternelle'
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
: assistante.user.fullName,
|
children: [
|
||||||
subtitle: assistante.user.email,
|
// 🔎 Zone de filtre
|
||||||
fields: [
|
_buildFilterSection(),
|
||||||
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
|
|
||||||
AdminDetailField(
|
const SizedBox(height: 16),
|
||||||
label: 'Numero agrement',
|
|
||||||
value: _v(assistante.approvalNumber),
|
// 📋 Liste des assistantes
|
||||||
),
|
if (_isLoading)
|
||||||
AdminDetailField(
|
const Center(child: CircularProgressIndicator())
|
||||||
label: 'Ville residence',
|
else if (_error != null)
|
||||||
value: _v(assistante.residenceCity),
|
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||||
),
|
else if (_filteredAssistantes.isEmpty)
|
||||||
AdminDetailField(
|
const Center(child: Text("Aucune assistante maternelle trouvée."))
|
||||||
label: 'Capacite max',
|
else
|
||||||
value: assistante.maxChildren?.toString() ?? '-',
|
Expanded(
|
||||||
),
|
child: ListView.builder(
|
||||||
AdminDetailField(
|
itemCount: _filteredAssistantes.length,
|
||||||
label: 'Places disponibles',
|
itemBuilder: (context, index) {
|
||||||
value: assistante.placesAvailable?.toString() ?? '-',
|
final assistante = _filteredAssistantes[index];
|
||||||
),
|
return Card(
|
||||||
AdminDetailField(
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
label: 'Telephone',
|
child: ListTile(
|
||||||
value: _v(assistante.user.telephone) != '–' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '–',
|
leading: CircleAvatar(
|
||||||
),
|
backgroundImage: assistante.user.photoUrl != null
|
||||||
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
|
? NetworkImage(assistante.user.photoUrl!)
|
||||||
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
|
: null,
|
||||||
AdminDetailField(
|
child: assistante.user.photoUrl == null
|
||||||
label: 'Code postal',
|
? const Icon(Icons.face)
|
||||||
value: _v(assistante.user.codePostal),
|
: null,
|
||||||
),
|
),
|
||||||
|
title: Text(assistante.user.fullName.isNotEmpty
|
||||||
|
? assistante.user.fullName
|
||||||
|
: 'Sans nom'),
|
||||||
|
subtitle: Text(
|
||||||
|
"N° Agrément : ${assistante.approvalNumber ?? 'N/A'}\nZone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}"),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Ajouter modification
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Ajouter suppression
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
onEdit: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Action Modifier a implementer')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDelete: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('Action Supprimer a implementer')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
Widget _buildFilterSection() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: TextField(
|
||||||
|
controller: _zoneController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Zone géographique",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.location_on),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: TextField(
|
||||||
|
controller: _capacityController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Capacité minimum",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
|
|
||||||
|
|
||||||
class UserList extends StatelessWidget {
|
|
||||||
final bool isLoading;
|
|
||||||
final String? error;
|
|
||||||
final bool isEmpty;
|
|
||||||
final String emptyMessage;
|
|
||||||
final int itemCount;
|
|
||||||
final Widget Function(BuildContext context, int index) itemBuilder;
|
|
||||||
final EdgeInsetsGeometry padding;
|
|
||||||
|
|
||||||
const UserList({
|
|
||||||
super.key,
|
|
||||||
required this.isLoading,
|
|
||||||
required this.error,
|
|
||||||
required this.isEmpty,
|
|
||||||
required this.emptyMessage,
|
|
||||||
required this.itemCount,
|
|
||||||
required this.itemBuilder,
|
|
||||||
this.padding = const EdgeInsets.all(16),
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: padding,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AdminListState(
|
|
||||||
isLoading: isLoading,
|
|
||||||
error: error,
|
|
||||||
isEmpty: isEmpty,
|
|
||||||
emptyMessage: emptyMessage,
|
|
||||||
list: ListView.builder(
|
|
||||||
itemCount: itemCount,
|
|
||||||
itemBuilder: itemBuilder,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
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,138 +1,140 @@
|
|||||||
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';
|
||||||
|
|
||||||
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
|
/// Barre du dashboard admin : onglets Gestion des utilisateurs | Paramètres + déconnexion.
|
||||||
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
|
class DashboardAppBarAdmin extends StatelessWidget implements PreferredSizeWidget {
|
||||||
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
|
final int selectedIndex;
|
||||||
class DashboardUserManagementSubBar extends StatelessWidget {
|
final ValueChanged<int> onTabChange;
|
||||||
final int selectedSubIndex;
|
final bool setupCompleted;
|
||||||
final ValueChanged<int> onSubTabChange;
|
|
||||||
final TextEditingController searchController;
|
|
||||||
final String searchHint;
|
|
||||||
final Widget? filterControl;
|
|
||||||
final VoidCallback? onAddPressed;
|
|
||||||
final String addLabel;
|
|
||||||
final int subTabCount;
|
|
||||||
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
|
|
||||||
final List<String>? tabLabels;
|
|
||||||
|
|
||||||
static const List<String> _defaultTabLabels = [
|
const DashboardAppBarAdmin({
|
||||||
'Gestionnaires',
|
|
||||||
'Parents',
|
|
||||||
'Assistantes maternelles',
|
|
||||||
'Administrateurs',
|
|
||||||
];
|
|
||||||
|
|
||||||
const DashboardUserManagementSubBar({
|
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedSubIndex,
|
required this.selectedIndex,
|
||||||
required this.onSubTabChange,
|
required this.onTabChange,
|
||||||
required this.searchController,
|
this.setupCompleted = true,
|
||||||
required this.searchHint,
|
|
||||||
this.filterControl,
|
|
||||||
this.onAddPressed,
|
|
||||||
this.addLabel = '+ Ajouter',
|
|
||||||
this.subTabCount = 4,
|
|
||||||
this.tabLabels,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight + 10);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final labels = _labels;
|
return AppBar(
|
||||||
return Container(
|
elevation: 0,
|
||||||
height: 56,
|
automaticallyImplyLeading: false,
|
||||||
decoration: BoxDecoration(
|
title: Row(
|
||||||
color: Colors.grey.shade100,
|
|
||||||
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < labels.length; i++) ...[
|
const SizedBox(width: 24),
|
||||||
if (i > 0) const SizedBox(width: 12),
|
Image.asset(
|
||||||
_buildSubNavItem(context, labels[i], i),
|
'assets/images/logo.png',
|
||||||
],
|
height: 40,
|
||||||
const SizedBox(width: 36),
|
fit: BoxFit.contain,
|
||||||
_pillField(
|
),
|
||||||
width: 320,
|
Expanded(
|
||||||
child: TextField(
|
child: Center(
|
||||||
controller: searchController,
|
child: Row(
|
||||||
decoration: InputDecoration(
|
mainAxisSize: MainAxisSize.min,
|
||||||
hintText: searchHint,
|
children: [
|
||||||
prefixIcon: const Icon(Icons.search, size: 18),
|
_buildNavItem(context, 'Gestion des utilisateurs', 0, enabled: setupCompleted),
|
||||||
border: InputBorder.none,
|
const SizedBox(width: 24),
|
||||||
isDense: true,
|
_buildNavItem(context, 'Paramètres', 1, enabled: 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),
|
||||||
Widget _pillField({required double width, required Widget child}) {
|
child: Center(
|
||||||
return Container(
|
child: Text(
|
||||||
width: width,
|
'Admin',
|
||||||
height: 34,
|
style: TextStyle(
|
||||||
decoration: BoxDecoration(
|
color: Colors.black,
|
||||||
color: Colors.white,
|
fontSize: 16,
|
||||||
borderRadius: BorderRadius.circular(18),
|
fontWeight: FontWeight.w500,
|
||||||
border: Border.all(color: Colors.black26),
|
),
|
||||||
),
|
),
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAddButton() {
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
onPressed: onAddPressed,
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: Text(addLabel),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSubNavItem(BuildContext context, String title, int index) {
|
|
||||||
final bool isActive = index == selectedSubIndex;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onSubTabChange(index),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: isActive ? null : Border.all(color: Colors.black26),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isActive ? Colors.white : Colors.black87,
|
|
||||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 16),
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => _handleLogout(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF9CC5C0),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Se déconnecter'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNavItem(BuildContext context, String title, int index, {bool enabled = true}) {
|
||||||
|
final bool isActive = index == selectedIndex;
|
||||||
|
return InkWell(
|
||||||
|
onTap: enabled ? () => onTabChange(index) : null,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: enabled ? 1.0 : 0.5,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: isActive ? null : Border.all(color: Colors.black26),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isActive ? Colors.white : Colors.black,
|
||||||
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Paramètres : Paramètres généraux | Paramètres territoriaux.
|
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | Administrateurs.
|
||||||
class DashboardSettingsSubBar extends StatelessWidget {
|
class DashboardUserManagementSubBar extends StatelessWidget {
|
||||||
final int selectedSubIndex;
|
final int selectedSubIndex;
|
||||||
final ValueChanged<int> onSubTabChange;
|
final ValueChanged<int> onSubTabChange;
|
||||||
|
|
||||||
const DashboardSettingsSubBar({
|
const DashboardUserManagementSubBar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.selectedSubIndex,
|
required this.selectedSubIndex,
|
||||||
required this.onSubTabChange,
|
required this.onSubTabChange,
|
||||||
@ -151,9 +153,13 @@ class DashboardSettingsSubBar extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildSubNavItem(context, 'Paramètres généraux', 0),
|
_buildSubNavItem(context, 'Gestionnaires', 0),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
|
_buildSubNavItem(context, 'Parents', 1),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildSubNavItem(context, 'Assistantes maternelles', 2),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildSubNavItem(context, 'Administrateurs', 3),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
75
frontend/lib/widgets/admin/gestionnaire_card.dart
Normal file
75
frontend/lib/widgets/admin/gestionnaire_card.dart
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
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,17 +1,10 @@
|
|||||||
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/common/admin_user_card.dart';
|
import 'package:p_tits_pas/widgets/admin/gestionnaire_card.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
|
|
||||||
|
|
||||||
class GestionnaireManagementWidget extends StatefulWidget {
|
class GestionnaireManagementWidget extends StatefulWidget {
|
||||||
final String searchQuery;
|
const GestionnaireManagementWidget({Key? key}) : super(key: key);
|
||||||
|
|
||||||
const GestionnaireManagementWidget({
|
|
||||||
Key? key,
|
|
||||||
required this.searchQuery,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GestionnaireManagementWidget> createState() =>
|
State<GestionnaireManagementWidget> createState() =>
|
||||||
@ -23,15 +16,21 @@ 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() => super.dispose();
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadGestionnaires() async {
|
Future<void> _loadGestionnaires() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -39,10 +38,11 @@ class _GestionnaireManagementWidgetState
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final gestionnaires = await UserService.getGestionnaires();
|
final list = await UserService.getGestionnaires();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_gestionnaires = gestionnaires;
|
_gestionnaires = list;
|
||||||
|
_filteredGestionnaires = list;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -54,56 +54,71 @@ class _GestionnaireManagementWidgetState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openGestionnaireEditDialog(AppUser user) async {
|
void _onSearchChanged() {
|
||||||
final changed = await showDialog<bool>(
|
final query = _searchController.text.toLowerCase();
|
||||||
context: context,
|
setState(() {
|
||||||
barrierDismissible: false,
|
_filteredGestionnaires = _gestionnaires.where((u) {
|
||||||
builder: (dialogContext) {
|
final name = u.fullName.toLowerCase();
|
||||||
return AdminUserFormDialog(initialUser: user);
|
final email = u.email.toLowerCase();
|
||||||
},
|
return name.contains(query) || email.contains(query);
|
||||||
);
|
}).toList();
|
||||||
if (changed == true) {
|
});
|
||||||
await _loadGestionnaires();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
return Padding(
|
||||||
final filteredGestionnaires = _gestionnaires.where((u) {
|
padding: const EdgeInsets.all(16),
|
||||||
final name = u.fullName.toLowerCase();
|
child: Column(
|
||||||
final email = u.email.toLowerCase();
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
return name.contains(query) || email.contains(query);
|
children: [
|
||||||
}).toList();
|
// 🔹 Barre du haut avec bouton
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: "Rechercher un gestionnaire...",
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Rediriger vers la page de création
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text("Créer un gestionnaire"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
return UserList(
|
// 🔹 Liste des gestionnaires
|
||||||
isLoading: _isLoading,
|
if (_isLoading)
|
||||||
error: _error,
|
const Center(child: CircularProgressIndicator())
|
||||||
isEmpty: filteredGestionnaires.isEmpty,
|
else if (_error != null)
|
||||||
emptyMessage: 'Aucun gestionnaire trouvé.',
|
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||||
itemCount: filteredGestionnaires.length,
|
else if (_filteredGestionnaires.isEmpty)
|
||||||
itemBuilder: (context, index) {
|
const Center(child: Text("Aucun gestionnaire trouvé."))
|
||||||
final user = filteredGestionnaires[index];
|
else
|
||||||
return AdminUserCard(
|
Expanded(
|
||||||
title: user.fullName,
|
child: ListView.builder(
|
||||||
fallbackIcon: Icons.assignment_ind_outlined,
|
itemCount: _filteredGestionnaires.length,
|
||||||
avatarUrl: user.photoUrl,
|
itemBuilder: (context, index) {
|
||||||
subtitleLines: [
|
final user = _filteredGestionnaires[index];
|
||||||
user.email,
|
return GestionnaireCard(
|
||||||
'Statut : ${user.statut ?? 'Inconnu'}',
|
name: user.fullName.isNotEmpty ? user.fullName : "Sans nom",
|
||||||
'Relais : ${user.relaisNom ?? 'Non rattaché'}',
|
email: user.email,
|
||||||
],
|
);
|
||||||
actions: [
|
},
|
||||||
IconButton(
|
),
|
||||||
icon: const Icon(Icons.edit),
|
)
|
||||||
tooltip: 'Modifier',
|
],
|
||||||
onPressed: () {
|
),
|
||||||
_openGestionnaireEditDialog(user);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,13 @@
|
|||||||
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({
|
const ParametresPanel({super.key, this.redirectToLoginAfterSave = false});
|
||||||
super.key,
|
|
||||||
this.redirectToLoginAfterSave = false,
|
|
||||||
this.selectedSettingsTabIndex = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParametresPanel> createState() => _ParametresPanelState();
|
State<ParametresPanel> createState() => _ParametresPanelState();
|
||||||
@ -39,18 +33,10 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
|
|
||||||
void _createControllers() {
|
void _createControllers() {
|
||||||
final keys = [
|
final keys = [
|
||||||
'smtp_host',
|
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_password',
|
||||||
'smtp_port',
|
'email_from_name', 'email_from_address',
|
||||||
'smtp_user',
|
'app_name', 'app_url', 'app_logo_url',
|
||||||
'smtp_password',
|
'password_reset_token_expiry_days', 'jwt_expiry_hours', 'max_upload_size_mb',
|
||||||
'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();
|
||||||
@ -107,29 +93,18 @@ 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 != '***********') {
|
if (pwd.isNotEmpty && pwd != '***********') payload['smtp_password'] = 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'] =
|
payload['email_from_address'] = _controllers['email_from_address']!.text.trim();
|
||||||
_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(
|
final tokenDays = int.tryParse(_controllers['password_reset_token_expiry_days']!.text.trim());
|
||||||
_controllers['password_reset_token_expiry_days']!.text.trim());
|
if (tokenDays != null) payload['password_reset_token_expiry_days'] = tokenDays;
|
||||||
if (tokenDays != null) {
|
final jwtHours = int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
|
||||||
payload['password_reset_token_expiry_days'] = tokenDays;
|
if (jwtHours != null) payload['jwt_expiry_hours'] = jwtHours;
|
||||||
}
|
|
||||||
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) {
|
if (maxMb != null) payload['max_upload_size_mb'] = maxMb;
|
||||||
payload['max_upload_size_mb'] = maxMb;
|
|
||||||
}
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,10 +191,6 @@ 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());
|
||||||
}
|
}
|
||||||
@ -243,8 +214,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isSuccess = _message != null &&
|
final isSuccess = _message != null &&
|
||||||
(_message!.startsWith('Configuration') ||
|
(_message!.startsWith('Configuration') || _message!.startsWith('Connexion'));
|
||||||
_message!.startsWith('Connexion'));
|
|
||||||
|
|
||||||
return Form(
|
return Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
@ -264,21 +234,12 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
title: 'Configuration Email (SMTP)',
|
title: 'Configuration Email (SMTP)',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField(
|
_buildField('smtp_host', 'Serveur SMTP', hint: 'mail.example.com'),
|
||||||
'smtp_host',
|
|
||||||
'Serveur SMTP',
|
|
||||||
hint: 'mail.example.com',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('smtp_port', 'Port SMTP', keyboard: TextInputType.number, hint: '25, 465, 587'),
|
||||||
'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),
|
||||||
@ -286,17 +247,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Checkbox(
|
||||||
value: _smtpSecure,
|
value: _smtpSecure,
|
||||||
onChanged: (v) =>
|
onChanged: (v) => setState(() => _smtpSecure = v ?? false),
|
||||||
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(
|
onChanged: (v) => setState(() => _smtpAuthRequired = v ?? false),
|
||||||
() => _smtpAuthRequired = v ?? false,
|
|
||||||
),
|
|
||||||
activeColor: const Color(0xFF9CC5C0),
|
activeColor: const Color(0xFF9CC5C0),
|
||||||
),
|
),
|
||||||
const Text('Authentification requise'),
|
const Text('Authentification requise'),
|
||||||
@ -305,19 +263,11 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
),
|
),
|
||||||
_buildField('smtp_user', 'Utilisateur SMTP'),
|
_buildField('smtp_user', 'Utilisateur SMTP'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('smtp_password', 'Mot de passe SMTP', obscure: true),
|
||||||
'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(
|
_buildField('email_from_address', 'Email expéditeur', hint: 'no-reply@example.com'),
|
||||||
'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,
|
||||||
@ -327,13 +277,8 @@ 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(
|
side: const BorderSide(color: Color(0xFF9CC5C0)),
|
||||||
color: Color(0xFF9CC5C0),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -345,22 +290,14 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
context,
|
context,
|
||||||
icon: Icons.palette_outlined,
|
icon: Icons.palette_outlined,
|
||||||
title: 'Personnalisation',
|
title: 'Personnalisation',
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField('app_name', 'Nom de l\'application'),
|
_buildField('app_name', 'Nom de l\'application'),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('app_url', 'URL de l\'application', hint: 'https://app.example.com'),
|
||||||
'app_url',
|
|
||||||
'URL de l\'application',
|
|
||||||
hint: 'https://app.example.com',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('app_logo_url', 'URL du logo', hint: '/assets/logo.png'),
|
||||||
'app_logo_url',
|
|
||||||
'URL du logo',
|
|
||||||
hint: '/assets/logo.png',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -372,23 +309,11 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildField(
|
_buildField('password_reset_token_expiry_days', 'Validité token MDP (jours)', keyboard: TextInputType.number),
|
||||||
'password_reset_token_expiry_days',
|
|
||||||
'Validité token MDP (jours)',
|
|
||||||
keyboard: TextInputType.number,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('jwt_expiry_hours', 'Validité session JWT (heures)', keyboard: TextInputType.number),
|
||||||
'jwt_expiry_hours',
|
|
||||||
'Validité session JWT (heures)',
|
|
||||||
keyboard: TextInputType.number,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
_buildField(
|
_buildField('max_upload_size_mb', 'Taille max upload (MB)', keyboard: TextInputType.number),
|
||||||
'max_upload_size_mb',
|
|
||||||
'Taille max upload (MB)',
|
|
||||||
keyboard: TextInputType.number,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -402,14 +327,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
),
|
),
|
||||||
child: _isSaving
|
child: _isSaving
|
||||||
? const SizedBox(
|
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('Sauvegarder la configuration'),
|
: const Text('Sauvegarder la configuration'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -421,8 +339,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionCard(BuildContext context,
|
Widget _buildSectionCard(BuildContext context, {required IconData icon, required String title, required Widget child}) {
|
||||||
{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)),
|
||||||
@ -452,8 +369,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildField(String key, String label,
|
Widget _buildField(String key, String label, {bool obscure = false, TextInputType? keyboard, String? hint}) {
|
||||||
{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(
|
||||||
@ -465,8 +381,7 @@ class _ParametresPanelState extends State<ParametresPanel> {
|
|||||||
labelText: label,
|
labelText: label,
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
contentPadding:
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,9 @@
|
|||||||
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 {
|
||||||
final String searchQuery;
|
const ParentManagementWidget({super.key});
|
||||||
final String? statusFilter;
|
|
||||||
|
|
||||||
const ParentManagementWidget({
|
|
||||||
super.key,
|
|
||||||
required this.searchQuery,
|
|
||||||
this.statusFilter,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
|
||||||
@ -24,15 +13,23 @@ 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() => super.dispose();
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadParents() async {
|
Future<void> _loadParents() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -44,6 +41,7 @@ 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) {
|
||||||
@ -55,102 +53,139 @@ 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) {
|
||||||
final query = widget.searchQuery.toLowerCase();
|
return Padding(
|
||||||
final filteredParents = _parents.where((p) {
|
padding: const EdgeInsets.all(16),
|
||||||
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
|
child: Column(
|
||||||
p.user.email.toLowerCase().contains(query);
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
final matchesStatus =
|
children: [
|
||||||
widget.statusFilter == null || p.user.statut == widget.statusFilter;
|
_buildSearchSection(),
|
||||||
return matchesName && matchesStatus;
|
const SizedBox(height: 16),
|
||||||
}).toList();
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
return UserList(
|
else if (_error != null)
|
||||||
isLoading: _isLoading,
|
Center(child: Text('Erreur: $_error', style: const TextStyle(color: Colors.red)))
|
||||||
error: _error,
|
else if (_filteredParents.isEmpty)
|
||||||
isEmpty: filteredParents.isEmpty,
|
const Center(child: Text("Aucun parent trouvé."))
|
||||||
emptyMessage: 'Aucun parent trouvé.',
|
else
|
||||||
itemCount: filteredParents.length,
|
Expanded(
|
||||||
itemBuilder: (context, index) {
|
child: ListView.builder(
|
||||||
final parent = filteredParents[index];
|
itemCount: _filteredParents.length,
|
||||||
return AdminUserCard(
|
itemBuilder: (context, index) {
|
||||||
title: parent.user.fullName,
|
final parent = _filteredParents[index];
|
||||||
fallbackIcon: Icons.supervisor_account_outlined,
|
return Card(
|
||||||
avatarUrl: parent.user.photoUrl,
|
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||||
subtitleLines: [
|
child: ListTile(
|
||||||
parent.user.email,
|
leading: CircleAvatar(
|
||||||
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
|
backgroundImage: parent.user.photoUrl != null
|
||||||
],
|
? NetworkImage(parent.user.photoUrl!)
|
||||||
actions: [
|
: null,
|
||||||
IconButton(
|
child: parent.user.photoUrl == null
|
||||||
icon: const Icon(Icons.edit),
|
? const Icon(Icons.person)
|
||||||
tooltip: 'Modifier',
|
: null,
|
||||||
onPressed: () {
|
),
|
||||||
_openParentDetails(parent);
|
title: Text(parent.user.fullName.isNotEmpty
|
||||||
},
|
? parent.user.fullName
|
||||||
|
: 'Sans nom'),
|
||||||
|
subtitle: Text(
|
||||||
|
"${parent.user.email}\nStatut : ${parent.user.statut ?? 'Inconnu'} | Enfants : ${parent.childrenCount}",
|
||||||
|
),
|
||||||
|
isThreeLine: true,
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.visibility),
|
||||||
|
tooltip: "Voir dossier",
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Voir le statut du dossier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
tooltip: "Modifier",
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Modifier parent
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: "Supprimer",
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Supprimer compte
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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')),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
|
Widget _buildSearchSection() {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 16,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Nom du parent",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 220,
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: "Statut",
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
value: _selectedStatus,
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text("Tous")),
|
||||||
|
DropdownMenuItem(value: "actif", child: Text("Actif")),
|
||||||
|
DropdownMenuItem(value: "en_attente", child: Text("En attente")),
|
||||||
|
DropdownMenuItem(value: "suspendu", child: Text("Suspendu")),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_selectedStatus = value;
|
||||||
|
_filter();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,354 +0,0 @@
|
|||||||
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
Loading…
x
Reference in New Issue
Block a user