From ca98821b3eb8a280831905ad8129801d050d6baa Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 26 Feb 2026 13:55:42 +0100 Subject: [PATCH] Merge develop into master (squash): ticket #102 NIR harmonisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: DTO NIR 15 car 2A/2B, validation format+clé, warning cohérence - BDD: nir_chiffre NOT NULL, migration pour bases existantes - Seeds: 02 nir_chiffre, 03 Marie 2A / Fatima 99 - Frontend: nir_utils, nir_text_field, formulaire pro, mock inscription AM Made-with: Cursor --- backend/src/common/utils/nir.util.ts | 109 +++++++++++++++++ backend/src/routes/auth/auth.service.ts | 15 ++- .../auth/dto/register-am-complet.dto.ts | 6 +- database/BDD.sql | 2 +- .../migrations/2026_nir_chiffre_not_null.sql | 16 +++ database/seed/02_seed.sql | 6 +- database/seed/03_seed_test_data.sql | 9 +- .../auth/am_register_step2_screen.dart | 8 +- frontend/lib/utils/nir_utils.dart | 113 ++++++++++++++++++ .../lib/widgets/custom_app_text_field.dart | 4 + frontend/lib/widgets/nir_text_field.dart | 55 +++++++++ .../professional_info_form_screen.dart | 50 ++++---- 12 files changed, 355 insertions(+), 38 deletions(-) create mode 100644 backend/src/common/utils/nir.util.ts create mode 100644 database/migrations/2026_nir_chiffre_not_null.sql create mode 100644 frontend/lib/utils/nir_utils.dart create mode 100644 frontend/lib/widgets/nir_text_field.dart diff --git a/backend/src/common/utils/nir.util.ts b/backend/src/common/utils/nir.util.ts new file mode 100644 index 0000000..30ecb87 --- /dev/null +++ b/backend/src/common/utils/nir.util.ts @@ -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 }; +} diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 1fdb8cd..76f2ddd 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -23,6 +23,7 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { LoginDto } from './dto/login.dto'; import { AppConfigService } from 'src/modules/config/config.service'; +import { validateNir } from 'src/common/utils/nir.util'; @Injectable() 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); if (existe) { throw new ConflictException('Un compte avec cet email existe déjà'); @@ -370,7 +383,7 @@ export class AuthService { const am = amRepo.create({ user_id: userEnregistre.id, approval_number: dto.numero_agrement, - nir: dto.nir, + nir: nirNormalized, max_children: dto.capacite_accueil, biography: dto.biographie, residence_city: dto.ville ?? undefined, diff --git a/backend/src/routes/auth/dto/register-am-complet.dto.ts b/backend/src/routes/auth/dto/register-am-complet.dto.ts index 72728ca..5800bdd 100644 --- a/backend/src/routes/auth/dto/register-am-complet.dto.ts +++ b/backend/src/routes/auth/dto/register-am-complet.dto.ts @@ -103,10 +103,12 @@ export class RegisterAMCompletDto { @MaxLength(100) lieu_naissance_pays?: string; - @ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' }) + @ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' }) @IsString() @IsNotEmpty({ message: 'Le NIR est requis' }) - @Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' }) + @Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { + message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)', + }) nir: string; @ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" }) diff --git a/database/BDD.sql b/database/BDD.sql index 6a26917..46a741e 100644 --- a/database/BDD.sql +++ b/database/BDD.sql @@ -80,7 +80,7 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp CREATE TABLE assistantes_maternelles ( id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, numero_agrement VARCHAR(50), - nir_chiffre CHAR(15), + nir_chiffre CHAR(15) NOT NULL, nb_max_enfants INT, biographie TEXT, disponible BOOLEAN DEFAULT true, diff --git a/database/migrations/2026_nir_chiffre_not_null.sql b/database/migrations/2026_nir_chiffre_not_null.sql new file mode 100644 index 0000000..0c94d35 --- /dev/null +++ b/database/migrations/2026_nir_chiffre_not_null.sql @@ -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; diff --git a/database/seed/02_seed.sql b/database/seed/02_seed.sql index c8ef3b4..0cda26f 100644 --- a/database/seed/02_seed.sql +++ b/database/seed/02_seed.sql @@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent) VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent ON CONFLICT (id_utilisateur) DO NOTHING; --- assistantes_maternelles -INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence) -VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille') +-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102) +INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence) +VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille') ON CONFLICT (id_utilisateur) DO NOTHING; -- ------------------------------------------------------------ diff --git a/database/seed/03_seed_test_data.sql b/database/seed/03_seed_test_data.sql index 1aa805f..be8569d 100644 --- a/database/seed/03_seed_test_data.sql +++ b/database/seed/03_seed_test_data.sql @@ -2,6 +2,9 @@ -- 03_seed_test_data.sql : Données de test complètes (dashboard admin) -- Aligné sur utilisateurs-test-complet.json -- Mot de passe universel : password (bcrypt) +-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre). +-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse). +-- - Fatima El Mansouri : née à l'étranger → NIR 99. -- À exécuter après BDD.sql (init DB) -- ============================================================ @@ -36,10 +39,12 @@ VALUES ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ASSISTANTES MATERNELLES ========== +-- Marie Dubois (a0000003) : née en Corse à Ajaccio – NIR 2A pour test exception Corse (1980-06-08, F). +-- Fatima El Mansouri (a0000004) : née à l'étranger – NIR 99 pour test (1975-11-12, F). INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible) VALUES - ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), - ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) + ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), + ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) ON CONFLICT (id_utilisateur) DO NOTHING; -- ========== ENFANTS ========== diff --git a/frontend/lib/screens/auth/am_register_step2_screen.dart b/frontend/lib/screens/auth/am_register_step2_screen.dart index 1496c6f..447280a 100644 --- a/frontend/lib/screens/auth/am_register_step2_screen.dart +++ b/frontend/lib/screens/auth/am_register_step2_screen.dart @@ -54,15 +54,15 @@ class _AmRegisterStep2ScreenState extends State { capacity: registrationData.capacity, ); - // Générer des données de test si les champs sont vides + // Générer des données de test si les champs sont vides (NIR = Marie Dubois du seed, Corse 2A) if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) { initialData = ProfessionalInfoData( photoPath: 'assets/images/icon_assmat.png', photoConsent: true, - dateOfBirth: DateTime(1985, 3, 15), - birthCity: DataGenerator.city(), + dateOfBirth: DateTime(1980, 6, 8), + birthCity: 'Ajaccio', birthCountry: 'France', - nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}', + nir: '280062A00100191', agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}', capacity: DataGenerator.randomIntInRange(1, 5), ); diff --git a/frontend/lib/utils/nir_utils.dart b/frontend/lib/utils/nir_utils.dart new file mode 100644 index 0000000..ea8d072 --- /dev/null +++ b/frontend/lib/utils/nir_utils.dart @@ -0,0 +1,113 @@ +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 +String formatNir(String raw) { + final r = nirToRaw(raw); + if (r.length < 15) return r; + final dept = r.substring(5, 7); + final isCorsica = dept == '2A' || dept == '2B'; + if (isCorsica) { + return '${r.substring(0, 5)} ${r.substring(5, 7)} ${r.substring(7, 10)} ${r.substring(10, 13)}-${r.substring(13, 15)}'; + } + 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{6}$').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), + ); + } +} diff --git a/frontend/lib/widgets/custom_app_text_field.dart b/frontend/lib/widgets/custom_app_text_field.dart index 0645bb1..967865a 100644 --- a/frontend/lib/widgets/custom_app_text_field.dart +++ b/frontend/lib/widgets/custom_app_text_field.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; // Définition de l'enum pour les styles de couleur/fond @@ -30,6 +31,7 @@ class CustomAppTextField extends StatefulWidget { final Iterable? autofillHints; final TextInputAction? textInputAction; final ValueChanged? onFieldSubmitted; + final List? inputFormatters; const CustomAppTextField({ super.key, @@ -54,6 +56,7 @@ class CustomAppTextField extends StatefulWidget { this.autofillHints, this.textInputAction, this.onFieldSubmitted, + this.inputFormatters, }); @override @@ -114,6 +117,7 @@ class _CustomAppTextFieldState extends State { focusNode: widget.focusNode, obscureText: widget.obscureText, keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, autofillHints: widget.autofillHints, textInputAction: widget.textInputAction, onFieldSubmitted: widget.onFieldSubmitted, diff --git a/frontend/lib/widgets/nir_text_field.dart b/frontend/lib/widgets/nir_text_field.dart new file mode 100644 index 0000000..e1beca1 --- /dev/null +++ b/frontend/lib/widgets/nir_text_field.dart @@ -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, + ); + } +} diff --git a/frontend/lib/widgets/professional_info_form_screen.dart b/frontend/lib/widgets/professional_info_form_screen.dart index 24b8f56..1923b96 100644 --- a/frontend/lib/widgets/professional_info_form_screen.dart +++ b/frontend/lib/widgets/professional_info_form_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; @@ -6,7 +7,9 @@ import 'dart:math' as math; import 'dart:io'; import '../models/card_assets.dart'; import '../config/display_config.dart'; +import '../utils/nir_utils.dart'; import 'custom_app_text_field.dart'; +import 'nir_text_field.dart'; import 'form_field_wrapper.dart'; import 'app_custom_checkbox.dart'; import 'hover_relief_widget.dart'; @@ -97,7 +100,8 @@ class _ProfessionalInfoFormScreenState extends State : ''; _birthCityController.text = data.birthCity; _birthCountryController.text = data.birthCountry; - _nirController.text = data.nir; + final nirRaw = nirToRaw(data.nir); + _nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir; _agrementController.text = data.agrementNumber; _capacityController.text = data.capacity?.toString() ?? ''; _photoPathFramework = data.photoPath; @@ -161,7 +165,7 @@ class _ProfessionalInfoFormScreenState extends State dateOfBirth: _selectedDate, birthCity: _birthCityController.text, birthCountry: _birthCountryController.text, - nir: _nirController.text, + nir: normalizeNir(_nirController.text), agrementNumber: _agrementController.text, capacity: int.tryParse(_capacityController.text), ); @@ -499,7 +503,7 @@ class _ProfessionalInfoFormScreenState extends State children: [ Expanded(flex: 2, child: _buildReadonlyField('Date de naissance', _dateOfBirthController.text)), const SizedBox(width: 16), - Expanded(flex: 3, child: _buildReadonlyField('NIR', _nirController.text)), + Expanded(flex: 3, child: _buildReadonlyField('NIR', _formatNirForDisplay(_nirController.text))), ], ), const SizedBox(height: 12), @@ -525,6 +529,12 @@ class _ProfessionalInfoFormScreenState extends State ); } + /// NIR formaté pour affichage (1 12 34 56 789 012-34 ou 2A pour la Corse). + String _formatNirForDisplay(String value) { + final raw = nirToRaw(value); + return raw.length == 15 ? formatNir(raw) : value; + } + /// Helper pour champ Readonly style "Beige" Widget _buildReadonlyField(String label, String value) { return Column( @@ -609,18 +619,12 @@ class _ProfessionalInfoFormScreenState extends State ], ), SizedBox(height: verticalSpacing), - _buildField( - config: config, - label: 'N° Sécurité Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR à 13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - if (v.length != 13) return 'Le NIR doit contenir 13 chiffres'; - if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3'; - return null; - }, + fieldWidth: double.infinity, + fieldHeight: config.isMobile ? 45.0 : 53.0, + labelFontSize: config.isMobile ? 15.0 : 22.0, + inputFontSize: config.isMobile ? 14.0 : 20.0, ), SizedBox(height: verticalSpacing), Row( @@ -695,18 +699,12 @@ class _ProfessionalInfoFormScreenState extends State ), const SizedBox(height: 12), - _buildField( - config: config, - label: 'N° Sécurité Sociale (NIR)', + NirTextField( controller: _nirController, - hint: 'Votre NIR à 13 chiffres', - keyboardType: TextInputType.number, - validator: (v) { - if (v == null || v.isEmpty) return 'NIR requis'; - if (v.length != 13) return 'Le NIR doit contenir 13 chiffres'; - if (!RegExp(r'^[1-3]').hasMatch(v[0])) return 'Le NIR doit commencer par 1, 2 ou 3'; - return null; - }, + fieldWidth: double.infinity, + fieldHeight: 45.0, + labelFontSize: 15.0, + inputFontSize: 14.0, ), const SizedBox(height: 12), @@ -796,6 +794,7 @@ class _ProfessionalInfoFormScreenState extends State VoidCallback? onTap, IconData? suffixIcon, String? Function(String?)? validator, + List? inputFormatters, }) { if (config.isReadonly) { return FormFieldWrapper( @@ -817,6 +816,7 @@ class _ProfessionalInfoFormScreenState extends State onTap: onTap, suffixIcon: suffixIcon, validator: validator, + inputFormatters: inputFormatters, ); } }