diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 31ea722..b16e89f 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -478,6 +478,25 @@ class UserService { return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); } + static Future deleteEnfant(String enfantId) async { + final response = await http.delete( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + headers: await _headers(), + ); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant')); + } + } + + /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). + static Future findAmForEnfant(String enfantId) async { + final ams = await getAssistantesMaternelles(); + for (final am in ams) { + if (am.children.any((c) => c.id == enfantId)) return am; + } + return null; + } + static ParentModel _parentModelFromBody(String body) { final decoded = jsonDecode(body); return ParentModel.fromJson( 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 30941ed..df2c7bc 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,17 +1,27 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.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_am_edit_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; -/// Fiche enfant consultation / édition (ticket #138). +/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. class AdminChildDetailModal extends StatefulWidget { final EnfantAdminModel enfant; final VoidCallback? onSaved; + final VoidCallback? onDeleted; const AdminChildDetailModal({ super.key, required this.enfant, this.onSaved, + this.onDeleted, }); @override @@ -29,14 +39,39 @@ class _AdminChildDetailModalState extends State { late bool _isMultiple; bool _dirty = false; bool _saving = false; + bool _deleting = false; + bool _placementBusy = false; + bool _loadingAm = true; + String? _currentUserRole; + AssistanteMaternelleModel? _linkedAm; - static const _genders = ['H', 'F', 'Autre']; + static const double _modalWidth = 930; + static const double _mainRowHeight = 380; + static const double _placementHeight = 96; + static const double _fieldHeight = 44; + static const double _photoProGap = 24; + static const double _proColumnMinWidth = 260; + static const double _photoColumnMinWidth = 160; + static const double _photoColumnMaxWidth = 280; + static const List _fieldsRowLayout = [2, 2, 1]; - static const _labelStyle = TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.black87, - ); + bool get _isUnborn => _status == 'a_naitre'; + bool get _isScolarise => _status == 'scolarise'; + bool get _showsAmPlacement => !_isScolarise; + + List get _availableGenders => + _isUnborn ? const ['H', 'F', 'Autre'] : const ['H', 'F']; + + TextEditingController get _activeDateCtrl => + _isUnborn ? _dueCtrl : _birthCtrl; + + String get _dateFieldLabel => + _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; + + bool get _canDelete => + (_currentUserRole ?? '').toLowerCase() == 'super_admin'; + + bool get _busy => _saving || _deleting || _placementBusy; @override void initState() { @@ -44,18 +79,55 @@ class _AdminChildDetailModalState extends State { final e = widget.enfant; _prenomCtrl = TextEditingController(text: e.firstName ?? ''); _nomCtrl = TextEditingController(text: e.lastName ?? ''); - _birthCtrl = TextEditingController(text: e.birthDate ?? ''); - _dueCtrl = TextEditingController(text: e.dueDate ?? ''); + _birthCtrl = TextEditingController( + text: formatIsoDateFr(e.birthDate, ifEmpty: ''), + ); + _dueCtrl = TextEditingController( + text: formatIsoDateFr(e.dueDate, ifEmpty: ''), + ); _status = normalizeEnfantStatus(e.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e.gender); + _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn); _consentPhoto = e.consentPhoto; _isMultiple = e.isMultiple; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { c.addListener(_markDirty); } + _prenomCtrl.addListener(_onNameChanged); + _nomCtrl.addListener(_onNameChanged); + _loadCurrentUserRole(); + _loadLinkedAm(); + } + + void _onNameChanged() => setState(() {}); + + Future _loadLinkedAm() async { + setState(() => _loadingAm = true); + try { + final am = await UserService.findAmForEnfant(widget.enfant.id); + if (!mounted) return; + setState(() { + _linkedAm = am; + _loadingAm = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loadingAm = false); + } + } + + Future _loadCurrentUserRole() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() => _currentUserRole = cached.role); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() => _currentUserRole = refreshed.role); } @override @@ -66,17 +138,48 @@ class _AdminChildDetailModalState extends State { super.dispose(); } - static String _normalizeGender(String? raw) { + static String _normalizeGender(String? raw, {required bool allowUnknown}) { final g = (raw ?? '').trim(); if (g == 'M') return 'H'; - if (_genders.contains(g)) return g; + if (g == 'F' || g == 'H') return g; + if (g == 'Autre' && allowUnknown) return 'Autre'; return 'H'; } + void _coerceGenderForStatus() { + if (!_availableGenders.contains(_gender)) { + _gender = 'H'; + } + } + void _markDirty() { if (!_dirty) setState(() => _dirty = true); } + String? _dateToIso(String text) => parseFrDateToIso(text); + + String _headerTitle() { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) { + final fallback = widget.enfant.fullName.trim(); + return fallback.isNotEmpty ? fallback : 'Enfant'; + } + return '$fn $ln'.trim(); + } + + String? _parentsSubtitle() { + final names = widget.enfant.parentLinks + .map((l) { + final n = (l.parentName ?? '').trim(); + return n.isNotEmpty ? n : 'Parent rattaché'; + }) + .where((s) => s.isNotEmpty) + .toList(); + if (names.isEmpty) return null; + return 'Responsables : ${names.join(', ')}'; + } + Future _save() async { if (!_dirty) return; setState(() => _saving = true); @@ -88,9 +191,11 @@ class _AdminChildDetailModalState extends State { 'last_name': _nomCtrl.text.trim(), 'status': _status, 'gender': _gender, - if (_birthCtrl.text.trim().isNotEmpty) - 'birth_date': _birthCtrl.text.trim(), - if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(), + if (_isUnborn) + if (_dateToIso(_dueCtrl.text) != null) + 'due_date': _dateToIso(_dueCtrl.text) + else if (_dateToIso(_birthCtrl.text) != null) + 'birth_date': _dateToIso(_birthCtrl.text), 'consent_photo': _consentPhoto, 'is_multiple': _isMultiple, }, @@ -113,289 +218,564 @@ class _AdminChildDetailModalState extends State { } } - String _parentsLine() { - final names = widget.enfant.parentLinks - .map((l) { - final n = (l.parentName ?? '').trim(); - return n.isNotEmpty ? n : 'Parent rattaché'; - }) - .where((s) => s.isNotEmpty) - .toList(); - if (names.isEmpty) return ''; - return names.join(', '); + Future _delete() async { + if (!_canDelete || _deleting) return; + + final name = _headerTitle(); + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Supprimer l\'enfant'), + content: Text( + 'Supprimer définitivement la fiche de $name ?\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + ), + child: const Text('Supprimer'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _deleting = true); + try { + await UserService.deleteEnfant(widget.enfant.id); + if (!mounted) return; + widget.onDeleted?.call(); + widget.onSaved?.call(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Fiche enfant supprimée')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _deleting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } } - @override - Widget build(BuildContext context) { - final parents = _parentsLine(); - final showDueDate = _status == 'a_naitre'; + Future _openLinkedAm() async { + final am = _linkedAm; + if (am == null) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: am, + onSaved: () { + _loadLinkedAm(); + widget.onSaved?.call(); + }, + ), + ); + } - return Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - child: SizedBox( - width: 480, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 640), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.enfant.fullName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - ), - ), - if (parents.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - 'Responsables : $parents', - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), - ], - ], - ), - ), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - tooltip: 'Fermer', - ), - ], - ), - const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), - Flexible( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledField( - 'Prénom', - TextField( - controller: _prenomCtrl, - decoration: _decoration(), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledField( - 'Nom', - TextField( - controller: _nomCtrl, - decoration: _decoration(), - ), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledDropdown( - 'Statut', - _status, - enfantStatusValues - .map( - (s) => MapEntry( - s, - enfantStatusLabel(s, gender: _gender), - ), - ) - .toList(), - (v) => setState(() { - _status = v; - _dirty = true; - }), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledDropdown( - 'Genre', - _gender, - _genders - .map((g) => MapEntry(g, _genderLabel(g))) - .toList(), - (v) => setState(() { - _gender = v; - _dirty = true; - }), - ), - ), - ], - ), - const SizedBox(height: 12), - if (showDueDate) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _labeledField( - 'Date de naissance', - TextField( - controller: _birthCtrl, - decoration: - _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _labeledField( - 'Date prévue', - TextField( - controller: _dueCtrl, - decoration: - _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - ), - ], - ) - else - _labeledField( - 'Date de naissance', - TextField( - controller: _birthCtrl, - decoration: _decoration(hint: 'AAAA-MM-JJ'), - ), - ), - const SizedBox(height: 16), - _switchRow( - 'Consentement photo', - _consentPhoto, - (v) => setState(() { - _consentPhoto = v; - _dirty = true; - }), - ), - _switchRow( - 'Naissance multiple', - _isMultiple, - (v) => setState(() { - _isMultiple = v; - _dirty = true; - }), - ), - ], + Future _attachAm() async { + List all; + try { + all = await UserService.getAssistantesMaternelles(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + return; + } + + if (_linkedAm != null) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + ), + ); + return; + } + + final candidates = all; + if (candidates.isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucune assistante maternelle disponible')), + ); + return; + } + + if (!mounted) return; + final selected = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Choisir une assistante maternelle'), + children: candidates + .map( + (am) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, am), + child: Text(am.user.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + setState(() => _placementBusy = true); + try { + await UserService.attachEnfantToAm( + amUserId: selected.user.id, + enfantId: widget.enfant.id, + ); + if (!mounted) return; + await _loadLinkedAm(); + widget.onSaved?.call(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Assistante maternelle rattachée')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _placementBusy = false); + } + } + + Future _detachAm() async { + final am = _linkedAm; + if (am == null) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Détacher l\'assistante'), + content: Text( + 'Retirer ${am.user.fullName} de la garde de cet enfant ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Détacher'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + setState(() => _placementBusy = true); + try { + await UserService.detachEnfantFromAm( + amUserId: am.user.id, + enfantId: widget.enfant.id, + ); + if (!mounted) return; + await _loadLinkedAm(); + widget.onSaved?.call(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Assistante maternelle détachée')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } finally { + if (mounted) setState(() => _placementBusy = false); + } + } + + InputDecoration _inputDecoration({String? hint}) { + return ValidationFieldDecoration.input(hint: hint).copyWith( + isDense: false, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ); + } + + Widget _dropdownField({ + Key? key, + required String value, + required List> items, + required ValueChanged onChanged, + }) { + final safeValue = + items.any((e) => e.key == value) ? value : items.first.key; + return SizedBox( + height: _fieldHeight, + child: DropdownButtonFormField( + key: key, + value: safeValue, + isExpanded: true, + decoration: _inputDecoration(), + items: items + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: _busy ? null : (v) { + if (v != null) onChanged(v); + }, + ), + ); + } + + Widget _textField(TextEditingController controller, {String? hint, Key? key}) { + return SizedBox( + height: _fieldHeight, + child: TextField( + key: key, + controller: controller, + textAlignVertical: TextAlignVertical.center, + style: const TextStyle(color: Colors.black87, fontSize: 14), + decoration: _inputDecoration(hint: hint), + ), + ); + } + + Widget _fieldsGrid() { + return ValidationEditableSection( + rowLayout: _fieldsRowLayout, + fields: [ + ValidationLabeledField( + label: 'Prénom', + field: _textField(_prenomCtrl), + ), + ValidationLabeledField( + label: 'Nom', + field: _textField(_nomCtrl), + ), + ValidationLabeledField( + label: _dateFieldLabel, + field: _textField( + _activeDateCtrl, + hint: 'jj/mm/aaaa', + key: ValueKey(_status), + ), + ), + ValidationLabeledField( + label: 'Statut', + field: _dropdownField( + value: _status, + items: enfantStatusValues + .map( + (s) => MapEntry( + s, + enfantStatusLabel(s, gender: _gender), + ), + ) + .toList(), + onChanged: (v) => setState(() { + _status = v; + _coerceGenderForStatus(); + _dirty = true; + }), + ), + ), + ValidationLabeledField( + label: 'Genre', + field: _dropdownField( + key: ValueKey('genre-$_status'), + value: _gender, + items: _availableGenders + .map((g) => MapEntry(g, _genderLabel(g))) + .toList(), + onChanged: (v) => setState(() { + _gender = v; + _dirty = true; + }), + ), + ), + ], + ); + } + + Widget _consentPhotoSwitch() { + return SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + title: const Text( + 'Consentement photo', + style: TextStyle(fontSize: 13), + ), + value: _consentPhoto, + onChanged: _busy + ? null + : (v) => setState(() { + _consentPhoto = v; + _dirty = true; + }), + ); + } + + Widget _mainRow() { + return LayoutBuilder( + builder: (context, c) { + final maxRowW = c.maxWidth; + final maxRowH = c.maxHeight; + final idealPhotoW = + maxRowH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; + final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) + .clamp(0.0, double.infinity); + var photoW = idealPhotoW.clamp(_photoColumnMinWidth, _photoColumnMaxWidth); + if (photoW > maxPhotoW) photoW = maxPhotoW; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: photoW, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: AdminAmPhotoFrame( + photoUrl: widget.enfant.photoUrl, ), ), + const SizedBox(height: 8), + _consentPhotoSwitch(), + ], + ), + ), + const SizedBox(width: _photoProGap), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _fieldsGrid(), + const Spacer(), + const SizedBox(height: 16), + _placementSection(), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _scolariseCard() { + return Container( + height: _placementHeight, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFE8F4FD), + const Color(0xFFDCEEFB), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFF90CAF9)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.school_rounded, + size: 32, + color: Colors.blue.shade700, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Scolarisé', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.blue.shade900, + ), ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Fermer'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: !_dirty || _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.save), - label: const Text('Sauvegarder'), - ), - ], + const SizedBox(height: 4), + Text( + 'Enfant scolarisé — pas de garde chez une assistante maternelle', + style: TextStyle( + fontSize: 13, + color: Colors.blue.shade800.withValues(alpha: 0.85), + ), ), ], ), ), + Icon(Icons.auto_stories_outlined, size: 28, color: Colors.blue.shade400), + ], + ), + ); + } + + Widget _emptyAmSlot() { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _attachAm, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.face_outlined, size: 28, color: Colors.grey.shade400), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune assistante maternelle', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + Text( + 'Cliquer pour choisir une assistante', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + const SizedBox(width: 8), + Icon(Icons.add_circle_outline, color: Colors.grey.shade500), + ], + ), ), ), ); } - InputDecoration _decoration({String? hint}) { - return InputDecoration( - isDense: true, - border: const OutlineInputBorder(), - hintText: hint, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ); - } - - Widget _labeledField(String label, Widget field) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(label, style: _labelStyle), - const SizedBox(height: 4), - field, - ], - ); - } - - Widget _labeledDropdown( - String label, - String value, - List> items, - ValueChanged onChanged, - ) { - final safeValue = - items.any((e) => e.key == value) ? value : items.first.key; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(label, style: _labelStyle), - const SizedBox(height: 4), - DropdownButtonFormField( - value: safeValue, - isExpanded: true, - decoration: _decoration(), - items: items - .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) - .toList(), - onChanged: (v) { - if (v != null) onChanged(v); - }, - ), - ], - ); - } - - Widget _switchRow(String label, bool value, ValueChanged onChanged) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - children: [ - Expanded(child: Text(label, style: _labelStyle)), - Switch( - value: value, - onChanged: onChanged, + Widget _linkedAmCard() { + final am = _linkedAm!; + return SizedBox( + height: _placementHeight, + child: AdminUserCard( + margin: EdgeInsets.zero, + title: am.user.fullName, + avatarUrl: am.user.photoUrl, + fallbackIcon: Icons.face, + onCardTap: _busy ? null : _openLinkedAm, + subtitleLines: [ + if (am.residenceCity != null && am.residenceCity!.trim().isNotEmpty) + 'Zone : ${am.residenceCity!.trim()}', + if (am.approvalNumber != null && am.approvalNumber!.trim().isNotEmpty) + 'Agrément : ${am.approvalNumber!.trim()}', + ], + actions: [ + IconButton( + icon: const Icon(Icons.open_in_new), + tooltip: 'Ouvrir la fiche AM', + onPressed: _busy ? null : _openLinkedAm, + ), + IconButton( + icon: Icon(Icons.link_off, color: Colors.orange.shade800), + tooltip: 'Détacher', + onPressed: _busy ? null : _detachAm, ), ], ), ); } + Widget _placementSection() { + if (!_showsAmPlacement) return _scolariseCard(); + + if (_loadingAm || _placementBusy) { + return SizedBox( + height: _placementHeight, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade600, + ), + ), + ), + ); + } + + if (_linkedAm != null) return _linkedAmCard(); + return _emptyAmSlot(); + } + + Widget _buildFooter() { + return Row( + children: [ + if (_canDelete) + OutlinedButton( + onPressed: _busy ? null : _delete, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade700, + ), + child: _deleting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Supprimer'), + ), + const Spacer(), + TextButton( + onPressed: _busy ? null : () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + const SizedBox(width: 12), + ElevatedButton( + style: ValidationModalTheme.primaryElevatedStyle, + onPressed: !_dirty || _busy ? null : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), + ], + ); + } + String _genderLabel(String gender) { switch (gender) { case 'H': @@ -403,9 +783,79 @@ class _AdminChildDetailModalState extends State { case 'F': return 'Fille'; case 'Autre': - return 'Autre'; + return 'Inconnu'; default: return gender; } } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _headerTitle(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + if (_parentsSubtitle() != null) ...[ + const SizedBox(height: 4), + Text( + _parentsSubtitle()!, + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + ), + ], + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 40, + minHeight: 40, + ), + icon: const Icon(Icons.close), + onPressed: _busy ? null : () => Navigator.of(context).pop(), + tooltip: 'Fermer', + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox(height: _mainRowHeight, child: _mainRow()), + const SizedBox(height: 12), + _buildFooter(), + ], + ), + ), + ], + ), + ), + ); + } } diff --git a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart index e984e66..526d7f8 100644 --- a/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart +++ b/frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart @@ -64,6 +64,7 @@ class AdminChildrenAffiliationPanel extends StatelessWidget { final c = children[i]; return AdminEnfantUserCard.fromSummary( c, + onCardTap: () => onOpen(c), actions: [ IconButton( icon: const Icon(Icons.visibility_outlined), diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 354ae68..2d02650 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -14,6 +14,7 @@ class AdminUserCard extends StatefulWidget { final Color? infoColor; final String? vigilanceTooltip; final VoidCallback? onCardTap; + final EdgeInsetsGeometry? margin; const AdminUserCard({ super.key, @@ -28,6 +29,7 @@ class AdminUserCard extends StatefulWidget { this.infoColor, this.vigilanceTooltip, this.onCardTap, + this.margin, }); @override @@ -59,7 +61,7 @@ class _AdminUserCardState extends State { borderRadius: BorderRadius.circular(10), hoverColor: const Color(0x149CC5C0), child: Card( - margin: const EdgeInsets.only(bottom: 12), + margin: widget.margin ?? const EdgeInsets.only(bottom: 12), elevation: 0, color: widget.backgroundColor, shape: RoundedRectangleBorder(