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:
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
|
||||
@@ -34,7 +36,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
_nomController.text = user.nom ?? '';
|
||||
_prenomController.text = user.prenom ?? '';
|
||||
_emailController.text = user.email;
|
||||
_telephoneController.text = user.telephone ?? '';
|
||||
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
||||
// En édition, on ne préremplit jamais le mot de passe.
|
||||
_passwordController.clear();
|
||||
}
|
||||
@@ -91,7 +93,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
nom: _nomController.text.trim(),
|
||||
prenom: _prenomController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
telephone: _telephoneController.text.trim(),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
password: _passwordController.text.trim().isEmpty
|
||||
? null
|
||||
: _passwordController.text,
|
||||
@@ -102,7 +104,7 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
prenom: _prenomController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _telephoneController.text.trim(),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
@@ -347,8 +349,13 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
|
||||
return TextFormField(
|
||||
controller: _telephoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone',
|
||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (v) => _required(v, 'Téléphone'),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:p_tits_pas/models/relais_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/relais_service.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
@@ -40,12 +41,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
String? _selectedRelaisId;
|
||||
bool get _isEditMode => widget.initialUser != null;
|
||||
bool get _isSuperAdminTarget =>
|
||||
widget.initialUser?.role.toLowerCase() == 'super_admin';
|
||||
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
|
||||
bool get _isLockedAdminIdentity =>
|
||||
_isEditMode && widget.adminMode && _isSuperAdminTarget;
|
||||
String get _targetRoleKey {
|
||||
if (widget.initialUser != null) {
|
||||
return widget.initialUser!.role.toLowerCase();
|
||||
return (widget.initialUser!.role).toLowerCase();
|
||||
}
|
||||
return widget.adminMode ? 'administrateur' : 'gestionnaire';
|
||||
}
|
||||
@@ -92,7 +93,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
_nomController.text = user.nom ?? '';
|
||||
_prenomController.text = user.prenom ?? '';
|
||||
_emailController.text = user.email;
|
||||
_telephoneController.text = _formatPhoneForDisplay(user.telephone ?? '');
|
||||
_telephoneController.text = formatPhoneForDisplay(user.telephone ?? '');
|
||||
// En édition, on ne préremplit jamais le mot de passe.
|
||||
_passwordController.clear();
|
||||
final initialRelaisId = user.relaisId?.trim();
|
||||
@@ -185,7 +186,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
}
|
||||
final base = _required(value, 'Téléphone');
|
||||
if (base != null) return base;
|
||||
final digits = _normalizePhone(value!);
|
||||
final digits = normalizePhone(value!);
|
||||
if (digits.length != 10) {
|
||||
return 'Le téléphone doit contenir 10 chiffres';
|
||||
}
|
||||
@@ -195,22 +196,6 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
return null;
|
||||
}
|
||||
|
||||
String _normalizePhone(String raw) {
|
||||
return raw.replaceAll(RegExp(r'\D'), '');
|
||||
}
|
||||
|
||||
String _formatPhoneForDisplay(String raw) {
|
||||
final normalized = _normalizePhone(raw);
|
||||
final digits =
|
||||
normalized.length > 10 ? normalized.substring(0, 10) : normalized;
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && i.isEven) buffer.write(' ');
|
||||
buffer.write(digits[i]);
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _toTitleCase(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) return trimmed;
|
||||
@@ -251,7 +236,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
try {
|
||||
final normalizedNom = _toTitleCase(_nomController.text);
|
||||
final normalizedPrenom = _toTitleCase(_prenomController.text);
|
||||
final normalizedPhone = _normalizePhone(_telephoneController.text);
|
||||
final normalizedPhone = normalizePhone(_telephoneController.text);
|
||||
final passwordProvided = _passwordController.text.trim().isNotEmpty;
|
||||
|
||||
if (_isEditMode) {
|
||||
@@ -264,7 +249,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: _isLockedAdminIdentity ? lockedPrenom : normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
telephone: normalizedPhone.isEmpty
|
||||
? _normalizePhone(widget.initialUser!.telephone ?? '')
|
||||
? normalizePhone(widget.initialUser!.telephone ?? '')
|
||||
: normalizedPhone,
|
||||
password: passwordProvided ? _passwordController.text : null,
|
||||
);
|
||||
@@ -273,7 +258,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
final initialNom = _toTitleCase(currentUser.nom ?? '');
|
||||
final initialPrenom = _toTitleCase(currentUser.prenom ?? '');
|
||||
final initialEmail = currentUser.email.trim();
|
||||
final initialPhone = _normalizePhone(currentUser.telephone ?? '');
|
||||
final initialPhone = normalizePhone(currentUser.telephone ?? '');
|
||||
|
||||
final onlyRelaisChanged =
|
||||
normalizedNom == initialNom &&
|
||||
@@ -306,7 +291,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _normalizePhone(_telephoneController.text),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
);
|
||||
} else {
|
||||
await UserService.createGestionnaire(
|
||||
@@ -314,7 +299,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
prenom: normalizedPrenom,
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
telephone: _normalizePhone(_telephoneController.text),
|
||||
telephone: normalizePhone(_telephoneController.text),
|
||||
relaisId: _selectedRelaisId,
|
||||
);
|
||||
}
|
||||
@@ -608,7 +593,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(10),
|
||||
_FrenchPhoneNumberFormatter(),
|
||||
FrenchPhoneNumberFormatter(),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Téléphone (ex: 06 12 34 56 78)',
|
||||
@@ -662,27 +647,3 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return TextEditingValue(
|
||||
text: formatted,
|
||||
selection: TextSelection.collapsed(offset: formatted.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class LoginScreen extends StatefulWidget {
|
||||
State<LoginScreen> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
class _LoginPageState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
@@ -27,31 +27,30 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
|
||||
static const double _mobileBreakpoint = 900.0;
|
||||
|
||||
/// Une seule fois : évite de relancer `_getImageDimensions()` à chaque rebuild (sinon sur web,
|
||||
/// tout événement de layout / métriques recréait un Future et pouvait provoquer des erreurs DWDS
|
||||
/// ou des comportements bizarres pendant la saisie dans les champs).
|
||||
late final Future<ImageDimensions> _desktopRiverLogoDimensionsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_desktopRiverLogoDimensionsFuture = _getImageDimensions();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
super.didChangeMetrics();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
final v = value ?? '';
|
||||
if (v.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(v)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
return null;
|
||||
@@ -116,7 +115,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
TextInput.finishAutofillContext(shouldSave: true);
|
||||
|
||||
// Rediriger selon le rôle de l'utilisateur
|
||||
_redirectUserByRole(user.role);
|
||||
_redirectUserByRole(user.role.isEmpty ? null : user.role);
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -126,9 +125,10 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
/// Redirige l'utilisateur selon son rôle (GoRouter : context.go).
|
||||
void _redirectUserByRole(String role) {
|
||||
void _redirectUserByRole(String? role) {
|
||||
setState(() => _isLoading = false);
|
||||
switch (role.toLowerCase()) {
|
||||
final r = (role ?? '').toLowerCase();
|
||||
switch (r) {
|
||||
case 'super_admin':
|
||||
case 'administrateur':
|
||||
context.go('/admin-dashboard');
|
||||
@@ -162,8 +162,8 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
builder: (context, constraints) {
|
||||
final w = constraints.maxWidth;
|
||||
final h = constraints.maxHeight;
|
||||
return FutureBuilder(
|
||||
future: _getImageDimensions(),
|
||||
return FutureBuilder<ImageDimensions>(
|
||||
future: _desktopRiverLogoDimensionsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
@@ -203,7 +203,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
bottom: 0,
|
||||
width: w * 0.6, // 60% de la largeur de l'écran
|
||||
height: h * 0.5, // 50% de la hauteur de l'écran
|
||||
child: Padding(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(w * 0.02), // 2% de padding
|
||||
child: AutofillGroup(
|
||||
child: Form(
|
||||
@@ -240,7 +240,7 @@ class _LoginPageState extends State<LoginScreen> with WidgetsBindingObserver {
|
||||
hintText: 'Votre mot de passe',
|
||||
obscureText: true,
|
||||
autofillHints: const [
|
||||
AutofillHints.password
|
||||
AutofillHints.password,
|
||||
],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted:
|
||||
|
||||
Reference in New Issue
Block a user