feat(#131): grille enfants AM, statuts garde et polish champs admin.

Aligne le front sur les statuts enfant back (garde/sans_garde), refond l’onglet Enfants accueillis en grille 2×2 avec correction des places, et unifie les champs validation éditables/lecture seule.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
MARTIN Julien 2026-07-07 23:01:29 +02:00
parent 53721ffbb3
commit 66c7f22280
14 changed files with 557 additions and 131 deletions

View File

@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/user.dart'; 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. /// Réponse unifiée GET /dossiers/:numeroDossier. Ticket #119, #107.
class DossierUnifie { class DossierUnifie {
@ -220,7 +221,7 @@ class EnfantDossier {
lastName: (json['last_name'] ?? json['nom'])?.toString(), lastName: (json['last_name'] ?? json['nom'])?.toString(),
birthDate: json['birth_date']?.toString(), birthDate: json['birth_date']?.toString(),
gender: (json['gender'] ?? json['genre'])?.toString(), gender: (json['gender'] ?? json['genre'])?.toString(),
status: json['status']?.toString(), status: normalizeEnfantStatus(json['status']?.toString()),
dueDate: json['due_date']?.toString(), dueDate: json['due_date']?.toString(),
photoUrl: resolvedPhoto, photoUrl: resolvedPhoto,
consentPhoto: consentPhoto:

View File

@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/user.dart'; 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). /// Enfant tel que renvoyé par `GET /enfants` (dashboard admin).
class EnfantAdminModel { class EnfantAdminModel {
@ -54,7 +55,7 @@ class EnfantAdminModel {
gender: json['gender'] as String?, gender: json['gender'] as String?,
birthDate: _dateString(json['birth_date']), birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']), dueDate: _dateString(json['due_date']),
status: (json['status'] ?? '').toString(), status: normalizeEnfantStatus(json['status']?.toString()),
photoUrl: json['photo_url'] as String?, photoUrl: json['photo_url'] as String?,
consentPhoto: json['consent_photo'] == true, consentPhoto: json['consent_photo'] == true,
isMultiple: json['is_multiple'] == true, isMultiple: json['is_multiple'] == true,

View File

@ -1,4 +1,5 @@
import 'package:p_tits_pas/models/enfant_admin_model.dart'; 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). /// Résumé enfant affiché dans la fiche parent (dashboard admin).
class ParentChildSummary { class ParentChildSummary {
@ -33,7 +34,9 @@ class ParentChildSummary {
id: (json['id'] ?? '').toString(), id: (json['id'] ?? '').toString(),
firstName: (json['first_name'] ?? json['prenom'])?.toString(), firstName: (json['first_name'] ?? json['prenom'])?.toString(),
lastName: (json['last_name'] ?? json['nom'])?.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(), photoUrl: json['photo_url']?.toString(),
birthDate: _dateString(json['birth_date']), birthDate: _dateString(json['birth_date']),
dueDate: _dateString(json['due_date']), dueDate: _dateString(json['due_date']),

View File

@ -1,4 +1,5 @@
import 'package:intl/intl.dart'; 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]. /// Affiche une date ISO / parseable en `dd/MM/yyyy`, avec repli sur la chaîne ou [ifEmpty].
String formatIsoDateFr(String? s, {String ifEmpty = ''}) { String formatIsoDateFr(String? s, {String ifEmpty = ''}) {
@ -63,7 +64,7 @@ String formatChildAgeLabel({
String? dueDate, String? dueDate,
String? status, String? status,
}) { }) {
if (status == 'a_naitre') { if (status == 'a_naitre' || normalizeEnfantStatus(status) == 'a_naitre') {
final due = formatIsoDateFr(dueDate, ifEmpty: ''); final due = formatIsoDateFr(dueDate, ifEmpty: '');
if (due.isNotEmpty) return 'Naissance prévue : $due'; if (due.isNotEmpty) return 'Naissance prévue : $due';
return 'À naître'; return 'À naître';

View File

@ -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;
}
}

View File

@ -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<ParentChildSummary> 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),
),
),
),
);
}
}

View File

@ -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/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_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/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_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_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/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart';
@ -51,8 +51,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late String _statut; late String _statut;
late bool _disponible; late bool _disponible;
late int? _placesAvailable;
late List<ParentChildSummary> _children; late List<ParentChildSummary> _children;
late final ScrollController _childrenScrollCtrl;
bool _saving = false; bool _saving = false;
bool _dirty = false; bool _dirty = false;
@ -64,12 +64,24 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
static const double _proTabHeight = 300; static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2]; static const List<int> _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) { double _tabViewHeight(int index) {
switch (index) { switch (index) {
case 1: case 1:
return _proTabHeight; return _proTabHeight;
case 2: case 2:
return 280; return _childrenTabHeight();
default: default:
return 292; return 292;
} }
@ -110,8 +122,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
); );
_statut = u.statut ?? 'en_attente'; _statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true; _disponible = am.available ?? true;
_placesAvailable = am.placesAvailable;
_children = List.of(am.children); _children = List.of(am.children);
_childrenScrollCtrl = ScrollController();
for (final c in [ for (final c in [
_nomCtrl, _nomCtrl,
@ -167,7 +179,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
]) { ]) {
c.dispose(); c.dispose();
} }
_childrenScrollCtrl.dispose();
super.dispose(); super.dispose();
} }
@ -217,19 +228,27 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
bool _placesInconsistent() => amHasPlacesInconsistency( bool _placesInconsistent() => amHasPlacesInconsistency(
maxChildren: _capaciteMax(), maxChildren: _capaciteMax(),
placesAvailable: widget.assistante.placesAvailable, placesAvailable: _placesAvailable,
childrenCount: _children.length, childrenCount: _children.length,
); );
String _placesDisplayValue() { String _placesDisplayValue() {
final stored = widget.assistante.placesAvailable; if (_placesAvailable != null) return _placesAvailable.toString();
if (stored != null) return stored.toString();
return ''; return '';
} }
void _applyPlacesCorrection() {
final expected = _computedPlacesAvailable();
if (expected == null) return;
setState(() {
_placesAvailable = expected;
_dirty = true;
});
}
String? _placesInconsistencyMessage() { String? _placesInconsistencyMessage() {
if (!_placesInconsistent()) return null; if (!_placesInconsistent()) return null;
final stored = widget.assistante.placesAvailable; final stored = _placesAvailable;
final expected = _computedPlacesAvailable(); final expected = _computedPlacesAvailable();
final storedLabel = stored?.toString() ?? 'non renseigné'; final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? ''; final expectedLabel = expected?.toString() ?? '';
@ -289,6 +308,8 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
if (_frDateToIso(_dateAgrementCtrl.text) != null) if (_frDateToIso(_dateAgrementCtrl.text) != null)
'agreement_date': _frDateToIso(_dateAgrementCtrl.text), 'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
if (_capaciteMax() != null) 'max_children': _capaciteMax(), if (_capaciteMax() != null) 'max_children': _capaciteMax(),
if (_placesAvailable != widget.assistante.placesAvailable)
'places_available': _placesAvailable,
'available': _disponible, 'available': _disponible,
}, },
); );
@ -363,9 +384,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom( style: ValidationModalTheme.primaryElevatedStyle,
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'), child: const Text('Détacher'),
), ),
], ],
@ -470,13 +489,11 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
return ValidationFormGrid( return ValidationFormGrid(
title: 'Dossier professionnel', title: 'Dossier professionnel',
rowLayout: _photoProRowLayout, rowLayout: _photoProRowLayout,
compact: true,
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
label: 'NIR', label: 'NIR',
field: ValidationEditableField( field: ValidationEditableField(
controller: _nirCtrl, controller: _nirCtrl,
compact: true,
hintText: '15 chiffres', hintText: '15 chiffres',
inputFormatters: [ inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')), FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
@ -487,7 +504,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
label: 'Date de naissance', label: 'Date de naissance',
field: ValidationEditableField( field: ValidationEditableField(
controller: _dateNaissanceCtrl, controller: _dateNaissanceCtrl,
compact: true,
hintText: 'jj/mm/aaaa', hintText: 'jj/mm/aaaa',
), ),
), ),
@ -495,28 +511,24 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
label: 'Ville de naissance', label: 'Ville de naissance',
field: ValidationEditableField( field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl, controller: _lieuNaissanceVilleCtrl,
compact: true,
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Pays de naissance', label: 'Pays de naissance',
field: ValidationEditableField( field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl, controller: _lieuNaissancePaysCtrl,
compact: true,
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'N° Agrément', label: 'N° Agrément',
field: ValidationEditableField( field: ValidationEditableField(
controller: _agrementCtrl, controller: _agrementCtrl,
compact: true,
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Date d\'agrément', label: 'Date d\'agrément',
field: ValidationEditableField( field: ValidationEditableField(
controller: _dateAgrementCtrl, controller: _dateAgrementCtrl,
compact: true,
hintText: 'jj/mm/aaaa', hintText: 'jj/mm/aaaa',
), ),
), ),
@ -583,7 +595,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
ValidationEditableSection( ValidationEditableSection(
compact: true,
rowLayout: const [2], rowLayout: const [2],
fields: [ fields: [
ValidationLabeledField( ValidationLabeledField(
@ -592,14 +603,12 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
controller: _capaciteCtrl, controller: _capaciteCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
compact: true,
), ),
), ),
ValidationLabeledField( ValidationLabeledField(
label: 'Places disponibles', label: 'Places disponibles',
field: ValidationReadOnlyField( field: ValidationReadOnlyField(
value: _placesDisplayValue(), value: _placesDisplayValue(),
compact: true,
error: inconsistent, error: inconsistent,
), ),
), ),
@ -607,13 +616,37 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
), ),
if (inconsistent) ...[ if (inconsistent) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Wrap(
_placesInconsistencyMessage()!, crossAxisAlignment: WrapCrossAlignment.center,
style: TextStyle( spacing: 6,
fontSize: 12, runSpacing: 4,
color: Colors.red.shade700, children: [
height: 1.3, 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<AdminAmEditModal>
} }
Widget _childrenTab() { Widget _childrenTab() {
final capacity = (_capaciteMax() ?? 4).clamp(1, 4);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
@ -634,20 +668,12 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
color: Colors.black87, 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), const SizedBox(height: 8),
Expanded( AdminAmChildrenCapacityGrid(
child: AdminChildrenAffiliationPanel( children: _children,
children: _children, capacity: capacity,
scrollController: _childrenScrollCtrl, onOpen: _openChild,
onOpen: _openChild, onDetach: _detachChild,
onDetach: _detachChild,
emptyMessage: 'Aucun enfant rattaché à cette assistante',
),
), ),
], ],
); );

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.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/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
/// Fiche enfant consultation / édition (ticket #138). /// Fiche enfant consultation / édition (ticket #138).
class AdminChildDetailModal extends StatefulWidget { class AdminChildDetailModal extends StatefulWidget {
@ -29,7 +30,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
bool _dirty = false; bool _dirty = false;
bool _saving = false; bool _saving = false;
static const _statuses = ['a_naitre', 'actif', 'scolarise'];
static const _genders = ['H', 'F', 'Autre']; static const _genders = ['H', 'F', 'Autre'];
static const _labelStyle = TextStyle( static const _labelStyle = TextStyle(
@ -46,7 +46,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_nomCtrl = TextEditingController(text: e.lastName ?? ''); _nomCtrl = TextEditingController(text: e.lastName ?? '');
_birthCtrl = TextEditingController(text: e.birthDate ?? ''); _birthCtrl = TextEditingController(text: e.birthDate ?? '');
_dueCtrl = TextEditingController(text: e.dueDate ?? ''); _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); _gender = _normalizeGender(e.gender);
_consentPhoto = e.consentPhoto; _consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple; _isMultiple = e.isMultiple;
@ -213,8 +216,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
child: _labeledDropdown( child: _labeledDropdown(
'Statut', 'Statut',
_status, _status,
_statuses enfantStatusValues
.map((s) => MapEntry(s, _statusLabel(s))) .map(
(s) => MapEntry(
s,
enfantStatusLabel(s, gender: _gender),
),
)
.toList(), .toList(),
(v) => setState(() { (v) => setState(() {
_status = v; _status = v;
@ -388,19 +396,6 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
); );
} }
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) { String _genderLabel(String gender) {
switch (gender) { switch (gender) {
case 'H': case 'H':

View File

@ -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/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.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/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'; 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<String> enfantAdminSubtitleLines({ List<String> enfantAdminSubtitleLines({
required String status, required String status,
String? birthDate, String? birthDate,
String? dueDate, String? dueDate,
String? gender,
List<String> extra = const [], List<String> extra = const [],
}) { }) {
final lines = <String>[]; final lines = <String>[];
final age = formatChildAgeLabel( final age = formatChildAgeLabel(
birthDate: birthDate, birthDate: birthDate,
dueDate: dueDate, dueDate: dueDate,
status: status, status: normalizeEnfantStatus(status),
); );
if (age.isNotEmpty) lines.add(age); if (age.isNotEmpty) lines.add(age);
if (status.isNotEmpty) { final normalized = normalizeEnfantStatus(status);
lines.add('Statut : ${enfantStatusLabel(status)}'); if (normalized.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}');
} }
lines.addAll(extra); lines.addAll(extra);
return lines; return lines;
@ -67,6 +57,7 @@ class AdminEnfantUserCard extends StatelessWidget {
status: enfant.status, status: enfant.status,
birthDate: enfant.birthDate, birthDate: enfant.birthDate,
dueDate: enfant.dueDate, dueDate: enfant.dueDate,
gender: enfant.gender,
extra: [ extra: [
if (parents.isNotEmpty) 'Responsables : $parents', if (parents.isNotEmpty) 'Responsables : $parents',
...extraSubtitleLines, ...extraSubtitleLines,

View File

@ -230,9 +230,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.pop(ctx, true), onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom( style: ValidationModalTheme.primaryElevatedStyle,
backgroundColor: Colors.red.shade700,
),
child: const Text('Détacher'), child: const Text('Détacher'),
), ),
], ],

View File

@ -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( return BoxDecoration(
color: Colors.grey.shade50, color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!compact || maxLines > 1) { if (maxLines > 1) {
return TextField( return TextField(
controller: controller, controller: controller,
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: maxLines, maxLines: maxLines,
style: TextStyle( style: const TextStyle(color: Colors.black87, fontSize: 14),
color: Colors.black87, decoration: ValidationFieldDecoration.input(hint: hintText),
fontSize: compact ? 13 : 14, );
), }
decoration: ValidationFieldDecoration.input( if (!compact) {
hint: hintText, return TextField(
compact: compact, controller: controller,
), keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
); );
} }
return SizedBox( return SizedBox(
@ -259,10 +282,11 @@ class ValidationEditableField extends StatelessWidget {
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
maxLines: 1, maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle( style: const TextStyle(
color: Colors.black87, color: Colors.black87,
fontSize: 13, fontSize: 13,
height: 1.15, height: 1.0,
), ),
decoration: _compactInputDecoration(hint: hintText), decoration: _compactInputDecoration(hint: hintText),
), ),
@ -297,8 +321,8 @@ class ValidationEditableSection extends StatelessWidget {
} }
} }
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). /// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
class ValidationReadOnlyField extends StatelessWidget { class ValidationReadOnlyField extends StatefulWidget {
final String value; final String value;
final int? maxLines; final int? maxLines;
final bool compact; final bool compact;
@ -312,34 +336,69 @@ class ValidationReadOnlyField extends StatelessWidget {
this.error = false, this.error = false,
}); });
@override
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
}
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
late final TextEditingController _controller;
static const double _compactFieldHeight = 34; 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 @override
Widget build(BuildContext context) { 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( return Container(
width: double.infinity, width: double.infinity,
height: compact && maxLines == 1 ? _compactFieldHeight : null, height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
alignment: compact ? Alignment.centerLeft : null, alignment: widget.compact ? Alignment.centerLeft : null,
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12, horizontal: widget.compact ? 10 : 12,
vertical: compact ? 7 : 10, vertical: widget.compact ? 7 : 10,
), ),
decoration: error decoration: ValidationFieldDecoration.container(error: widget.error),
? BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.shade400),
)
: ValidationFieldDecoration.container(),
child: Text( child: Text(
value, widget.value,
style: TextStyle( style: TextStyle(
color: error ? Colors.red.shade800 : Colors.black87, color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: compact ? 13 : 14, fontSize: widget.compact ? 13 : 14,
height: compact ? 1.2 : null, height: widget.compact ? 1.0 : null,
fontWeight: error ? FontWeight.w600 : null, fontWeight: widget.error ? FontWeight.w600 : null,
), ),
maxLines: maxLines, maxLines: widget.maxLines,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
); );

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; 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/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
@ -67,8 +68,9 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) { final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query); final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = final matchesStatus = widget.statusFilter == null ||
widget.statusFilter == null || e.status == widget.statusFilter; normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus; return matchesName && matchesStatus;
}).toList(); }).toList();

View File

@ -205,10 +205,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
), ),
), ),
DropdownMenuItem<String?>( DropdownMenuItem<String?>(
value: 'actif', value: 'sans_garde',
child: Padding( child: Padding(
padding: EdgeInsets.only(left: 10), padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)), child: Text('Sans garde', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)),
), ),
), ),
DropdownMenuItem<String?>( DropdownMenuItem<String?>(

View File

@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/dossier_unifie.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/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/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
@ -397,20 +398,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
); );
} }
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut). /// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
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) { String? _enfantColumnStatusLabel(EnfantDossier e) {
final s = (e.status ?? '').trim().toLowerCase(); return enfantColumnStatusLabel(status: e.status, gender: e.gender);
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). /// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).