From e235b30140c77b97e9553d03ba711deb44e32b4c Mon Sep 17 00:00:00 2001 From: Julien Martin Date: Thu, 10 Sep 2026 22:16:05 +0200 Subject: [PATCH] =?UTF-8?q?feat(#160):=20suppressions=20dashboard=20?= =?UTF-8?q?=E2=80=94=20poubelle,=20confirmations=20et=20sans=5Fenfant.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- frontend/lib/models/dossier_list_item.dart | 43 ++ .../creation/admin_create.dart | 34 +- .../creation/gestionnaires_create.dart | 40 +- frontend/lib/services/user_service.dart | 101 +++- frontend/lib/utils/staff_deletion_rights.dart | 53 ++ .../admin/admin_management_widget.dart | 68 +++ ...sistante_maternelle_management_widget.dart | 56 +++ .../common/admin_child_detail_modal.dart | 126 +++-- .../common/suppression_confirm_dialog.dart | 467 ++++++++++++++++++ .../lib/widgets/admin/dossier_list_card.dart | 33 +- .../admin/dossiers_management_widget.dart | 128 ++++- .../admin/enfant_management_widget.dart | 139 ++++++ .../admin/gestionnaire_management_widget.dart | 59 +++ .../admin/parent_managmant_widget.dart | 75 +++ .../admin/pending_validation_widget.dart | 115 ++++- .../admin/relais_management_panel.dart | 29 +- 16 files changed, 1456 insertions(+), 110 deletions(-) create mode 100644 frontend/lib/utils/staff_deletion_rights.dart create mode 100644 frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart index 25ec22f..4d35c35 100644 --- a/frontend/lib/models/dossier_list_item.dart +++ b/frontend/lib/models/dossier_list_item.dart @@ -13,6 +13,10 @@ class DossierListItem { final String? statut; /// Photo profil (AM) — affichée à la place de l’icône si présente. final String? photoUrl; + /// Dossier famille sans enfant lié (#159 / #160). + final bool sansEnfant; + /// Nombre d’enfants du foyer (famille uniquement ; null pour AM). + final int? enfantsCount; const DossierListItem({ required this.type, @@ -21,8 +25,32 @@ class DossierListItem { this.emails = const [], this.statut, this.photoUrl, + this.sansEnfant = false, + this.enfantsCount, }); + DossierListItem copyWith({ + DossierListType? type, + String? numeroDossier, + String? libelle, + List? emails, + String? statut, + String? photoUrl, + bool? sansEnfant, + int? enfantsCount, + }) { + return DossierListItem( + type: type ?? this.type, + numeroDossier: numeroDossier ?? this.numeroDossier, + libelle: libelle ?? this.libelle, + emails: emails ?? this.emails, + statut: statut ?? this.statut, + photoUrl: photoUrl ?? this.photoUrl, + sansEnfant: sansEnfant ?? this.sansEnfant, + enfantsCount: enfantsCount ?? this.enfantsCount, + ); + } + bool get isFamille => type == DossierListType.famille; bool get isAm => type == DossierListType.assistanteMaternelle; @@ -98,6 +126,19 @@ class DossierListItem { } } + final childIds = {}; + var maxCountFallback = 0; + for (final p in entry.value) { + for (final c in p.children) { + final id = c.id.trim(); + if (id.isNotEmpty) childIds.add(id); + } + final n = p.children.isNotEmpty ? p.children.length : p.childrenCount; + if (n > maxCountFallback) maxCountFallback = n; + } + final enfantsCount = + childIds.isNotEmpty ? childIds.length : maxCountFallback; + items.add( DossierListItem( type: DossierListType.famille, @@ -105,6 +146,8 @@ class DossierListItem { libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', emails: emails, statut: _preferStatut(statuts), + enfantsCount: enfantsCount, + sansEnfant: enfantsCount == 0, ), ); } diff --git a/frontend/lib/screens/administrateurs/creation/admin_create.dart b/frontend/lib/screens/administrateurs/creation/admin_create.dart index 1db28b2..1334519 100644 --- a/frontend/lib/screens/administrateurs/creation/admin_create.dart +++ b/frontend/lib/screens/administrateurs/creation/admin_create.dart @@ -3,6 +3,7 @@ import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/utils/email_utils.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/email_text_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart'; @@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State { Future _delete() async { if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Confirmer la suppression'), - content: Text( - 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - ], - ); - }, + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'administrateur', + people: [SuppressionPersonLine.administrateur(name)], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; setState(() { _isSubmitting = true; diff --git a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart index 9a24de6..628bf89 100644 --- a/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart +++ b/frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart @@ -8,6 +8,7 @@ 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'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class AdminUserFormDialog extends StatefulWidget { final AppUser? initialUser; @@ -378,30 +379,25 @@ class _AdminUserFormDialogState extends State { if (!_canDeleteTarget) return; if (!_isEditMode || _isSubmitting) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) { - return AlertDialog( - title: const Text('Confirmer la suppression'), - content: Text( - 'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), - child: const Text('Supprimer'), - ), - ], - ); - }, + final name = widget.initialUser!.fullName.isEmpty + ? widget.initialUser!.email + : widget.initialUser!.fullName; + final confirmed = await showSuppressionConfirmDialog( + context, + title: widget.adminMode + ? 'Supprimer l\'administrateur' + : 'Supprimer le gestionnaire', + people: [ + widget.adminMode + ? SuppressionPersonLine.administrateur(name) + : SuppressionPersonLine.gestionnaire(name), + ], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; setState(() { _isSubmitting = true; diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index 4c078aa..95e5ede 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -646,14 +646,93 @@ class UserService { return enrichEnfantParentNames(enfant); } - static Future deleteEnfant(String enfantId) async { + /// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160. + static Future> deleteEnfant( + String enfantId, { + bool deleteDossier = false, + }) async { + final uri = Uri.parse( + '${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId', + ).replace( + queryParameters: { + 'deleteDossier': deleteDossier ? 'true' : 'false', + }, + ); + final response = await http.delete(uri, headers: await _headers()); + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression enfant'), + ); + } + return _parseSuppressionBody(response.body); + } + + /// DELETE /dossiers/:numeroDossier — ticket #159 / #160. + static Future> deleteDossier( + String numeroDossier, + ) async { + final num = numeroDossier.trim(); + if (num.isEmpty) { + throw Exception('Numéro de dossier manquant.'); + } + final encoded = Uri.encodeComponent(num); final response = await http.delete( - Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), + Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'), headers: await _headers(), ); if (response.statusCode != 200 && response.statusCode != 204) { - throw Exception(_extractErrorMessage(response.body, 'Erreur suppression enfant')); + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression dossier'), + ); } + return _parseSuppressionBody(response.body); + } + + /// Liste unifiée GET /dossiers — flags `sans_enfant` (#159). + static Future>> listDossiers({String? q}) async { + final uri = Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}') + .replace( + queryParameters: (q != null && q.trim().isNotEmpty) + ? {'q': q.trim()} + : null, + ); + final response = await http.get(uri, headers: await _headers()); + if (response.statusCode != 200) { + throw Exception( + _extractErrorMessage(response.body, 'Erreur chargement dossiers'), + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! List) return const []; + return decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + + /// Map `numero_dossier` → `sans_enfant` (familles uniquement). + static Future> getSansEnfantByNumero() async { + final rows = await listDossiers(); + final out = {}; + for (final row in rows) { + final num = (row['numero_dossier'] ?? '').toString().trim(); + if (num.isEmpty) continue; + final type = (row['type'] ?? '').toString().toLowerCase(); + if (type != 'famille') continue; + out[num] = row['sans_enfant'] == true; + } + return out; + } + + static Map _parseSuppressionBody(String body) { + if (body.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(body); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + return {}; } /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). @@ -1224,22 +1303,18 @@ class UserService { return AppUser.fromJson(data); } - static Future deleteUser(String userId) async { + /// DELETE /users/:id — cascades métier #159 / #160. + static Future> deleteUser(String userId) async { final response = await http.delete( Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), headers: await _headers(), ); if (response.statusCode != 200 && response.statusCode != 204) { - final decoded = jsonDecode(response.body); - if (decoded is Map) { - final message = decoded['message']; - if (message is List && message.isNotEmpty) { - throw Exception(message.join(' - ')); - } - throw Exception(_toStr(message) ?? 'Erreur suppression utilisateur'); - } - throw Exception('Erreur suppression utilisateur'); + throw Exception( + _extractErrorMessage(response.body, 'Erreur suppression utilisateur'), + ); } + return _parseSuppressionBody(response.body); } } diff --git a/frontend/lib/utils/staff_deletion_rights.dart b/frontend/lib/utils/staff_deletion_rights.dart new file mode 100644 index 0000000..eae8a11 --- /dev/null +++ b/frontend/lib/utils/staff_deletion_rights.dart @@ -0,0 +1,53 @@ +/// Droits d’affichage poubelle — tickets #154 / #160. +bool canDeleteMetier(String? role) { + final r = (role ?? '').trim().toLowerCase(); + return r == 'gestionnaire' || + r == 'administrateur' || + r == 'super_admin'; +} + +bool canDeleteGestionnaire(String? role) { + final r = (role ?? '').trim().toLowerCase(); + return r == 'administrateur' || r == 'super_admin'; +} + +bool canDeleteAdministrateur({ + required String? currentRole, + required String? currentUserId, + required String targetUserId, + required String targetRole, + required int adminCount, +}) { + final me = (currentRole ?? '').trim().toLowerCase(); + if (me != 'administrateur' && me != 'super_admin') return false; + if (targetUserId.trim().isEmpty) return false; + if (currentUserId != null && + currentUserId.trim() == targetUserId.trim()) { + return false; + } + final target = targetRole.trim().toLowerCase(); + if (target == 'super_admin') return false; + if (adminCount <= 1 && me != 'super_admin') return false; + return true; +} + +String? adminDeleteBlockedReason({ + required String? currentRole, + required String? currentUserId, + required String targetUserId, + required String targetRole, + required int adminCount, +}) { + if (currentUserId != null && + currentUserId.trim() == targetUserId.trim()) { + return 'Vous ne pouvez pas supprimer votre propre compte.'; + } + if (targetRole.trim().toLowerCase() == 'super_admin') { + return 'Le super administrateur ne peut pas être supprimé.'; + } + if (adminCount <= 1 && + (currentRole ?? '').trim().toLowerCase() != 'super_admin') { + return 'Seul un super administrateur peut supprimer le dernier administrateur.'; + } + return null; +} diff --git a/frontend/lib/widgets/admin/admin_management_widget.dart b/frontend/lib/widgets/admin/admin_management_widget.dart index d0a2bed..08d40a3 100644 --- a/frontend/lib/widgets/admin/admin_management_widget.dart +++ b/frontend/lib/widgets/admin/admin_management_widget.dart @@ -4,7 +4,9 @@ import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AdminManagementWidget extends StatefulWidget { @@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State { String? _error; List _admins = []; String? _currentUserRole; + String? _currentUserId; @override void initState() { @@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State { if (cached != null) { setState(() { _currentUserRole = (cached.role).toLowerCase(); + _currentUserId = cached.id; }); return; } @@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State { if (!mounted || refreshed == null) return; setState(() { _currentUserRole = (refreshed.role).toLowerCase(); + _currentUserId = refreshed.id; }); } @@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State { return _currentUserRole == 'super_admin'; } + bool _canDeleteAdmin(AppUser target) { + return canDeleteAdministrateur( + currentRole: _currentUserRole, + currentUserId: _currentUserId, + targetUserId: target.id, + targetRole: target.role, + adminCount: _admins.length, + ); + } + Future _openAdminEditDialog(AppUser user) async { final canEdit = _canEditAdmin(user); final changed = await showDialog( @@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State { } } + Future _confirmDelete(AppUser user) async { + final blocked = adminDeleteBlockedReason( + currentRole: _currentUserRole, + currentUserId: _currentUserId, + targetUserId: user.id, + targetRole: user.role, + adminCount: _admins.length, + ); + if (blocked != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(blocked)), + ); + return; + } + + final name = user.fullName.isNotEmpty ? user.fullName : user.email; + final isLast = _admins.length <= 1; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'administrateur', + people: [SuppressionPersonLine.administrateur(name)], + footnotes: [ + if (isLast) + 'Attention : c’est le dernier administrateur.', + 'Le compte sera définitivement supprimé.', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(user.id); + if (!mounted) return; + final msg = + (result['message'] ?? 'Administrateur supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadAdmins(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State { final user = filteredAdmins[index]; final isSuperAdmin = _isSuperAdmin(user); final canEdit = _canEditAdmin(user); + final canDelete = _canDeleteAdmin(user); return AdminUserCard( title: user.fullName, fallbackIcon: isSuperAdmin @@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State { _openAdminEditDialog(user); }, ), + if (canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(user)), ], ); }, diff --git a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart index dc28b11..b48212b 100644 --- a/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart +++ b/frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/utils/am_vigilance.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/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class AssistanteMaternelleManagementWidget extends StatefulWidget { @@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState bool _isLoading = false; String? _error; List _assistantes = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAssistantes(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAssistantes() async { setState(() { _isLoading = true; @@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState } } + Future _confirmDelete(AssistanteMaternelleModel am) async { + final num = (am.user.numeroDossier ?? '').trim(); + final name = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'assistante maternelle', + subtitle: num.isEmpty ? null : 'Dossier $num', + people: [SuppressionPersonLine.am(name)], + footnotes: const [ + 'Le compte et le dossier AM seront supprimés.', + 'Les enfants accueillis ne seront pas supprimés ' + '(placements clos).', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(am.user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'AM supprimée.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadAssistantes(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState _openAssistanteDetails(assistante); }, ), + if (_canDelete) + suppressionIconButton( + onPressed: () => _confirmDelete(assistante), + ), ], ); }, 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 eae7d4d..28f3374 100644 --- a/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart +++ b/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/dossier_list_item.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'; @@ -9,11 +10,13 @@ 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/utils/name_format_utils.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.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/admin_select_famille_modal.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.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'; @@ -97,8 +100,7 @@ class _AdminChildDetailModalState extends State { _isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; bool get _canDelete => - !widget.isCreating && - (_currentUserRole ?? '').toLowerCase() == 'super_admin'; + !widget.isCreating && canDeleteMetier(_currentUserRole); bool get _busy => _saving || _deleting; @@ -506,37 +508,99 @@ class _AdminChildDetailModalState extends State { if (!_canDelete || _deleting) return; final name = _headerTitle(); - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer l\'enfant'), - content: Text( - 'Supprimer définitivement la fiche de $name ?\n' - 'Cette action est irréversible.', + final enfant = widget.enfant; + if (enfant == null) return; + + bool deleteDossierFlag = false; + String? numero; + String familleLabel = ''; + bool isLast = false; + + String? amLabel; + final linked = _linkedAm; + if (linked != null) { + final label = formatDossierPersonLabel( + nom: linked.user.nom, + prenom: linked.user.prenom, + email: linked.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } else { + try { + final am = await UserService.findAmForEnfant(enfant.id); + if (am != null) { + final label = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } + } catch (_) {} + } + + final parentId = enfant.parentLinks + .map((l) => l.parentId.trim()) + .firstWhere((id) => id.isNotEmpty, orElse: () => ''); + if (parentId.isNotEmpty) { + try { + final parent = await UserService.getParent(parentId); + final num = (parent.user.numeroDossier ?? '').trim(); + numero = num; + familleLabel = parent.user.fullName.isNotEmpty + ? parent.user.fullName + : parent.user.email; + if (num.isNotEmpty) { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + isLast = dossier.asFamily.enfants.length <= 1; + final names = dossier.asFamily.parents + .map((p) => formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + )) + .where((s) => s.isNotEmpty) + .join(' - '); + if (names.isNotEmpty) familleLabel = names; + } + } + } catch (_) {} + } + + if (!mounted) return; + + if (isLast && (numero ?? '').isNotEmpty) { + final choice = await showDernierEnfantSuppressionDialog( + context, + enfantName: name, + familleLabel: familleLabel, + numeroDossier: numero!, + amLabel: amLabel, + ); + if (choice == null || !mounted) return; + deleteDossierFlag = + choice == DernierEnfantSuppressionChoice.dossierAussi; + } else { + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'enfant', + subtitle: (numero ?? '').isEmpty ? null : 'Dossier $numero', + people: [SuppressionPersonLine.enfant(name)], + footnotes: enfantSuppressionFootnotes( + numeroDossier: numero, + familleLabel: familleLabel, + amLabel: amLabel, ), - 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; + ); + if (!confirmed || !mounted) return; + } setState(() => _deleting = true); try { final id = widget.enfant?.id; if (id == null || id.isEmpty) return; - await UserService.deleteEnfant(id); + await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag); if (!mounted) return; widget.onDeleted?.call(); widget.onSaved?.call(); @@ -548,7 +612,13 @@ class _AdminChildDetailModalState extends State { if (!mounted) return; setState(() => _deleting = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), ); } } diff --git a/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart new file mode 100644 index 0000000..38bf785 --- /dev/null +++ b/frontend/lib/widgets/admin/common/suppression_confirm_dialog.dart @@ -0,0 +1,467 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; + +/// Choix pour le dernier enfant d’un dossier famille (#160). +enum DernierEnfantSuppressionChoice { + enfantSeul, + dossierAussi, +} + +/// Ligne d’impact (une personne / une fiche). +class SuppressionPersonLine { + final String label; + final IconData icon; + final String? role; + + const SuppressionPersonLine({ + required this.label, + this.icon = Icons.person_outline, + this.role, + }); + + factory SuppressionPersonLine.parent(String label) => SuppressionPersonLine( + label: label, + icon: Icons.supervisor_account_outlined, + role: 'Parent', + ); + + factory SuppressionPersonLine.enfant(String label) => SuppressionPersonLine( + label: label, + icon: Icons.child_care_outlined, + role: 'Enfant', + ); + + factory SuppressionPersonLine.am(String label) => SuppressionPersonLine( + label: label, + icon: Icons.face, + role: 'AM', + ); + + factory SuppressionPersonLine.gestionnaire(String label) => + SuppressionPersonLine( + label: label, + icon: Icons.assignment_ind_outlined, + role: 'Gestionnaire', + ); + + factory SuppressionPersonLine.administrateur(String label) => + SuppressionPersonLine( + label: label, + icon: Icons.manage_accounts_outlined, + role: 'Admin', + ); + + factory SuppressionPersonLine.relais(String label) => SuppressionPersonLine( + label: label, + icon: Icons.apartment_outlined, + role: 'Relais', + ); +} + +/// Widget unique pour toutes les boîtes de confirmation de suppression (#160). +class SuppressionConfirmDialog extends StatelessWidget { + final String title; + final String? subtitle; + final String? message; + final List people; + final List footnotes; + final List actions; + + const SuppressionConfirmDialog({ + super.key, + required this.title, + this.subtitle, + this.message, + this.people = const [], + this.footnotes = const [], + required this.actions, + }); + + /// Variante oui/non standard (Annuler / Supprimer). + static SuppressionConfirmDialog yesNo({ + required String title, + String? subtitle, + String? message, + List people = const [], + List footnotes = const [], + String confirmLabel = 'Supprimer', + required VoidCallback onCancel, + required VoidCallback onConfirm, + }) { + return SuppressionConfirmDialog( + title: title, + subtitle: subtitle, + message: message, + people: people, + footnotes: footnotes, + actions: [ + TextButton(onPressed: onCancel, child: const Text('Annuler')), + FilledButton( + onPressed: onConfirm, + style: _dangerButtonStyle, + child: Text(confirmLabel), + ), + ], + ); + } + + static ButtonStyle get _dangerButtonStyle => FilledButton.styleFrom( + backgroundColor: Colors.red.shade700, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + backgroundColor: const Color(0xFFF7F2FB), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + titlePadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), + contentPadding: const EdgeInsets.fromLTRB(24, 12, 24, 8), + actionsPadding: const EdgeInsets.fromLTRB(16, 4, 16, 14), + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: Colors.red.shade700, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: const Color(0xFF2F2F2F), + fontSize: 20, + ), + ), + if ((subtitle ?? '').trim().isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!.trim(), + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color(0xFF6D4EA1), + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ], + ), + content: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if ((message ?? '').trim().isNotEmpty) ...[ + Text( + message!.trim(), + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.black87, + height: 1.35, + ), + ), + if (people.isNotEmpty || footnotes.isNotEmpty) + const SizedBox(height: 12), + ], + if (people.isNotEmpty) ...[ + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE5D8F2)), + ), + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + children: [ + for (var i = 0; i < people.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + indent: 44, + endIndent: 12, + color: Colors.grey.shade200, + ), + _SuppressionPersonRow(line: people[i]), + ], + ], + ), + ), + if (footnotes.isNotEmpty) const SizedBox(height: 12), + ], + for (final note in footnotes) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + Icons.info_outline, + size: 16, + color: Colors.orange.shade800, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + note, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.black54, + height: 1.35, + ), + ), + ), + ], + ), + ), + ], + ), + ), + actions: actions, + ); + } +} + +class _SuppressionPersonRow extends StatelessWidget { + final SuppressionPersonLine line; + + const _SuppressionPersonRow({required this.line}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(line.icon, size: 18, color: const Color(0xFF6D4EA1)), + const SizedBox(width: 10), + Expanded( + child: Text( + line.label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF2F2F2F), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if ((line.role ?? '').trim().isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEDE5FA), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + line.role!.trim(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF6D4EA1), + ), + ), + ), + ], + ], + ), + ); + } +} + +/// Affiche [SuppressionConfirmDialog] et renvoie `true` si confirmé. +Future showSuppressionConfirmDialog( + BuildContext context, { + required String title, + String? subtitle, + String? message, + List people = const [], + List footnotes = const [], + String confirmLabel = 'Supprimer', +}) async { + final result = await showDialog( + context: context, + builder: (ctx) => SuppressionConfirmDialog.yesNo( + title: title, + subtitle: subtitle, + message: message, + people: people, + footnotes: footnotes, + confirmLabel: confirmLabel, + onCancel: () => Navigator.of(ctx).pop(false), + onConfirm: () => Navigator.of(ctx).pop(true), + ), + ); + return result == true; +} + +/// Confirmation delete dossier famille / AM avec liste nominative. +Future showDossierSuppressionConfirmDialog( + BuildContext context, { + required String numeroDossier, + required bool isFamille, + required List people, + String? fallbackSummary, +}) { + final num = numeroDossier.trim(); + if (isFamille) { + return showSuppressionConfirmDialog( + context, + title: 'Supprimer le dossier', + subtitle: 'Dossier famille $num', + people: people, + footnotes: people.isEmpty && (fallbackSummary ?? '').isNotEmpty + ? [fallbackSummary!] + : const [ + 'Tous les comptes et fiches listés seront définitivement ' + 'supprimés.', + 'Les placements AM des enfants seront clos.', + ], + ); + } + return showSuppressionConfirmDialog( + context, + title: 'Supprimer le dossier', + subtitle: 'Dossier AM $num', + people: people, + footnotes: const [ + 'Le compte et le dossier AM seront supprimés.', + 'Les enfants accueillis ne seront pas supprimés ' + '(placements clos).', + ], + ); +} + +/// Dialog dernier enfant : deux actions métier. +Future showDernierEnfantSuppressionDialog( + BuildContext context, { + required String enfantName, + required String familleLabel, + required String numeroDossier, + String? amLabel, +}) { + final am = (amLabel ?? '').trim(); + return showDialog( + context: context, + builder: (ctx) => SuppressionConfirmDialog( + title: 'Dernier enfant du dossier', + subtitle: 'Dossier $numeroDossier' + '${familleLabel.isEmpty ? '' : ' · $familleLabel'}', + people: [SuppressionPersonLine.enfant(enfantName)], + footnotes: [ + '« Enfant seulement » : le dossier reste sans enfant.', + '« Dossier aussi » : parents et dossier sont également ' + 'supprimés.', + if (am.isNotEmpty) 'Le placement chez $am sera clos.', + ], + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Annuler'), + ), + OutlinedButton( + onPressed: () => Navigator.of(ctx).pop( + DernierEnfantSuppressionChoice.enfantSeul, + ), + child: const Text('Enfant seulement'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop( + DernierEnfantSuppressionChoice.dossierAussi, + ), + style: SuppressionConfirmDialog._dangerButtonStyle, + child: const Text('Dossier aussi'), + ), + ], + ), + ); +} + +/// Notes d’impact pour suppression d’un enfant (AM optionnelle). +List enfantSuppressionFootnotes({ + required String? numeroDossier, + required String familleLabel, + String? amLabel, +}) { + final notes = []; + final num = (numeroDossier ?? '').trim(); + final famille = familleLabel.trim(); + final am = (amLabel ?? '').trim(); + if (num.isEmpty) { + notes.add('Supprimer définitivement cette fiche enfant.'); + } else { + notes.add( + 'L’enfant sera retiré du dossier de ' + '${famille.isEmpty ? 'la famille' : famille}.', + ); + } + if (am.isNotEmpty) { + notes.add('Le placement chez $am sera clos.'); + } + return notes; +} + +/// Bouton poubelle compact pour les cartes liste. +Widget suppressionIconButton({ + required VoidCallback? onPressed, + String tooltip = 'Supprimer', +}) { + return IconButton( + icon: Icon(Icons.delete_outline, color: Colors.red.shade700), + tooltip: tooltip, + onPressed: onPressed, + ); +} + +/// Construit les lignes parents / enfants depuis un dossier unifié. +List suppressionPeopleFromDossier({ + required bool isFamille, + required List<({String nom, String prenom, String email})> parents, + required List<({String nom, String prenom})> enfants, + String? amName, +}) { + final lines = []; + if (isFamille) { + for (final p in parents) { + final label = formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + ); + if (label.isEmpty) continue; + lines.add(SuppressionPersonLine.parent(label)); + } + for (final e in enfants) { + final label = formatDossierPersonLabel(nom: e.nom, prenom: e.prenom); + if (label.isEmpty) continue; + lines.add(SuppressionPersonLine.enfant(label)); + } + } else if ((amName ?? '').trim().isNotEmpty) { + lines.add(SuppressionPersonLine.am(amName!.trim())); + } + return lines; +} diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart index b824eae..7608df2 100644 --- a/frontend/lib/widgets/admin/dossier_list_card.dart +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône. class DossierListCard extends StatelessWidget { @@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget { final VoidCallback onOpen; /// Photo AM (si absente → icône fallback). final String? photoUrl; + final VoidCallback? onDelete; + /// Warning vigilance (ex. dossier sans enfant #160). + final String? vigilanceTooltip; + /// Nombre d’enfants (famille) — affiché à côté des noms. + final int? enfantsCount; + final bool sansEnfant; /// Lavande — Famille / Parents. static const Color familleAccent = Color(0xFFB289C9); @@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget { required this.isFamille, required this.onOpen, this.photoUrl, + this.onDelete, + this.vigilanceTooltip, + this.enfantsCount, + this.sansEnfant = false, }); @override @@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget { final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); final names = namesLine.trim(); final avatar = (photoUrl ?? '').trim(); + final emptyKids = isFamille && (sansEnfant || enfantsCount == 0); + final count = enfantsCount; + final countLabel = (!isFamille || count == null) + ? null + : (count <= 1 ? '$count enfant' : '$count enfants'); + + final subtitle = [ + if (names.isNotEmpty) names, + if (emptyKids) 'Sans enfant', + if (!emptyKids && countLabel != null) countLabel, + ]; return AdminUserCard( title: num, - subtitleLines: names.isEmpty ? const [] : [names], + subtitleLines: subtitle, avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, fallbackIcon: isFamille ? Icons.family_restroom_outlined : Icons.face, // N° = titre neutre (comme Parents / AM) ; accent = icône seule. avatarIconColor: accent, - infoColor: Colors.black87, + infoColor: emptyKids ? Colors.red.shade700 : Colors.black87, onCardTap: onOpen, + vigilanceTooltip: emptyKids + ? (vigilanceTooltip ?? + 'Aucun enfant rattaché à ce dossier famille') + : vigilanceTooltip, + borderColor: emptyKids ? Colors.red.shade300 : null, actions: [ IconButton( tooltip: 'Ouvrir', icon: Icon(Icons.open_in_new, size: 20, color: accent), onPressed: onOpen, ), + if (onDelete != null) + suppressionIconButton(onPressed: onDelete), ], ); } diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart index 4cafa2f..a616525 100644 --- a/frontend/lib/widgets/admin/dossiers_management_widget.dart +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/dossier_list_item.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/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; /// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. @@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State { List _all = []; Set _pendingNumeros = {}; int _pendingRefreshTick = 0; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadAll(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadAll() async { setState(() { _loading = true; @@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State { try { final parents = await UserService.getParents(); final ams = await UserService.getAssistantesMaternelles(); + Map sansEnfant = {}; + try { + sansEnfant = await UserService.getSansEnfantByNumero(); + } catch (_) { + // Flag optionnel : ne bloque pas la liste. + } if (!mounted) return; final items = [ ...DossierListItem.fromParents(parents), ...DossierListItem.fromAssistantes(ams), - ]; + ].map((item) { + if (!item.isFamille) return item; + final apiFlag = sansEnfant[item.numeroDossier] == true; + final localEmpty = (item.enfantsCount ?? 0) == 0; + return item.copyWith( + sansEnfant: apiFlag || localEmpty || item.sansEnfant, + ); + }).toList(); items.sort((a, b) { + // Dossiers sans enfant en tête (#160), comme orphelins #157. + final ae = a.sansEnfant ? 0 : 1; + final be = b.sansEnfant ? 0 : 1; + if (ae != be) return ae.compareTo(be); final byNum = a.numeroDossier.compareTo(b.numeroDossier); if (byNum != 0) return byNum; return a.typeLabel.compareTo(b.typeLabel); @@ -95,6 +123,95 @@ class _DossiersManagementWidgetState extends State { ); } + Future _confirmDeleteDossier(DossierListItem item) async { + final num = item.numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + String? fallbackSummary; + try { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + final f = dossier.asFamily; + people = suppressionPeopleFromDossier( + isFamille: true, + parents: f.parents + .map((p) => ( + nom: p.nom ?? '', + prenom: p.prenom ?? '', + email: p.email, + )) + .toList(), + enfants: f.enfants + .map((e) => ( + nom: e.lastName ?? '', + prenom: e.firstName ?? '', + )) + .toList(), + ); + } else { + final am = dossier.asAm.user; + people = suppressionPeopleFromDossier( + isFamille: false, + parents: const [], + enfants: const [], + amName: formatDossierPersonLabel( + nom: am.nom, + prenom: am.prenom, + email: am.email, + ), + ); + } + } catch (_) { + fallbackSummary = item.isFamille + ? 'Tous les parents et enfants rattachés seront supprimés.' + : 'Le compte AM sera supprimé ; les enfants accueillis ' + 'seront conservés.'; + if (item.namesLine.trim().isNotEmpty) { + for (final part in item.namesLine.split(' - ')) { + final label = part.trim(); + if (label.isEmpty) continue; + people.add(SuppressionPersonLine( + label: label, + icon: item.isFamille + ? Icons.supervisor_account_outlined + : Icons.face, + role: item.isFamille ? 'Parent' : 'AM', + )); + } + } + } + + if (!mounted) return; + final confirmed = await showDossierSuppressionConfirmDialog( + context, + numeroDossier: num, + isFamille: item.isFamille, + people: people, + fallbackSummary: fallbackSummary, + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteDossier(num); + if (!mounted) return; + final msg = (result['message'] ?? 'Dossier supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _refreshEverything(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery; @@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State { key: ValueKey('pending-$_pendingRefreshTick'), searchQuery: query, compactWhenEmpty: true, + canDelete: _canDelete, onPendingNumerosChanged: (nums) { if (!mounted) return; setState(() => _pendingNumeros = nums); @@ -189,7 +307,15 @@ class _DossiersManagementWidgetState extends State { namesLine: item.namesLine, isFamille: item.isFamille, photoUrl: item.photoUrl, + sansEnfant: item.sansEnfant, + enfantsCount: item.enfantsCount, + vigilanceTooltip: item.sansEnfant + ? 'Aucun enfant rattaché à ce dossier famille' + : null, onOpen: () => _openDossier(item.numeroDossier), + onDelete: _canDelete + ? () => _confirmDeleteDossier(item) + : null, ); }, childCount: filtered.length, diff --git a/frontend/lib/widgets/admin/enfant_management_widget.dart b/frontend/lib/widgets/admin/enfant_management_widget.dart index 5ef03f1..6731ff7 100644 --- a/frontend/lib/widgets/admin/enfant_management_widget.dart +++ b/frontend/lib/widgets/admin/enfant_management_widget.dart @@ -1,9 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/utils/enfant_status_utils.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; /// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). @@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State { bool _isLoading = false; String? _error; List _enfants = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadEnfants(); } + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadEnfants() async { setState(() { _isLoading = true; @@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State { builder: (ctx) => AdminChildDetailModal( enfant: enfant, onSaved: _loadEnfants, + onDeleted: _loadEnfants, ), ); } + Future<({String? numero, String famille, bool isLast, String? amLabel})> + _resolveContext( + EnfantAdminModel enfant, + ) async { + String? amLabel; + try { + final am = await UserService.findAmForEnfant(enfant.id); + if (am != null) { + final label = formatDossierPersonLabel( + nom: am.user.nom, + prenom: am.user.prenom, + email: am.user.email, + ); + if (label.isNotEmpty) amLabel = label; + } + } catch (_) {} + + final parentId = enfant.parentLinks + .map((l) => l.parentId.trim()) + .firstWhere((id) => id.isNotEmpty, orElse: () => ''); + if (parentId.isEmpty) { + return (numero: null, famille: '', isLast: true, amLabel: amLabel); + } + try { + final parent = await UserService.getParent(parentId); + final num = (parent.user.numeroDossier ?? '').trim(); + final famille = parent.user.fullName.isNotEmpty + ? parent.user.fullName + : parent.user.email; + if (num.isEmpty) { + return ( + numero: null, + famille: famille, + isLast: true, + amLabel: amLabel, + ); + } + final dossier = await UserService.getDossier(num); + if (!dossier.isFamily) { + return ( + numero: num, + famille: famille, + isLast: true, + amLabel: amLabel, + ); + } + final n = dossier.asFamily.enfants.length; + final names = dossier.asFamily.parents + .map((p) => formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + )) + .where((s) => s.isNotEmpty) + .join(' - '); + return ( + numero: num, + famille: names.isNotEmpty ? names : famille, + isLast: n <= 1, + amLabel: amLabel, + ); + } catch (_) { + return (numero: null, famille: '', isLast: false, amLabel: amLabel); + } + } + + Future _confirmDelete(EnfantAdminModel enfant) async { + final ctx = await _resolveContext(enfant); + if (!mounted) return; + + bool deleteDossier = false; + if (ctx.isLast && (ctx.numero ?? '').isNotEmpty) { + final choice = await showDernierEnfantSuppressionDialog( + context, + enfantName: enfant.fullName, + familleLabel: ctx.famille, + numeroDossier: ctx.numero!, + amLabel: ctx.amLabel, + ); + if (choice == null || !mounted) return; + deleteDossier = + choice == DernierEnfantSuppressionChoice.dossierAussi; + } else { + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer l\'enfant', + subtitle: (ctx.numero ?? '').isEmpty + ? null + : 'Dossier ${ctx.numero}', + people: [SuppressionPersonLine.enfant(enfant.fullName)], + footnotes: enfantSuppressionFootnotes( + numeroDossier: ctx.numero, + familleLabel: ctx.famille, + amLabel: ctx.amLabel, + ), + ); + if (!confirmed || !mounted) return; + } + + try { + final result = await UserService.deleteEnfant( + enfant.id, + deleteDossier: deleteDossier, + ); + if (!mounted) return; + final msg = (result['message'] ?? 'Enfant supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadEnfants(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State { tooltip: 'Voir / modifier', onPressed: () => _openEnfant(enfant), ), + if (_canDelete) + suppressionIconButton( + onPressed: () => _confirmDelete(enfant), + ), ], ); }, diff --git a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart index 1ce89d6..f5cda69 100644 --- a/frontend/lib/widgets/admin/gestionnaire_management_widget.dart +++ b/frontend/lib/widgets/admin/gestionnaire_management_widget.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class GestionnaireManagementWidget extends StatefulWidget { @@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState bool _isLoading = false; String? _error; List _gestionnaires = []; + bool _canDelete = false; + String? _currentUserId; @override void initState() { super.initState(); + _loadRights(); _loadGestionnaires(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() { + _canDelete = canDeleteGestionnaire(user?.role); + _currentUserId = user?.id; + }); + } + Future _loadGestionnaires() async { setState(() { _isLoading = true; @@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState } } + Future _confirmDelete(AppUser user) async { + if (_currentUserId != null && _currentUserId == user.id) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Vous ne pouvez pas supprimer votre propre compte.'), + ), + ); + return; + } + final name = user.fullName.isNotEmpty ? user.fullName : user.email; + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le gestionnaire', + people: [SuppressionPersonLine.gestionnaire(name)], + footnotes: const [ + 'Le compte sera définitivement supprimé.', + ], + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'Gestionnaire supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadGestionnaires(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState itemCount: filteredGestionnaires.length, itemBuilder: (context, index) { final user = filteredGestionnaires[index]; + final isSelf = + _currentUserId != null && _currentUserId == user.id; return AdminUserCard( title: user.fullName, fallbackIcon: Icons.assignment_ind_outlined, @@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState _openGestionnaireEditDialog(user); }, ), + if (_canDelete && !isSelf) + suppressionIconButton(onPressed: () => _confirmDelete(user)), ], ); }, diff --git a/frontend/lib/widgets/admin/parent_managmant_widget.dart b/frontend/lib/widgets/admin/parent_managmant_widget.dart index 24e0ef5..69b2683 100644 --- a/frontend/lib/widgets/admin/parent_managmant_widget.dart +++ b/frontend/lib/widgets/admin/parent_managmant_widget.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/utils/staff_deletion_rights.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart'; class ParentManagementWidget extends StatefulWidget { @@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State { bool _isLoading = false; String? _error; List _parents = []; + bool _canDelete = false; @override void initState() { super.initState(); + _loadRights(); _loadParents(); } @override void dispose() => super.dispose(); + Future _loadRights() async { + final user = await AuthService.getCurrentUser(); + if (!mounted) return; + setState(() => _canDelete = canDeleteMetier(user?.role)); + } + Future _loadParents() async { setState(() { _isLoading = true; @@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State { } } + bool _isLastParent(ParentModel parent) { + final co = parent.coParent?.id.trim(); + if (co != null && co.isNotEmpty) return false; + final num = (parent.user.numeroDossier ?? '').trim(); + if (num.isEmpty) return true; + return !_parents.any((other) { + if (other.user.id == parent.user.id) return false; + return (other.user.numeroDossier ?? '').trim() == num; + }); + } + + Future _confirmDelete(ParentModel parent) async { + final num = (parent.user.numeroDossier ?? '').trim(); + final name = formatDossierPersonLabel( + nom: parent.user.nom, + prenom: parent.user.prenom, + email: parent.user.email, + ); + final last = _isLastParent(parent); + final enfants = ParentModel.foyerChildrenCount(parent, _parents); + final footnotes = last + ? [ + if (num.isNotEmpty) 'Dernier parent du dossier $num.', + if (num.isEmpty) 'Dernier parent du dossier.', + 'Les $enfants enfant(s) rattaché(s) seront aussi supprimés.', + ] + : [ + if (num.isNotEmpty) + 'Ce parent sera retiré du dossier $num.', + 'Les enfants restent avec le co-parent.', + ]; + + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le parent', + subtitle: num.isEmpty ? null : 'Dossier $num', + people: [SuppressionPersonLine.parent(name)], + footnotes: footnotes, + ); + if (!confirmed || !mounted) return; + + try { + final result = await UserService.deleteUser(parent.user.id); + if (!mounted) return; + final msg = (result['message'] ?? 'Parent supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _loadParents(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + @override Widget build(BuildContext context) { final query = widget.searchQuery.toLowerCase(); @@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State { _openParentDetails(parent); }, ), + if (_canDelete) + suppressionIconButton(onPressed: () => _confirmDelete(parent)), ], ); }, diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 78296bd..4a71085 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -4,6 +4,7 @@ import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/pending_family.dart'; import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. @@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget { final bool compactWhenEmpty; /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). final ValueChanged>? onPendingNumerosChanged; + /// Afficher la poubelle (#160) — mêmes règles que dossiers validés. + final bool canDelete; const PendingValidationWidget({ super.key, @@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget { this.searchQuery = '', this.compactWhenEmpty = false, this.onPendingNumerosChanged, + this.canDelete = false, }); @override @@ -141,6 +145,97 @@ class _PendingValidationWidgetState extends State { ); } + Future _confirmDeletePending({ + required String numeroDossier, + required String namesLine, + required bool isFamille, + }) async { + final num = numeroDossier.trim(); + if (num.isEmpty) return; + + var people = []; + String? fallbackSummary; + try { + final dossier = await UserService.getDossier(num); + if (dossier.isFamily) { + final f = dossier.asFamily; + people = suppressionPeopleFromDossier( + isFamille: true, + parents: f.parents + .map((p) => ( + nom: p.nom ?? '', + prenom: p.prenom ?? '', + email: p.email, + )) + .toList(), + enfants: f.enfants + .map((e) => ( + nom: e.lastName ?? '', + prenom: e.firstName ?? '', + )) + .toList(), + ); + } else { + final am = dossier.asAm.user; + people = suppressionPeopleFromDossier( + isFamille: false, + parents: const [], + enfants: const [], + amName: formatDossierPersonLabel( + nom: am.nom, + prenom: am.prenom, + email: am.email, + ), + ); + } + } catch (_) { + fallbackSummary = isFamille + ? 'Tous les parents et enfants rattachés seront supprimés.' + : 'Le compte AM sera supprimé ; les enfants accueillis ' + 'seront conservés.'; + for (final part in namesLine.split(' - ')) { + final label = part.trim(); + if (label.isEmpty) continue; + people.add(SuppressionPersonLine( + label: label, + icon: isFamille + ? Icons.supervisor_account_outlined + : Icons.face, + role: isFamille ? 'Parent' : 'AM', + )); + } + } + + if (!mounted) return; + final confirmed = await showDossierSuppressionConfirmDialog( + context, + numeroDossier: num, + isFamille: isFamille, + people: people, + fallbackSummary: fallbackSummary, + ); + if (!confirmed || !mounted) return; + try { + final result = await UserService.deleteDossier(num); + if (!mounted) return; + final msg = (result['message'] ?? 'Dossier supprimé.').toString(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + await _load(); + widget.onRefresh?.call(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur suppression', + ), + ), + ); + } + } + bool _matchesQuery(String haystack) { final q = widget.searchQuery.trim().toLowerCase(); if (q.isEmpty) return true; @@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State { } Widget _buildAMCard(AppUser user) { + final names = _amNamesLine(user); + final num = user.numeroDossier ?? ''; return DossierListCard( - numeroDossier: user.numeroDossier ?? '', - namesLine: _amNamesLine(user), + numeroDossier: num, + namesLine: names, isFamille: false, photoUrl: user.photoUrl, onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: false, + ) + : null, ); } @@ -306,6 +410,13 @@ class _PendingValidationWidgetState extends State { namesLine: names, isFamille: true, onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), + onDelete: widget.canDelete + ? () => _confirmDeletePending( + numeroDossier: num, + namesLine: names, + isFamille: true, + ) + : null, ); } } diff --git a/frontend/lib/widgets/admin/relais_management_panel.dart b/frontend/lib/widgets/admin/relais_management_panel.dart index 614ecee..f63b5fe 100644 --- a/frontend/lib/widgets/admin/relais_management_panel.dart +++ b/frontend/lib/widgets/admin/relais_management_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/services/relais_service.dart'; +import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart'; class RelaisManagementPanel extends StatefulWidget { const RelaisManagementPanel({super.key}); @@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State { try { if (result.action == _RelaisDialogAction.delete) { if (relais == null) return; - final confirmed = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Supprimer le relais'), - content: Text('Confirmer la suppression de "${relais.nom}" ?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text('Annuler'), - ), - FilledButton( - onPressed: () => Navigator.of(ctx).pop(true), - style: FilledButton.styleFrom( - backgroundColor: Colors.red.shade700, - ), - child: const Text('Supprimer'), - ), - ], - ), + final confirmed = await showSuppressionConfirmDialog( + context, + title: 'Supprimer le relais', + people: [SuppressionPersonLine.relais(relais.nom)], + footnotes: const [ + 'Cette action est irréversible.', + ], ); - if (confirmed != true) return; + if (!confirmed) return; await RelaisService.deleteRelais(relais.id); } else if (relais == null) { await RelaisService.createRelais(result.payload!);