Merge develop into master (squash): ticket #102 NIR harmonisation
- 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
This commit is contained in:
parent
b1a80f85c9
commit
ca98821b3e
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" })
|
||||||
|
|||||||
@ -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 ==========
|
||||||
|
|||||||
@ -54,15 +54,15 @@ class _AmRegisterStep2ScreenState extends State<AmRegisterStep2Screen> {
|
|||||||
capacity: registrationData.capacity,
|
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) {
|
if (registrationData.dateOfBirth == null && registrationData.nir.isEmpty) {
|
||||||
initialData = ProfessionalInfoData(
|
initialData = ProfessionalInfoData(
|
||||||
photoPath: 'assets/images/icon_assmat.png',
|
photoPath: 'assets/images/icon_assmat.png',
|
||||||
photoConsent: true,
|
photoConsent: true,
|
||||||
dateOfBirth: DateTime(1985, 3, 15),
|
dateOfBirth: DateTime(1980, 6, 8),
|
||||||
birthCity: DataGenerator.city(),
|
birthCity: 'Ajaccio',
|
||||||
birthCountry: 'France',
|
birthCountry: 'France',
|
||||||
nir: '${DataGenerator.randomIntInRange(1, 3)}${DataGenerator.randomIntInRange(80, 96)}${DataGenerator.randomIntInRange(1, 13).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(1, 100).toString().padLeft(2, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(100, 1000).toString().padLeft(3, '0')}${DataGenerator.randomIntInRange(10, 100).toString().padLeft(2, '0')}',
|
nir: '280062A00100191',
|
||||||
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
agrementNumber: 'AM${DataGenerator.randomIntInRange(10000, 100000)}',
|
||||||
capacity: DataGenerator.randomIntInRange(1, 5),
|
capacity: DataGenerator.randomIntInRange(1, 5),
|
||||||
);
|
);
|
||||||
|
|||||||
113
frontend/lib/utils/nir_utils.dart
Normal file
113
frontend/lib/utils/nir_utils.dart
Normal file
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user