Compare commits

...
17 changed files with 1497 additions and 155 deletions
@@ -13,6 +13,10 @@ class DossierListItem {
final String? statut; final String? statut;
/// Photo profil (AM) — affichée à la place de licône si présente. /// Photo profil (AM) — affichée à la place de licône si présente.
final String? photoUrl; final String? photoUrl;
/// Dossier famille sans enfant lié (#159 / #160).
final bool sansEnfant;
/// Nombre denfants du foyer (famille uniquement ; null pour AM).
final int? enfantsCount;
const DossierListItem({ const DossierListItem({
required this.type, required this.type,
@@ -21,8 +25,32 @@ class DossierListItem {
this.emails = const [], this.emails = const [],
this.statut, this.statut,
this.photoUrl, this.photoUrl,
this.sansEnfant = false,
this.enfantsCount,
}); });
DossierListItem copyWith({
DossierListType? type,
String? numeroDossier,
String? libelle,
List<String>? 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 isFamille => type == DossierListType.famille;
bool get isAm => type == DossierListType.assistanteMaternelle; bool get isAm => type == DossierListType.assistanteMaternelle;
@@ -98,6 +126,19 @@ class DossierListItem {
} }
} }
final childIds = <String>{};
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( items.add(
DossierListItem( DossierListItem(
type: DossierListType.famille, type: DossierListType.famille,
@@ -105,6 +146,8 @@ class DossierListItem {
libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', libelle: names.isNotEmpty ? names.join(' - ') : 'Famille',
emails: emails, emails: emails,
statut: _preferStatut(statuts), statut: _preferStatut(statuts),
enfantsCount: enfantsCount,
sansEnfant: enfantsCount == 0,
), ),
); );
} }
@@ -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/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/email_utils.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/email_text_field.dart';
import 'package:p_tits_pas/widgets/french_phone_field.dart'; import 'package:p_tits_pas/widgets/french_phone_field.dart';
@@ -151,30 +152,19 @@ class _AdminCreateDialogState extends State<AdminCreateDialog> {
Future<void> _delete() async { Future<void> _delete() async {
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final name = widget.initialUser!.fullName.isEmpty
context: context, ? widget.initialUser!.email
builder: (ctx) { : widget.initialUser!.fullName;
return AlertDialog( final confirmed = await showSuppressionConfirmDialog(
title: const Text('Confirmer la suppression'), context,
content: Text( title: 'Supprimer l\'administrateur',
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', people: [SuppressionPersonLine.administrateur(name)],
), footnotes: const [
actions: [ 'Le compte sera définitivement supprimé.',
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'),
),
],
);
},
); );
if (confirmed != true) return; if (!confirmed) return;
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
@@ -8,6 +8,8 @@ import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/auth_service.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/relais_service.dart';
import 'package:p_tits_pas/services/user_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/suppression_confirm_dialog.dart';
class AdminUserFormDialog extends StatefulWidget { class AdminUserFormDialog extends StatefulWidget {
final AppUser? initialUser; final AppUser? initialUser;
@@ -43,6 +45,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
List<RelaisModel> _relais = []; List<RelaisModel> _relais = [];
String? _selectedRelaisId; String? _selectedRelaisId;
String? _currentUserId; String? _currentUserId;
String? _currentUserRole;
bool get _isEditMode => widget.initialUser != null; bool get _isEditMode => widget.initialUser != null;
bool get _isSuperAdminTarget => bool get _isSuperAdminTarget =>
(widget.initialUser?.role ?? '').toLowerCase() == 'super_admin'; (widget.initialUser?.role ?? '').toLowerCase() == 'super_admin';
@@ -50,7 +53,12 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
_isEditMode && _isEditMode &&
_currentUserId != null && _currentUserId != null &&
widget.initialUser!.id == _currentUserId; widget.initialUser!.id == _currentUserId;
bool get _canDeleteTarget => !_isSuperAdminTarget && !_isSelfTarget; /// Gestionnaire : pas de delete staff. Admin+ seulement (#154 / #160).
bool get _canDeleteTarget {
if (!_isEditMode || widget.readOnly) return false;
if (_isSelfTarget || _isSuperAdminTarget) return false;
return canDeleteGestionnaire(_currentUserRole);
}
bool get _isLockedAdminIdentity => bool get _isLockedAdminIdentity =>
_isEditMode && widget.adminMode && _isSuperAdminTarget; _isEditMode && widget.adminMode && _isSuperAdminTarget;
String get _targetRoleKey { String get _targetRoleKey {
@@ -125,6 +133,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserId = cached.id; _currentUserId = cached.id;
_currentUserRole = cached.role;
}); });
return; return;
} }
@@ -132,6 +141,7 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserId = refreshed.id; _currentUserId = refreshed.id;
_currentUserRole = refreshed.role;
}); });
} }
@@ -378,30 +388,25 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (!_canDeleteTarget) return; if (!_canDeleteTarget) return;
if (!_isEditMode || _isSubmitting) return; if (!_isEditMode || _isSubmitting) return;
final confirmed = await showDialog<bool>( final name = widget.initialUser!.fullName.isEmpty
context: context, ? widget.initialUser!.email
builder: (ctx) { : widget.initialUser!.fullName;
return AlertDialog( final confirmed = await showSuppressionConfirmDialog(
title: const Text('Confirmer la suppression'), context,
content: Text( title: widget.adminMode
'Supprimer ${widget.initialUser!.fullName.isEmpty ? widget.initialUser!.email : widget.initialUser!.fullName} ?', ? 'Supprimer l\'administrateur'
), : 'Supprimer le gestionnaire',
actions: [ people: [
TextButton( widget.adminMode
onPressed: () => Navigator.of(ctx).pop(false), ? SuppressionPersonLine.administrateur(name)
child: const Text('Annuler'), : SuppressionPersonLine.gestionnaire(name),
), ],
FilledButton( footnotes: const [
onPressed: () => Navigator.of(ctx).pop(true), 'Le compte sera définitivement supprimé.',
style: FilledButton.styleFrom(backgroundColor: Colors.red.shade700), ],
child: const Text('Supprimer'),
),
],
);
},
); );
if (confirmed != true) return; if (!confirmed) return;
setState(() { setState(() {
_isSubmitting = true; _isSubmitting = true;
+88 -13
View File
@@ -646,14 +646,93 @@ class UserService {
return enrichEnfantParentNames(enfant); return enrichEnfantParentNames(enfant);
} }
static Future<void> deleteEnfant(String enfantId) async { /// DELETE /enfants/:id?deleteDossier= — ticket #159 / #160.
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.enfants}/$enfantId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.dossiers}/$encoded'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { 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<List<Map<String, dynamic>>> 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>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
/// Map `numero_dossier` → `sans_enfant` (familles uniquement).
static Future<Map<String, bool>> getSansEnfantByNumero() async {
final rows = await listDossiers();
final out = <String, bool>{};
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<String, dynamic> _parseSuppressionBody(String body) {
if (body.trim().isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(body);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
} catch (_) {}
return <String, dynamic>{};
} }
/// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle). /// AM dont la liste d'enfants actifs contient [enfantId] (API actuelle).
@@ -1224,22 +1303,18 @@ class UserService {
return AppUser.fromJson(data); return AppUser.fromJson(data);
} }
static Future<void> deleteUser(String userId) async { /// DELETE /users/:id — cascades métier #159 / #160.
static Future<Map<String, dynamic>> deleteUser(String userId) async {
final response = await http.delete( final response = await http.delete(
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'), Uri.parse('${ApiConfig.baseUrl}${ApiConfig.users}/$userId'),
headers: await _headers(), headers: await _headers(),
); );
if (response.statusCode != 200 && response.statusCode != 204) { if (response.statusCode != 200 && response.statusCode != 204) {
final decoded = jsonDecode(response.body); throw Exception(
if (decoded is Map<String, dynamic>) { _extractErrorMessage(response.body, 'Erreur suppression utilisateur'),
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');
} }
return _parseSuppressionBody(response.body);
} }
} }
@@ -0,0 +1,53 @@
/// Droits daffichage 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;
}
@@ -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/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart'; import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_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/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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget { class AdminManagementWidget extends StatefulWidget {
@@ -24,6 +26,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
String? _error; String? _error;
List<AppUser> _admins = []; List<AppUser> _admins = [];
String? _currentUserRole; String? _currentUserRole;
String? _currentUserId;
@override @override
void initState() { void initState() {
@@ -62,6 +65,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (cached != null) { if (cached != null) {
setState(() { setState(() {
_currentUserRole = (cached.role).toLowerCase(); _currentUserRole = (cached.role).toLowerCase();
_currentUserId = cached.id;
}); });
return; return;
} }
@@ -69,6 +73,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
if (!mounted || refreshed == null) return; if (!mounted || refreshed == null) return;
setState(() { setState(() {
_currentUserRole = (refreshed.role).toLowerCase(); _currentUserRole = (refreshed.role).toLowerCase();
_currentUserId = refreshed.id;
}); });
} }
@@ -79,6 +84,16 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
return _currentUserRole == 'super_admin'; return _currentUserRole == 'super_admin';
} }
bool _canDeleteAdmin(AppUser target) {
return canDeleteAdministrateur(
currentRole: _currentUserRole,
currentUserId: _currentUserId,
targetUserId: target.id,
targetRole: target.role,
adminCount: _admins.length,
);
}
Future<void> _openAdminEditDialog(AppUser user) async { Future<void> _openAdminEditDialog(AppUser user) async {
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
final changed = await showDialog<bool>( final changed = await showDialog<bool>(
@@ -98,6 +113,56 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
} }
} }
Future<void> _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 : cest 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -117,6 +182,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final user = filteredAdmins[index]; final user = filteredAdmins[index];
final isSuperAdmin = _isSuperAdmin(user); final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user); final canEdit = _canEditAdmin(user);
final canDelete = _canDeleteAdmin(user);
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: isSuperAdmin fallbackIcon: isSuperAdmin
@@ -148,6 +214,8 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
_openAdminEditDialog(user); _openAdminEditDialog(user);
}, },
), ),
if (canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
], ],
); );
}, },
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.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/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/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_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget { class AssistanteMaternelleManagementWidget extends StatefulWidget {
@@ -26,16 +30,24 @@ class _AssistanteMaternelleManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AssistanteMaternelleModel> _assistantes = []; List<AssistanteMaternelleModel> _assistantes = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadAssistantes(); _loadAssistantes();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAssistantes() async { Future<void> _loadAssistantes() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -57,6 +69,46 @@ class _AssistanteMaternelleManagementWidgetState
} }
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -96,6 +148,10 @@ class _AssistanteMaternelleManagementWidgetState
_openAssistanteDetails(assistante); _openAssistanteDetails(assistante);
}, },
), ),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(assistante),
),
], ],
); );
}, },
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
static const double _proTabHeight = 300; static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2]; static const List<int> _photoProRowLayout = [2, 2, 2];
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
double _childrenTabHeight() {
const capacityFields = 72.0;
const titleSection = 40.0;
const inconsistencyExtra = 46.0;
var h = capacityFields +
titleSection +
AdminAmChildrenCapacityGrid.fixedHeight;
if (_placesInconsistent()) h += inconsistencyExtra;
return h + 4;
}
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return _childrenTabHeight();
default:
return 292;
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _identityTab() { Widget _identityTab() {
return SingleChildScrollView( return IdentityBlock.editable(
child: IdentityBlock.editable( title: 'Identité et coordonnées',
title: 'Identité et coordonnées', nomController: _nomCtrl,
nomController: _nomCtrl, prenomController: _prenomCtrl,
prenomController: _prenomCtrl, telephoneController: _telCtrl,
telephoneController: _telCtrl, emailController: _emailCtrl,
emailController: _emailCtrl, adresseController: _adresseCtrl,
adresseController: _adresseCtrl, codePostalController: _cpCtrl,
codePostalController: _cpCtrl, villeController: _villeCtrl,
villeController: _villeCtrl,
),
); );
} }
@@ -610,7 +585,9 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
return LayoutBuilder( return LayoutBuilder(
builder: (context, c) { builder: (context, c) {
final maxRowW = c.maxWidth; final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight; // Hauteur bornée : longlet pro a une hauteur fixe (photo ID).
final maxRowH =
c.maxHeight.isFinite ? c.maxHeight : _proTabHeight;
final bodyH = maxRowH; final bodyH = maxRowH;
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16; final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth) final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
@@ -659,6 +636,18 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
); );
} }
/// Corps de longlet actif — hauteur naturelle (pas de TabBarView).
Widget _buildActiveTabBody() {
switch (_tabCtrl.index) {
case 1:
return SizedBox(height: _proTabHeight, child: _proTab());
case 2:
return _childrenTab();
default:
return _identityTab();
}
}
Widget _childrenCapacityFields() { Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent(); final inconsistent = _placesInconsistent();
return Column( return Column(
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const Divider(height: 1), const Divider(height: 1),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox( child: AnimatedSize(
height: _tabViewHeight(_tabCtrl.index), duration: const Duration(milliseconds: 180),
child: TabBarView( curve: Curves.easeInOut,
controller: _tabCtrl, alignment: Alignment.topCenter,
children: [ child: KeyedSubtree(
_identityTab(), key: ValueKey<int>(_tabCtrl.index),
_proTab(), child: _buildActiveTabBody(),
_childrenTab(),
],
), ),
), ),
), ),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.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/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/api/api_config.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/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/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_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/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_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_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_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_am_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_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/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart'; import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart'; import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
@@ -97,8 +100,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance'; _isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
bool get _canDelete => bool get _canDelete =>
!widget.isCreating && !widget.isCreating && canDeleteMetier(_currentUserRole);
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting; bool get _busy => _saving || _deleting;
@@ -506,37 +508,99 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!_canDelete || _deleting) return; if (!_canDelete || _deleting) return;
final name = _headerTitle(); final name = _headerTitle();
final confirmed = await showDialog<bool>( final enfant = widget.enfant;
context: context, if (enfant == null) return;
builder: (ctx) => AlertDialog(
title: const Text('Supprimer l\'enfant'), bool deleteDossierFlag = false;
content: Text( String? numero;
'Supprimer définitivement la fiche de $name ?\n' String familleLabel = '';
'Cette action est irréversible.', 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( if (!confirmed || !mounted) return;
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); setState(() => _deleting = true);
try { try {
final id = widget.enfant?.id; final id = widget.enfant?.id;
if (id == null || id.isEmpty) return; if (id == null || id.isEmpty) return;
await UserService.deleteEnfant(id); await UserService.deleteEnfant(id, deleteDossier: deleteDossierFlag);
if (!mounted) return; if (!mounted) return;
widget.onDeleted?.call(); widget.onDeleted?.call();
widget.onSaved?.call(); widget.onSaved?.call();
@@ -548,7 +612,13 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!mounted) return; if (!mounted) return;
setState(() => _deleting = false); setState(() => _deleting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))), SnackBar(
content: Text(
e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur suppression',
),
),
); );
} }
} }
@@ -0,0 +1,467 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
/// Choix pour le dernier enfant dun dossier famille (#160).
enum DernierEnfantSuppressionChoice {
enfantSeul,
dossierAussi,
}
/// Ligne dimpact (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<SuppressionPersonLine> people;
final List<String> footnotes;
final List<Widget> 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<SuppressionPersonLine> people = const [],
List<String> 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<bool> showSuppressionConfirmDialog(
BuildContext context, {
required String title,
String? subtitle,
String? message,
List<SuppressionPersonLine> people = const [],
List<String> footnotes = const [],
String confirmLabel = 'Supprimer',
}) async {
final result = await showDialog<bool>(
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<bool> showDossierSuppressionConfirmDialog(
BuildContext context, {
required String numeroDossier,
required bool isFamille,
required List<SuppressionPersonLine> 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<DernierEnfantSuppressionChoice?> showDernierEnfantSuppressionDialog(
BuildContext context, {
required String enfantName,
required String familleLabel,
required String numeroDossier,
String? amLabel,
}) {
final am = (amLabel ?? '').trim();
return showDialog<DernierEnfantSuppressionChoice>(
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 dimpact pour suppression dun enfant (AM optionnelle).
List<String> enfantSuppressionFootnotes({
required String? numeroDossier,
required String familleLabel,
String? amLabel,
}) {
final notes = <String>[];
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(
'Lenfant 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<SuppressionPersonLine> suppressionPeopleFromDossier({
required bool isFamille,
required List<({String nom, String prenom, String email})> parents,
required List<({String nom, String prenom})> enfants,
String? amName,
}) {
final lines = <SuppressionPersonLine>[];
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;
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; 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/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 licône. /// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône.
class DossierListCard extends StatelessWidget { class DossierListCard extends StatelessWidget {
@@ -9,6 +10,12 @@ class DossierListCard extends StatelessWidget {
final VoidCallback onOpen; final VoidCallback onOpen;
/// Photo AM (si absente → icône fallback). /// Photo AM (si absente → icône fallback).
final String? photoUrl; final String? photoUrl;
final VoidCallback? onDelete;
/// Warning vigilance (ex. dossier sans enfant #160).
final String? vigilanceTooltip;
/// Nombre denfants (famille) — affiché à côté des noms.
final int? enfantsCount;
final bool sansEnfant;
/// Lavande — Famille / Parents. /// Lavande — Famille / Parents.
static const Color familleAccent = Color(0xFFB289C9); static const Color familleAccent = Color(0xFFB289C9);
@@ -23,6 +30,10 @@ class DossierListCard extends StatelessWidget {
required this.isFamille, required this.isFamille,
required this.onOpen, required this.onOpen,
this.photoUrl, this.photoUrl,
this.onDelete,
this.vigilanceTooltip,
this.enfantsCount,
this.sansEnfant = false,
}); });
@override @override
@@ -31,23 +42,41 @@ class DossierListCard extends StatelessWidget {
final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim(); final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim();
final names = namesLine.trim(); final names = namesLine.trim();
final avatar = (photoUrl ?? '').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 = <String>[
if (names.isNotEmpty) names,
if (emptyKids) 'Sans enfant',
if (!emptyKids && countLabel != null) countLabel,
];
return AdminUserCard( return AdminUserCard(
title: num, title: num,
subtitleLines: names.isEmpty ? const [] : [names], subtitleLines: subtitle,
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null, avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
fallbackIcon: fallbackIcon:
isFamille ? Icons.family_restroom_outlined : Icons.face, isFamille ? Icons.family_restroom_outlined : Icons.face,
// N° = titre neutre (comme Parents / AM) ; accent = icône seule. // N° = titre neutre (comme Parents / AM) ; accent = icône seule.
avatarIconColor: accent, avatarIconColor: accent,
infoColor: Colors.black87, infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
onCardTap: onOpen, onCardTap: onOpen,
vigilanceTooltip: emptyKids
? (vigilanceTooltip ??
'Aucun enfant rattaché à ce dossier famille')
: vigilanceTooltip,
borderColor: emptyKids ? Colors.red.shade300 : null,
actions: [ actions: [
IconButton( IconButton(
tooltip: 'Ouvrir', tooltip: 'Ouvrir',
icon: Icon(Icons.open_in_new, size: 20, color: accent), icon: Icon(Icons.open_in_new, size: 20, color: accent),
onPressed: onOpen, onPressed: onOpen,
), ),
if (onDelete != null)
suppressionIconButton(onPressed: onDelete),
], ],
); );
} }
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.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/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/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.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'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. /// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
@@ -27,13 +30,21 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
List<DossierListItem> _all = []; List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {}; Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0; int _pendingRefreshTick = 0;
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadAll(); _loadAll();
} }
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAll() async { Future<void> _loadAll() async {
setState(() { setState(() {
_loading = true; _loading = true;
@@ -42,12 +53,29 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
try { try {
final parents = await UserService.getParents(); final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles(); final ams = await UserService.getAssistantesMaternelles();
Map<String, bool> sansEnfant = {};
try {
sansEnfant = await UserService.getSansEnfantByNumero();
} catch (_) {
// Flag optionnel : ne bloque pas la liste.
}
if (!mounted) return; if (!mounted) return;
final items = <DossierListItem>[ final items = <DossierListItem>[
...DossierListItem.fromParents(parents), ...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams), ...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) { 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); final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum; if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel); return a.typeLabel.compareTo(b.typeLabel);
@@ -95,6 +123,95 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
); );
} }
Future<void> _confirmDeleteDossier(DossierListItem item) async {
final num = item.numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery; final query = widget.searchQuery;
@@ -115,6 +232,7 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
key: ValueKey('pending-$_pendingRefreshTick'), key: ValueKey('pending-$_pendingRefreshTick'),
searchQuery: query, searchQuery: query,
compactWhenEmpty: true, compactWhenEmpty: true,
canDelete: _canDelete,
onPendingNumerosChanged: (nums) { onPendingNumerosChanged: (nums) {
if (!mounted) return; if (!mounted) return;
setState(() => _pendingNumeros = nums); setState(() => _pendingNumeros = nums);
@@ -189,7 +307,15 @@ class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
namesLine: item.namesLine, namesLine: item.namesLine,
isFamille: item.isFamille, isFamille: item.isFamille,
photoUrl: item.photoUrl, photoUrl: item.photoUrl,
sansEnfant: item.sansEnfant,
enfantsCount: item.enfantsCount,
vigilanceTooltip: item.sansEnfant
? 'Aucun enfant rattaché à ce dossier famille'
: null,
onOpen: () => _openDossier(item.numeroDossier), onOpen: () => _openDossier(item.numeroDossier),
onDelete: _canDelete
? () => _confirmDeleteDossier(item)
: null,
); );
}, },
childCount: filtered.length, childCount: filtered.length,
@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; 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/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart'; import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart'; import 'package:p_tits_pas/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_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/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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137). /// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
@@ -25,13 +29,21 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<EnfantAdminModel> _enfants = []; List<EnfantAdminModel> _enfants = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadEnfants(); _loadEnfants();
} }
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadEnfants() async { Future<void> _loadEnfants() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -59,10 +71,133 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
builder: (ctx) => AdminChildDetailModal( builder: (ctx) => AdminChildDetailModal(
enfant: enfant, enfant: enfant,
onSaved: _loadEnfants, 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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -98,6 +233,10 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
tooltip: 'Voir / modifier', tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant), onPressed: () => _openEnfant(enfant),
), ),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(enfant),
),
], ],
); );
}, },
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.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/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/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/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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class GestionnaireManagementWidget extends StatefulWidget { class GestionnaireManagementWidget extends StatefulWidget {
@@ -23,16 +26,28 @@ class _GestionnaireManagementWidgetState
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<AppUser> _gestionnaires = []; List<AppUser> _gestionnaires = [];
bool _canDelete = false;
String? _currentUserId;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadGestionnaires(); _loadGestionnaires();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() {
_canDelete = canDeleteGestionnaire(user?.role);
_currentUserId = user?.id;
});
}
Future<void> _loadGestionnaires() async { Future<void> _loadGestionnaires() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -67,6 +82,46 @@ class _GestionnaireManagementWidgetState
} }
} }
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -84,6 +139,8 @@ class _GestionnaireManagementWidgetState
itemCount: filteredGestionnaires.length, itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final user = filteredGestionnaires[index]; final user = filteredGestionnaires[index];
final isSelf =
_currentUserId != null && _currentUserId == user.id;
return AdminUserCard( return AdminUserCard(
title: user.fullName, title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined, fallbackIcon: Icons.assignment_ind_outlined,
@@ -102,6 +159,8 @@ class _GestionnaireManagementWidgetState
_openGestionnaireEditDialog(user); _openGestionnaireEditDialog(user);
}, },
), ),
if (_canDelete && !isSelf)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
], ],
); );
}, },
@@ -1,8 +1,12 @@
import 'package:flutter/material.dart'; 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/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/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_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.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'; import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class ParentManagementWidget extends StatefulWidget { class ParentManagementWidget extends StatefulWidget {
@@ -23,16 +27,24 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
List<ParentModel> _parents = []; List<ParentModel> _parents = [];
bool _canDelete = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadRights();
_loadParents(); _loadParents();
} }
@override @override
void dispose() => super.dispose(); void dispose() => super.dispose();
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadParents() async { Future<void> _loadParents() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
@@ -54,6 +66,67 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
} }
} }
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<void> _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
? <String>[
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.',
]
: <String>[
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase(); final query = widget.searchQuery.toLowerCase();
@@ -90,6 +163,8 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
_openParentDetails(parent); _openParentDetails(parent);
}, },
), ),
if (_canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
], ],
); );
}, },
@@ -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/models/pending_family.dart';
import 'package:p_tits_pas/services/user_service.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/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'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. /// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
@@ -15,6 +16,8 @@ class PendingValidationWidget extends StatefulWidget {
final bool compactWhenEmpty; final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? onPendingNumerosChanged; final ValueChanged<Set<String>>? onPendingNumerosChanged;
/// Afficher la poubelle (#160) — mêmes règles que dossiers validés.
final bool canDelete;
const PendingValidationWidget({ const PendingValidationWidget({
super.key, super.key,
@@ -22,6 +25,7 @@ class PendingValidationWidget extends StatefulWidget {
this.searchQuery = '', this.searchQuery = '',
this.compactWhenEmpty = false, this.compactWhenEmpty = false,
this.onPendingNumerosChanged, this.onPendingNumerosChanged,
this.canDelete = false,
}); });
@override @override
@@ -141,6 +145,97 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
); );
} }
Future<void> _confirmDeletePending({
required String numeroDossier,
required String namesLine,
required bool isFamille,
}) async {
final num = numeroDossier.trim();
if (num.isEmpty) return;
var people = <SuppressionPersonLine>[];
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) { bool _matchesQuery(String haystack) {
final q = widget.searchQuery.trim().toLowerCase(); final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true; if (q.isEmpty) return true;
@@ -286,12 +381,21 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
} }
Widget _buildAMCard(AppUser user) { Widget _buildAMCard(AppUser user) {
final names = _amNamesLine(user);
final num = user.numeroDossier ?? '';
return DossierListCard( return DossierListCard(
numeroDossier: user.numeroDossier ?? '', numeroDossier: num,
namesLine: _amNamesLine(user), namesLine: names,
isFamille: false, isFamille: false,
photoUrl: user.photoUrl, photoUrl: user.photoUrl,
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: false,
)
: null,
); );
} }
@@ -306,6 +410,13 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
namesLine: names, namesLine: names,
isFamille: true, isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: true,
)
: null,
); );
} }
} }
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/models/relais_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart'; import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/relais_service.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 { class RelaisManagementPanel extends StatefulWidget {
const RelaisManagementPanel({super.key}); const RelaisManagementPanel({super.key});
@@ -56,28 +57,16 @@ class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
try { try {
if (result.action == _RelaisDialogAction.delete) { if (result.action == _RelaisDialogAction.delete) {
if (relais == null) return; if (relais == null) return;
final confirmed = await showDialog<bool>( final confirmed = await showSuppressionConfirmDialog(
context: context, context,
builder: (ctx) => AlertDialog( title: 'Supprimer le relais',
title: const Text('Supprimer le relais'), people: [SuppressionPersonLine.relais(relais.nom)],
content: Text('Confirmer la suppression de "${relais.nom}" ?'), footnotes: const [
actions: [ 'Cette action est irréversible.',
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'),
),
],
),
); );
if (confirmed != true) return; if (!confirmed) return;
await RelaisService.deleteRelais(relais.id); await RelaisService.deleteRelais(relais.id);
} else if (relais == null) { } else if (relais == null) {
await RelaisService.createRelais(result.payload!); await RelaisService.createRelais(result.payload!);