diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index b13d50b..3ab0f16 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -515,6 +515,32 @@ class UserService { return enrichEnfantParentNames(enfant); } + /// Création enfant côté staff (#132) — nécessite `POST /enfants` étendu (voir mini-spec). + /// [parentUserId] : parent pivot du foyer (`parent_user_id`). + static Future createEnfant({ + required String parentUserId, + required Map body, + }) async { + final payload = { + ...body, + 'parent_user_id': parentUserId, + }; + final response = await http.post( + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + headers: await _headers(), + body: jsonEncode(payload), + ); + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur création enfant'), + ); + } + final enfant = EnfantAdminModel.fromJson( + jsonDecode(response.body) as Map, + ); + return enrichEnfantParentNames(enfant); + } + static Future deleteEnfant(String enfantId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), 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 cae3ca8..561e3c6 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -10,22 +10,32 @@ 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_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM. +/// Fiche enfant consultation / édition (#138) ou création (#132). class AdminChildDetailModal extends StatefulWidget { - final EnfantAdminModel enfant; + final EnfantAdminModel? enfant; final VoidCallback? onSaved; final VoidCallback? onDeleted; + final bool isCreating; const AdminChildDetailModal({ super.key, - required this.enfant, + required EnfantAdminModel this.enfant, this.onSaved, this.onDeleted, - }); + }) : isCreating = false; + + /// Création depuis l'onglet Enfants (#132). + const AdminChildDetailModal.create({ + super.key, + this.onSaved, + }) : enfant = null, + onDeleted = null, + isCreating = true; @override State createState() => _AdminChildDetailModalState(); @@ -49,6 +59,9 @@ class _AdminChildDetailModalState extends State { /// AM rattachée au chargement (pour sync différé au Sauvegarder). String? _baselineAmUserId; + /// Famille choisie en mode création (#132). + AdminFamilleFoyer? _selectedFamily; + static const double _modalWidth = 930; static const double _mainRowHeight = 380; static const double _placementHeight = 96; @@ -74,6 +87,7 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => + !widget.isCreating && (_currentUserRole ?? '').toLowerCase() == 'super_admin'; bool get _busy => _saving || _deleting; @@ -82,36 +96,50 @@ class _AdminChildDetailModalState extends State { void initState() { super.initState(); final e = widget.enfant; - _prenomCtrl = TextEditingController(text: e.firstName ?? ''); - _nomCtrl = TextEditingController(text: e.lastName ?? ''); + _prenomCtrl = TextEditingController(text: e?.firstName ?? ''); + _nomCtrl = TextEditingController(text: e?.lastName ?? ''); _birthCtrl = TextEditingController( - text: formatIsoDateFr(e.birthDate, ifEmpty: ''), + text: formatIsoDateFr(e?.birthDate, ifEmpty: ''), ); _dueCtrl = TextEditingController( - text: formatIsoDateFr(e.dueDate, ifEmpty: ''), + text: formatIsoDateFr(e?.dueDate, ifEmpty: ''), ); - _status = normalizeEnfantStatus(e.status); + _status = normalizeEnfantStatus(e?.status); if (!enfantStatusValues.contains(_status)) { _status = 'sans_garde'; } - _gender = _normalizeGender(e.gender, allowUnknown: _isUnborn); - _consentPhoto = e.consentPhoto; - _isMultiple = e.isMultiple; + _gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn); + _consentPhoto = e?.consentPhoto ?? false; + _isMultiple = e?.isMultiple ?? false; for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) { c.addListener(_markDirty); } _prenomCtrl.addListener(_onNameChanged); _nomCtrl.addListener(_onNameChanged); _loadCurrentUserRole(); - _loadLinkedAm(); + if (widget.isCreating) { + _loadingAm = false; + _dirty = true; + } else { + _loadLinkedAm(); + } } void _onNameChanged() => setState(() {}); Future _loadLinkedAm() async { + if (widget.isCreating) { + setState(() => _loadingAm = false); + return; + } + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) { + setState(() => _loadingAm = false); + return; + } setState(() => _loadingAm = true); try { - final am = await UserService.findAmForEnfant(widget.enfant.id); + final am = await UserService.findAmForEnfant(enfantId); if (!mounted) return; setState(() { _linkedAm = am; @@ -168,18 +196,25 @@ class _AdminChildDetailModalState extends State { String? _dateToIso(String text) => parseFrDateToIso(text); String _headerTitle() { + if (widget.isCreating) { + final fn = _prenomCtrl.text.trim(); + final ln = _nomCtrl.text.trim(); + if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant'; + return '$fn $ln'.trim(); + } final fn = _prenomCtrl.text.trim(); final ln = _nomCtrl.text.trim(); if (fn.isEmpty && ln.isEmpty) { - final fallback = widget.enfant.fullName.trim(); + final fallback = widget.enfant?.fullName.trim() ?? ''; return fallback.isNotEmpty ? fallback : 'Enfant'; } return '$fn $ln'.trim(); } - List get _parentLinks => widget.enfant.parentLinks - .where((l) => l.parentId.trim().isNotEmpty) - .toList(); + List get _parentLinks => + (widget.enfant?.parentLinks ?? const []) + .where((l) => l.parentId.trim().isNotEmpty) + .toList(); Future _openParent(EnfantParentLink link) async { if (_busy) return; @@ -205,6 +240,17 @@ class _AdminChildDetailModalState extends State { } Widget? _parentsSubtitle() { + if (widget.isCreating) { + final family = _selectedFamily; + if (family == null) return null; + return Text( + family.parentNames.isNotEmpty + ? 'Famille : ${family.parentNames.join(', ')}' + : family.displayTitle, + style: const TextStyle(fontSize: 13, color: Colors.black54), + ); + } + final links = _parentLinks; if (links.isEmpty) return null; @@ -252,12 +298,21 @@ class _AdminChildDetailModalState extends State { Future _save() async { if (!_dirty) return; + + if (widget.isCreating) { + await _saveCreate(); + return; + } + + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + setState(() => _saving = true); try { await _syncAmPlacement(); await UserService.updateEnfant( - enfantId: widget.enfant.id, + enfantId: enfantId, body: { 'first_name': _prenomCtrl.text.trim(), 'last_name': _nomCtrl.text.trim(), @@ -291,21 +346,86 @@ class _AdminChildDetailModalState extends State { } } + Future _saveCreate() async { + final family = _selectedFamily; + if (family == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Choisissez une famille / un dossier avant d\'enregistrer'), + ), + ); + return; + } + final prenom = _prenomCtrl.text.trim(); + final nom = _nomCtrl.text.trim(); + if (prenom.isEmpty || nom.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Prénom et nom sont obligatoires')), + ); + return; + } + if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Date de naissance invalide ou manquante')), + ); + return; + } + + setState(() => _saving = true); + try { + await UserService.createEnfant( + parentUserId: family.pivotParentUserId, + body: { + 'first_name': prenom, + 'last_name': nom, + 'status': _status, + 'gender': _gender, + 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, + }, + ); + if (!mounted) return; + setState(() { + _dirty = false; + _saving = false; + }); + widget.onSaved?.call(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enfant créé')), + ); + } catch (e) { + if (!mounted) return; + setState(() => _saving = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). Future _syncAmPlacement() async { + final enfantId = widget.enfant?.id; + if (enfantId == null || enfantId.isEmpty) return; + final baselineId = _baselineAmUserId; final currentId = _linkedAm?.user.id; if (baselineId != null && baselineId != currentId) { await UserService.detachEnfantFromAm( amUserId: baselineId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } if (currentId != null && currentId != baselineId) { await UserService.attachEnfantToAm( amUserId: currentId, - enfantId: widget.enfant.id, + enfantId: enfantId, ); } } @@ -342,7 +462,9 @@ class _AdminChildDetailModalState extends State { setState(() => _deleting = true); try { - await UserService.deleteEnfant(widget.enfant.id); + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; + await UserService.deleteEnfant(id); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -376,10 +498,12 @@ class _AdminChildDetailModalState extends State { /// Recharge AM + statut après une modale externe (ex. fiche AM). Future _reloadPlacementFromServer() async { + final id = widget.enfant?.id; + if (id == null || id.isEmpty) return; try { final results = await Future.wait([ - UserService.findAmForEnfant(widget.enfant.id), - UserService.getEnfant(widget.enfant.id), + UserService.findAmForEnfant(id), + UserService.getEnfant(id), ]); if (!mounted) return; final am = results[0] as AssistanteMaternelleModel?; @@ -625,7 +749,7 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( child: AdminAmPhotoFrame( - photoUrl: widget.enfant.photoUrl, + photoUrl: widget.enfant?.photoUrl, ), ), const SizedBox(height: 8), @@ -893,8 +1017,10 @@ class _AdminChildDetailModalState extends State { ); } - String get _placementTitle => - _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + String get _placementTitle { + if (widget.isCreating) return 'Famille / dossier'; + return _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + } Widget _placementBlock() { return Column( @@ -910,11 +1036,161 @@ class _AdminChildDetailModalState extends State { ), ), const SizedBox(height: 8), - _placementSection(), + if (widget.isCreating) _familyPlacementSection() else _placementSection(), ], ); } + Future _pickFamily() async { + if (_busy) return; + final selected = await AdminSelectFamilleModal.show( + context, + title: 'Choisir une famille', + ); + if (selected == null || !mounted) return; + setState(() { + _selectedFamily = selected; + _dirty = true; + }); + } + + Widget _familyPlacementSection() { + final family = _selectedFamily; + if (family == null) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _pickFamily, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.grey.shade300), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Icon( + Icons.family_restroom, + size: 30, + color: Colors.grey.shade400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune famille sélectionnée', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Cliquer pour choisir un dossier / foyer', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Icon(Icons.add_circle_outline, color: Colors.grey.shade500), + ], + ), + ), + ), + ); + } + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _pickFamily, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF8F5FC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD8CCE8)), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: const Color(0xFFEDE5FA), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.family_restroom, + size: 30, + color: Color(0xFF6B3FA0), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + family.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (family.parentNames.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + family.parentNames.join(', '), + style: const TextStyle( + fontSize: 13, + color: Colors.black54, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + IconButton( + icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700), + tooltip: 'Changer de famille', + onPressed: _busy ? null : _pickFamily, + ), + ], + ), + ), + ), + ); + } + Widget _placementSection() { if (!_showsAmPlacement) return _scolariseCard(); @@ -976,7 +1252,11 @@ class _AdminChildDetailModalState extends State { color: Colors.white, ), ) - : Text(_dirty ? 'Sauvegarder' : 'Aucune modification'), + : Text( + widget.isCreating + ? 'Créer' + : (_dirty ? 'Sauvegarder' : 'Aucune modification'), + ), ), ], ); diff --git a/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart new file mode 100644 index 0000000..ebfc947 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_famille_modal.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132). +class AdminFamilleFoyer { + /// Parent pivot pour `POST /enfants` (`parent_user_id`). + final String pivotParentUserId; + final String? numeroDossier; + final String displayTitle; + final List parentNames; + + const AdminFamilleFoyer({ + required this.pivotParentUserId, + required this.displayTitle, + required this.parentNames, + this.numeroDossier, + }); + + String get subtitle { + final parts = []; + final dossier = (numeroDossier ?? '').trim(); + if (dossier.isNotEmpty) parts.add('Dossier $dossier'); + if (parentNames.isNotEmpty) { + parts.add('Responsables : ${parentNames.join(', ')}'); + } + return parts.join(' '); + } +} + +/// Construit la liste des foyers uniques à partir de `GET /parents`. +List buildFamilleFoyers(List parents) { + final seenDossiers = {}; + final seenUserIds = {}; + final foyers = []; + + for (final p in parents) { + final dossier = (p.user.numeroDossier ?? '').trim(); + if (dossier.isNotEmpty) { + if (seenDossiers.contains(dossier)) continue; + seenDossiers.add(dossier); + } else if (seenUserIds.contains(p.user.id)) { + continue; + } + + seenUserIds.add(p.user.id); + final co = p.coParent; + if (co != null) seenUserIds.add(co.id); + + final names = [ + if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(), + if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(), + ]; + + final title = dossier.isNotEmpty + ? 'Dossier $dossier' + : (names.isNotEmpty ? names.first : 'Famille'); + + foyers.add( + AdminFamilleFoyer( + pivotParentUserId: p.user.id, + numeroDossier: dossier.isNotEmpty ? dossier : null, + displayTitle: title, + parentNames: names, + ), + ); + } + + foyers.sort( + (a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()), + ); + return foyers; +} + +/// Sélection d'une famille / dossier pour créer un enfant — ticket #132. +class AdminSelectFamilleModal { + AdminSelectFamilleModal._(); + + static Future show( + BuildContext context, { + String title = 'Choisir une famille', + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par dossier, nom…', + emptyMessage: 'Aucune famille disponible', + noResultsMessage: 'Aucun résultat pour cette recherche', + loadItems: () async { + final parents = await UserService.getParents(); + return buildFamilleFoyers(parents); + }, + matchesQuery: (f, q) { + final dossier = (f.numeroDossier ?? '').toLowerCase(); + final title = f.displayTitle.toLowerCase(); + final names = f.parentNames.join(' ').toLowerCase(); + return dossier.contains(q) || title.contains(q) || names.contains(q); + }, + itemBuilder: (context, f, onSelect) { + return AdminUserCard( + title: f.displayTitle, + fallbackIcon: Icons.family_restroom, + subtitleLines: [ + if (f.parentNames.isNotEmpty) f.parentNames.join(', '), + if ((f.numeroDossier ?? '').isNotEmpty) + 'Dossier ${f.numeroDossier}', + ], + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + actions: [ + IconButton( + icon: const Icon(Icons.check_circle_outline), + tooltip: 'Choisir', + onPressed: onSelect, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index d272864..3abcb4c 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart'; @@ -26,6 +27,7 @@ class _UserManagementPanelState extends State { int _subIndex = 0; int _gestionnaireRefreshTick = 0; int _adminRefreshTick = 0; + int _enfantRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; @@ -264,6 +266,7 @@ class _UserManagementPanelState extends State { ); case 1: return EnfantManagementWidget( + key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); @@ -311,6 +314,23 @@ class _UserManagementPanelState extends State { Future _handleAddPressed() async { final contentIndex = _subIndex - _contentIndexOffset; + + if (contentIndex == 1) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AdminChildDetailModal.create( + onSaved: () { + if (!mounted) return; + setState(() => _enfantRefreshTick++); + }, + ); + }, + ); + return; + } + if (contentIndex == 3) { final created = await showDialog( context: context,