From 1f8f1b9507d706acaa9398c65c888cec4495bd03 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 13:21:43 +0200 Subject: [PATCH 1/5] feat(#138,#143,#144): fiche enfant admin, consentement photo et fix gestionnaire. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge develop → master. - #138 : modale enfant paysage, zone AM/scolarisation, liens responsables - #144 : persistance consent_photo à l'inscription enfant - #143 : masquer Supprimer sur la propre fiche gestionnaire Co-authored-by: Cursor --- ...te-gitea-issue-gestionnaire-self-delete.js | 111 ++ backend/src/routes/auth/auth.service.ts | 9 +- .../routes/auth/dto/enfant-inscription.dto.ts | 9 + backend/src/routes/enfants/enfants.service.ts | 8 +- frontend/lib/models/enfant_admin_model.dart | 76 +- .../creation/gestionnaires_create.dart | 28 +- frontend/lib/services/user_service.dart | 59 +- frontend/lib/utils/enfant_status_utils.dart | 10 +- .../utils/parent_registration_payload.dart | 1 + frontend/lib/utils/reprise_mapper.dart | 7 +- .../common/admin_child_detail_modal.dart | 1241 +++++++++++++---- .../admin_children_affiliation_panel.dart | 1 + .../widgets/admin/common/admin_user_card.dart | 4 +- .../widgets/admin/user_management_panel.dart | 2 +- 14 files changed, 1255 insertions(+), 311 deletions(-) create mode 100644 backend/scripts/create-gitea-issue-gestionnaire-self-delete.js diff --git a/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js new file mode 100644 index 0000000..0bc75cb --- /dev/null +++ b/backend/scripts/create-gitea-issue-gestionnaire-self-delete.js @@ -0,0 +1,111 @@ +/** + * Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer. + * Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js + */ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.join(__dirname, '../..'); +const MILESTONE_0_1_0 = 10; + +let token = process.env.GITEA_TOKEN; +if (!token) { + try { + const tokenFile = path.join(repoRoot, '.gitea-token'); + if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim(); + } catch (_) {} +} +if (!token) { + try { + const briefing = fs.readFileSync( + path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'), + 'utf8', + ); + const m = briefing.match(/Token:\s*(gitebu_[a-f0-9]+)/); + if (m) token = m[1].trim(); + } catch (_) {} +} +if (!token) { + console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN'); + process.exit(1); +} + +const body = `## Contexte + +Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**. + +Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte. + +## Comportement attendu + +Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) : + +- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche** +- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée** + +## Périmètre + +- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`) +- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`) +- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin + +## Critères d'acceptation + +- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer +- [ ] Gestionnaire connecté → peut **Modifier** ses informations +- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API) +- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué) + +## Fichiers clés + +- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\` +- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\` + +## Milestone + +**0.1.0** — correction UX / sécurité gestion utilisateurs.`; + +const payloadClean = JSON.stringify({ + title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche', + body, + milestone: MILESTONE_0_1_0, +}); + +const opts = { + hostname: 'git.ptits-pas.fr', + path: '/api/v1/repos/jmartin/petitspas/issues', + method: 'POST', + headers: { + Authorization: 'token ' + token, + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payloadClean), + }, +}; + +const req = https.request(opts, (res) => { + let d = ''; + res.on('data', (c) => (d += c)); + res.on('end', () => { + try { + const o = JSON.parse(d); + if (o.number) { + console.log('NUMBER:', o.number); + console.log('URL:', o.html_url); + console.log('MILESTONE:', o.milestone?.title ?? '(aucun)'); + } else { + console.error('Réponse:', d); + process.exit(1); + } + } catch (e) { + console.error(d); + process.exit(1); + } + }); +}); +req.on('error', (e) => { + console.error(e); + process.exit(1); +}); +req.write(payloadClean); +req.end(); diff --git a/backend/src/routes/auth/auth.service.ts b/backend/src/routes/auth/auth.service.ts index 308bb81..e31085e 100644 --- a/backend/src/routes/auth/auth.service.ts +++ b/backend/src/routes/auth/auth.service.ts @@ -552,7 +552,8 @@ export class AuthService { : undefined; enfant.photo_url = urlPhoto || undefined; enfant.status = enfantDto.date_naissance ? StatutEnfantType.SANS_GARDE : StatutEnfantType.A_NAITRE; - enfant.consent_photo = false; + enfant.consent_photo = !!enfantDto.consent_photo; + enfant.consent_photo_at = enfant.consent_photo ? new Date() : null!; enfant.is_multiple = enfantDto.grossesse_multiple || false; const enfantEnregistre = await manager.save(Children, enfant); @@ -1074,6 +1075,12 @@ export class AuthService { if (enfantDto.grossesse_multiple !== undefined) { enfant.is_multiple = enfantDto.grossesse_multiple; } + if (enfantDto.consent_photo !== undefined) { + enfant.consent_photo = !!enfantDto.consent_photo; + enfant.consent_photo_at = enfant.consent_photo + ? new Date() + : null!; + } if (enfantDto.photo_base64 && enfantDto.photo_filename) { enfant.photo_url = await this.sauvegarderPhotoDepuisBase64( diff --git a/backend/src/routes/auth/dto/enfant-inscription.dto.ts b/backend/src/routes/auth/dto/enfant-inscription.dto.ts index 9ee120c..dce0f51 100644 --- a/backend/src/routes/auth/dto/enfant-inscription.dto.ts +++ b/backend/src/routes/auth/dto/enfant-inscription.dto.ts @@ -59,5 +59,14 @@ export class EnfantInscriptionDto { @IsOptional() @IsBoolean() grossesse_multiple?: boolean; + + @ApiProperty({ + example: true, + required: false, + description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)', + }) + @IsOptional() + @IsBoolean() + consent_photo?: boolean; } diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index e4b46c4..7f4552c 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -120,7 +120,13 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - await this.childrenRepository.update(id, dto); + const patch: Partial = { ...dto } as Partial; + if (dto.consent_photo !== undefined) { + patch.consent_photo = dto.consent_photo; + patch.consent_photo_at = dto.consent_photo ? new Date() : null!; + } + + await this.childrenRepository.update(id, patch); return this.findOne(id, currentUser); } diff --git a/frontend/lib/models/enfant_admin_model.dart b/frontend/lib/models/enfant_admin_model.dart index 5c7c84d..e07e5b0 100644 --- a/frontend/lib/models/enfant_admin_model.dart +++ b/frontend/lib/models/enfant_admin_model.dart @@ -37,6 +37,34 @@ class EnfantAdminModel { return '$fn $ln'; } + EnfantAdminModel copyWith({ + String? id, + String? firstName, + String? lastName, + String? gender, + String? birthDate, + String? dueDate, + String? status, + String? photoUrl, + bool? consentPhoto, + bool? isMultiple, + List? parentLinks, + }) { + return EnfantAdminModel( + id: id ?? this.id, + firstName: firstName ?? this.firstName, + lastName: lastName ?? this.lastName, + gender: gender ?? this.gender, + birthDate: birthDate ?? this.birthDate, + dueDate: dueDate ?? this.dueDate, + status: status ?? this.status, + photoUrl: photoUrl ?? this.photoUrl, + consentPhoto: consentPhoto ?? this.consentPhoto, + isMultiple: isMultiple ?? this.isMultiple, + parentLinks: parentLinks ?? this.parentLinks, + ); + } + factory EnfantAdminModel.fromJson(Map json) { final linksRaw = json['parentLinks'] as List?; final links = []; @@ -48,17 +76,25 @@ class EnfantAdminModel { } } + final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?; + final consentPhoto = _parseBool(json['consent_photo']) || + _parseBool(json['consentement_photo']) || + _parseBool(json['consentPhoto']); + return EnfantAdminModel( id: (json['id'] ?? '').toString(), - firstName: json['first_name'] as String?, - lastName: json['last_name'] as String?, - gender: json['gender'] as String?, - birthDate: _dateString(json['birth_date']), - dueDate: _dateString(json['due_date']), - status: normalizeEnfantStatus(json['status']?.toString()), - photoUrl: json['photo_url'] as String?, - consentPhoto: json['consent_photo'] == true, - isMultiple: json['is_multiple'] == true, + firstName: json['first_name'] as String? ?? json['prenom'] as String?, + lastName: json['last_name'] as String? ?? json['nom'] as String?, + gender: json['gender'] as String? ?? json['genre'] as String?, + birthDate: _dateString(json['birth_date'] ?? json['date_naissance']), + dueDate: _dateString(json['due_date'] ?? json['date_prevue_naissance']), + status: normalizeEnfantStatus( + (json['status'] ?? json['statut'])?.toString(), + ), + photoUrl: photoUrl, + consentPhoto: consentPhoto, + isMultiple: _parseBool(json['is_multiple']) || + _parseBool(json['est_multiple']), parentLinks: links, ); } @@ -76,6 +112,15 @@ class EnfantAdminModel { }; } + static bool _parseBool(dynamic value) { + if (value == true || value == 1) return true; + if (value is String) { + final s = value.trim().toLowerCase(); + return s == 'true' || s == '1' || s == 't' || s == 'yes'; + } + return false; + } + static String? _dateString(dynamic v) { if (v == null) return null; if (v is String) return v.split('T').first; @@ -89,6 +134,13 @@ class EnfantParentLink { EnfantParentLink({required this.parentId, this.parentName}); + EnfantParentLink copyWith({String? parentId, String? parentName}) { + return EnfantParentLink( + parentId: parentId ?? this.parentId, + parentName: parentName ?? this.parentName, + ); + } + factory EnfantParentLink.fromJson(Map json) { final parentId = (json['parentId'] ?? json['id_parent'] ?? '').toString(); @@ -99,6 +151,12 @@ class EnfantParentLink { if (user is Map) { final u = AppUser.fromJson(user); name = u.fullName.isNotEmpty ? u.fullName : u.email; + } else { + // Parfois le parent est aplati (prenom/nom) sans nested user. + final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim(); + final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim(); + final flat = '$prenom $nom'.trim(); + if (flat.isNotEmpty) name = flat; } } return EnfantParentLink( diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index a1b5f92..4f89ab7 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -5,6 +5,7 @@ import 'package:p_tits_pas/utils/email_utils.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/models/user.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/relais_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; @@ -41,9 +42,15 @@ class _AdminUserFormDialogState extends State { bool _isLoadingRelais = true; List _relais = []; String? _selectedRelaisId; + String? _currentUserId; bool get _isEditMode => widget.initialUser != null; bool get _isSuperAdminTarget => (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; + bool get _isSelfTarget => + _isEditMode && + _currentUserId != null && + widget.initialUser!.id == _currentUserId; + bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; bool get _isLockedAdminIdentity => _isEditMode && widget.adminMode && _isSuperAdminTarget; String get _targetRoleKey { @@ -109,6 +116,23 @@ class _AdminUserFormDialogState extends State { } else { _isLoadingRelais = false; } + _loadCurrentUserId(); + } + + Future _loadCurrentUserId() async { + final cached = await AuthService.getCurrentUser(); + if (!mounted) return; + if (cached != null) { + setState(() { + _currentUserId = cached.id; + }); + return; + } + final refreshed = await AuthService.refreshCurrentUser(); + if (!mounted || refreshed == null) return; + setState(() { + _currentUserId = refreshed.id; + }); } @override @@ -335,7 +359,7 @@ class _AdminUserFormDialogState extends State { Future _delete() async { if (widget.readOnly) return; - if (_isSuperAdminTarget) return; + if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; final confirmed = await showDialog( @@ -462,7 +486,7 @@ class _AdminUserFormDialogState extends State { child: const Text('Fermer'), ), ] else if (_isEditMode) ...[ - if (!_isSuperAdminTarget) + if (_canDeleteTarget) OutlinedButton( onPressed: _isSubmitting ? null : _delete, style: OutlinedButton.styleFrom(foregroundColor: Colors.red.shade700), diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 31ea722..f190463 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -460,7 +460,40 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant')); } - return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + final enfant = EnfantAdminModel.fromJson( + jsonDecode(response.body) as Map, + ); + // GET /enfants/:id ne joint pas toujours parent.user → noms manquants. + return enrichEnfantParentNames(enfant); + } + + /// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user. + static Future enrichEnfantParentNames( + EnfantAdminModel enfant, + ) async { + final links = enfant.parentLinks; + if (links.isEmpty) return enfant; + if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) { + return enfant; + } + + final enriched = await Future.wait( + links.map((link) async { + if ((link.parentName ?? '').trim().isNotEmpty) return link; + final id = link.parentId.trim(); + if (id.isEmpty) return link; + try { + final parent = await getParent(id); + final name = parent.user.fullName.trim(); + if (name.isEmpty) return link; + return link.copyWith(parentName: name); + } catch (_) { + return link; + } + }), + ); + + return enfant.copyWith(parentLinks: enriched); } static Future updateEnfant({ @@ -475,7 +508,29 @@ class UserService { if (response.statusCode != 200) { throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant')); } - return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map); + 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'), + 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) { diff --git a/frontend/lib/utils/enfant_status_utils.dart b/frontend/lib/utils/enfant_status_utils.dart index 9ea84b5..deb5f03 100644 --- a/frontend/lib/utils/enfant_status_utils.dart +++ b/frontend/lib/utils/enfant_status_utils.dart @@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) { return s; } -String _scolariseAccordeAuGenre(String? gender) { +String _accordeAuGenre(String masculin, String feminin, String? gender) { final g = (gender ?? '').trim().toUpperCase(); - if (g == 'F') return 'Scolarisée'; - return 'Scolarisé'; + if (g == 'F') return feminin; + return masculin; } /// Libellé affiché pour un statut enfant. @@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) { case 'sans_garde': return 'Sans garde'; case 'garde': - return 'En garde'; + return _accordeAuGenre('Gardé', 'Gardée', gender); case 'scolarise': - return _scolariseAccordeAuGenre(gender); + return _accordeAuGenre('Scolarisé', 'Scolarisée', gender); default: return status?.trim().isNotEmpty == true ? status!.trim() : '–'; } diff --git a/frontend/lib/utils/parent_registration_payload.dart b/frontend/lib/utils/parent_registration_payload.dart index 70a9459..467cd35 100644 --- a/frontend/lib/utils/parent_registration_payload.dart +++ b/frontend/lib/utils/parent_registration_payload.dart @@ -167,6 +167,7 @@ class ParentRegistrationPayload { final map = { 'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre', 'grossesse_multiple': c.multipleBirth, + 'consent_photo': c.photoConsent, }; final prenom = c.firstName.trim(); diff --git a/frontend/lib/utils/reprise_mapper.dart b/frontend/lib/utils/reprise_mapper.dart index 53c285b..f33b53d 100644 --- a/frontend/lib/utils/reprise_mapper.dart +++ b/frontend/lib/utils/reprise_mapper.dart @@ -38,15 +38,12 @@ class RepriseMapper { ? isoToDdMmYyyy(e.dueDate) : isoToDdMmYyyy(e.birthDate); final photo = e.photoUrl?.trim(); - final hasPhoto = photo != null && photo.isNotEmpty; return ChildData( firstName: e.firstName ?? '', lastName: e.lastName ?? '', dob: dob, genre: e.gender ?? '', - // Inscription initiale exigeait la coche pour envoyer la photo ; le back - // ne persistait pas toujours consent_photo — on pré-coche si photo en base. - photoConsent: e.consentPhoto || hasPhoto, + photoConsent: e.consentPhoto, multipleBirth: e.estMultiple, isUnbornChild: isUnborn, cardColor: _childCardColors[index % _childCardColors.length], @@ -200,7 +197,7 @@ class RepriseMapper { postalCode: dossier.codePostal ?? '', city: dossier.ville ?? '', existingPhotoUrl: displayPhoto, - consentementPhoto: dossier.consentementPhoto || hasPhoto, + consentementPhoto: dossier.consentementPhoto, dateOfBirth: parseIsoDate(dossier.dateNaissance), birthCity: dossier.lieuNaissanceVille ?? '', birthCountry: dossier.lieuNaissancePays ?? '', 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..b1dac62 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,29 @@ 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/api/api_config.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_parent_edit_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). +/// 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 +41,41 @@ class _AdminChildDetailModalState extends State { late bool _isMultiple; bool _dirty = false; bool _saving = false; + bool _deleting = false; + bool _loadingAm = true; + String? _currentUserRole; + AssistanteMaternelleModel? _linkedAm; + /// AM rattachée au chargement (pour sync différé au Sauvegarder). + String? _baselineAmUserId; - 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 _isSansGarde => _status == 'sans_garde'; + 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; @override void initState() { @@ -44,18 +83,59 @@ 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; + _baselineAmUserId = am?.user.id; + _loadingAm = false; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _baselineAmUserId = null; + _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,21 +146,115 @@ 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(); + } + + List get _parentLinks => widget.enfant.parentLinks + .where((l) => l.parentId.trim().isNotEmpty) + .toList(); + + Future _openParent(EnfantParentLink link) async { + if (_busy) return; + final id = link.parentId.trim(); + if (id.isEmpty) return; + + try { + final parent = await UserService.getParent(id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminParentEditModal( + parent: parent, + onSaved: () => widget.onSaved?.call(), + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget? _parentsSubtitle() { + final links = _parentLinks; + if (links.isEmpty) return null; + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text( + 'Responsables : ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + for (var i = 0; i < links.length; i++) ...[ + if (i > 0) + const Text( + ', ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + _parentNameLink(links[i]), + ], + ], + ); + } + + Widget _parentNameLink(EnfantParentLink link) { + final name = (link.parentName ?? '').trim(); + final label = name.isNotEmpty ? name : 'Parent rattaché'; + return InkWell( + onTap: _busy ? null : () => _openParent(link), + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: ValidationModalTheme.primaryActionBackground, + decoration: TextDecoration.underline, + decorationColor: ValidationModalTheme.primaryActionBackground + .withValues(alpha: 0.5), + ), + ), + ), + ); + } + Future _save() async { if (!_dirty) return; setState(() => _saving = true); try { + await _syncAmPlacement(); + await UserService.updateEnfant( enfantId: widget.enfant.id, body: { @@ -88,9 +262,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, }, @@ -99,6 +275,7 @@ class _AdminChildDetailModalState extends State { setState(() { _dirty = false; _saving = false; + _baselineAmUserId = _linkedAm?.user.id; }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -113,289 +290,721 @@ 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(', '); + /// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder). + Future _syncAmPlacement() async { + final baselineId = _baselineAmUserId; + final currentId = _linkedAm?.user.id; + + if (baselineId != null && baselineId != currentId) { + await UserService.detachEnfantFromAm( + amUserId: baselineId, + enfantId: widget.enfant.id, + ); + } + if (currentId != null && currentId != baselineId) { + await UserService.attachEnfantToAm( + amUserId: currentId, + enfantId: widget.enfant.id, + ); + } } - @override - Widget build(BuildContext context) { - final parents = _parentsLine(); - final showDueDate = _status == 'a_naitre'; + Future _delete() async { + if (!_canDelete || _deleting) return; - 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; - }), - ), - ], + 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: ', ''))), + ); + } + } + + Future _openLinkedAm() async { + final am = _linkedAm; + if (am == null) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: am, + onSaved: () async { + await _reloadPlacementFromServer(); + widget.onSaved?.call(); + }, + ), + ); + } + + /// Recharge AM + statut après une modale externe (ex. fiche AM). + Future _reloadPlacementFromServer() async { + try { + final results = await Future.wait([ + UserService.findAmForEnfant(widget.enfant.id), + UserService.getEnfant(widget.enfant.id), + ]); + if (!mounted) return; + final am = results[0] as AssistanteMaternelleModel?; + final enfant = results[1] as EnfantAdminModel; + setState(() { + _linkedAm = am; + _baselineAmUserId = am?.user.id; + _status = normalizeEnfantStatus(enfant.status); + if (!enfantStatusValues.contains(_status)) { + _status = 'sans_garde'; + } + _coerceGenderForStatus(); + }); + } catch (_) { + if (!mounted) return; + await _loadLinkedAm(); + } + } + + 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 && !_isSansGarde) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + ), + ); + return; + } + + if (all.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: all + .map( + (am) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, am), + child: Text(am.user.fullName), + ), + ) + .toList(), + ), + ); + if (selected == null || !mounted) return; + + setState(() { + _linkedAm = selected; + if (_status == 'sans_garde') { + _status = 'garde'; + } + _dirty = true; + }); + } + + 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 ?\n' + '(Effectif après Sauvegarder.)', + ), + 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(() { + _linkedAm = null; + if (_status != 'a_naitre' && _status != 'scolarise') { + _status = 'sans_garde'; + } + _dirty = true; + }); + } + + /// Passe en « Sans garde » : cadre vide local (détachement au Sauvegarder). + void _applySansGardeStatus() { + if (_linkedAm == null) return; + setState(() { + _linkedAm = null; + _dirty = true; + }); + } + + 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; + }); + if (v == 'sans_garde') { + _applySansGardeStatus(); + } + }, + ), + ), + 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(), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 16), + child: Align( + alignment: Alignment.bottomCenter, + child: _placementBlock(), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ); + } + + Widget _scolariseCard() { + return Container( + height: _placementHeight, + width: double.infinity, + 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( + enfantStatusLabel('scolarise', gender: _gender), + 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), + ), ), ], ), ), - ), - ), - ); - } - - 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, - ), + 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, + 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.face_outlined, + size: 30, + color: Colors.grey.shade400, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Aucune assistante maternelle', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 4), + Text( + 'Cliquer pour choisir une assistante', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Icon(Icons.add_circle_outline, color: Colors.grey.shade500), + ], + ), + ), + ), + ); + } + + Widget _linkedAmCard() { + final am = _linkedAm!; + final zone = (am.residenceCity ?? '').trim(); + final agrement = (am.approvalNumber ?? '').trim(); + final subtitle = [ + if (zone.isNotEmpty) 'Zone : $zone', + if (agrement.isNotEmpty) 'Agrément : $agrement', + ].join(' · '); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: _busy ? null : _openLinkedAm, + borderRadius: BorderRadius.circular(10), + child: Container( + height: _placementHeight, + width: double.infinity, + decoration: BoxDecoration( + color: const Color(0xFFF7F3FC), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFD4C4EF)), + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + _amAvatar(am.user.photoUrl), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + am.user.fullName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: Color(0xFF4A2F7A), + ), + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + ), + ], + ], + ), + ), + IconButton( + icon: const Icon(Icons.open_in_new, size: 20), + tooltip: 'Ouvrir la fiche AM', + onPressed: _busy ? null : _openLinkedAm, + ), + IconButton( + icon: Icon(Icons.link_off, size: 20, color: Colors.orange.shade800), + tooltip: 'Détacher', + onPressed: _busy ? null : _detachAm, + ), + ], + ), + ), + ), + ); + } + + Widget _amAvatar(String? photoUrl) { + const size = 56.0; + const bg = Color(0xFFEDE5FA); + const iconColor = Color(0xFF6B3FA0); + final url = ApiConfig.absoluteMediaUrl(photoUrl); + + if (url.isEmpty) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.face, size: 30, color: iconColor), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: AuthNetworkImage( + url: url, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: size, + height: size, + color: bg, + child: const Icon(Icons.face, size: 30, color: iconColor), + ), + ), + ); + } + + String get _placementTitle => + _isScolarise ? 'Scolarisation' : 'Assistante maternelle'; + + Widget _placementBlock() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _placementTitle, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 8), + _placementSection(), + ], + ); + } + + Widget _placementSection() { + if (!_showsAmPlacement) return _scolariseCard(); + + if (_loadingAm) { + return SizedBox( + height: _placementHeight, + width: double.infinity, + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.grey.shade600, + ), + ), + ), + ); + } + + // Sans garde : toujours le cadre vide (comme après détachement AM). + if (_isSansGarde) return _emptyAmSlot(); + 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 +1012,73 @@ 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), + _parentsSubtitle()!, + ], + ], + ), + ), + 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( diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 9de03bc..d272864 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -215,7 +215,7 @@ class _UserManagementPanelState extends State { value: 'garde', child: Padding( padding: EdgeInsets.only(left: 10), - child: Text('En garde', style: TextStyle(fontSize: 12)), + child: Text('Gardé', style: TextStyle(fontSize: 12)), ), ), DropdownMenuItem( From fde63f8e720b2f0a698d5ed21075370c6d4ccfba Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 14:56:45 +0200 Subject: [PATCH 2/5] feat(#145): lien co-parent cliquable dans la fiche parent. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squash merge develop → master. Co-authored-by: Cursor --- .../admin/common/admin_parent_edit_modal.dart | 77 ++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) 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 b1fe436..85c70a6 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State { return '$fn $ln'.trim(); } - String? _coParentSubtitle() { + String? _coParentName() { final name = _coParent?.fullName.trim() ?? ''; - if (name.isEmpty) return null; - return 'Co-parent : $name'; + return name.isEmpty ? null : name; + } + + Future _openCoParent() async { + if (_saving) return; + final co = _coParent; + final id = (co?.id ?? '').trim(); + if (co == null || id.isEmpty) return; + + try { + final parent = await UserService.getParent(id); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminParentEditModal( + parent: parent, + onSaved: () async { + try { + final refreshed = await UserService.getParent(widget.parent.user.id); + if (!mounted) return; + setState(() => _coParent = refreshed.coParent); + } catch (_) {} + widget.onSaved?.call(); + }, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + ); + } + } + + Widget? _coParentSubtitle() { + final name = _coParentName(); + if (name == null) return null; + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + const Text( + 'Co-parent : ', + style: TextStyle(fontSize: 13, color: Colors.black54), + ), + InkWell( + onTap: _saving ? null : _openCoParent, + borderRadius: BorderRadius.circular(4), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2), + child: Text( + name, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: ValidationModalTheme.primaryActionBackground, + decoration: TextDecoration.underline, + decorationColor: ValidationModalTheme.primaryActionBackground + .withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ); } Future _save() async { @@ -395,13 +458,7 @@ class _AdminParentEditModalState extends State { ), if (_coParentSubtitle() != null) ...[ const SizedBox(height: 4), - Text( - _coParentSubtitle()!, - style: const TextStyle( - fontSize: 13, - color: Colors.black54, - ), - ), + _coParentSubtitle()!, ], ], ), From dcd407a3da6b7e7561c010fd58b7e830b9eecdac Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:25:42 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(#146=E2=80=93#149):=20s=C3=A9lection?= =?UTF-8?q?=20enfant/AM,=20capacit=C3=A9=20max=20et=20case=20libre.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modales de rattachement partagées, filtre Libre/Sans garde, recalcul des places à l'attach, désactivation si capacité pleine, clic sur case libre. Co-authored-by: Cursor --- frontend/lib/services/user_service.dart | 20 +- frontend/lib/utils/am_vigilance.dart | 12 + .../admin_am_children_capacity_grid.dart | 57 ++- .../admin/common/admin_am_edit_modal.dart | 131 +++++-- .../common/admin_child_detail_modal.dart | 43 +- .../admin/common/admin_enfant_user_card.dart | 14 + .../admin/common/admin_parent_edit_modal.dart | 40 +- .../admin/common/admin_select_am_modal.dart | 218 +++++++++++ .../common/admin_select_enfant_modal.dart | 72 ++++ .../admin/common/admin_select_list_modal.dart | 367 ++++++++++++++++++ .../widgets/admin/common/admin_user_card.dart | 8 +- 11 files changed, 862 insertions(+), 120 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_am_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart create mode 100644 frontend/lib/widgets/admin/common/admin_select_list_modal.dart diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index f190463..b13d50b 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/pending_family.dart'; import 'package:p_tits_pas/models/dossier_unifie.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/services/api/tokenService.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; class DocumentActifInfo { final String id; @@ -688,7 +689,24 @@ class UserService { if (response.statusCode != 200 && response.statusCode != 201) { throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant')); } - return _amModelFromBody(response.body); + final am = _amModelFromBody(response.body); + // Recalcule toujours places_available (capacité − enfants) après attachement. + return syncAmPlacesAvailable(am); + } + + /// Aligne `places_available` sur capacité − enfants rattachés. + static Future syncAmPlacesAvailable( + AssistanteMaternelleModel am, + ) async { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected == null || am.placesAvailable == expected) return am; + return updateAmFiche( + amUserId: am.user.id, + body: {'places_available': expected}, + ); } static Future detachEnfantFromAm({ diff --git a/frontend/lib/utils/am_vigilance.dart b/frontend/lib/utils/am_vigilance.dart index 944dc93..85a9bf6 100644 --- a/frontend/lib/utils/am_vigilance.dart +++ b/frontend/lib/utils/am_vigilance.dart @@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({ return (maxChildren - childrenCount).clamp(0, maxChildren); } +/// True s'il reste au moins une place d'accueil (capacité − enfants). +bool amHasFreePlace(AssistanteMaternelleModel am) { + final expected = amExpectedPlacesAvailable( + maxChildren: am.maxChildren, + childrenCount: am.children.length, + ); + if (expected != null) return expected > 0; + final places = am.placesAvailable; + if (places != null) return places > 0; + return true; +} + /// True si la valeur déclarée par l'AM ne correspond pas au calcul métier. bool amHasPlacesInconsistency({ required int? maxChildren, 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 index b77871c..5b28e3c 100644 --- a/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart +++ b/frontend/lib/widgets/admin/common/admin_am_children_capacity_grid.dart @@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { final int capacity; final void Function(ParentChildSummary child) onOpen; final void Function(ParentChildSummary child) onDetach; + /// Clic sur une case libre → même flux que « Rattacher un enfant » (#149). + final VoidCallback? onAttachEmpty; const AdminAmChildrenCapacityGrid({ super.key, @@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { required this.capacity, required this.onOpen, required this.onDetach, + this.onAttachEmpty, }); @override @@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget { ); } if (index < maxSlots) { - return const _EmptySlot(); + return _EmptySlot(onTap: onAttachEmpty); } return const _UnavailableSlot(); } @@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget { } } -class _EmptySlot extends StatelessWidget { - const _EmptySlot(); +class _EmptySlot extends StatefulWidget { + final VoidCallback? onTap; + + const _EmptySlot({this.onTap}); + + @override + State<_EmptySlot> createState() => _EmptySlotState(); +} + +class _EmptySlotState extends State<_EmptySlot> { + bool _hovered = false; @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), + final clickable = widget.onTap != null; + return MouseRegion( + onEnter: clickable ? (_) => setState(() => _hovered = true) : null, + onExit: clickable ? (_) => setState(() => _hovered = false) : null, + cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer, + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + hoverColor: const Color(0x149CC5C0), + child: _SlotShell( + backgroundColor: _hovered + ? const Color(0xFFF3F0FA) + : Colors.grey.shade50, + borderColor: _hovered + ? const Color(0xFFB8A4D4) + : Colors.grey.shade300, + child: Center( + child: Text( + 'Place libre', + style: TextStyle( + fontSize: 12, + color: _hovered + ? const Color(0xFF6B3FA0) + : Colors.grey.shade500, + fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400, + ), + ), + ), + ), ), ), ); 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 b3ca7f1..6663be2 100644 --- a/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_am_edit_modal.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.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/models/parent_child_summary.dart'; import 'package:p_tits_pas/utils/am_vigilance.dart'; import 'package:p_tits_pas/utils/date_display_utils.dart'; @@ -11,6 +10,7 @@ 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_select_enfant_modal.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'; @@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State late List _children; late Set _baselineChildIds; + /// Enfants ajoutés localement qui étaient déjà chez une autre AM + /// (enfantId → amUserId d'origine). Au save : détacher puis rattacher. + final Map _transferFromAmIds = {}; + bool _saving = false; bool _dirty = false; @@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State int? _capaciteMax() => _parseIntField(_capaciteCtrl); + /// True si plus aucune place d'accueil (enfants ≥ capacité max). + bool get _capacityFull { + final max = _capaciteMax(); + if (max == null) return false; + return _children.length >= max; + } + int? _computedPlacesAvailable() => amExpectedPlacesAvailable( maxChildren: _capaciteMax(), childrenCount: _children.length, @@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State ); } for (final id in currentIds.difference(_baselineChildIds)) { + // Transfert : détacher l'ancienne AM sans recalculer ses places + // (le ! de vigilance apparaît côté liste AM). + final previousAmId = _transferFromAmIds[id] ?? + (await UserService.findAmForEnfant(id))?.user.id; + if (previousAmId != null && + previousAmId != widget.assistante.user.id) { + await UserService.detachEnfantFromAm( + amUserId: previousAmId, + enfantId: id, + ); + } await UserService.attachEnfantToAm( amUserId: widget.assistante.user.id, enfantId: id, @@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State _dirty = false; _saving = false; _baselineChildIds = currentIds; + _transferFromAmIds.clear(); }); widget.onSaved?.call(); ScaffoldMessenger.of(context).showSnackBar( @@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State setState(() { _children = kids; _baselineChildIds = kids.map((c) => c.id).toSet(); + _transferFromAmIds.clear(); }); } catch (_) {} } @@ -442,55 +466,75 @@ class _AdminAmEditModalState extends State setState(() { _children = _children.where((c) => c.id != child.id).toList(); + _transferFromAmIds.remove(child.id); _syncPlacesAfterChildrenChange(); _dirty = true; }); } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + if (_capacityFull || !mounted) return; + final selected = await AdminSelectEnfantModal.show( + context, + excludeIds: _children.map((c) => c.id).toSet(), + title: 'Rattacher un enfant', + showSansGardeFilter: true, ); if (selected == null || !mounted) return; + AssistanteMaternelleModel? previousAm; + try { + previousAm = await UserService.findAmForEnfant(selected.id); + } catch (_) { + previousAm = null; + } + if (!mounted) return; + + final previousAmId = previousAm?.user.id; + final isTransfer = previousAmId != null && + previousAmId != widget.assistante.user.id; + + if (isTransfer) { + final amName = previousAm!.user.fullName.trim().isNotEmpty + ? previousAm.user.fullName.trim() + : 'une autre assistante maternelle'; + final gardeLabel = + (selected.gender ?? '').trim().toUpperCase() == 'F' + ? 'déjà gardée' + : 'déjà gardé'; + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Changer d\'affectation'), + content: Text( + '${selected.fullName} est $gardeLabel par $amName.\n\n' + 'Confirmer le transfert vers cette assistante ? ' + 'L\'enfant sera détaché de $amName.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + style: ValidationModalTheme.primaryElevatedStyle, + child: const Text('Confirmer'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + } + setState(() { _children = [ ..._children, ParentChildSummary.fromEnfant(selected), ]; + if (isTransfer) { + _transferFromAmIds[selected.id] = previousAmId!; + } _syncPlacesAfterChildrenChange(); _dirty = true; }); @@ -701,6 +745,7 @@ class _AdminAmEditModalState extends State capacity: capacity, onOpen: _openChild, onDetach: _detachChild, + onAttachEmpty: _capacityFull ? null : _attachChild, ), ], ); @@ -708,6 +753,7 @@ class _AdminAmEditModalState extends State Widget _buildFooter() { final isChildrenTab = _tabCtrl.index == 2; + final canAttachChild = !_saving && !_capacityFull; return Row( children: [ TextButton( @@ -716,10 +762,15 @@ class _AdminAmEditModalState extends State ), const Spacer(), if (isChildrenTab) - TextButton.icon( - onPressed: _attachChild, - icon: const Icon(Icons.link, size: 18), - label: const Text('Rattacher un enfant'), + Tooltip( + message: _capacityFull + ? 'Capacité maximale atteinte' + : 'Rattacher un enfant', + child: TextButton.icon( + onPressed: canAttachChild ? _attachChild : null, + icon: const Icon(Icons.link, size: 18), + label: const Text('Rattacher un enfant'), + ), ), if (isChildrenTab) const SizedBox(width: 12), ElevatedButton( 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 b1dac62..cae3ca8 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -9,6 +9,7 @@ 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_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/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'; @@ -399,49 +400,25 @@ class _AdminChildDetailModalState extends State { } 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 && !_isSansGarde) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'), + content: Text( + 'Détachez l\'assistante actuelle avant d\'en choisir une autre', + ), ), ); return; } - if (all.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: all - .map( - (am) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, am), - child: Text(am.user.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectAmModal.show( + context, + excludeIds: { + if (_linkedAm != null) _linkedAm!.user.id, + }, + title: 'Choisir une assistante maternelle', ); if (selected == null || !mounted) return; 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 69dfa65..a9b0f7f 100644 --- a/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_enfant_user_card.dart @@ -34,6 +34,8 @@ class AdminEnfantUserCard extends StatelessWidget { final List subtitleLines; final List actions; final VoidCallback? onCardTap; + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminEnfantUserCard({ super.key, @@ -42,6 +44,8 @@ class AdminEnfantUserCard extends StatelessWidget { required this.subtitleLines, this.actions = const [], this.onCardTap, + this.margin, + this.contentPadding, }); factory AdminEnfantUserCard.fromEnfant( @@ -49,6 +53,8 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { final parents = enfant.parentLinks .map((l) => l.parentName ?? 'Parent') @@ -68,6 +74,8 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } @@ -76,6 +84,8 @@ class AdminEnfantUserCard extends StatelessWidget { List extraSubtitleLines = const [], List actions = const [], VoidCallback? onCardTap, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? contentPadding, }) { return AdminEnfantUserCard( title: child.fullName, @@ -88,6 +98,8 @@ class AdminEnfantUserCard extends StatelessWidget { ), actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } @@ -100,6 +112,8 @@ class AdminEnfantUserCard extends StatelessWidget { subtitleLines: subtitleLines, actions: actions, onCardTap: onCardTap, + margin: margin, + contentPadding: contentPadding, ); } } 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 85c70a6..176f1ce 100644 --- a/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/parent_child_summary.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -7,6 +6,7 @@ import 'package:p_tits_pas/models/user.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_children_affiliation_panel.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart'; import 'package:p_tits_pas/widgets/common/identity_block.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; @@ -321,41 +321,11 @@ class _AdminParentEditModalState extends State { } Future _attachChild() async { - List all; - try { - all = await UserService.getEnfants(); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), - ); - return; - } - - final linkedIds = _children.map((c) => c.id).toSet(); - final candidates = all.where((e) => !linkedIds.contains(e.id)).toList(); - if (candidates.isEmpty) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Aucun enfant disponible à rattacher')), - ); - return; - } - if (!mounted) return; - final selected = await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text('Rattacher un enfant'), - children: candidates - .map( - (e) => SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, e), - child: Text(e.fullName), - ), - ) - .toList(), - ), + final selected = await AdminSelectEnfantModal.show( + context, + excludeIds: _children.map((c) => c.id).toSet(), + title: 'Rattacher un enfant', ); if (selected == null || !mounted) return; diff --git a/frontend/lib/widgets/admin/common/admin_select_am_modal.dart b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart new file mode 100644 index 0000000..ab46601 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_am_modal.dart @@ -0,0 +1,218 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/am_vigilance.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.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'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +List _amSelectSubtitleLines(AssistanteMaternelleModel am) { + final lines = []; + final zone = (am.residenceCity ?? '').trim(); + if (zone.isNotEmpty) lines.add('Zone : $zone'); + final agrement = (am.approvalNumber ?? '').trim(); + if (agrement.isNotEmpty) lines.add('Agrément : $agrement'); + final max = am.maxChildren; + final free = amExpectedPlacesAvailable( + maxChildren: max, + childrenCount: am.children.length, + ) ?? + am.placesAvailable; + if (free != null || max != null) { + lines.add('Places libres : ${free ?? '–'} / capa. ${max ?? '–'}'); + } + lines.add('${am.children.length} enfant(s)'); + return lines; +} + +/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146). +class AdminSelectAmModal { + AdminSelectAmModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Choisir une assistante maternelle', + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom, prénom ou zone…', + emptyMessage: 'Aucune assistante maternelle disponible', + noResultsMessage: 'Aucune AM avec place libre pour cette recherche', + toggleFilter: const AdminSelectToggleFilter( + label: 'Libre', + initialValue: true, + whenEnabled: amHasFreePlace, + ), + loadItems: () async { + final list = await UserService.getAssistantesMaternelles(); + return list + .where((am) => !excludeIds.contains(am.user.id)) + .toList() + ..sort( + (a, b) => a.user.fullName + .toLowerCase() + .compareTo(b.user.fullName.toLowerCase()), + ); + }, + matchesQuery: (am, q) { + final u = am.user; + final name = u.fullName.toLowerCase(); + final fn = (u.prenom ?? '').toLowerCase(); + final ln = (u.nom ?? '').toLowerCase(); + final zone = (am.residenceCity ?? '').toLowerCase(); + final agrement = (am.approvalNumber ?? '').toLowerCase(); + return name.contains(q) || + fn.contains(q) || + ln.contains(q) || + zone.contains(q) || + agrement.contains(q); + }, + resolveSelect: (ctx, am, reload) => + _resolveAmSelection(ctx, am, reload), + itemBuilder: (context, am, onSelect) { + final full = !amHasFreePlace(am); + return AdminUserCard( + title: am.user.fullName, + avatarUrl: am.user.photoUrl, + fallbackIcon: Icons.face, + subtitleLines: _amSelectSubtitleLines(am), + vigilanceTooltip: amPlacesVigilanceMessage(am), + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + backgroundColor: full ? const Color(0xFFFFEBEE) : null, + borderColor: full ? Colors.red.shade200 : null, + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } + + static Future _resolveAmSelection( + BuildContext context, + AssistanteMaternelleModel am, + Future Function() reloadList, + ) async { + if (amHasFreePlace(am)) return am; + + final selected = await showDialog( + context: context, + builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am), + ); + await reloadList(); + return selected; + } +} + +/// Avertissement AM saturée + lien vers la fiche pour ajuster les places. +class _AmNoPlaceWarningDialog extends StatefulWidget { + final AssistanteMaternelleModel initialAm; + + const _AmNoPlaceWarningDialog({required this.initialAm}); + + @override + State<_AmNoPlaceWarningDialog> createState() => + _AmNoPlaceWarningDialogState(); +} + +class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> { + late AssistanteMaternelleModel _am; + TapGestureRecognizer? _linkRecognizer; + + @override + void initState() { + super.initState(); + _am = widget.initialAm; + _linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche; + } + + @override + void dispose() { + _linkRecognizer?.dispose(); + super.dispose(); + } + + Future _openAmFiche() async { + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AdminAmEditModal( + assistante: _am, + onSaved: () async { + try { + final fresh = + await UserService.getAssistanteMaternelle(_am.user.id); + if (mounted) setState(() => _am = fresh); + } catch (_) {} + }, + ), + ); + if (!mounted) return; + + try { + final fresh = await UserService.getAssistanteMaternelle(_am.user.id); + if (!mounted) return; + setState(() => _am = fresh); + if (amHasFreePlace(fresh)) { + Navigator.of(context).pop(fresh); + } + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + final name = _am.user.fullName.trim().isNotEmpty + ? _am.user.fullName.trim() + : 'Cette assistante maternelle'; + const linkColor = ValidationModalTheme.primaryActionBackground; + + return AlertDialog( + title: const Text('Plus de place disponible'), + content: Text.rich( + TextSpan( + style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4), + children: [ + TextSpan( + text: '$name n\'a plus de place libre pour accueillir ' + 'un enfant supplémentaire.\n\n', + ), + const TextSpan(text: 'Vous pouvez '), + TextSpan( + text: 'ouvrir sa fiche', + style: TextStyle( + color: linkColor, + decoration: TextDecoration.underline, + fontWeight: FontWeight.w600, + ), + recognizer: _linkRecognizer, + ), + const TextSpan( + text: ' pour modifier la capacité ou les places, ' + 'puis la sélectionner si une place se libère.', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Fermer'), + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart new file mode 100644 index 0000000..dafe5d4 --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_enfant_modal.dart @@ -0,0 +1,72 @@ +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'; +import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart'; + +/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146. +/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147). +class AdminSelectEnfantModal { + AdminSelectEnfantModal._(); + + static Future show( + BuildContext context, { + Set excludeIds = const {}, + String title = 'Rattacher un enfant', + /// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde. + bool showSansGardeFilter = false, + }) { + return AdminSelectListModal.show( + context, + title: title, + searchHint: 'Rechercher par nom ou prénom…', + emptyMessage: 'Aucun enfant disponible à rattacher', + noResultsMessage: showSansGardeFilter + ? 'Aucun enfant sans garde pour cette recherche' + : 'Aucun résultat pour cette recherche', + toggleFilter: showSansGardeFilter + ? AdminSelectToggleFilter( + label: 'Sans garde', + initialValue: true, + whenEnabled: (e) => + normalizeEnfantStatus(e.status) == 'sans_garde', + ) + : null, + loadItems: () async { + final list = await UserService.getEnfants(); + return list + .where((e) => !excludeIds.contains(e.id)) + .toList() + ..sort( + (a, b) => + a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()), + ); + }, + matchesQuery: (e, q) { + final name = e.fullName.toLowerCase(); + final fn = (e.firstName ?? '').toLowerCase(); + final ln = (e.lastName ?? '').toLowerCase(); + return name.contains(q) || fn.contains(q) || ln.contains(q); + }, + itemBuilder: (context, e, onSelect) { + return AdminEnfantUserCard.fromEnfant( + e, + onCardTap: onSelect, + margin: const EdgeInsets.only(bottom: 4), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + actions: [ + IconButton( + icon: const Icon(Icons.add_link), + tooltip: 'Rattacher', + onPressed: onSelect, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_select_list_modal.dart b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart new file mode 100644 index 0000000..4fdc70c --- /dev/null +++ b/frontend/lib/widgets/admin/common/admin_select_list_modal.dart @@ -0,0 +1,367 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; + +/// Filtre optionnel (switch) sur la même ligne que la barre de recherche. +class AdminSelectToggleFilter { + final String label; + final bool initialValue; + + /// Si le switch est activé, ne garde que les éléments pour lesquels + /// [whenEnabled] renvoie `true`. + final bool Function(T item) whenEnabled; + + const AdminSelectToggleFilter({ + required this.label, + required this.whenEnabled, + this.initialValue = true, + }); +} + +/// Shell générique « rechercher + liste + sélection » pour les modales admin. +/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147). +class AdminSelectListModal extends StatefulWidget { + final String title; + final String searchHint; + final Future> Function() loadItems; + final bool Function(T item, String query) matchesQuery; + final Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder; + final String emptyMessage; + final String noResultsMessage; + final double modalWidth; + + /// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]). + final double cardExtent; + + /// Nombre minimum de cartes visibles dans la zone scrollable. + final int minVisibleCards; + + /// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »). + final AdminSelectToggleFilter? toggleFilter; + + /// Si fourni, appelé avant de valider la sélection. + /// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler. + final Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect; + + const AdminSelectListModal({ + super.key, + required this.title, + required this.loadItems, + required this.matchesQuery, + required this.itemBuilder, + this.searchHint = 'Rechercher…', + this.emptyMessage = 'Aucun élément disponible', + this.noResultsMessage = 'Aucun résultat pour cette recherche', + this.modalWidth = 930, + this.cardExtent = 52, + this.minVisibleCards = 8, + this.toggleFilter, + this.resolveSelect, + }); + + static Future show( + BuildContext context, { + required String title, + required Future> Function() loadItems, + required bool Function(T item, String query) matchesQuery, + required Widget Function( + BuildContext context, + T item, + VoidCallback onSelect, + ) itemBuilder, + String searchHint = 'Rechercher…', + String emptyMessage = 'Aucun élément disponible', + String noResultsMessage = 'Aucun résultat pour cette recherche', + double modalWidth = 930, + double cardExtent = 52, + int minVisibleCards = 8, + AdminSelectToggleFilter? toggleFilter, + Future Function( + BuildContext context, + T item, + Future Function() reload, + )? resolveSelect, + }) { + return showDialog( + context: context, + builder: (ctx) => AdminSelectListModal( + title: title, + loadItems: loadItems, + matchesQuery: matchesQuery, + itemBuilder: itemBuilder, + searchHint: searchHint, + emptyMessage: emptyMessage, + noResultsMessage: noResultsMessage, + modalWidth: modalWidth, + cardExtent: cardExtent, + minVisibleCards: minVisibleCards, + toggleFilter: toggleFilter, + resolveSelect: resolveSelect, + ), + ); + } + + @override + State> createState() => + _AdminSelectListModalState(); +} + +class _AdminSelectListModalState extends State> { + final _searchCtrl = TextEditingController(); + List _all = []; + bool _loading = true; + String? _error; + late bool _toggleOn; + bool _resolving = false; + + double get _listHeight => + widget.cardExtent * widget.minVisibleCards; + + @override + void initState() { + super.initState(); + _toggleOn = widget.toggleFilter?.initialValue ?? false; + _searchCtrl.addListener(() => setState(() {})); + _load(); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await widget.loadItems(); + if (!mounted) return; + setState(() { + _all = list; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = e.toString().replaceFirst('Exception: ', ''); + }); + } + } + + Future _handleSelect(T item) async { + if (_resolving) return; + final resolve = widget.resolveSelect; + if (resolve == null) { + Navigator.of(context).pop(item); + return; + } + setState(() => _resolving = true); + try { + final chosen = await resolve(context, item, _load); + if (!mounted || chosen == null) return; + Navigator.of(context).pop(chosen); + } finally { + if (mounted) setState(() => _resolving = false); + } + } + + List get _filtered { + var list = _all; + final toggle = widget.toggleFilter; + if (toggle != null && _toggleOn) { + list = list.where(toggle.whenEnabled).toList(); + } + final q = _searchCtrl.text.trim().toLowerCase(); + if (q.isEmpty) return list; + return list.where((item) => widget.matchesQuery(item, q)).toList(); + } + + @override + Widget build(BuildContext context) { + final toggle = widget.toggleFilter; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.modalWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 4, 0), + child: Row( + children: [ + Expanded( + child: Text( + widget.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Fermer', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + isDense: true, + hintText: widget.searchHint, + prefixIcon: const Icon(Icons.search, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + ), + ), + ), + if (toggle != null) ...[ + const SizedBox(width: 12), + Text( + toggle.label, + style: const TextStyle(fontSize: 13), + ), + const SizedBox(width: 4), + Switch( + value: _toggleOn, + activeColor: + ValidationModalTheme.primaryActionBackground, + onChanged: (v) => setState(() => _toggleOn = v), + ), + ], + ], + ), + const SizedBox(height: 12), + SizedBox( + height: _listHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildBody(), + ), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_loading) { + return const Center( + child: CircularProgressIndicator( + color: ValidationModalTheme.primaryActionBackground, + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 40, color: Colors.red.shade400), + const SizedBox(height: 12), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red.shade700), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Réessayer'), + ), + ], + ), + ), + ); + } + + final items = _filtered; + if (_all.isEmpty) { + return Center( + child: Text( + widget.emptyMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + if (items.isEmpty) { + return Center( + child: Text( + widget.noResultsMessage, + style: const TextStyle(fontSize: 14, color: Colors.black54), + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 4), + itemExtent: widget.cardExtent, + itemCount: items.length, + itemBuilder: (context, i) { + final item = items[i]; + return widget.itemBuilder( + context, + item, + () => _handleSelect(item), + ); + }, + ); + } +} diff --git a/frontend/lib/widgets/admin/common/admin_user_card.dart b/frontend/lib/widgets/admin/common/admin_user_card.dart index 2d02650..f7cbc1b 100644 --- a/frontend/lib/widgets/admin/common/admin_user_card.dart +++ b/frontend/lib/widgets/admin/common/admin_user_card.dart @@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget { final String? vigilanceTooltip; final VoidCallback? onCardTap; final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? contentPadding; const AdminUserCard({ super.key, @@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget { this.vigilanceTooltip, this.onCardTap, this.margin, + this.contentPadding, }); @override @@ -69,7 +71,8 @@ class _AdminUserCardState extends State { side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + padding: widget.contentPadding ?? + const EdgeInsets.symmetric(horizontal: 12, vertical: 9), child: Row( children: [ _buildAvatar(avatarUrl), @@ -88,7 +91,10 @@ class _AdminUserCardState extends State { Expanded( child: Row( children: [ + // flex: 0 → largeur du nom ; le reste va aux infos + // (évite le partage 50/50 qui tronque « Responsables »). Flexible( + flex: 0, fit: FlexFit.loose, child: Text( widget.title.isNotEmpty ? widget.title : 'Sans nom', From 3c7f4f6e1662116e72c679a4a65f6e00bcae2df2 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 16:54:17 +0200 Subject: [PATCH 4/5] fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permet au gestionnaire de peupler la liste Relais (création/édition). Co-authored-by: Cursor --- .../src/routes/relais/relais.controller.ts | 10 ++++++--- .../creation/gestionnaires_create.dart | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/relais/relais.controller.ts b/backend/src/routes/relais/relais.controller.ts index 5d30871..8125c16 100644 --- a/backend/src/routes/relais/relais.controller.ts +++ b/backend/src/routes/relais/relais.controller.ts @@ -24,15 +24,19 @@ export class RelaisController { } @Get() - @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) - @ApiOperation({ summary: 'Lister tous les relais' }) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) + @ApiOperation({ + summary: 'Lister tous les relais', + description: + 'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.', + }) @ApiResponse({ status: 200, description: 'Liste des relais.' }) findAll() { return this.relaisService.findAll(); } @Get(':id') - @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) + @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE) @ApiOperation({ summary: 'Récupérer un relais par ID' }) @ApiResponse({ status: 200, description: 'Le relais trouvé.' }) findOne(@Param('id') id: string) { diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 4f89ab7..9a24de6 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State { super.dispose(); } + /// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur. + List _fallbackRelaisFromUser() { + final id = _selectedRelaisId?.trim(); + if (id == null || id.isEmpty) return const []; + final nom = (widget.initialUser?.relaisNom ?? '').trim(); + return [ + RelaisModel( + id: id, + nom: nom.isNotEmpty ? nom : 'Relais actuel', + adresse: '', + actif: true, + ), + ]; + } + Future _loadRelais() async { try { final list = await RelaisService.getRelais(); @@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State { if (selected != null) { filtered.add(selected); } else { - _selectedRelaisId = null; + // Garder l'id sélectionné et afficher un item de secours (nom carte). + filtered.addAll(_fallbackRelaisFromUser()); } } @@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State { }); } catch (_) { if (!mounted) return; + // Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé. setState(() { - _selectedRelaisId = null; - _relais = []; + _relais = _fallbackRelaisFromUser(); _isLoadingRelais = false; }); } From cc3b52db166fb65e42653fbcbeca83cf1c1e1479 Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Fri, 17 Jul 2026 18:29:43 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(#132):=20cr=C3=A9ation=20enfant=20staf?= =?UTF-8?q?f=20(onglet=20Enfants)=20=E2=80=94=20front=20+=20back.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /enfants pour gestionnaire/admin avec parent_user_id, liens foyer, multipart photo optionnel ; UI modale création + sélection famille. Co-authored-by: Cursor --- .../routes/enfants/dto/create_enfants.dto.ts | 30 +- .../src/routes/enfants/enfants.controller.ts | 101 +++-- backend/src/routes/enfants/enfants.service.ts | 115 ++++- frontend/lib/services/user_service.dart | 90 ++++ .../admin/common/admin_am_photo_frame.dart | 95 +++- .../common/admin_child_detail_modal.dart | 408 ++++++++++++++++-- .../common/admin_select_famille_modal.dart | 127 ++++++ .../widgets/admin/user_management_panel.dart | 20 + frontend/pubspec.lock | 2 +- frontend/pubspec.yaml | 1 + 10 files changed, 902 insertions(+), 87 deletions(-) create mode 100644 frontend/lib/widgets/admin/common/admin_select_famille_modal.dart diff --git a/backend/src/routes/enfants/dto/create_enfants.dto.ts b/backend/src/routes/enfants/dto/create_enfants.dto.ts index 7d69067..0d66d46 100644 --- a/backend/src/routes/enfants/dto/create_enfants.dto.ts +++ b/backend/src/routes/enfants/dto/create_enfants.dto.ts @@ -1,4 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsBoolean, IsDateString, @@ -6,11 +7,23 @@ import { IsNotEmpty, IsOptional, IsString, + IsUUID, MaxLength, ValidateIf, } from 'class-validator'; import { GenreType, StatutEnfantType } from 'src/entities/children.entity'; +/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */ +function toBoolean({ value }: { value: unknown }): boolean | unknown { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const v = value.trim().toLowerCase(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0' || v === '') return false; + } + return value; +} + export class CreateEnfantsDto { @ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE }) @IsEnum(StatutEnfantType) @@ -52,6 +65,7 @@ export class CreateEnfantsDto { photo_url?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() consent_photo: boolean; @@ -61,6 +75,20 @@ export class CreateEnfantsDto { consent_photo_at?: string; @ApiProperty({ default: false }) + @Transform(toBoolean) @IsBoolean() is_multiple: boolean; + + /** + * Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin). + * Ignoré / interdit en externe pour un PARENT (ticket #132). + */ + @ApiPropertyOptional({ + description: + 'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.', + format: 'uuid', + }) + @IsOptional() + @IsUUID('4') + parent_user_id?: string; } diff --git a/backend/src/routes/enfants/enfants.controller.ts b/backend/src/routes/enfants/enfants.controller.ts index 2579a45..492c885 100644 --- a/backend/src/routes/enfants/enfants.controller.ts +++ b/backend/src/routes/enfants/enfants.controller.ts @@ -1,20 +1,33 @@ import { Body, + CallHandler, Controller, Delete, + ExecutionContext, Get, + HttpCode, + HttpStatus, + Injectable, + NestInterceptor, Param, ParseUUIDPipe, Patch, Post, + UploadedFile, UseGuards, UseInterceptors, - UploadedFile, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { diskStorage } from 'multer'; import { extname } from 'path'; +import { Observable } from 'rxjs'; import { EnfantsService } from './enfants.service'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; import { UpdateEnfantsDto } from './dto/update_enfants.dto'; @@ -24,6 +37,47 @@ import { AuthGuard } from 'src/common/guards/auth.guard'; import { Roles } from 'src/common/decorators/roles.decorator'; import { RolesGuard } from 'src/common/guards/roles.guard'; +const photoMulterOptions = { + storage: diskStorage({ + destination: './uploads/photos', + filename: (req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = extname(file.originalname); + cb(null, `enfant-${uniqueSuffix}${ext}`); + }, + }), + fileFilter: (req, file, cb) => { + if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { + return cb(new Error('Seules les images sont autorisées'), false); + } + cb(null, true); + }, + limits: { + fileSize: 5 * 1024 * 1024, + }, +}; + +/** + * Multer uniquement si Content-Type multipart (parent ou staff + photo). + * JSON sans photo (#132) passe sans interceptor fichier. + */ +@Injectable() +class OptionalEnfantPhotoInterceptor implements NestInterceptor { + private readonly multipart = new (FileInterceptor( + 'photo', + photoMulterOptions, + ))(); + + intercept(context: ExecutionContext, next: CallHandler): Observable | Promise> { + const req = context.switchToHttp().getRequest(); + const ct = String(req.headers['content-type'] ?? ''); + if (!ct.includes('multipart/form-data')) { + return next.handle(); + } + return this.multipart.intercept(context, next); + } +} + @ApiBearerAuth('access-token') @ApiTags('Enfants') @UseGuards(AuthGuard, RolesGuard) @@ -31,30 +85,27 @@ import { RolesGuard } from 'src/common/guards/roles.guard'; export class EnfantsController { constructor(private readonly enfantsService: EnfantsService) { } - @Roles(RoleType.PARENT) - @Post() - @ApiConsumes('multipart/form-data') - @UseInterceptors( - FileInterceptor('photo', { - storage: diskStorage({ - destination: './uploads/photos', - filename: (req, file, cb) => { - const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); - const ext = extname(file.originalname); - cb(null, `enfant-${uniqueSuffix}${ext}`); - }, - }), - fileFilter: (req, file, cb) => { - if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) { - return cb(new Error('Seules les images sont autorisées'), false); - } - cb(null, true); - }, - limits: { - fileSize: 5 * 1024 * 1024, - }, - }), + @Roles( + RoleType.PARENT, + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, ) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Créer un enfant', + description: + 'PARENT : multipart éventuel, rattache au compte connecté. ' + + 'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.', + }) + @ApiConsumes('application/json', 'multipart/form-data') + @ApiBody({ + description: + 'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.', + type: CreateEnfantsDto, + }) + @UseInterceptors(OptionalEnfantPhotoInterceptor) create( @Body() dto: CreateEnfantsDto, @UploadedFile() photo: Express.Multer.File, diff --git a/backend/src/routes/enfants/enfants.service.ts b/backend/src/routes/enfants/enfants.service.ts index 7f4552c..bd77d68 100644 --- a/backend/src/routes/enfants/enfants.service.ts +++ b/backend/src/routes/enfants/enfants.service.ts @@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity'; import { RoleType, Users } from 'src/entities/users.entity'; import { CreateEnfantsDto } from './dto/create_enfants.dto'; +const STAFF_ROLES: RoleType[] = [ + RoleType.GESTIONNAIRE, + RoleType.ADMINISTRATEUR, + RoleType.SUPER_ADMIN, +]; + @Injectable() export class EnfantsService { constructor( @@ -24,20 +30,35 @@ export class EnfantsService { private readonly parentsChildrenRepository: Repository, ) { } - // Création d'un enfant - async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise { + private isStaff(user: Users): boolean { + return STAFF_ROLES.includes(user.role); + } + + /** + * Création d'un enfant. + * - PARENT : rattache au parent connecté (multipart photo optionnel). + * - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ; + * avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132. + */ + async create( + dto: CreateEnfantsDto, + currentUser: Users, + photoFile?: Express.Multer.File, + ): Promise { + const pivotUserId = this.resolvePivotParentUserId(dto, currentUser); + const parent = await this.parentsRepository.findOne({ - where: { user_id: currentUser.id }, + where: { user_id: pivotUserId }, relations: ['co_parent'], }); if (!parent) throw new NotFoundException('Parent introuvable'); - // Vérif métier simple + // Vérif métier simple (aligné comportement historique parent) if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) { throw new BadRequestException('Un enfant né doit avoir une date de naissance'); } - // Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent) + // Vérif doublon éventuel (ex: même prénom + date de naissance) const exist = await this.childrenRepository.findOne({ where: { first_name: dto.first_name, @@ -47,37 +68,84 @@ export class EnfantsService { }); if (exist) throw new ConflictException('Cet enfant existe déjà'); - // Gestion de la photo uploadée + // Gestion de la photo uploadée (multipart parent ou staff) + let photoUrl = dto.photo_url; + let consentAt: Date | undefined; if (photoFile) { - dto.photo_url = `/uploads/photos/${photoFile.filename}`; + photoUrl = `/uploads/photos/${photoFile.filename}`; if (dto.consent_photo) { - dto.consent_photo_at = new Date().toISOString(); + consentAt = new Date(); } + } else if (dto.consent_photo) { + consentAt = dto.consent_photo_at + ? new Date(dto.consent_photo_at) + : new Date(); } - // Création - const child = this.childrenRepository.create(dto); + const child = this.childrenRepository.create({ + status: dto.status, + first_name: dto.first_name, + last_name: dto.last_name, + gender: dto.gender, + birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined, + due_date: dto.due_date ? new Date(dto.due_date) : undefined, + photo_url: photoUrl, + consent_photo: !!dto.consent_photo, + consent_photo_at: consentAt, + is_multiple: !!dto.is_multiple, + }); await this.childrenRepository.save(child); - // Lien parent-enfant (Parent 1) - const parentLink = this.parentsChildrenRepository.create({ - parentId: parent.user_id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(parentLink); + // Lien parent-enfant (pivot) + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.user_id, + enfantId: child.id, + }), + ); // Rattachement automatique au co-parent s'il existe if (parent.co_parent) { - const coParentLink = this.parentsChildrenRepository.create({ - parentId: parent.co_parent.id, - enfantId: child.id, - }); - await this.parentsChildrenRepository.save(coParentLink); + await this.parentsChildrenRepository.save( + this.parentsChildrenRepository.create({ + parentId: parent.co_parent.id, + enfantId: child.id, + }), + ); } return this.findOne(child.id, currentUser); } + private resolvePivotParentUserId( + dto: CreateEnfantsDto, + currentUser: Users, + ): string { + if (this.isStaff(currentUser)) { + const id = dto.parent_user_id?.trim(); + if (!id) { + throw new BadRequestException( + 'parent_user_id est obligatoire pour créer un enfant (staff)', + ); + } + return id; + } + + if (currentUser.role === RoleType.PARENT) { + if ( + dto.parent_user_id && + dto.parent_user_id.trim() !== currentUser.id + ) { + throw new ForbiddenException( + 'Un parent ne peut pas créer un enfant pour un autre compte', + ); + } + return currentUser.id; + } + + throw new ForbiddenException('Accès interdit'); + } + // Liste des enfants (admin/gestionnaire) async findAll(): Promise { return this.childrenRepository.find({ @@ -90,7 +158,7 @@ export class EnfantsService { async findOne(id: string, currentUser: Users): Promise { const child = await this.childrenRepository.findOne({ where: { id }, - relations: ['parentLinks'], + relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], }); if (!child) throw new NotFoundException('Enfant introuvable'); @@ -120,7 +188,8 @@ export class EnfantsService { const child = await this.childrenRepository.findOne({ where: { id } }); if (!child) throw new NotFoundException('Enfant introuvable'); - const patch: Partial = { ...dto } as Partial; + const { parent_user_id: _ignored, ...rest } = dto; + const patch: Partial = { ...rest } as Partial; if (dto.consent_photo !== undefined) { patch.consent_photo = dto.consent_photo; patch.consent_photo_at = dto.consent_photo ? new Date() : null!; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index b13d50b..86f5cad 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/parent_model.dart'; @@ -57,6 +58,33 @@ class UserService { return v.toString(); } + /// MIME pour upload photo enfant (filtre Nest : jpg/jpeg/png/gif). + static MediaType _imageMediaType(String filename, List bytes) { + if (bytes.length >= 3 && + bytes[0] == 0xFF && + bytes[1] == 0xD8 && + bytes[2] == 0xFF) { + return MediaType('image', 'jpeg'); + } + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4E && + bytes[3] == 0x47) { + return MediaType('image', 'png'); + } + if (bytes.length >= 6 && + bytes[0] == 0x47 && + bytes[1] == 0x49 && + bytes[2] == 0x46) { + return MediaType('image', 'gif'); + } + final lower = filename.toLowerCase(); + if (lower.endsWith('.png')) return MediaType('image', 'png'); + if (lower.endsWith('.gif')) return MediaType('image', 'gif'); + return MediaType('image', 'jpeg'); + } + static String _errMessage(dynamic err) { if (err == null) return 'Erreur inconnue'; if (err is String) return err; @@ -515,6 +543,68 @@ 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`). + /// Avec [photoBytes] : `multipart/form-data` (champ fichier `photo`), sinon JSON. + static Future createEnfant({ + required String parentUserId, + required Map body, + List? photoBytes, + String? photoFilename, + }) async { + final hasPhoto = photoBytes != null && photoBytes.isNotEmpty; + final http.Response response; + if (hasPhoto) { + final token = await TokenService.getToken(); + final req = http.MultipartRequest( + 'POST', + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}'), + ); + req.headers['Accept'] = 'application/json'; + if (token != null) { + req.headers['Authorization'] = 'Bearer $token'; + } + body.forEach((key, value) { + if (value == null) return; + req.fields[key] = value is bool + ? (value ? 'true' : 'false') + : value.toString(); + }); + req.fields['parent_user_id'] = parentUserId; + final name = (photoFilename ?? '').trim(); + final filename = name.isNotEmpty ? name : 'photo.jpg'; + req.files.add( + http.MultipartFile.fromBytes( + 'photo', + photoBytes, + filename: filename, + contentType: _imageMediaType(filename, photoBytes), + ), + ); + final streamed = await req.send(); + response = await http.Response.fromStream(streamed); + } else { + final payload = { + ...body, + 'parent_user_id': parentUserId, + }; + 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_am_photo_frame.dart b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart index 17ac62e..3da8051 100644 --- a/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart +++ b/frontend/lib/widgets/admin/common/admin_am_photo_frame.dart @@ -1,14 +1,27 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:p_tits_pas/services/api/api_config.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; -/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard]. +/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard]. class AdminAmPhotoFrame extends StatelessWidget { final String? photoUrl; + final Uint8List? imageBytes; + final VoidCallback? onTap; + final VoidCallback? onClear; + final String emptyLabel; static const double idPhotoAspectRatio = 35 / 45; - const AdminAmPhotoFrame({super.key, this.photoUrl}); + const AdminAmPhotoFrame({ + super.key, + this.photoUrl, + this.imageBytes, + this.onTap, + this.onClear, + this.emptyLabel = 'Aucune photo', + }); /// Largeur colonne photo pour remplir [height] (cadre inclus). static double columnWidthForHeight(double height) { @@ -36,9 +49,12 @@ class AdminAmPhotoFrame extends StatelessWidget { ph = pw / ar; } + final hasLocal = imageBytes != null && imageBytes!.isNotEmpty; + final showClear = onClear != null && hasLocal; + // Cadre gris = taille photo + padding uniforme ; centré dans la colonne // (évite le vide blanc en bas quand le conteneur parent est plus haut). - return Align( + Widget frame = Align( alignment: Alignment.topCenter, child: Container( decoration: BoxDecoration( @@ -60,22 +76,81 @@ class AdminAmPhotoFrame extends StatelessWidget { ), ), ); + + if (onTap != null) { + frame = MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: frame, + ), + ); + } + + if (!showClear) return frame; + + return Stack( + clipBehavior: Clip.none, + children: [ + frame, + Positioned( + top: 0, + right: 0, + child: Material( + color: Colors.transparent, + child: IconButton( + tooltip: 'Retirer la photo', + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 32, + minHeight: 32, + ), + icon: Icon( + Icons.cancel, + size: 22, + color: Colors.grey.shade700, + ), + onPressed: onClear, + ), + ), + ), + ], + ); }, ); } Widget _photoContent(String fullUrl, double pw, double ph) { + if (imageBytes != null && imageBytes!.isNotEmpty) { + return Image.memory( + imageBytes!, + width: pw, + height: ph, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + ); + } if (fullUrl.isEmpty) { return ColoredBox( color: Colors.grey.shade200, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400), + Icon( + onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined, + size: 36, + color: Colors.grey.shade400, + ), const SizedBox(height: 6), - Text( - 'Aucune photo', - style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + emptyLabel, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, fontSize: 11), + ), ), ], ), @@ -108,7 +183,11 @@ class AdminAmPhotoFrame extends StatelessWidget { }, errorBuilder: (_, __, ___) => ColoredBox( color: Colors.grey.shade200, - child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400), + child: Icon( + Icons.broken_image_outlined, + size: 36, + color: Colors.grey.shade400, + ), ), ); } 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..2678ace 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.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/api/api_config.dart'; @@ -10,22 +13,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 +62,13 @@ 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; + + /// Photo locale (création) — upload multipart `photo`. + Uint8List? _photoBytes; + String? _photoFilename; + static const double _modalWidth = 930; static const double _mainRowHeight = 380; static const double _placementHeight = 96; @@ -74,6 +94,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 +103,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 +203,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 +247,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 +305,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 +353,99 @@ 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; + } + final hasPhoto = _photoBytes != null && _photoBytes!.isNotEmpty; + if (hasPhoto && !_consentPhoto) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Activez le consentement photo pour enregistrer une photo', + ), + ), + ); + return; + } + + setState(() => _saving = true); + try { + await UserService.createEnfant( + parentUserId: family.pivotParentUserId, + photoBytes: hasPhoto ? _photoBytes : null, + photoFilename: _photoFilename, + 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 +482,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 +518,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?; @@ -603,6 +747,43 @@ class _AdminChildDetailModalState extends State { ); } + Future _pickPhoto() async { + if (_busy || !widget.isCreating) return; + try { + final picked = await ImagePicker().pickImage( + source: ImageSource.gallery, + imageQuality: 75, + maxWidth: 1200, + maxHeight: 1200, + ); + if (picked == null) return; + final bytes = await picked.readAsBytes(); + if (bytes.isEmpty) return; + final name = picked.name.trim(); + if (!mounted) return; + setState(() { + _photoBytes = bytes; + _photoFilename = name.isNotEmpty ? name : 'photo.jpg'; + _consentPhoto = true; + _dirty = true; + }); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Impossible de charger la photo')), + ); + } + } + + void _clearPhoto() { + if (_busy || !widget.isCreating) return; + setState(() { + _photoBytes = null; + _photoFilename = null; + _dirty = true; + }); + } + Widget _mainRow() { return LayoutBuilder( builder: (context, c) { @@ -625,7 +806,20 @@ class _AdminChildDetailModalState extends State { children: [ Expanded( child: AdminAmPhotoFrame( - photoUrl: widget.enfant.photoUrl, + photoUrl: widget.isCreating + ? null + : widget.enfant?.photoUrl, + imageBytes: widget.isCreating ? _photoBytes : null, + onTap: widget.isCreating && !_busy ? _pickPhoto : null, + onClear: widget.isCreating && + !_busy && + _photoBytes != null && + _photoBytes!.isNotEmpty + ? _clearPhoto + : null, + emptyLabel: widget.isCreating + ? 'Cliquer pour ajouter' + : 'Aucune photo', ), ), const SizedBox(height: 8), @@ -893,8 +1087,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 +1106,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 +1322,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, diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 09ded02..3672396 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -206,7 +206,7 @@ packages: source: hosted version: "1.2.2" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 0f2e0aa..b3f4ca8 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: js: ^0.6.7 url_launcher: ^6.2.4 http: ^1.2.2 + http_parser: ^4.0.2 # flutter_secure_storage: ^9.0.0 pdfx: ^2.5.0 universal_platform: ^1.1.0