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
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
@@ -60,18 +61,18 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted) return;
if (cached != null) {
setState(() {
_currentUserRole = cached.role.toLowerCase();
_currentUserRole = (cached.role).toLowerCase();
});
return;
}
final refreshed = await AuthService.refreshCurrentUser();
if (!mounted || refreshed == null) return;
setState(() {
_currentUserRole = refreshed.role.toLowerCase();
_currentUserRole = (refreshed.role).toLowerCase();
});
}
bool _isSuperAdmin(AppUser user) => user.role.toLowerCase() == 'super_admin';
bool _isSuperAdmin(AppUser user) => (user.role).toLowerCase() == 'super_admin';
bool _canEditAdmin(AppUser target) {
if (!_isSuperAdmin(target)) return true;
@@ -123,7 +124,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
: Icons.manage_accounts_outlined,
subtitleLines: [
user.email,
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? user.telephone : 'Non renseigné'}',
'Téléphone : ${user.telephone?.trim().isNotEmpty == true ? formatPhoneForDisplay(user.telephone!) : 'Non renseigné'}',
],
avatarUrl: user.photoUrl,
borderColor: isSuperAdmin
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
@@ -126,7 +127,7 @@ class _AssistanteMaternelleManagementWidgetState
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone),
value: _v(assistante.user.telephone) != '' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'admin_detail_modal.dart';
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
class ValidationDetailSection extends StatelessWidget {
final String title;
final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout;
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex;
const ValidationDetailSection({
super.key,
required this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0;
int rowIndex = 0;
final rows = <Widget>[];
for (final count in layout) {
if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList();
index += count;
if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex];
rowIndex++;
if (count == 1) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildFieldCell(rowFields.first),
));
} else {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: _buildFieldCell(rowFields[i]),
),
],
],
),
));
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
...rows,
],
);
}
Widget _buildFieldCell(AdminDetailField field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
field.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: field.value),
],
);
}
}
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
class ValidationReadOnlyField extends StatelessWidget {
final String value;
final int? maxLines;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
value,
style: const TextStyle(color: Colors.black87, fontSize: 14),
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
),
);
}
}
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
/// Sous-barre : Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [subTabCount] = 3 pour masquer l'onglet Administrateurs (dashboard gestionnaire).
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
@@ -11,8 +12,10 @@ class DashboardUserManagementSubBar extends StatelessWidget {
final VoidCallback? onAddPressed;
final String addLabel;
final int subTabCount;
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
final List<String>? tabLabels;
static const List<String> _tabLabels = [
static const List<String> _defaultTabLabels = [
'Gestionnaires',
'Parents',
'Assistantes maternelles',
@@ -29,10 +32,14 @@ class DashboardUserManagementSubBar extends StatelessWidget {
this.onAddPressed,
this.addLabel = '+ Ajouter',
this.subTabCount = 4,
this.tabLabels,
}) : super(key: key);
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
@override
Widget build(BuildContext context) {
final labels = _labels;
return Container(
height: 56,
decoration: BoxDecoration(
@@ -42,9 +49,9 @@ class DashboardUserManagementSubBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row(
children: [
for (int i = 0; i < subTabCount; i++) ...[
for (int i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
_buildSubNavItem(context, _tabLabels[i], i),
_buildSubNavItem(context, labels[i], i),
],
const SizedBox(width: 36),
_pillField(
@@ -68,7 +75,7 @@ class DashboardUserManagementSubBar extends StatelessWidget {
_pillField(width: 150, child: filterControl!),
],
const Spacer(),
_buildAddButton(),
if (onAddPressed != null) _buildAddButton(),
],
),
);
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
@@ -122,7 +123,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
),
AdminDetailField(
label: 'Telephone',
value: _v(parent.user.telephone),
value: _v(parent.user.telephone) != '' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
@@ -0,0 +1,354 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107.
class PendingValidationWidget extends StatefulWidget {
final VoidCallback? onRefresh;
const PendingValidationWidget({super.key, this.onRefresh});
@override
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
bool _isLoading = true;
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
setState(() {
_pendingAM = am;
_pendingFamilies = families;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
_isLoading = false;
});
}
}
void _onOpenValidation({String? type, String? id, String? numeroDossier}) {
final num = numeroDossier?.trim();
if (num == null || num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_load();
widget.onRefresh?.call();
},
),
);
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _error!.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _load,
child: const Text('Réessayer'),
),
],
),
),
);
}
final hasAM = _pendingAM.isNotEmpty;
final hasFamilies = _pendingFamilies.isNotEmpty;
if (!hasAM && !hasFamilies) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun dossier en attente de validation',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
await _load();
widget.onRefresh?.call();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (hasAM) ...[
_sectionTitle('Assistantes maternelles en attente'),
const SizedBox(height: 8),
..._pendingAM.map((u) => _buildAMCard(u)),
const SizedBox(height: 24),
],
if (hasFamilies) ...[
_sectionTitle('Familles en attente'),
const SizedBox(height: 8),
..._pendingFamilies.map((f) => _buildFamilyCard(f)),
],
],
),
),
);
}
Widget _sectionTitle(String title) {
return Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
);
}
/// Ligne commune : icône | titre (+ sous-titre) | bouton Ouvrir.
/// [titleWidget] remplace [title] si les deux sont fournis : priorité à [titleWidget].
Widget _buildPendingRow({
required IconData icon,
String? title,
Widget? titleWidget,
String? subtitle,
TextStyle? subtitleStyle,
required VoidCallback onOpen,
}) {
assert(title != null || titleWidget != null);
final titleChild = titleWidget ??
Text(
title!,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
);
final subStyle = subtitleStyle ??
TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: Colors.grey.shade600, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
titleChild,
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: subStyle,
),
],
],
),
),
ElevatedButton.icon(
onPressed: onOpen,
icon: const Icon(Icons.open_in_new, size: 18),
label: const Text('Ouvrir'),
),
],
),
),
);
}
/// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider).
String _amSubtitleLine(AppUser user) {
final email = user.email.trim();
final bits = <String>[];
bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal()));
final tel = user.telephone?.trim();
if (tel != null && tel.isNotEmpty) {
bits.add(formatPhoneForDisplay(tel));
}
final cp = user.codePostal?.trim();
final ville = user.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville]
.join(' ')
.trim();
if (loc.isNotEmpty) bits.add(loc);
final infos = bits.join('');
if (email.isEmpty) return infos;
return '$email - $infos';
}
Widget _buildAMCard(AppUser user) {
final numDossier = user.numeroDossier ?? '';
final nameBold =
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '');
return _buildPendingRow(
icon: Icons.person_outline,
titleWidget: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _amSubtitleLine(user),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'AM',
id: user.id,
numeroDossier: user.numeroDossier,
),
);
}
/// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`.
String _familyParentSegment(PendingParentLine p) {
final parts = <String>[];
final e = p.email?.trim();
if (e != null && e.isNotEmpty) parts.add(e);
final t = p.telephone?.trim();
if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t));
final cp = p.codePostal?.trim();
final v = p.ville?.trim();
final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v]
.join(' ')
.trim();
if (loc.isNotEmpty) parts.add(loc);
return parts.join(', ');
}
String _familySubtitleLine(PendingFamily family) {
final blocks = family.parentLines
.map(_familyParentSegment)
.where((s) => s.isNotEmpty)
.join(' - ');
final tail = <String>[];
final date = family.dateSoumission;
if (date != null) {
tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal()));
}
if (family.nombreEnfants > 0) {
tail.add(
family.nombreEnfants > 1
? '${family.nombreEnfants} enfants'
: '1 enfant',
);
}
final right = tail.join(' - ');
if (blocks.isEmpty && right.isEmpty) return '';
if (blocks.isEmpty) return right;
if (right.isEmpty) return blocks;
return '$blocks - $right';
}
Widget _buildFamilyCard(PendingFamily family) {
final numDossier = family.numeroDossier ?? '';
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
return _buildPendingRow(
icon: Icons.family_restroom_outlined,
titleWidget: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: [
TextSpan(
text: nameBold,
style: const TextStyle(fontWeight: FontWeight.w700),
),
TextSpan(
text: ' - $numDossier',
style: const TextStyle(fontWeight: FontWeight.w400),
),
],
),
),
subtitle: _familySubtitleLine(family),
subtitleStyle: TextStyle(
fontSize: 12,
fontStyle: FontStyle.italic,
color: Colors.grey.shade600,
),
onOpen: () => _onOpenValidation(
type: 'famille',
id: family.parentIds.isNotEmpty ? family.parentIds.first : null,
numeroDossier: family.numeroDossier,
),
);
}
}
@@ -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/services/relais_service.dart';
class RelaisManagementPanel extends StatefulWidget {
@@ -723,7 +724,7 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
_FrenchPhoneNumberFormatter(),
FrenchPhoneNumberFormatter(),
],
decoration: const InputDecoration(
labelText: 'Ligne fixe',
@@ -991,30 +992,6 @@ class _RelaisFormDialogState extends State<_RelaisFormDialog> {
}
}
class _FrenchPhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i > 0 && i.isEven) {
buffer.write(' ');
}
buffer.write(digits[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
class _RelaisAddressFields extends StatelessWidget {
final TextEditingController streetController;
final TextEditingController postalCodeController;
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
@@ -26,12 +28,41 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
_loadPending();
}
Future<void> _loadPending() async {
try {
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
if (!mounted) return;
final hasPending = am.isNotEmpty || families.isNotEmpty;
setState(() {
final hadPending = _hasPending;
_hasPending = hasPending;
_pendingLoading = false;
// Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît).
if (hadPending && !hasPending) {
_subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1);
} else if (!hadPending && hasPending) {
_subIndex = 0; // Afficher l'onglet À valider
}
});
} catch (_) {
if (!mounted) return;
setState(() {
_hasPending = false;
_pendingLoading = false;
});
}
}
@override
@@ -48,8 +79,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
setState(() {});
}
List<String> get _tabLabels {
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
final withAdmin = [...base, 'Administrateurs'];
final list = widget.showAdministrateursTab ? withAdmin : base;
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
return list;
}
void _onSubTabChange(int index) {
final maxIndex = widget.showAdministrateursTab ? 3 : 2;
final maxIndex = _tabLabels.length - 1;
setState(() {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
@@ -58,14 +98,20 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
});
}
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
switch (contentIndex) {
case -1:
return 'À valider (pas de recherche)';
case 0:
return 'Rechercher un gestionnaire...';
case 1:
return 'Rechercher un parent...';
case 2:
case 1:
return 'Rechercher une assistante...';
case 2:
return 'Rechercher un gestionnaire...';
case 3:
return 'Rechercher un administrateur...';
default:
@@ -74,7 +120,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Widget? _subBarFilterControl() {
if (_subIndex == 1) {
if (_subIndex == _contentIndexOffset + 0) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
@@ -122,7 +168,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
if (_subIndex == 2) {
if (_subIndex == _contentIndexOffset + 1) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@@ -139,22 +185,26 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Widget _buildBody() {
switch (_subIndex) {
final contentIndex = _subIndex - _contentIndexOffset;
if (_hasPending && !_pendingLoading && contentIndex == -1) {
return PendingValidationWidget(onRefresh: _loadPending);
}
switch (contentIndex) {
case 0:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
case 1:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 2:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 3:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
@@ -167,7 +217,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
@override
Widget build(BuildContext context) {
final subTabCount = widget.showAdministrateursTab ? 4 : 3;
final labels = _tabLabels;
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
return Column(
children: [
DashboardUserManagementSubBar(
@@ -176,9 +227,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: _handleAddPressed,
onAddPressed: isAValiderTab ? null : _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: subTabCount,
subTabCount: labels.length,
tabLabels: labels,
),
Expanded(child: _buildBody()),
],
@@ -186,7 +238,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Future<void> _handleAddPressed() async {
if (_subIndex == 0) {
final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 2) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -204,7 +257,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (_subIndex == 3) {
if (contentIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -0,0 +1,507 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
/// Wizard de validation dossier AM : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationAmWizard extends StatefulWidget {
final DossierAM dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationAmWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
@override
State<ValidationAmWizard> createState() => _ValidationAmWizardState();
}
class _ValidationAmWizardState extends State<ValidationAmWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
static const int _stepCount = 3;
bool get _isEnAttente => widget.dossier.user.statut == 'en_attente';
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : '';
/// Présentation lisible : `1 12 34 56 789 012 - 34` (15 caractères utiles requis).
static String _formatNirForDisplay(String? nir) {
final v = _v(nir);
if (v == '') return v;
final raw = nirToRaw(v).toUpperCase();
return raw.length == 15 ? formatNir(raw) : v;
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _personalFields(AppUser u) => [
AdminDetailField(label: 'Nom', value: _v(u.nom)),
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(u.telephone) != ''
? formatPhoneForDisplay(_v(u.telephone))
: ''),
AdminDetailField(label: 'Email', value: _v(u.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
AdminDetailField(label: 'Ville', value: _v(u.ville)),
];
/// Informations professionnelles : N° Agrément|Date agrément, NIR, Capacité|Places, Ville.
List<AdminDetailField> _proFields(DossierAM d) => [
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
AdminDetailField(
label: 'Date dagrément',
value: d.dateAgrement != null && d.dateAgrement!.trim().isNotEmpty
? d.dateAgrement!.trim()
: '',
),
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
AdminDetailField(
label: 'Capacité max (enfants)',
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '',
),
AdminDetailField(
label: 'Places disponibles',
value: d.placesDisponibles != null
? d.placesDisponibles.toString()
: '',
),
AdminDetailField(
label: 'Ville de résidence', value: _v(d.villeResidence)),
];
static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
/// Proportion photo didentité (35×45 mm).
static const double _idPhotoAspectRatio = 35 / 45;
static const double _photoProGap = 24;
/// Largeur mini réservée aux champs (évite une colonne photo trop gourmande).
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
/// URL complète pour la photo : si relatif, on préfixe par lorigine de lAPI.
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
Widget _buildPhotoSection(AppUser u) {
final photoUrl = _fullPhotoUrl(u.photoUrl);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Photo de profil',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: LayoutBuilder(
builder: (context, c) {
// Cadre clair : une seule épaisseur partout (photo + padding identique haut/bas/gauche/droite).
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = _idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
return Align(
alignment: Alignment.center,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: photoUrl.isEmpty
? ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Aucune photo fournie',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
)
: Image.network(
photoUrl,
fit: BoxFit.cover,
width: pw,
height: ph,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: CircularProgressIndicator(
value: progress.expectedTotalBytes !=
null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined,
size: 40,
color: Colors.grey.shade400),
const SizedBox(height: 8),
Text(
'Impossible de charger la photo',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12),
),
],
),
),
),
),
),
),
),
);
},
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
if (_showRefusForm) {
return _buildRefusPage();
}
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Expanded(child: _buildStepContent()),
const SizedBox(height: 24),
_buildNavigation(),
],
),
);
}
Widget _buildStepContent() {
final d = widget.dossier;
final u = d.user;
switch (_step) {
case 0:
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: ValidationDetailSection(
title: 'Informations personnelles',
fields: _personalFields(u),
rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex,
),
),
);
},
);
case 1:
// Pas de SingleChildScrollView sur la Row (hauteur non bornée). Défilement à droite.
// Largeur photo ≈ ratio × hauteur utile, plafonnée pour laisser au moins [_proColumnMinWidth] aux champs.
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
// Titre « Photo de profil » + espacement (~52 px) : hauteur dispo pour le cadre photo.
const photoHeaderH = 52.0;
final bodyH = (maxRowH - photoHeaderH).clamp(0.0, double.infinity);
final idealPhotoW =
bodyH * _idPhotoAspectRatio + 16; // marge approx. cadre clair
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 360.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
photoW = photoW.clamp(0.0, maxRowW - _photoProGap);
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: _buildPhotoSection(u),
),
const SizedBox(width: _photoProGap),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: constraints.maxWidth),
child: ValidationDetailSection(
title: 'Informations professionnelles',
fields: _proFields(d),
rowLayout: const [
2,
1,
2,
1
], // N° Agrément|Date agrément, NIR, Capacité|Places, Ville
),
),
);
},
),
),
],
);
},
);
case 2:
final presentation =
(d.presentation != null && d.presentation!.trim().isNotEmpty)
? d.presentation!
: '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Présentation',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87),
),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints:
BoxConstraints(minHeight: constraints.maxHeight),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
presentation,
style: const TextStyle(
color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
default:
return const SizedBox();
}
}
Widget _buildNavigation() {
if (_step == 2) {
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() => _step = 1);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (_isEnAttente) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
child: const Text('Refuser')),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _onValiderPressed,
child: Text(_submitting ? 'Envoi...' : 'Valider'),
),
] else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: widget.onClose,
child: const Text('Fermer'),
),
],
),
],
);
}
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
if (_step > 0) ...[
TextButton(
onPressed: () {
setState(() => _step--);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
],
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
setState(() => _step++);
_emitStep();
},
child: const Text('Suivant'),
),
],
);
}
Future<void> _onValiderPressed() async {
if (_submitting) return;
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Voulez-vous valider le dossier de cette assistante maternelle ? Cette action confirme le compte.',
);
if (!mounted || !ok) return;
await _valider();
}
Future<void> _valider() async {
if (_submitting) return;
setState(() => _submitting = true);
try {
await UserService.validateUser(widget.dossier.user.id);
if (!mounted) return;
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _refuser() => setState(() => _showRefusForm = true);
Widget _buildRefusPage() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ValidationRefusForm(
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Refus (à brancher sur lAPI refus)')),
);
widget.onClose();
},
),
),
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille. Ticket #107, #119.
class ValidationDossierModal extends StatefulWidget {
final String numeroDossier;
final VoidCallback onClose;
final VoidCallback? onSuccess;
const ValidationDossierModal({
super.key,
required this.numeroDossier,
required this.onClose,
this.onSuccess,
});
@override
State<ValidationDossierModal> createState() => _ValidationDossierModalState();
}
class _ValidationDossierModalState extends State<ValidationDossierModal> {
bool _loading = true;
String? _error;
DossierUnifie? _dossier;
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
// step = 0-based dans les wizards, affichage 1-based dans l'en-tête.
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
_dossier = null;
_stepIndex = null;
_stepTotal = null;
});
try {
final d = await UserService.getDossier(widget.numeroDossier);
if (!mounted) return;
setState(() {
_dossier = d;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
void _onSuccess() {
widget.onSuccess?.call();
// La modale est fermée par lappelant dans onSuccess (Navigator.pop).
}
/// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5
// Hauteur uniforme (ajustée +5px pour éviter l'overflow des étapes parents sans scroll).
static const double _bodyHeight = 435;
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
final showStep =
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Dossier ${widget.numeroDossier}',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (showStep) ...[
Text(
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.black54,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: _buildBody(),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
);
}
if (_error != null && _error!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _load, child: const Text('Réessayer')),
const SizedBox(height: 8),
TextButton(onPressed: widget.onClose, child: const Text('Fermer')),
],
),
);
}
final d = _dossier!;
if (d.isAm) {
return ValidationAmWizard(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
}
@@ -0,0 +1,730 @@
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
import 'validation_valider_confirm_dialog.dart';
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
class ValidationFamilyWizard extends StatefulWidget {
final DossierFamille dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationFamilyWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
@override
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
}
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
int _step = 0;
bool _showRefusForm = false;
bool _submitting = false;
final ScrollController _enfantsScrollController = ScrollController();
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
bool _enfantsIsScrollable = false;
bool _enfantsFadeLeft = false;
bool _enfantsFadeRight = false;
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
static const double _enfantsFadeExtent = 0.05;
int get _stepCount => 4;
@override
void initState() {
super.initState();
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
}
@override
void dispose() {
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
_enfantsScrollController.dispose();
super.dispose();
}
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
void _syncEnfantsScrollFades() {
if (!mounted) return;
if (!_enfantsScrollController.hasClients) {
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
setState(() {
_enfantsIsScrollable = false;
_enfantsFadeLeft = false;
_enfantsFadeRight = false;
});
}
return;
}
final p = _enfantsScrollController.position;
final scrollable = p.maxScrollExtent > 0;
final left = scrollable &&
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
final right = scrollable &&
p.pixels <
(p.maxScrollExtent -
(p.viewportDimension * _enfantsFadeExtent / 2));
if (scrollable != _enfantsIsScrollable ||
left != _enfantsFadeLeft ||
right != _enfantsFadeRight) {
setState(() {
_enfantsIsScrollable = scrollable;
_enfantsFadeLeft = left;
_enfantsFadeRight = right;
});
}
}
bool get _isEnAttente => widget.dossier.isEnAttente;
String? get _firstParentId => widget.dossier.parents.isNotEmpty
? widget.dossier.parents.first.id
: null;
static String _v(String? s) =>
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
static String _formatBirthDate(String? s) {
if (s == null || s.trim().isEmpty) return 'Non défini';
try {
final d = DateTime.parse(s.trim());
return DateFormat('dd/MM/yyyy').format(d);
} catch (_) {
return s.trim();
}
}
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [
AdminDetailField(label: 'Nom', value: _v(p.nom)),
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(p.telephone) != 'Non défini'
? formatPhoneForDisplay(_v(p.telephone))
: 'Non défini'),
AdminDetailField(label: 'Email', value: _v(p.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
AdminDetailField(label: 'Ville', value: _v(p.ville)),
];
static const List<int> _parentRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _parentRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) {
if (url == null || url.trim().isEmpty) return '';
final u = url.trim();
if (u.startsWith('http://') || u.startsWith('https://')) return u;
final base = ApiConfig.baseUrl;
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
}
@override
Widget build(BuildContext context) {
if (_showRefusForm) {
return _buildRefusPage();
}
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
Expanded(child: _buildStepContent()),
const SizedBox(height: 24),
_buildNavigation(),
],
),
);
}
Widget _buildStepContent() {
final d = widget.dossier;
switch (_step) {
case 0:
return ValidationDetailSection(
title: 'Parent principal',
fields: _parentFields(d.parents.first),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
case 1:
return _buildParent2Step();
case 2:
return _buildEnfantsStep();
case 3:
return _buildPresentationStep();
default:
return const SizedBox();
}
}
Widget _buildParent2Step() {
if (widget.dossier.parents.length < 2) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Un seul parent pour ce dossier.',
style: TextStyle(color: Colors.black87)),
);
}
return ValidationDetailSection(
title: 'Deuxième parent',
fields: _parentFields(widget.dossier.parents[1]),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
}
static const double _idPhotoAspectRatio = 35 / 45;
Widget _buildEnfantsStep() {
final enfants = widget.dossier.enfants;
if (enfants.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text('Aucun enfant renseigné.',
style: TextStyle(color: Colors.black87)),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Enfants',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final cardHeight = constraints.maxHeight;
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
return NotificationListener<ScrollMetricsNotification>(
onNotification: (_) {
_syncEnfantsScrollFades();
return false;
},
child: ShaderMask(
blendMode: BlendMode.dstIn,
shaderCallback: (Rect bounds) {
final stops = <double>[
0.0,
_enfantsFadeExtent,
1.0 - _enfantsFadeExtent,
1.0,
];
if (!_enfantsIsScrollable) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: const <Color>[
Colors.black,
Colors.black,
Colors.black,
Colors.black,
],
stops: stops,
).createShader(bounds);
}
final leftMask =
_enfantsFadeLeft ? Colors.transparent : Colors.black;
final rightMask =
_enfantsFadeRight ? Colors.transparent : Colors.black;
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[
leftMask,
Colors.black,
Colors.black,
rightMask,
],
stops: stops,
).createShader(bounds);
},
child: Listener(
onPointerSignal: (event) {
if (event is PointerScrollEvent &&
_enfantsScrollController.hasClients) {
final offset = _enfantsScrollController.offset +
event.scrollDelta.dy;
_enfantsScrollController.jumpTo(offset.clamp(
_enfantsScrollController.position.minScrollExtent,
_enfantsScrollController.position.maxScrollExtent,
));
}
},
child: ListView.builder(
controller: _enfantsScrollController,
scrollDirection: Axis.horizontal,
itemCount: enfants.length,
itemBuilder: (_, i) => Padding(
padding: EdgeInsets.only(
right: i < enfants.length - 1 ? 16 : 0),
child: SizedBox(
width: cardWidth,
height: cardHeight,
child: _buildEnfantCard(enfants[i]),
),
),
),
),
),
);
},
),
),
],
);
}
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
static const double _enfantCardRadius = 12;
static List<BoxShadow> _enfantCardShadows() => [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 14,
offset: const Offset(0, 4),
),
];
static BoxDecoration _enfantCardDecoration(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'H') {
return BoxDecoration(
color: _enfantCardBoyBg,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: _enfantCardBoyBorder, width: 1),
boxShadow: _enfantCardShadows(),
);
}
if (g == 'F') {
return BoxDecoration(
color: _enfantCardGirlBg,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: _enfantCardGirlBorder, width: 1),
boxShadow: _enfantCardShadows(),
);
}
return BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(_enfantCardRadius),
border: Border.all(color: Colors.grey.shade300),
boxShadow: _enfantCardShadows(),
);
}
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
Widget _buildEnfantCard(EnfantDossier e) {
final photoUrl = _fullPhotoUrl(e.photoUrl);
final columnStatusLabel = _enfantColumnStatusLabel(e);
return ClipRRect(
borderRadius: BorderRadius.circular(_enfantCardRadius),
child: Container(
decoration: _enfantCardDecoration(e.gender),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
child: _enfantLabeledField('Prénom', _v(e.firstName)),
),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
flex: 1,
child: LayoutBuilder(
builder: (context, c) {
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
const padL = 12.0;
const padR = 8.0;
const padV = 8.0;
final maxW =
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
final maxH =
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
const ar = _idPhotoAspectRatio;
double ph = maxH;
double pw = ph * ar;
if (pw > maxW) {
pw = maxW;
ph = pw / ar;
}
return Padding(
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
child: Align(
alignment: Alignment.centerLeft,
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
),
);
},
),
),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
child: columnStatusLabel == null
? SingleChildScrollView(
child: _buildEnfantInfoFields(e),
)
: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _buildEnfantInfoFields(e),
),
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Text(
columnStatusLabel,
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 14,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
),
),
],
),
),
),
],
),
),
],
),
),
);
}
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
static String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
/// `actif` : pas de ligne statut.
String? _enfantColumnStatusLabel(EnfantDossier e) {
final s = (e.status ?? '').trim().toLowerCase();
if (s == 'a_naitre') return 'À naître';
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
return null;
}
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
Widget _buildEnfantInfoFields(EnfantDossier e) {
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
final dateValue = isANaitre
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : ' (P)')
: _formatBirthDate(e.birthDate);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: _enfantLabeledField('Date de naissance', dateValue),
),
const SizedBox(width: 16),
Expanded(
flex: 2,
child: _enfantLabeledField(
'Genre',
_genreEnfantLabel(e.gender, e.status),
),
),
],
),
],
);
}
Widget _enfantLabeledField(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: value),
],
);
}
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
clipBehavior: Clip.antiAlias,
child: photoUrl.isEmpty
? ColoredBox(
color: Colors.grey.shade100,
child: Center(
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
),
)
: Image.network(
photoUrl,
fit: BoxFit.contain,
width: width,
height: height,
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade100,
child: Center(
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
),
),
),
);
}
static String _formatNom(String? lastName) {
final n = (lastName ?? '').trim().toUpperCase();
return n.isEmpty ? 'Non défini' : n;
}
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
static String _genreEnfantLabel(String? gender, String? status) {
final g = (gender ?? '').trim().toUpperCase();
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
if (g == 'H') return 'Garçon';
if (g == 'F') return 'Fille';
if (isANaitre) return 'Non connu';
if (g.isEmpty) return 'Non défini';
return (gender ?? '').trim();
}
Widget _buildPresentationStep() {
final p = widget.dossier.presentation ?? '';
final text = p.trim().isEmpty ? 'Non défini' : p;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text(
'Présentation / Motivation',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
),
const SizedBox(height: 12),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
),
child: SelectableText(
text,
style:
const TextStyle(color: Colors.black87, fontSize: 14),
),
),
),
);
},
),
),
],
);
}
Widget _buildNavigation() {
if (_step == 3) {
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: () {
setState(() => _step = 2);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (_isEnAttente && _firstParentId != null) ...[
OutlinedButton(
onPressed: _submitting ? null : _refuser,
child: const Text('Refuser')),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: _submitting ? null : _onValiderPressed,
child: Text(_submitting ? 'Envoi...' : 'Valider'),
),
] else if (!_isEnAttente)
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: widget.onClose,
child: const Text('Fermer'),
),
],
),
],
);
}
return Row(
children: [
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
const Spacer(),
if (_step > 0) ...[
TextButton(
onPressed: () {
setState(() => _step--);
_emitStep();
},
child: const Text('Précédent'),
),
const SizedBox(width: 8),
],
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
setState(() => _step++);
_emitStep();
},
child: const Text('Suivant'),
),
],
);
}
Future<void> _onValiderPressed() async {
if (_submitting || _firstParentId == null) return;
final ok = await showValidationValiderConfirmDialog(
context,
body:
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
);
if (!mounted || !ok) return;
await _valider();
}
Future<void> _valider() async {
if (_submitting || _firstParentId == null) return;
setState(() => _submitting = true);
try {
await UserService.validerDossierFamille(_firstParentId!);
if (!mounted) return;
widget.onSuccess();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur'),
backgroundColor: Colors.red.shade700,
),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _refuser() => setState(() => _showRefusForm = true);
Widget _buildRefusPage() {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ValidationRefusForm(
onCancel: widget.onClose,
onPrevious: () => setState(() => _showRefusForm = false),
onSubmit: (comment) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Refus (à brancher sur lAPI refus ticket #110)')),
);
widget.onClose();
},
),
),
],
),
);
}
}
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin).
abstract final class ValidationModalTheme {
/// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`).
static const Color primaryActionBackground = Color(0xFF6D4EA1);
static const Color primaryActionForeground = Colors.white;
static ButtonStyle get primaryElevatedStyle {
return ElevatedButton.styleFrom(
backgroundColor: primaryActionBackground,
foregroundColor: primaryActionForeground,
disabledBackgroundColor: primaryActionBackground.withOpacity(0.45),
disabledForegroundColor: primaryActionForeground.withOpacity(0.7),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107.
class ValidationRefusForm extends StatefulWidget {
/// Ferme la modale (abandon du flux).
final VoidCallback onCancel;
/// Retour à l’étape précédente du wizard (écran avec Valider / Refuser).
final VoidCallback onPrevious;
final ValueChanged<String?> onSubmit;
const ValidationRefusForm({
super.key,
required this.onCancel,
required this.onPrevious,
required this.onSubmit,
});
@override
State<ValidationRefusForm> createState() => _ValidationRefusFormState();
}
class _ValidationRefusFormState extends State<ValidationRefusForm> {
final _controller = TextEditingController();
final _formKey = GlobalKey<FormState>();
static const int _minLength = 20;
String? _validateMotifs(String? value) {
final t = value?.trim() ?? '';
if (t.isEmpty) return 'Les motifs du refus sont obligatoires.';
if (t.length < _minLength) {
return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).';
}
return null;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Indiquez les motifs du refus',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade400),
),
child: TextFormField(
controller: _controller,
maxLines: null,
minLines: 1,
validator: _validateMotifs,
decoration: InputDecoration(
hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
alignLabelWithHint: true,
filled: false,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
);
},
),
),
const SizedBox(height: 24),
Row(
children: [
TextButton(
onPressed: widget.onCancel,
child: const Text('Annuler'),
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: widget.onPrevious,
child: const Text('Précédent'),
),
const SizedBox(width: 8),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(_controller.text.trim());
}
},
child: const Text('Envoyer'),
),
],
),
],
),
],
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Affiche une confirmation avant dappeler lAPI de validation du dossier.
/// Retourne `true` si lutilisateur confirme.
Future<bool> showValidationValiderConfirmDialog(
BuildContext context, {
required String body,
}) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Confirmer la validation'),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Confirmer'),
),
],
);
},
);
return result == true;
}