diff --git a/frontend/lib/models/dossier_unifie.dart b/frontend/lib/models/dossier_unifie.dart index 07b2d56..78b2fb3 100644 --- a/frontend/lib/models/dossier_unifie.dart +++ b/frontend/lib/models/dossier_unifie.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107. class DossierUnifie { @@ -220,7 +221,7 @@ class EnfantDossier { lastName: (json['last_name'] ?? json['nom'])?.toString(), birthDate: json['birth_date']?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(), - status: json['status']?.toString(), + status: normalizeEnfantStatus(json['status']?.toString()), dueDate: json['due_date']?.toString(), photoUrl: resolvedPhoto, consentPhoto: diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 396e263..5c7c84d 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Enfant tel que renvoyé par `GET /enfants` (dashboard admin). class EnfantAdminModel { @@ -54,7 +55,7 @@ class EnfantAdminModel { gender: json['gender'] as String?, birthDate: _dateString(json['birth_date']), dueDate: _dateString(json['due_date']), - status: (json['status'] ?? '').toString(), + status: normalizeEnfantStatus(json['status']?.toString()), photoUrl: json['photo_url'] as String?, consentPhoto: json['consent_photo'] == true, isMultiple: json['is_multiple'] == true, diff --git a/frontend/lib/models/parent_child_summary.dart b/frontend/lib/models/parent_child_summary.dart index 837ff4b..3a30bcb 100644 --- a/frontend/lib/models/parent_child_summary.dart +++ b/frontend/lib/models/parent_child_summary.dart @@ -1,4 +1,5 @@ import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Résumé enfant affiché dans la fiche parent (dashboard admin). class ParentChildSummary { @@ -33,7 +34,9 @@ class ParentChildSummary { id: (json['id'] ?? '').toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(), - status: (json['status'] ?? json['statut'] ?? '').toString(), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), photoUrl: json['photo_url']?.toString(), birthDate: _dateString(json['birth_date']), dueDate: _dateString(json['due_date']), diff --git a/frontend/lib/utils/date_display_utils.dart b/frontend/lib/utils/date_display_utils.dart index 9836e82..61ecb5e 100644 --- a/frontend/lib/utils/date_display_utils.dart +++ b/frontend/lib/utils/date_display_utils.dart @@ -1,4 +1,5 @@ import 'package:intl/intl.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty]. String formatIsoDateFr(String? s, {String ifEmpty = '–'}) { @@ -63,7 +64,7 @@ String formatChildAgeLabel({ String? dueDate, String? status, }) { - if (status == 'a_naitre') { + if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') { final due = formatIsoDateFr(dueDate, ifEmpty: ''); if (due.isNotEmpty) return 'Naissance prévue : $due'; return 'À naître'; diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart new file mode 100644 index 0000000..9ea84b5 --- /dev/null +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -0,0 +1,51 @@ +/// Statuts enfant alignés sur `statut_enfant_type` (BDD / back #131). +const enfantStatusValues = [ + 'a_naitre', + 'sans_garde', + 'garde', + 'scolarise', +]; + +/// Normalise une valeur API (legacy `actif` → `sans_garde`). +String normalizeEnfantStatus(String? raw) { + final s = (raw ?? '').trim().toLowerCase(); + if (s.isEmpty) return 'sans_garde'; + if (s == 'actif') return 'sans_garde'; + return s; +} + +String _scolariseAccordeAuGenre(String? gender) { + final g = (gender ?? '').trim().toUpperCase(); + if (g == 'F') return 'Scolarisée'; + return 'Scolarisé'; +} + +/// Libellé affiché pour un statut enfant. +String enfantStatusLabel(String? status, {String? gender}) { + switch (normalizeEnfantStatus(status)) { + case 'a_naitre': + return 'À naître'; + case 'sans_garde': + return 'Sans garde'; + case 'garde': + return 'En garde'; + case 'scolarise': + return _scolariseAccordeAuGenre(gender); + default: + return status?.trim().isNotEmpty == true ? status!.trim() : '–'; + } +} + +/// Libellé court pour colonnes validation (null = pas de bandeau). +String? enfantColumnStatusLabel({String? status, String? gender}) { + final s = normalizeEnfantStatus(status); + switch (s) { + case 'a_naitre': + case 'sans_garde': + case 'garde': + case 'scolarise': + return enfantStatusLabel(s, gender: gender); + default: + return null; + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart new file mode 100644 index 0000000..b77871c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_child_summary.dart'; +import 'package:p_tits_pas/services/api/api_config.dart'; +import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; + +/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]). +class AdminAmChildrenCapacityGrid extends StatelessWidget { + static const int _gridSlots = 4; + static const double _slotHeight = 44; + static const double _gridPadding = 10; + static const double _gridGap = 8; + static const double _borderWidth = 1; + static const double fixedHeight = _borderWidth * 2 + + _gridPadding * 2 + + _slotHeight * 2 + + _gridGap; + + final List children; + final int capacity; + final void Function(ParentChildSummary child) onOpen; + final void Function(ParentChildSummary child) onDetach; + + const AdminAmChildrenCapacityGrid({ + super.key, + required this.children, + required this.capacity, + required this.onOpen, + required this.onDetach, + }); + + @override + Widget build(BuildContext context) { + final maxSlots = capacity.clamp(0, _gridSlots); + + return Container( + height: fixedHeight, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300, width: _borderWidth), + ), + padding: const EdgeInsets.all(_gridPadding), + child: Column( + children: [ + _buildRow(0, 1, maxSlots), + const SizedBox(height: _gridGap), + _buildRow(2, 3, maxSlots), + ], + ), + ); + } + + Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) { + return Row( + children: [ + Expanded(child: _buildSlot(leftIndex, maxSlots)), + const SizedBox(width: _gridGap), + Expanded(child: _buildSlot(rightIndex, maxSlots)), + ], + ); + } + + Widget _buildSlot(int index, int maxSlots) { + return SizedBox( + height: _slotHeight, + child: _buildSlotContent(index, maxSlots), + ); + } + + Widget _buildSlotContent(int index, int maxSlots) { + if (index < children.length) { + return _OccupiedSlot( + child: children[index], + overCapacity: index >= maxSlots, + onOpen: () => onOpen(children[index]), + onDetach: () => onDetach(children[index]), + ); + } + if (index < maxSlots) { + return const _EmptySlot(); + } + return const _UnavailableSlot(); + } +} + +class _SlotShell extends StatelessWidget { + final Color backgroundColor; + final Color borderColor; + final Widget child; + + const _SlotShell({ + required this.backgroundColor, + required this.borderColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: DecoratedBox( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: borderColor), + ), + child: child, + ), + ); + } +} + +class _UnavailableSlot extends StatelessWidget { + const _UnavailableSlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade100, + borderColor: Colors.grey.shade200, + child: Center( + child: Icon( + Icons.block_outlined, + size: 20, + color: Colors.grey.shade400, + ), + ), + ); + } +} + +class _EmptySlot extends StatelessWidget { + const _EmptySlot(); + + @override + Widget build(BuildContext context) { + return _SlotShell( + backgroundColor: Colors.grey.shade50, + borderColor: Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ); + } +} + +class _OccupiedSlot extends StatefulWidget { + final ParentChildSummary child; + final bool overCapacity; + final VoidCallback onOpen; + final VoidCallback onDetach; + + const _OccupiedSlot({ + required this.child, + required this.overCapacity, + required this.onOpen, + required this.onDetach, + }); + + @override + State<_OccupiedSlot> createState() => _OccupiedSlotState(); +} + +class _OccupiedSlotState extends State<_OccupiedSlot> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final age = formatChildAgeLabel( + birthDate: widget.child.birthDate, + dueDate: widget.child.dueDate, + status: widget.child.status, + ); + final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? ''); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: _SlotShell( + backgroundColor: widget.overCapacity + ? Colors.red.shade50 + : const Color(0xFFF8F5FC), + borderColor: widget.overCapacity + ? Colors.red.shade300 + : const Color(0xFFD8CCE8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildAvatar(avatarUrl), + const SizedBox(width: 8), + Expanded( + child: Row( + children: [ + Flexible( + fit: FlexFit.loose, + child: Text( + widget.child.fullName, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (age.isNotEmpty) ...[ + const SizedBox(width: 8), + Expanded( + child: Text( + age, + style: const TextStyle( + fontSize: 11, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ), + ), + AnimatedOpacity( + duration: const Duration(milliseconds: 120), + opacity: _hovered ? 1 : 0, + child: IgnorePointer( + ignoring: !_hovered, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Voir / modifier', + icon: const Icon(Icons.visibility_outlined, size: 20), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onOpen, + ), + IconButton( + tooltip: 'Détacher', + icon: Icon( + Icons.link_off, + size: 20, + color: Colors.orange.shade800, + ), + padding: const EdgeInsets.all(4), + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + onPressed: widget.onDetach, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildAvatar(String url) { + const size = 28.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + + if (url.isEmpty) { + return CircleAvatar( + radius: 14, + backgroundColor: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ); + } + + return ClipOval( + child: SizedBox( + width: size, + height: size, + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => ColoredBox( + color: bg, + child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart index 95e85e2..082356e 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -8,9 +8,9 @@ import 'package:p_tits_pas/utils/date_display_utils.dart'; import 'package:p_tits_pas/utils/nir_utils.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_am_children_capacity_grid.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; -import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; @@ -51,8 +51,8 @@ class _AdminAmEditModalState extends State late String _statut; late bool _disponible; + late int? _placesAvailable; late List _children; - late final ScrollController _childrenScrollCtrl; bool _saving = false; bool _dirty = false; @@ -64,12 +64,24 @@ class _AdminAmEditModalState extends State static const double _proTabHeight = 300; static const List _photoProRowLayout = [2, 2, 2]; + /// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin). + double _childrenTabHeight() { + const capacityFields = 72.0; + const titleSection = 40.0; + const inconsistencyExtra = 46.0; + var h = capacityFields + + titleSection + + AdminAmChildrenCapacityGrid.fixedHeight; + if (_placesInconsistent()) h += inconsistencyExtra; + return h + 4; + } + double _tabViewHeight(int index) { switch (index) { case 1: return _proTabHeight; case 2: - return 280; + return _childrenTabHeight(); default: return 292; } @@ -110,8 +122,8 @@ class _AdminAmEditModalState extends State ); _statut = u.statut ?? 'en_attente'; _disponible = am.available ?? true; + _placesAvailable = am.placesAvailable; _children = List.of(am.children); - _childrenScrollCtrl = ScrollController(); for (final c in [ _nomCtrl, @@ -167,7 +179,6 @@ class _AdminAmEditModalState extends State ]) { c.dispose(); } - _childrenScrollCtrl.dispose(); super.dispose(); } @@ -217,19 +228,27 @@ class _AdminAmEditModalState extends State bool _placesInconsistent() => amHasPlacesInconsistency( maxChildren: _capaciteMax(), - placesAvailable: widget.assistante.placesAvailable, + placesAvailable: _placesAvailable, childrenCount: _children.length, ); String _placesDisplayValue() { - final stored = widget.assistante.placesAvailable; - if (stored != null) return stored.toString(); + if (_placesAvailable != null) return _placesAvailable.toString(); return '–'; } + void _applyPlacesCorrection() { + final expected = _computedPlacesAvailable(); + if (expected == null) return; + setState(() { + _placesAvailable = expected; + _dirty = true; + }); + } + String? _placesInconsistencyMessage() { if (!_placesInconsistent()) return null; - final stored = widget.assistante.placesAvailable; + final stored = _placesAvailable; final expected = _computedPlacesAvailable(); final storedLabel = stored?.toString() ?? 'non renseigné'; final expectedLabel = expected?.toString() ?? '–'; @@ -289,6 +308,8 @@ class _AdminAmEditModalState extends State if (_frDateToIso(_dateAgrementCtrl.text) != null) 'agreement_date': _frDateToIso(_dateAgrementCtrl.text), if (_capaciteMax() != null) 'max_children': _capaciteMax(), + if (_placesAvailable != widget.assistante.placesAvailable) + 'places_available': _placesAvailable, 'available': _disponible, }, ); @@ -363,9 +384,7 @@ class _AdminAmEditModalState extends State ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), + style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Détacher'), ), ], @@ -470,13 +489,11 @@ class _AdminAmEditModalState extends State return ValidationFormGrid( title: 'Dossier professionnel', rowLayout: _photoProRowLayout, - compact: true, fields: [ ValidationLabeledField( label: 'NIR', field: ValidationEditableField( controller: _nirCtrl, - compact: true, hintText: '15 chiffres', inputFormatters: [ FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), @@ -487,7 +504,6 @@ class _AdminAmEditModalState extends State label: 'Date de naissance', field: ValidationEditableField( controller: _dateNaissanceCtrl, - compact: true, hintText: 'jj/mm/aaaa', ), ), @@ -495,28 +511,24 @@ class _AdminAmEditModalState extends State label: 'Ville de naissance', field: ValidationEditableField( controller: _lieuNaissanceVilleCtrl, - compact: true, ), ), ValidationLabeledField( label: 'Pays de naissance', field: ValidationEditableField( controller: _lieuNaissancePaysCtrl, - compact: true, ), ), ValidationLabeledField( label: 'N° Agrément', field: ValidationEditableField( controller: _agrementCtrl, - compact: true, ), ), ValidationLabeledField( label: 'Date d\'agrément', field: ValidationEditableField( controller: _dateAgrementCtrl, - compact: true, hintText: 'jj/mm/aaaa', ), ), @@ -583,7 +595,6 @@ class _AdminAmEditModalState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ValidationEditableSection( - compact: true, rowLayout: const [2], fields: [ ValidationLabeledField( @@ -592,14 +603,12 @@ class _AdminAmEditModalState extends State controller: _capaciteCtrl, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], - compact: true, ), ), ValidationLabeledField( label: 'Places disponibles', field: ValidationReadOnlyField( value: _placesDisplayValue(), - compact: true, error: inconsistent, ), ), @@ -607,13 +616,37 @@ class _AdminAmEditModalState extends State ), if (inconsistent) ...[ const SizedBox(height: 6), - Text( - _placesInconsistencyMessage()!, - style: TextStyle( - fontSize: 12, - color: Colors.red.shade700, - height: 1.3, - ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 6, + runSpacing: 4, + children: [ + Text( + _placesInconsistencyMessage()!, + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + height: 1.3, + ), + ), + OutlinedButton( + onPressed: _saving ? null : _applyPlacesCorrection, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade800, + disabledForegroundColor: Colors.red.shade300, + side: BorderSide(color: Colors.red.shade400), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + child: const Text('Mettre à jour'), + ), + ], ), ], ], @@ -621,6 +654,7 @@ class _AdminAmEditModalState extends State } Widget _childrenTab() { + final capacity = (_capaciteMax() ?? 4).clamp(1, 4); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -634,20 +668,12 @@ class _AdminAmEditModalState extends State color: Colors.black87, ), ), - const SizedBox(height: 2), - Text( - 'Enfants rattachés à cette assistante (accueil / garde).', - style: TextStyle(fontSize: 12, color: Colors.grey.shade700), - ), const SizedBox(height: 8), - Expanded( - child: AdminChildrenAffiliationPanel( - children: _children, - scrollController: _childrenScrollCtrl, - onOpen: _openChild, - onDetach: _detachChild, - emptyMessage: 'Aucun enfant rattaché à cette assistante', - ), + AdminAmChildrenCapacityGrid( + children: _children, + capacity: capacity, + onOpen: _openChild, + onDetach: _detachChild, ), ], ); diff --git a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart index 8e0cc97..30941ed 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; /// Fiche enfant consultation / édition (ticket #138). class AdminChildDetailModal extends StatefulWidget { @@ -29,7 +30,6 @@ class _AdminChildDetailModalState extends State { bool _dirty = false; bool _saving = false; - static const _statuses = ['a_naitre', 'actif', 'scolarise']; static const _genders = ['H', 'F', 'Autre']; static const _labelStyle = TextStyle( @@ -46,7 +46,10 @@ class _AdminChildDetailModalState extends State { _nomCtrl = TextEditingController(text: e.lastName ?? ''); _birthCtrl = TextEditingController(text: e.birthDate ?? ''); _dueCtrl = TextEditingController(text: e.dueDate ?? ''); - _status = _statuses.contains(e.status) ? e.status : 'actif'; + _status = normalizeEnfantStatus(e.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } _gender = _normalizeGender(e.gender); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; @@ -213,8 +216,13 @@ class _AdminChildDetailModalState extends State { child: _labeledDropdown( 'Statut', _status, - _statuses - .map((s) => MapEntry(s, _statusLabel(s))) + enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) .toList(), (v) => setState(() { _status = v; @@ -388,19 +396,6 @@ class _AdminChildDetailModalState extends State { ); } - String _statusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } - } - String _genderLabel(String gender) { switch (gender) { case 'H': diff --git a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart index f45f059..a6349b8 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -2,36 +2,26 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; -String enfantStatusLabel(String status) { - switch (status) { - case 'a_naitre': - return 'À naître'; - case 'actif': - return 'Actif'; - case 'scolarise': - return 'Scolarisé'; - default: - return status; - } -} - List enfantAdminSubtitleLines({ required String status, String? birthDate, String? dueDate, + String? gender, List extra = const [], }) { final lines = []; final age = formatChildAgeLabel( birthDate: birthDate, dueDate: dueDate, - status: status, + status: normalizeEnfantStatus(status), ); if (age.isNotEmpty) lines.add(age); - if (status.isNotEmpty) { - lines.add('Statut : ${enfantStatusLabel(status)}'); + final normalized = normalizeEnfantStatus(status); + if (normalized.isNotEmpty) { + lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}'); } lines.addAll(extra); return lines; @@ -67,6 +57,7 @@ class AdminEnfantUserCard extends StatelessWidget { status: enfant.status, birthDate: enfant.birthDate, dueDate: enfant.dueDate, + gender: enfant.gender, extra: [ if (parents.isNotEmpty) 'Responsables : $parents', ...extraSubtitleLines, diff --git a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart index f0f66ed..b1fe436 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -230,9 +230,7 @@ class _AdminParentEditModalState extends State { ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), + style: ValidationModalTheme.primaryElevatedStyle, child: const Text('Détacher'), ), ], diff --git a/frontend/lib/widgets/admin/common/validation_detail_section.dart b/frontend/lib/widgets/admin/common/validation_detail_section.dart index 0ece3fd..610dd98 100644 --- a/frontend/lib/widgets/admin/common/validation_detail_section.dart +++ b/frontend/lib/widgets/admin/common/validation_detail_section.dart @@ -148,11 +148,30 @@ class ValidationFieldDecoration { ); } - static BoxDecoration container() { + static InputDecoration readOnly({bool error = false, bool compact = false}) { + final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300; + final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50; + return input(compact: compact).copyWith( + filled: true, + fillColor: fillColor, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + borderSide: BorderSide(color: borderColor), + ), + ); + } + + static BoxDecoration container({bool error = false}) { return BoxDecoration( - color: Colors.grey.shade50, + color: error ? Colors.red.shade50 : Colors.grey.shade50, borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.grey.shade300), + border: Border.all( + color: error ? Colors.red.shade400 : Colors.grey.shade300, + ), ); } } @@ -234,20 +253,24 @@ class ValidationEditableField extends StatelessWidget { @override Widget build(BuildContext context) { - if (!compact || maxLines > 1) { + if (maxLines > 1) { return TextField( controller: controller, keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: maxLines, - style: TextStyle( - color: Colors.black87, - fontSize: compact ? 13 : 14, - ), - decoration: ValidationFieldDecoration.input( - hint: hintText, - compact: compact, - ), + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), + ); + } + if (!compact) { + return TextField( + controller: controller, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + maxLines: 1, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: ValidationFieldDecoration.input(hint: hintText), ); } return SizedBox( @@ -259,10 +282,11 @@ class ValidationEditableField extends StatelessWidget { keyboardType: keyboardType, inputFormatters: inputFormatters, maxLines: 1, + textAlignVertical: TextAlignVertical.center, style: const TextStyle( color: Colors.black87, fontSize: 13, - height: 1.15, + height: 1.0, ), decoration: _compactInputDecoration(hint: hintText), ), @@ -297,8 +321,8 @@ class ValidationEditableSection extends StatelessWidget { } } -/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). -class ValidationReadOnlyField extends StatelessWidget { +/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField]. +class ValidationReadOnlyField extends StatefulWidget { final String value; final int? maxLines; final bool compact; @@ -312,34 +336,69 @@ class ValidationReadOnlyField extends StatelessWidget { this.error = false, }); + @override + State createState() => _ValidationReadOnlyFieldState(); +} + +class _ValidationReadOnlyFieldState extends State { + late final TextEditingController _controller; + static const double _compactFieldHeight = 34; + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(ValidationReadOnlyField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + if (!widget.compact && widget.maxLines == 1) { + return TextField( + controller: _controller, + readOnly: true, + enableInteractiveSelection: false, + style: TextStyle( + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: 14, + fontWeight: widget.error ? FontWeight.w600 : null, + ), + decoration: ValidationFieldDecoration.readOnly(error: widget.error), + ); + } + return Container( width: double.infinity, - height: compact && maxLines == 1 ? _compactFieldHeight : null, - alignment: compact ? Alignment.centerLeft : null, + height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null, + alignment: widget.compact ? Alignment.centerLeft : null, padding: EdgeInsets.symmetric( - horizontal: compact ? 10 : 12, - vertical: compact ? 7 : 10, + horizontal: widget.compact ? 10 : 12, + vertical: widget.compact ? 7 : 10, ), - decoration: error - ? BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.red.shade400), - ) - : ValidationFieldDecoration.container(), + decoration: ValidationFieldDecoration.container(error: widget.error), child: Text( - value, + widget.value, style: TextStyle( - color: error ? Colors.red.shade800 : Colors.black87, - fontSize: compact ? 13 : 14, - height: compact ? 1.2 : null, - fontWeight: error ? FontWeight.w600 : null, + color: widget.error ? Colors.red.shade800 : Colors.black87, + fontSize: widget.compact ? 13 : 14, + height: widget.compact ? 1.0 : null, + fontWeight: widget.error ? FontWeight.w600 : null, ), - maxLines: maxLines, + maxLines: widget.maxLines, overflow: TextOverflow.ellipsis, ), ); diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index cb11674..bc90aaf 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; @@ -67,8 +68,9 @@ class _EnfantManagementWidgetState extends State { final query = widget.searchQuery.toLowerCase(); final filtered = _enfants.where((e) { final matchesName = e.fullName.toLowerCase().contains(query); - final matchesStatus = - widget.statusFilter == null || e.status == widget.statusFilter; + final matchesStatus = widget.statusFilter == null || + normalizeEnfantStatus(e.status) == + normalizeEnfantStatus(widget.statusFilter); return matchesName && matchesStatus; }).toList(); diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 51cc207..9de03bc 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -205,10 +205,17 @@ class _UserManagementPanelState extends State { ), ), DropdownMenuItem( - value: 'actif', + value: 'sans_garde', child: Padding( padding: EdgeInsets.only(left: 10), - child: Text('Actif', style: TextStyle(fontSize: 12)), + child: Text('Sans garde', style: TextStyle(fontSize: 12)), + ), + ), + DropdownMenuItem( + value: 'garde', + child: Padding( + padding: EdgeInsets.only(left: 10), + child: Text('En garde', style: TextStyle(fontSize: 12)), ), ), DropdownMenuItem( diff --git a/frontend/lib/widgets/admin/validation_family_wizard.dart b/frontend/lib/widgets/admin/validation_family_wizard.dart index 533f25d..76ac741 100644 --- a/frontend/lib/widgets/admin/validation_family_wizard.dart +++ b/frontend/lib/widgets/admin/validation_family_wizard.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; +import 'package:p_tits_pas/utils/enfant_status_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/validation_detail_section.dart'; @@ -397,20 +398,9 @@ class _ValidationFamilyWizardState extends State { ); } - /// « 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. + /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde). 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; + return enfantColumnStatusLabel(status: e.status, gender: e.gender); } /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).