Compare commits
38 Commits
19b8be684f
...
2fa546e6b7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fa546e6b7 | |||
| 8636b16659 | |||
| 7e17e5ff8d | |||
| e8b6d906e6 | |||
| ae0be04964 | |||
| ca98821b3e | |||
| 447f3d4137 | |||
| 721f40599b | |||
| a9c6b9e15b | |||
| 38c003ef6f | |||
| 3dbddbb8c4 | |||
| f46740c6ab | |||
| 85bfef7a6b | |||
| b1a80f85c9 | |||
| 3c2ecdff7a | |||
| 8b83702bd2 | |||
| e713c05da1 | |||
| 51d279e341 | |||
| fffe8cd202 | |||
| 619e39219f | |||
| 6749f2025a | |||
| 119edbcfb4 | |||
| 33cc7a9191 | |||
| 10ebc77ba1 | |||
| f9477d3fbe | |||
| 4d37131301 | |||
| 04b910295c | |||
| c136f28f12 | |||
| 4b176b7083 | |||
| 00c42c7bee | |||
| 42c569e491 | |||
| d32d956b0e | |||
| 1fca0cf132 | |||
| b16dd4b55c | |||
| 8682421453 | |||
| dfe7daed14 | |||
| 11aa66feff | |||
| d23f3c9f4f |
18
.gitattributes
vendored
Normal file
18
.gitattributes
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Fins de ligne : toujours LF dans le dépôt (évite les conflits Linux/Windows)
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Fichiers binaires : pas de conversion
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
|
||||||
|
# Scripts shell : toujours LF
|
||||||
|
*.sh text eol=lf
|
||||||
109
backend/src/common/utils/nir.util.ts
Normal file
109
backend/src/common/utils/nir.util.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
|
||||||
|
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
|
||||||
|
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 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 };
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ 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 { AppConfigService } from 'src/modules/config/config.service';
|
import { AppConfigService } from 'src/modules/config/config.service';
|
||||||
|
import { validateNir } from 'src/common/utils/nir.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -325,6 +326,18 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
||||||
|
const nirValidation = validateNir(nirNormalized, {
|
||||||
|
dateNaissance: dto.date_naissance,
|
||||||
|
});
|
||||||
|
if (!nirValidation.valid) {
|
||||||
|
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||||
|
}
|
||||||
|
if (nirValidation.warning) {
|
||||||
|
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||||
|
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
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à');
|
||||||
@ -370,7 +383,7 @@ export class AuthService {
|
|||||||
const am = amRepo.create({
|
const am = amRepo.create({
|
||||||
user_id: userEnregistre.id,
|
user_id: userEnregistre.id,
|
||||||
approval_number: dto.numero_agrement,
|
approval_number: dto.numero_agrement,
|
||||||
nir: dto.nir,
|
nir: nirNormalized,
|
||||||
max_children: dto.capacite_accueil,
|
max_children: dto.capacite_accueil,
|
||||||
biography: dto.biographie,
|
biography: dto.biographie,
|
||||||
residence_city: dto.ville ?? undefined,
|
residence_city: dto.ville ?? undefined,
|
||||||
|
|||||||
@ -103,10 +103,12 @@ export class RegisterAMCompletDto {
|
|||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
lieu_naissance_pays?: string;
|
lieu_naissance_pays?: string;
|
||||||
|
|
||||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
|
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty({ message: 'Le NIR est requis' })
|
@IsNotEmpty({ message: 'Le NIR est requis' })
|
||||||
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
|
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
|
||||||
|
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
|
||||||
|
})
|
||||||
nir: string;
|
nir: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
||||||
|
|||||||
@ -91,13 +91,7 @@ export class GestionnairesService {
|
|||||||
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.date_consentement_photo !== undefined) {
|
const { password, ...rest } = dto;
|
||||||
gestionnaire.date_consentement_photo = dto.date_consentement_photo
|
|
||||||
? new Date(dto.date_consentement_photo)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { password, date_consentement_photo, ...rest } = dto;
|
|
||||||
Object.entries(rest).forEach(([key, value]) => {
|
Object.entries(rest).forEach(([key, value]) => {
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
(gestionnaire as any)[key] = value;
|
(gestionnaire as any)[key] = value;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||||
@ -38,6 +38,16 @@ 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 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)
|
||||||
|
|||||||
@ -132,6 +132,14 @@ export class UserService {
|
|||||||
return this.usersRepository.save(entity);
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
async findAll(): Promise<Users[]> {
|
async findAll(): Promise<Users[]> {
|
||||||
return this.usersRepository.find();
|
return this.usersRepository.find();
|
||||||
}
|
}
|
||||||
|
|||||||
7
check_hash.js
Normal file
7
check_hash.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
|
||||||
|
const pass = '!Bezons2014';
|
||||||
|
|
||||||
|
bcrypt.hash(pass, 10).then(hash => {
|
||||||
|
console.log('New Hash:', hash);
|
||||||
|
}).catch(err => console.error(err));
|
||||||
@ -80,7 +80,7 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
|
|||||||
CREATE TABLE assistantes_maternelles (
|
CREATE TABLE assistantes_maternelles (
|
||||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||||
numero_agrement VARCHAR(50),
|
numero_agrement VARCHAR(50),
|
||||||
nir_chiffre CHAR(15),
|
nir_chiffre CHAR(15) NOT NULL,
|
||||||
nb_max_enfants INT,
|
nb_max_enfants INT,
|
||||||
biographie TEXT,
|
biographie TEXT,
|
||||||
disponible BOOLEAN DEFAULT true,
|
disponible BOOLEAN DEFAULT true,
|
||||||
|
|||||||
16
database/migrations/2026_nir_chiffre_not_null.sql
Normal file
16
database/migrations/2026_nir_chiffre_not_null.sql
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
-- Migration : rendre nir_chiffre NOT NULL (ticket #102)
|
||||||
|
-- À exécuter sur les bases existantes avant déploiement du schéma avec nir_chiffre NOT NULL.
|
||||||
|
-- Les lignes sans NIR reçoivent un NIR de test valide (format + clé) pour satisfaire la contrainte.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Renseigner un NIR de test valide pour toute ligne où nir_chiffre est NULL
|
||||||
|
UPDATE assistantes_maternelles
|
||||||
|
SET nir_chiffre = '275119900100102'
|
||||||
|
WHERE nir_chiffre IS NULL;
|
||||||
|
|
||||||
|
-- Appliquer la contrainte NOT NULL
|
||||||
|
ALTER TABLE assistantes_maternelles
|
||||||
|
ALTER COLUMN nir_chiffre SET NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
|
|||||||
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- assistantes_maternelles
|
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102)
|
||||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence)
|
||||||
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
|
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ------------------------------------------------------------
|
-- ------------------------------------------------------------
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
||||||
-- Aligné sur utilisateurs-test-complet.json
|
-- Aligné sur utilisateurs-test-complet.json
|
||||||
-- Mot de passe universel : password (bcrypt)
|
-- Mot de passe universel : password (bcrypt)
|
||||||
|
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
|
||||||
|
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
||||||
|
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
|
||||||
-- À exécuter après BDD.sql (init DB)
|
-- À exécuter après BDD.sql (init DB)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|
||||||
@ -36,10 +39,12 @@ VALUES
|
|||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ASSISTANTES MATERNELLES ==========
|
-- ========== ASSISTANTES MATERNELLES ==========
|
||||||
|
-- Marie Dubois (a0000003) : née en Corse à Ajaccio – NIR 2A pour test exception Corse (1980-06-08, F).
|
||||||
|
-- Fatima El Mansouri (a0000004) : née à l'étranger – NIR 99 pour test (1975-11-12, F).
|
||||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
||||||
VALUES
|
VALUES
|
||||||
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||||
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||||
|
|
||||||
-- ========== ENFANTS ==========
|
-- ========== ENFANTS ==========
|
||||||
|
|||||||
@ -840,7 +840,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`
|
||||||
|
|
||||||
@ -848,9 +848,10 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
|||||||
Créer la structure du dashboard gestionnaire avec 2 onglets.
|
Créer la structure du dashboard gestionnaire avec 2 onglets.
|
||||||
|
|
||||||
**Tâches** :
|
**Tâches** :
|
||||||
- [ ] Layout avec 2 onglets (Parents / AM)
|
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
|
||||||
- [ ] Navigation entre onglets
|
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
|
||||||
- [ ] État vide ("Aucune demande")
|
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
|
||||||
|
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ Num | Etat | Titre
|
|||||||
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
41 | closed | [Frontend] Inscription AM - Panneau 2 (Infos pro)
|
||||||
42 | closed | [Frontend] Inscription AM - Finalisation
|
42 | closed | [Frontend] Inscription AM - Finalisation
|
||||||
43 | open | [Frontend] Écran Création Mot de Passe
|
43 | open | [Frontend] Écran Création Mot de Passe
|
||||||
44 | open | [Frontend] Dashboard Gestionnaire - Structure
|
44 | closed | [Frontend] Dashboard Gestionnaire - Structure
|
||||||
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
45 | open | [Frontend] Dashboard Gestionnaire - Liste Parents
|
||||||
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
46 | open | [Frontend] Dashboard Gestionnaire - Liste AM
|
||||||
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
47 | open | [Frontend] Écran Changement MDP Obligatoire
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
dateOfBirth: DateTime(1980, 6, 8),
|
dateOfBirth: DateTime(1980, 6, 8),
|
||||||
birthCity: 'Bezons',
|
birthCity: 'Bezons',
|
||||||
birthCountry: 'France',
|
birthCountry: 'France',
|
||||||
nir: '280069512345671',
|
nir: '280062A00100191',
|
||||||
agrementNumber: 'AGR-2019-095001',
|
agrementNumber: 'AGR-2019-095001',
|
||||||
capacity: 4,
|
capacity: 4,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import 'dart:math' as math;
|
|||||||
import '../../models/am_registration_data.dart';
|
import '../../models/am_registration_data.dart';
|
||||||
import '../../models/card_assets.dart';
|
import '../../models/card_assets.dart';
|
||||||
import '../../config/display_config.dart';
|
import '../../config/display_config.dart';
|
||||||
|
import '../../services/auth_service.dart';
|
||||||
import '../../widgets/hover_relief_widget.dart';
|
import '../../widgets/hover_relief_widget.dart';
|
||||||
import '../../widgets/image_button.dart';
|
import '../../widgets/image_button.dart';
|
||||||
import '../../widgets/custom_navigation_button.dart';
|
import '../../widgets/custom_navigation_button.dart';
|
||||||
@ -22,6 +23,28 @@ class AmRegisterStep4Screen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
|
||||||
|
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||||
|
if (_isSubmitting) return;
|
||||||
|
setState(() => _isSubmitting = true);
|
||||||
|
try {
|
||||||
|
await AuthService.registerAM(registrationData);
|
||||||
|
if (!mounted) return;
|
||||||
|
_showConfirmationModal(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur lors de l\'inscription'),
|
||||||
|
backgroundColor: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSubmitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final registrationData = Provider.of<AmRegistrationData>(context);
|
final registrationData = Provider.of<AmRegistrationData>(context);
|
||||||
@ -90,12 +113,9 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: HoverReliefWidget(
|
child: HoverReliefWidget(
|
||||||
child: CustomNavigationButton(
|
child: CustomNavigationButton(
|
||||||
text: 'Soumettre',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre',
|
||||||
style: NavigationButtonStyle.green,
|
style: NavigationButtonStyle.green,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -106,17 +126,14 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ImageButton(
|
ImageButton(
|
||||||
bg: 'assets/images/bg_green.png',
|
bg: 'assets/images/bg_green.png',
|
||||||
text: 'Soumettre ma demande',
|
text: _isSubmitting ? 'Envoi...' : 'Soumettre ma demande',
|
||||||
textColor: const Color(0xFF2D6A4F),
|
textColor: const Color(0xFF2D6A4F),
|
||||||
width: 350,
|
width: 350,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
onPressed: () {
|
onPressed: () => _submitAMRegistration(registrationData),
|
||||||
print("Données AM finales: ${registrationData.firstName} ${registrationData.lastName}");
|
|
||||||
_showConfirmationModal(context);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
|
import '../models/am_registration_data.dart';
|
||||||
import 'api/api_config.dart';
|
import 'api/api_config.dart';
|
||||||
import 'api/tokenService.dart';
|
import 'api/tokenService.dart';
|
||||||
|
import '../utils/nir_utils.dart';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _currentUserKey = 'current_user';
|
static const String _currentUserKey = 'current_user';
|
||||||
@ -133,6 +136,70 @@ class AuthService {
|
|||||||
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
await prefs.setString(_currentUserKey, jsonEncode(user.toJson()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inscription AM complète (POST /auth/register/am).
|
||||||
|
/// En cas de succès (201), aucune donnée utilisateur retournée ; rediriger vers login.
|
||||||
|
static Future<void> registerAM(AmRegistrationData data) async {
|
||||||
|
String? photoBase64;
|
||||||
|
if (data.photoPath != null && data.photoPath!.isNotEmpty && !data.photoPath!.startsWith('assets/')) {
|
||||||
|
try {
|
||||||
|
final file = File(data.photoPath!);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
photoBase64 = 'data:image/jpeg;base64,${base64Encode(bytes)}';
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
'email': data.email,
|
||||||
|
'prenom': data.firstName,
|
||||||
|
'nom': data.lastName,
|
||||||
|
'telephone': data.phone,
|
||||||
|
'adresse': data.streetAddress.isNotEmpty ? data.streetAddress : null,
|
||||||
|
'code_postal': data.postalCode.isNotEmpty ? data.postalCode : null,
|
||||||
|
'ville': data.city.isNotEmpty ? data.city : null,
|
||||||
|
if (photoBase64 != null) 'photo_base64': photoBase64,
|
||||||
|
'consentement_photo': data.photoConsent,
|
||||||
|
'date_naissance': data.dateOfBirth != null
|
||||||
|
? '${data.dateOfBirth!.year}-${data.dateOfBirth!.month.toString().padLeft(2, '0')}-${data.dateOfBirth!.day.toString().padLeft(2, '0')}'
|
||||||
|
: null,
|
||||||
|
'lieu_naissance_ville': data.birthCity.isNotEmpty ? data.birthCity : null,
|
||||||
|
'lieu_naissance_pays': data.birthCountry.isNotEmpty ? data.birthCountry : null,
|
||||||
|
'nir': normalizeNir(data.nir),
|
||||||
|
'numero_agrement': data.agrementNumber,
|
||||||
|
'capacite_accueil': data.capacity ?? 1,
|
||||||
|
'biographie': data.presentationText.isNotEmpty ? data.presentationText : null,
|
||||||
|
'acceptation_cgu': data.cguAccepted,
|
||||||
|
'acceptation_privacy': data.cguAccepted,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.registerAM}'),
|
||||||
|
headers: ApiConfig.headers,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = response.body.isNotEmpty ? jsonDecode(response.body) : null;
|
||||||
|
final message = _extractErrorMessage(decoded, response.statusCode);
|
||||||
|
throw Exception(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrait le message d'erreur des réponses NestJS (message string, array, ou objet).
|
||||||
|
static String _extractErrorMessage(dynamic decoded, int statusCode) {
|
||||||
|
const fallback = 'Erreur lors de l\'inscription';
|
||||||
|
if (decoded == null || decoded is! Map) return '$fallback ($statusCode)';
|
||||||
|
final msg = decoded['message'];
|
||||||
|
if (msg == null) return decoded['error'] as String? ?? '$fallback ($statusCode)';
|
||||||
|
if (msg is String) return msg;
|
||||||
|
if (msg is List) return msg.map((e) => e.toString()).join('. ').trim();
|
||||||
|
if (msg is Map && msg['message'] != null) return msg['message'].toString();
|
||||||
|
return '$fallback ($statusCode)';
|
||||||
|
}
|
||||||
|
|
||||||
/// Rafraîchit le profil utilisateur depuis l'API
|
/// Rafraîchit le profil utilisateur depuis l'API
|
||||||
static Future<AppUser?> refreshCurrentUser() async {
|
static Future<AppUser?> refreshCurrentUser() async {
|
||||||
final token = await TokenService.getToken();
|
final token = await TokenService.getToken();
|
||||||
|
|||||||
109
frontend/lib/utils/nir_utils.dart
Normal file
109
frontend/lib/utils/nir_utils.dart
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
/// Utilitaires NIR (Numéro d'Inscription au Répertoire) – INSEE, 15 caractères.
|
||||||
|
/// Corse : 2A (2A) et 2B (2B) au lieu de 19/20. Clé de contrôle : 97 - (NIR13 mod 97).
|
||||||
|
|
||||||
|
/// Normalise le NIR : 15 caractères, sans espaces ni séparateurs. Corse conservée (2A/2B).
|
||||||
|
String normalizeNir(String input) {
|
||||||
|
if (input.isEmpty) return '';
|
||||||
|
final cleaned = input.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '').toUpperCase();
|
||||||
|
final buf = StringBuffer();
|
||||||
|
int i = 0;
|
||||||
|
while (i < cleaned.length && buf.length < 15) {
|
||||||
|
final c = cleaned[i];
|
||||||
|
if (buf.length < 5) {
|
||||||
|
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
|
||||||
|
i++;
|
||||||
|
} else if (buf.length == 5) {
|
||||||
|
if (c == '2' && i + 1 < cleaned.length && (cleaned[i + 1] == 'A' || cleaned[i + 1] == 'B')) {
|
||||||
|
buf.write('2');
|
||||||
|
buf.write(cleaned[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else if ((c == 'A' || c == 'B')) {
|
||||||
|
buf.write('2');
|
||||||
|
buf.write(c);
|
||||||
|
i++;
|
||||||
|
} else if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) {
|
||||||
|
buf.write(c);
|
||||||
|
if (i + 1 < cleaned.length && cleaned[i + 1].compareTo('0') >= 0 && cleaned[i + 1].compareTo('9') <= 0) {
|
||||||
|
buf.write(cleaned[i + 1]);
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c.compareTo('0') >= 0 && c.compareTo('9') <= 0) buf.write(c);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf.toString().length > 15 ? buf.toString().substring(0, 15) : buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la chaîne brute à 15 caractères (chiffres + 2A ou 2B).
|
||||||
|
String nirToRaw(String normalized) {
|
||||||
|
String s = normalized.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '');
|
||||||
|
if (s.length > 15) s = s.substring(0, 15);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse).
|
||||||
|
String formatNir(String raw) {
|
||||||
|
final r = nirToRaw(raw);
|
||||||
|
if (r.length < 15) return r;
|
||||||
|
// Même structure pour tous : sexe + année + mois + département + commune + ordre-clé.
|
||||||
|
return '${r.substring(0, 1)} ${r.substring(1, 3)} ${r.substring(3, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie le format : 15 caractères, structure 1+2+2+2+3+3+2, département 2A/2B autorisé.
|
||||||
|
bool _isFormatValid(String raw) {
|
||||||
|
if (raw.length != 15) return false;
|
||||||
|
final dept = raw.substring(5, 7);
|
||||||
|
final restDigits = raw.substring(0, 5) + (dept == '2A' ? '19' : dept == '2B' ? '18' : dept) + raw.substring(7, 15);
|
||||||
|
if (!RegExp(r'^[12]\d{12}\d{2}$').hasMatch(restDigits)) return false;
|
||||||
|
return RegExp(r'^[12]\d{4}(?:\d{2}|2A|2B)\d{8}$').hasMatch(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule la clé de contrôle (97 - (NIR13 mod 97)). Pour 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,4 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
// Définition de l'enum pour les styles de couleur/fond
|
// Définition de l'enum pour les styles de couleur/fond
|
||||||
@ -30,6 +31,7 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
final Iterable<String>? autofillHints;
|
final Iterable<String>? autofillHints;
|
||||||
final TextInputAction? textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final ValueChanged<String>? onFieldSubmitted;
|
final ValueChanged<String>? onFieldSubmitted;
|
||||||
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
|
|
||||||
const CustomAppTextField({
|
const CustomAppTextField({
|
||||||
super.key,
|
super.key,
|
||||||
@ -54,6 +56,7 @@ class CustomAppTextField extends StatefulWidget {
|
|||||||
this.autofillHints,
|
this.autofillHints,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
this.onFieldSubmitted,
|
this.onFieldSubmitted,
|
||||||
|
this.inputFormatters,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -114,6 +117,7 @@ class _CustomAppTextFieldState extends State<CustomAppTextField> {
|
|||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
obscureText: widget.obscureText,
|
obscureText: widget.obscureText,
|
||||||
keyboardType: widget.keyboardType,
|
keyboardType: widget.keyboardType,
|
||||||
|
inputFormatters: widget.inputFormatters,
|
||||||
autofillHints: widget.autofillHints,
|
autofillHints: widget.autofillHints,
|
||||||
textInputAction: widget.textInputAction,
|
textInputAction: widget.textInputAction,
|
||||||
onFieldSubmitted: widget.onFieldSubmitted,
|
onFieldSubmitted: widget.onFieldSubmitted,
|
||||||
|
|||||||
55
frontend/lib/widgets/nir_text_field.dart
Normal file
55
frontend/lib/widgets/nir_text_field.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../utils/nir_utils.dart';
|
||||||
|
import 'custom_app_text_field.dart';
|
||||||
|
|
||||||
|
/// Champ de saisie dédié au NIR (Numéro d'Inscription au Répertoire – 15 caractères).
|
||||||
|
/// Format affiché : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 pour la Corse.
|
||||||
|
/// La valeur envoyée au [controller] est formatée ; utiliser [normalizeNir](controller.text) à la soumission.
|
||||||
|
class NirTextField extends StatelessWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String labelText;
|
||||||
|
final String hintText;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final double fieldWidth;
|
||||||
|
final double fieldHeight;
|
||||||
|
final double labelFontSize;
|
||||||
|
final double inputFontSize;
|
||||||
|
final bool enabled;
|
||||||
|
final bool readOnly;
|
||||||
|
final CustomAppTextFieldStyle style;
|
||||||
|
|
||||||
|
const NirTextField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.labelText = 'N° Sécurité Sociale (NIR)',
|
||||||
|
this.hintText = '15 car. (ex. 1 12 34 56 789 012-34 ou 2A Corse)',
|
||||||
|
this.validator,
|
||||||
|
this.fieldWidth = double.infinity,
|
||||||
|
this.fieldHeight = 53.0,
|
||||||
|
this.labelFontSize = 18.0,
|
||||||
|
this.inputFontSize = 18.0,
|
||||||
|
this.enabled = true,
|
||||||
|
this.readOnly = false,
|
||||||
|
this.style = CustomAppTextFieldStyle.beige,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CustomAppTextField(
|
||||||
|
controller: controller,
|
||||||
|
labelText: labelText,
|
||||||
|
hintText: hintText,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
fieldHeight: fieldHeight,
|
||||||
|
labelFontSize: labelFontSize,
|
||||||
|
inputFontSize: inputFontSize,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
validator: validator ?? validateNir,
|
||||||
|
inputFormatters: [NirInputFormatter()],
|
||||||
|
enabled: enabled,
|
||||||
|
readOnly: readOnly,
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
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:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@ -6,7 +7,9 @@ import 'dart:math' as math;
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import '../models/card_assets.dart';
|
import '../models/card_assets.dart';
|
||||||
import '../config/display_config.dart';
|
import '../config/display_config.dart';
|
||||||
|
import '../utils/nir_utils.dart';
|
||||||
import 'custom_app_text_field.dart';
|
import 'custom_app_text_field.dart';
|
||||||
|
import 'nir_text_field.dart';
|
||||||
import 'form_field_wrapper.dart';
|
import 'form_field_wrapper.dart';
|
||||||
import 'app_custom_checkbox.dart';
|
import 'app_custom_checkbox.dart';
|
||||||
import 'hover_relief_widget.dart';
|
import 'hover_relief_widget.dart';
|
||||||
@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
: '';
|
: '';
|
||||||
_birthCityController.text = data.birthCity;
|
_birthCityController.text = data.birthCity;
|
||||||
_birthCountryController.text = data.birthCountry;
|
_birthCountryController.text = data.birthCountry;
|
||||||
_nirController.text = data.nir;
|
final nirRaw = nirToRaw(data.nir);
|
||||||
|
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
|
||||||
_agrementController.text = data.agrementNumber;
|
_agrementController.text = data.agrementNumber;
|
||||||
_capacityController.text = data.capacity?.toString() ?? '';
|
_capacityController.text = data.capacity?.toString() ?? '';
|
||||||
_photoPathFramework = data.photoPath;
|
_photoPathFramework = data.photoPath;
|
||||||
@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
dateOfBirth: _selectedDate,
|
dateOfBirth: _selectedDate,
|
||||||
birthCity: _birthCityController.text,
|
birthCity: _birthCityController.text,
|
||||||
birthCountry: _birthCountryController.text,
|
birthCountry: _birthCountryController.text,
|
||||||
nir: _nirController.text,
|
nir: normalizeNir(_nirController.text),
|
||||||
agrementNumber: _agrementController.text,
|
agrementNumber: _agrementController.text,
|
||||||
capacity: int.tryParse(_capacityController.text),
|
capacity: int.tryParse(_capacityController.text),
|
||||||
);
|
);
|
||||||
@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
children: [
|
children: [
|
||||||
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
|
Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)),
|
Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse).
|
||||||
|
String _formatNirForDisplay(String value) {
|
||||||
|
final raw = nirToRaw(value);
|
||||||
|
return raw.length == 15 ? formatNir(raw) : value;
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper pour champ Readonly style "Beige"
|
/// Helper pour champ Readonly style "Beige"
|
||||||
Widget _buildReadonlyField(String label, String value) {
|
Widget _buildReadonlyField(String label, String value) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: verticalSpacing),
|
SizedBox(height: verticalSpacing),
|
||||||
_buildField(
|
NirTextField(
|
||||||
config: config,
|
|
||||||
label: 'N° Sécurité Sociale (NIR)',
|
|
||||||
controller: _nirController,
|
controller: _nirController,
|
||||||
hint: 'Votre NIR à 13 chiffres',
|
fieldWidth: double.infinity,
|
||||||
keyboardType: TextInputType.number,
|
fieldHeight: config.isMobile ? 45.0 : 53.0,
|
||||||
validator: (v) {
|
labelFontSize: config.isMobile ? 15.0 : 22.0,
|
||||||
if (v == null || v.isEmpty) return 'NIR requis';
|
inputFontSize: config.isMobile ? 14.0 : 20.0,
|
||||||
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
|
|
||||||
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
SizedBox(height: verticalSpacing),
|
SizedBox(height: verticalSpacing),
|
||||||
Row(
|
Row(
|
||||||
@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
_buildField(
|
NirTextField(
|
||||||
config: config,
|
|
||||||
label: 'N° Sécurité Sociale (NIR)',
|
|
||||||
controller: _nirController,
|
controller: _nirController,
|
||||||
hint: 'Votre NIR à 13 chiffres',
|
fieldWidth: double.infinity,
|
||||||
keyboardType: TextInputType.number,
|
fieldHeight: 45.0,
|
||||||
validator: (v) {
|
labelFontSize: 15.0,
|
||||||
if (v == null || v.isEmpty) return 'NIR requis';
|
inputFontSize: 14.0,
|
||||||
if (v.length != 13) return 'Le NIR doit contenir 13 chiffres';
|
|
||||||
if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
VoidCallback? onTap,
|
VoidCallback? onTap,
|
||||||
IconData? suffixIcon,
|
IconData? suffixIcon,
|
||||||
String? Function(String?)? validator,
|
String? Function(String?)? validator,
|
||||||
|
List<TextInputFormatter>? inputFormatters,
|
||||||
}) {
|
}) {
|
||||||
if (config.isReadonly) {
|
if (config.isReadonly) {
|
||||||
return FormFieldWrapper(
|
return FormFieldWrapper(
|
||||||
@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
validator: validator,
|
validator: validator,
|
||||||
|
inputFormatters: inputFormatters,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
scripts/README-create-issue.md
Normal file
19
scripts/README-create-issue.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# Créer l’issue #84 (correctifs modale MDP) via l’API Gitea
|
||||||
|
|
||||||
|
1. Définir un token valide :
|
||||||
|
`export GITEA_TOKEN="votre_token"`
|
||||||
|
ou créer `.gitea-token` à la racine du projet avec le token seul.
|
||||||
|
|
||||||
|
2. Créer l’issue :
|
||||||
|
```bash
|
||||||
|
cd /chemin/vers/PetitsPas
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @scripts/issue-84-payload.json \
|
||||||
|
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. En cas de succès (HTTP 201), la réponse JSON contient le numéro de l’issue créée.
|
||||||
|
|
||||||
|
Payload utilisé : `scripts/issue-84-payload.json` (titre + corps depuis `scripts/issue-84-body.txt`).
|
||||||
51
scripts/create-gitea-issue.sh
Normal file
51
scripts/create-gitea-issue.sh
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Crée une issue Gitea via l'API.
|
||||||
|
# Usage: GITEA_TOKEN=xxx ./scripts/create-gitea-issue.sh
|
||||||
|
# Ou: mettre le token dans .gitea-token à la racine du projet.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
|
||||||
|
REPO="jmartin/petitspas"
|
||||||
|
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
if [ -f .gitea-token ]; then
|
||||||
|
GITEA_TOKEN=$(cat .gitea-token)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TITLE="$1"
|
||||||
|
BODY="$2"
|
||||||
|
if [ -z "$TITLE" ]; then
|
||||||
|
echo "Usage: $0 \"Titre de l'issue\" \"Corps (optionnel)\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build JSON (escape body for JSON)
|
||||||
|
BODY_ESC=$(echo "$BODY" | jq -Rs . 2>/dev/null || echo "null")
|
||||||
|
if [ "$BODY_ESC" = "null" ] || [ -z "$BODY" ]; then
|
||||||
|
PAYLOAD=$(jq -n --arg t "$TITLE" '{title: $t}')
|
||||||
|
else
|
||||||
|
PAYLOAD=$(jq -n --arg t "$TITLE" --arg b "$BODY" '{title: $t, body: $b}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$BASE_URL/repos/$REPO/issues")
|
||||||
|
HTTP_CODE=$(echo "$RESP" | tail -1)
|
||||||
|
BODY_RESP=$(echo "$RESP" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "201" ]; then
|
||||||
|
ISSUE_NUM=$(echo "$BODY_RESP" | jq -r .number)
|
||||||
|
echo "Issue #$ISSUE_NUM créée."
|
||||||
|
echo "$BODY_RESP" | jq .
|
||||||
|
else
|
||||||
|
echo "Erreur HTTP $HTTP_CODE: $BODY_RESP"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
58
scripts/gitea-close-issue-with-comment.sh
Normal file
58
scripts/gitea-close-issue-with-comment.sh
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Poste un commentaire sur une issue Gitea puis la ferme.
|
||||||
|
# Usage: GITEA_TOKEN=xxx ./scripts/gitea-close-issue-with-comment.sh <numéro> "Commentaire"
|
||||||
|
# Ou: mettre le token dans .gitea-token à la racine du projet.
|
||||||
|
# Exemple: ./scripts/gitea-close-issue-with-comment.sh 15 "Livré : panneau Paramètres opérationnel."
|
||||||
|
|
||||||
|
set -e
|
||||||
|
ISSUE="${1:?Usage: $0 <numéro_issue> \"Commentaire\"}"
|
||||||
|
COMMENT="${2:?Usage: $0 <numéro_issue> \"Commentaire\"}"
|
||||||
|
BASE_URL="${GITEA_URL:-https://git.ptits-pas.fr/api/v1}"
|
||||||
|
REPO="jmartin/petitspas"
|
||||||
|
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
if [ -f .gitea-token ]; then
|
||||||
|
GITEA_TOKEN=$(cat .gitea-token)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "Définir GITEA_TOKEN ou créer .gitea-token avec votre token Gitea."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1) Poster le commentaire
|
||||||
|
echo "Ajout du commentaire sur l'issue #$ISSUE..."
|
||||||
|
# Échapper pour JSON (guillemets et backslash)
|
||||||
|
COMMENT_ESC=$(printf '%s' "$COMMENT" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\r//g')
|
||||||
|
PAYLOAD="{\"body\":\"$COMMENT_ESC\"}"
|
||||||
|
RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$BASE_URL/repos/$REPO/issues/$ISSUE/comments")
|
||||||
|
HTTP_CODE=$(echo "$RESP" | tail -1)
|
||||||
|
BODY=$(echo "$RESP" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
|
echo "Erreur HTTP $HTTP_CODE lors du commentaire: $BODY"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Commentaire ajouté."
|
||||||
|
|
||||||
|
# 2) Fermer l'issue
|
||||||
|
echo "Fermeture de l'issue #$ISSUE..."
|
||||||
|
RESP2=$(curl -s -w "\n%{http_code}" -X PATCH \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"state":"closed"}' \
|
||||||
|
"$BASE_URL/repos/$REPO/issues/$ISSUE")
|
||||||
|
HTTP_CODE2=$(echo "$RESP2" | tail -1)
|
||||||
|
BODY2=$(echo "$RESP2" | sed '$d')
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE2" = "200" ] || [ "$HTTP_CODE2" = "201" ]; then
|
||||||
|
echo "Issue #$ISSUE fermée."
|
||||||
|
else
|
||||||
|
echo "Erreur HTTP $HTTP_CODE2: $BODY2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
14
scripts/issue-84-body.txt
Normal file
14
scripts/issue-84-body.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.
|
||||||
|
|
||||||
|
**Périmètre :**
|
||||||
|
- Ajustements visuels / UX de la modale (ChangePasswordDialog)
|
||||||
|
- Cohérence charte graphique, espacements, lisibilité
|
||||||
|
- Comportement (validation, messages d'erreur, fermeture)
|
||||||
|
- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages
|
||||||
|
|
||||||
|
**Tâches :**
|
||||||
|
- [ ] Revoir le design de la modale (relief, bordures, couleurs)
|
||||||
|
- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations
|
||||||
|
- [ ] Ajuster les textes et messages d'erreur
|
||||||
|
- [ ] Tester sur mobile et desktop
|
||||||
|
- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin
|
||||||
1
scripts/issue-84-payload.json
Normal file
1
scripts/issue-84-payload.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"title": "[Frontend] Bug – Correctifs modale Changement MDP (première connexion admin)", "body": "Correctifs et améliorations de la modale de changement de mot de passe obligatoire affichée à la première connexion admin.\n\n**Périmètre :**\n- Ajustements visuels / UX de la modale (ChangePasswordDialog)\n- Cohérence charte graphique, espacements, lisibilité\n- Comportement (validation, messages d'erreur, fermeture)\n- Lien de test en debug sur l'écran login (« Test modale MDP ») pour faciliter les réglages\n\n**Tâches :**\n- [ ] Revoir le design de la modale (relief, bordures, couleurs)\n- [ ] Vérifier les champs (MDP actuel, nouveau, confirmation) et validations\n- [ ] Ajuster les textes et messages d'erreur\n- [ ] Tester sur mobile et desktop\n- [ ] Retirer ou conditionner le lien « Test modale MDP » en production si besoin\n"}
|
||||||
Loading…
x
Reference in New Issue
Block a user