feat(admin): onglet À valider, dossier unifié et modales de validation

- Onglet « À valider » (AM + familles), pending-families et détail dossier par numéro.
- Wizards validation AM et famille, modale commune, chargement via GET /dossiers/:num.
- UI : cartes enfants, photo AM (cadre uniforme, ratio), NIR affiché formaté (espaces autour du tiret).
- Backend : DTO / routes parents pending ; scripts de test et mise à jour issue Gitea.

Tickets #107, #119.

Made-with: Cursor
This commit is contained in:
2026-03-26 00:15:18 +01:00
parent d832559027
commit 2e02f60601
36 changed files with 3303 additions and 173 deletions
+4 -4
View File
@@ -49,12 +49,12 @@ String nirToRaw(String normalized) {
return s;
}
/// Formate pour affichage : 1 12 34 56 789 012-34 ou 1 12 34 2A 789 012-34 (Corse).
/// 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)}';
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é.
@@ -82,7 +82,7 @@ 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)';
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);
@@ -90,7 +90,7 @@ String? validateNir(String? value) {
return null;
}
/// Formateur de saisie : affiche le NIR formaté (1 12 34 56 789 012-34) et limite à 15 caractères utiles.
/// 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(
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/services.dart';
/// Format français : 10 chiffres affichés par paires (ex. 06 12 34 56 78).
/// Ne garde que les chiffres (max 10).
String normalizePhone(String raw) {
final digits = raw.replaceAll(RegExp(r'\D'), '');
return digits.length > 10 ? digits.substring(0, 10) : digits;
}
/// Retourne le numéro formaté pour l'affichage (ex. "06 12 34 56 78").
/// Si [raw] est vide après normalisation, retourne [raw] tel quel (pour afficher "" etc.).
String formatPhoneForDisplay(String raw) {
if (raw.trim().isEmpty) return raw;
final normalized = normalizePhone(raw);
if (normalized.isEmpty) return raw;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
return buffer.toString();
}
/// Formatter de saisie : uniquement chiffres, espaces automatiques toutes les 2 chiffres, max 10 chiffres.
class FrenchPhoneNumberFormatter extends TextInputFormatter {
const FrenchPhoneNumberFormatter();
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final normalized = digits.length > 10 ? digits.substring(0, 10) : digits;
final buffer = StringBuffer();
for (var i = 0; i < normalized.length; i++) {
if (i > 0 && i.isEven) buffer.write(' ');
buffer.write(normalized[i]);
}
final formatted = buffer.toString();
// Conserver la position du curseur : compter les chiffres avant la sélection
final sel = newValue.selection;
final digitsBeforeCursor = newValue.text
.substring(0, sel.start.clamp(0, newValue.text.length))
.replaceAll(RegExp(r'\D'), '')
.length;
final newOffset = digitsBeforeCursor + (digitsBeforeCursor > 0 ? digitsBeforeCursor ~/ 2 : 0);
final clampedOffset = newOffset.clamp(0, formatted.length);
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: clampedOffset),
);
}
}