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(