feat(#159/#160): suppressions métier dashboard — API + UI (squash develop).

Matrice PO #154 : supprimer dossiers famille/AM, parents, enfants et comptes
staff avec règles métier (placements clôturés, sans_enfant, cascade foyer,
droits gestionnaire/admin). Poubelle en liste, dialogues de confirmation,
garde-fou gestionnaire→gestionnaire. Inclut polish hauteur modale fiche AM.
This commit is contained in:
2026-09-10 22:43:33 +02:00
parent ea0e97d930
commit d6d8b299dd
34 changed files with 2467 additions and 174 deletions
@@ -69,29 +69,6 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
static const double _proTabHeight = 300;
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
void initState() {
super.initState();
@@ -541,17 +518,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _identityTab() {
return SingleChildScrollView(
child: IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
@@ -610,7 +585,9 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
return LayoutBuilder(
builder: (context, c) {
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 idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
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() {
final inconsistent = _placesInconsistent();
return Column(
@@ -867,15 +856,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox(
height: _tabViewHeight(_tabCtrl.index),
child: TabBarView(
controller: _tabCtrl,
children: [
_identityTab(),
_proTab(),
_childrenTab(),
],
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: KeyedSubtree(
key: ValueKey<int>(_tabCtrl.index),
child: _buildActiveTabBody(),
),
),
),
@@ -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<AdminChildDetailModal> {
_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<AdminChildDetailModal> {
if (!_canDelete || _deleting) return;
final name = _headerTitle();
final confirmed = await showDialog<bool>(
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<AdminChildDetailModal> {
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',
),
),
);
}
}
@@ -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;
}