feat(#152/#155): cleanup est_multiple + dashboard sans préfixe Admin (squash develop).

Suppression complète grossesse multiple / est_multiple (BDD, API, front).
Rename option C : widgets partagés et panels staff sous widgets/dashboard/,
AdminManagementWidget seul restant dans widgets/admin/.
This commit is contained in:
2026-09-14 12:52:22 +02:00
parent eb5e4aa915
commit 5b83102a59
74 changed files with 565 additions and 304 deletions
@@ -5,9 +5,9 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
import 'package:p_tits_pas/widgets/dashboard/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/dashboard/common/user_list.dart';
class AdminManagementWidget extends StatefulWidget {
final String searchQuery;
@@ -183,7 +183,7 @@ class _AdminManagementWidgetState extends State<AdminManagementWidget> {
final isSuperAdmin = _isSuperAdmin(user);
final canEdit = _canEditAdmin(user);
final canDelete = _canDeleteAdmin(user);
return AdminUserCard(
return UserCard(
title: user.fullName,
fallbackIcon: isSuperAdmin
? Icons.verified_user_outlined
@@ -1,92 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
/// Modale de création dossier AM (#156) — même shell que [ValidationDossierModal].
class AmDossierCreateModal extends StatefulWidget {
final VoidCallback onClose;
final VoidCallback? onSuccess;
const AmDossierCreateModal({
super.key,
required this.onClose,
this.onSuccess,
});
@override
State<AmDossierCreateModal> createState() => _AmDossierCreateModalState();
}
class _AmDossierCreateModalState extends State<AmDossierCreateModal> {
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
void _onSuccess() {
widget.onSuccess?.call();
}
static const double _modalWidth = 930;
/// Hauteur calculée depuis 4 lignes de TF (voir [AmDossierWizard.shellBodyHeight]).
static double get _bodyHeight => AmDossierWizard.shellBodyHeight;
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
final showStep =
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Nouvelle assistante maternelle',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (showStep) ...[
Text(
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.black54,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: AmDossierWizard.create(
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,170 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class AssistanteMaternelleManagementWidget extends StatefulWidget {
final String searchQuery;
final int? capacityMin;
const AssistanteMaternelleManagementWidget({
super.key,
required this.searchQuery,
this.capacityMin,
});
@override
State<AssistanteMaternelleManagementWidget> createState() =>
_AssistanteMaternelleManagementWidgetState();
}
class _AssistanteMaternelleManagementWidgetState
extends State<AssistanteMaternelleManagementWidget> {
bool _isLoading = false;
String? _error;
List<AssistanteMaternelleModel> _assistantes = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadAssistantes();
}
@override
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 {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getAssistantesMaternelles();
if (!mounted) return;
setState(() {
_assistantes = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
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
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filteredAssistantes = _assistantes.where((am) {
final matchesName = am.user.fullName.toLowerCase().contains(query) ||
am.user.email.toLowerCase().contains(query) ||
(am.residenceCity?.toLowerCase().contains(query) ?? false);
final matchesCapacity = widget.capacityMin == null ||
(am.maxChildren != null && am.maxChildren! >= widget.capacityMin!);
return matchesName && matchesCapacity;
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredAssistantes.isEmpty,
emptyMessage: 'Aucune assistante maternelle trouvée.',
itemCount: filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
vigilanceTooltip: vigilance,
onCardTap: () => _openAssistanteDetails(assistante),
subtitleLines: [
assistante.user.email,
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () {
_openAssistanteDetails(assistante);
},
),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(assistante),
),
],
);
},
);
}
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminAmEditModal(
assistante: assistante,
onSaved: _loadAssistantes,
),
);
}
}
@@ -1,338 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AdminAmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4;
static const double _slotHeight = 44;
static const double _gridPadding = 10;
static const double _gridGap = 8;
static const double _borderWidth = 1;
static const double fixedHeight = _borderWidth * 2 +
_gridPadding * 2 +
_slotHeight * 2 +
_gridGap;
final List<ParentChildSummary> children;
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({
super.key,
required this.children,
required this.capacity,
required this.onOpen,
required this.onDetach,
this.onAttachEmpty,
});
@override
Widget build(BuildContext context) {
final maxSlots = capacity.clamp(0, _gridSlots);
return Container(
height: fixedHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300, width: _borderWidth),
),
padding: const EdgeInsets.all(_gridPadding),
child: Column(
children: [
_buildRow(0, 1, maxSlots),
const SizedBox(height: _gridGap),
_buildRow(2, 3, maxSlots),
],
),
);
}
Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) {
return Row(
children: [
Expanded(child: _buildSlot(leftIndex, maxSlots)),
const SizedBox(width: _gridGap),
Expanded(child: _buildSlot(rightIndex, maxSlots)),
],
);
}
Widget _buildSlot(int index, int maxSlots) {
return SizedBox(
height: _slotHeight,
child: _buildSlotContent(index, maxSlots),
);
}
Widget _buildSlotContent(int index, int maxSlots) {
if (index < children.length) {
return _OccupiedSlot(
child: children[index],
overCapacity: index >= maxSlots,
onOpen: () => onOpen(children[index]),
onDetach: () => onDetach(children[index]),
);
}
if (index < maxSlots) {
return _EmptySlot(onTap: onAttachEmpty);
}
return const _UnavailableSlot();
}
}
class _SlotShell extends StatelessWidget {
final Color backgroundColor;
final Color borderColor;
final Widget child;
const _SlotShell({
required this.backgroundColor,
required this.borderColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor),
),
child: child,
),
);
}
}
class _UnavailableSlot extends StatelessWidget {
const _UnavailableSlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade100,
borderColor: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.block_outlined,
size: 20,
color: Colors.grey.shade400,
),
),
);
}
}
class _EmptySlot extends StatefulWidget {
final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final clickable = widget.onTap != null;
return MouseRegion(
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
),
);
}
}
class _OccupiedSlot extends StatefulWidget {
final ParentChildSummary child;
final bool overCapacity;
final VoidCallback onOpen;
final VoidCallback onDetach;
const _OccupiedSlot({
required this.child,
required this.overCapacity,
required this.onOpen,
required this.onDetach,
});
@override
State<_OccupiedSlot> createState() => _OccupiedSlotState();
}
class _OccupiedSlotState extends State<_OccupiedSlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final age = formatChildAgeLabel(
birthDate: widget.child.birthDate,
dueDate: widget.child.dueDate,
status: widget.child.status,
);
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? '');
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: _SlotShell(
backgroundColor: widget.overCapacity
? Colors.red.shade50
: const Color(0xFFF8F5FC),
borderColor: widget.overCapacity
? Colors.red.shade300
: const Color(0xFFD8CCE8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 8),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.child.fullName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (age.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
age,
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_hovered,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voir / modifier',
icon: const Icon(Icons.visibility_outlined, size: 20),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onOpen,
),
IconButton(
tooltip: 'Détacher',
icon: Icon(
Icons.link_off,
size: 20,
color: Colors.orange.shade800,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onDetach,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
),
),
),
);
}
}
@@ -1,878 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved;
const AdminAmEditModal({
super.key,
required this.assistante,
this.onSaved,
});
@override
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
}
class _AdminAmEditModalState extends State<AdminAmEditModal>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
late final TextEditingController _agrementCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late String _statut;
late bool _disponible;
late int? _placesAvailable;
late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds;
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
final Map<String, String> _transferFromAmIds = {};
bool _saving = false;
bool _dirty = false;
static const double _modalWidth = 930;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2];
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 3, vsync: this);
final u = widget.assistante.user;
final am = widget.assistante;
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_telCtrl = TextEditingController(
text: formatPhoneForDisplay(u.telephone ?? ''),
);
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_agrementCtrl = TextEditingController(text: am.approvalNumber ?? '');
_nirCtrl = TextEditingController(text: _formatNirDisplay());
_dateNaissanceCtrl = TextEditingController(
text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''),
);
_lieuNaissanceVilleCtrl = TextEditingController(
text: u.lieuNaissanceVille ?? '',
);
_lieuNaissancePaysCtrl = TextEditingController(
text: u.lieuNaissancePays ?? '',
);
_dateAgrementCtrl = TextEditingController(
text: formatIsoDateFr(am.agreementDate, ifEmpty: ''),
);
_capaciteCtrl = TextEditingController(
text: am.maxChildren?.toString() ?? '',
);
_statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true;
_placesAvailable = am.placesAvailable;
_children = List.of(am.children);
_baselineChildIds = _children.map((c) => c.id).toSet();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.addListener(_markDirty);
}
_capaciteCtrl.addListener(_onCapacityChanged);
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_tabCtrl.addListener(_onTabChanged);
_fetchChildrenFromServer();
}
bool _childrenChanged() {
final current = _children.map((c) => c.id).toSet();
return current.length != _baselineChildIds.length ||
!current.containsAll(_baselineChildIds);
}
void _syncPlacesAfterChildrenChange() {
final expected = _computedPlacesAvailable();
if (expected != null) _placesAvailable = expected;
}
void _onTabChanged() {
if (!_tabCtrl.indexIsChanging) setState(() {});
}
void _onNameFieldChanged() => setState(() {});
void _onCapacityChanged() => setState(() {});
@override
void dispose() {
_tabCtrl.dispose();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.dispose();
}
super.dispose();
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String _headerTitle() {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.assistante.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Assistante maternelle';
}
return '$fn $ln'.trim();
}
String? _headerSubtitle() {
final parts = <String>[];
final zone = (widget.assistante.residenceCity ?? '').trim();
if (zone.isNotEmpty) parts.add('Zone : $zone');
final agrement = _agrementCtrl.text.trim();
if (agrement.isNotEmpty) parts.add('Agrément : $agrement');
final dossier = widget.assistante.user.numeroDossier?.trim();
if (dossier != null && dossier.isNotEmpty) {
parts.add('Dossier $dossier');
}
if (parts.isEmpty) return null;
return parts.join(' · ');
}
String _formatNirDisplay() {
final raw = widget.assistante.nir?.trim() ?? '';
if (raw.isEmpty) return '';
final digits = nirToRaw(raw).toUpperCase();
return digits.length == 15 ? formatNir(digits) : raw;
}
String? _frDateToIso(String text) => parseFrDateToIso(text);
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
);
bool _placesInconsistent() => amHasPlacesInconsistency(
maxChildren: _capaciteMax(),
placesAvailable: _placesAvailable,
childrenCount: _children.length,
);
String _placesDisplayValue() {
if (_placesAvailable != null) return _placesAvailable.toString();
return '';
}
void _applyPlacesCorrection() {
final expected = _computedPlacesAvailable();
if (expected == null) return;
setState(() {
_placesAvailable = expected;
_dirty = true;
});
}
String? _placesInconsistencyMessage() {
if (!_placesInconsistent()) return null;
final stored = _placesAvailable;
final expected = _computedPlacesAvailable();
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), '
'le calcul (capacité ${_capaciteMax() ?? ''} ${_children.length} '
'enfant(s) rattaché(s)) donne $expectedLabel.';
}
int? _parseIntField(TextEditingController c) {
final t = c.text.trim();
if (t.isEmpty) return null;
return int.tryParse(t);
}
Future<void> _save() async {
if (!_dirty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
content: const Text(
'Enregistrer les modifications de la fiche assistante maternelle ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sauvegarder'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
if (_childrenChanged()) _syncPlacesAfterChildrenChange();
final currentIds = _children.map((c) => c.id).toSet();
for (final id in _baselineChildIds.difference(currentIds)) {
await UserService.detachEnfantFromAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
for (final id in currentIds.difference(_baselineChildIds)) {
// Transfert : détacher l'ancienne AM sans recalculer ses places
// (le ! de vigilance apparaît côté liste AM).
final previousAmId = _transferFromAmIds[id] ??
(await UserService.findAmForEnfant(id))?.user.id;
if (previousAmId != null &&
previousAmId != widget.assistante.user.id) {
await UserService.detachEnfantFromAm(
amUserId: previousAmId,
enfantId: id,
);
}
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
await UserService.updateAmFiche(
amUserId: widget.assistante.user.id,
body: {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (_frDateToIso(_dateNaissanceCtrl.text) != null)
'date_naissance': _frDateToIso(_dateNaissanceCtrl.text),
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
if (_frDateToIso(_dateAgrementCtrl.text) != null)
'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
if (_capaciteMax() != null) 'max_children': _capaciteMax(),
if (_placesAvailable != null) 'places_available': _placesAvailable,
'available': _disponible,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
_baselineChildIds = currentIds;
_transferFromAmIds.clear();
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche assistante maternelle enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _fetchChildrenFromServer() async {
try {
final refreshed =
await UserService.getAssistanteMaternelle(widget.assistante.user.id);
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
final kids = refreshed.children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
if (!mounted) return;
setState(() {
_children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet();
_transferFromAmIds.clear();
});
} catch (_) {}
}
Future<void> _refreshChildrenDetails() async {
try {
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
if (!mounted) return;
setState(() {
_children = _children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
});
} catch (_) {}
}
Future<void> _openChild(ParentChildSummary child) async {
try {
final enfant = await UserService.getEnfant(child.id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _refreshChildrenDetails,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _detachChild(ParentChildSummary child) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} de la fiche de cette assistante ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_children = _children.where((c) => c.id != child.id).toList();
_transferFromAmIds.remove(child.id);
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Future<void> _attachChild() async {
if (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
showSansGardeFilter: true,
);
if (selected == null || !mounted) return;
AssistanteMaternelleModel? previousAm;
try {
previousAm = await UserService.findAmForEnfant(selected.id);
} catch (_) {
previousAm = null;
}
if (!mounted) return;
final previousAmId = previousAm?.user.id;
final isTransfer = previousAmId != null &&
previousAmId != widget.assistante.user.id;
if (isTransfer) {
final amName = previousAm!.user.fullName.trim().isNotEmpty
? previousAm.user.fullName.trim()
: 'une autre assistante maternelle';
final gardeLabel =
(selected.gender ?? '').trim().toUpperCase() == 'F'
? 'déjà gardée'
: 'déjà gardé';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Changer d\'affectation'),
content: Text(
'${selected.fullName} est $gardeLabel par $amName.\n\n'
'Confirmer le transfert vers cette assistante ? '
'L\'enfant sera détaché de $amName.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Confirmer'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() {
_children = [
..._children,
ParentChildSummary.fromEnfant(selected),
];
if (isTransfer) {
_transferFromAmIds[selected.id] = previousAmId!;
}
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Widget _identityTab() {
return IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
Widget _proFieldsGrid() {
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationEditableField(
controller: _nirCtrl,
hintText: '15 chiffres',
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
],
),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
hintText: 'jj/mm/aaaa',
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(
controller: _agrementCtrl,
),
),
ValidationLabeledField(
label: 'Date d\'agrément',
field: ValidationEditableField(
controller: _dateAgrementCtrl,
hintText: 'jj/mm/aaaa',
),
),
],
);
}
Widget _proTab() {
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
// 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)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: AdminAmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl,
),
),
const SizedBox(width: _photoProGap),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_proFieldsGrid(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
title: const Text(
'Disponible pour accueillir',
style: TextStyle(fontSize: 13),
),
value: _disponible,
onChanged: (v) => setState(() {
_disponible = v;
_dirty = true;
}),
),
],
),
),
),
],
);
},
);
}
/// 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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValidationEditableSection(
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationReadOnlyField(
value: _placesDisplayValue(),
error: inconsistent,
),
),
],
),
if (inconsistent) ...[
const SizedBox(height: 6),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Text(
_placesInconsistencyMessage()!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
height: 1.3,
),
),
OutlinedButton(
onPressed: _saving ? null : _applyPlacesCorrection,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade800,
disabledForegroundColor: Colors.red.shade300,
side: BorderSide(color: Colors.red.shade400),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
child: const Text('Mettre à jour'),
),
],
),
],
],
);
}
Widget _childrenTab() {
final capacity = (_capaciteMax() ?? 4).clamp(1, 4);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
AdminAmChildrenCapacityGrid(
children: _children,
capacity: capacity,
onOpen: _openChild,
onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
),
],
);
}
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
if (isChildrenTab)
Tooltip(
message: _capacityFull
? 'Capacité maximale atteinte'
: 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
),
if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_headerSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_headerSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
TabBar(
controller: _tabCtrl,
onTap: (_) => setState(() {}),
labelColor: ValidationModalTheme.primaryActionBackground,
unselectedLabelColor: Colors.black54,
indicatorColor: ValidationModalTheme.primaryActionBackground,
tabs: const [
Tab(text: 'Identité'),
Tab(text: 'Fiche professionnelle'),
Tab(text: 'Enfants accueillis'),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: KeyedSubtree(
key: ValueKey<int>(_tabCtrl.index),
child: _buildActiveTabBody(),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: _buildFooter(),
),
],
),
),
);
}
}
@@ -1,194 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM / enfant (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl;
final Uint8List? imageBytes;
final VoidCallback? onTap;
final VoidCallback? onClear;
final String emptyLabel;
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({
super.key,
this.photoUrl,
this.imageBytes,
this.onTap,
this.onClear,
this.emptyLabel = 'Aucune photo',
});
/// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) {
const frame = 16.0;
final innerH = (height - frame).clamp(0.0, double.infinity);
return innerH * idPhotoAspectRatio + frame;
}
@override
Widget build(BuildContext context) {
final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl);
return LayoutBuilder(
builder: (context, c) {
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
final hasLocal = imageBytes != null && imageBytes!.isNotEmpty;
final showClear = onClear != null && hasLocal;
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
Widget frame = Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: _photoContent(fullUrl, pw, ph),
),
),
),
),
);
if (onTap != null) {
frame = MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: frame,
),
);
}
if (!showClear) return frame;
return Stack(
clipBehavior: Clip.none,
children: [
frame,
Positioned(
top: 0,
right: 0,
child: Material(
color: Colors.transparent,
child: IconButton(
tooltip: 'Retirer la photo',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: Icon(
Icons.cancel,
size: 22,
color: Colors.grey.shade700,
),
onPressed: onClear,
),
),
),
],
);
},
);
}
Widget _photoContent(String fullUrl, double pw, double ph) {
if (imageBytes != null && imageBytes!.isNotEmpty) {
return Image.memory(
imageBytes!,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
);
}
if (fullUrl.isEmpty) {
return ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
onTap != null ? Icons.add_a_photo_outlined : Icons.person_off_outlined,
size: 36,
color: Colors.grey.shade400,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
emptyLabel,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
),
],
),
);
}
return AuthNetworkImage(
url: fullUrl,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Icon(
Icons.broken_image_outlined,
size: 36,
color: Colors.grey.shade400,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children;
final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
final String emptyMessage;
final double? height;
static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({
super.key,
required this.children,
required this.scrollController,
required this.onOpen,
required this.onDetach,
this.emptyMessage = 'Aucun enfant rattaché',
this.height,
});
@override
Widget build(BuildContext context) {
if (height != null) {
return _panel(height!);
}
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0
? constraints.maxHeight
: defaultViewportHeight;
return _panel(h);
},
);
}
Widget _panel(double panelHeight) {
return Container(
height: panelHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: children.isEmpty
? Center(
child: Text(
emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _itemHeight,
itemCount: children.length,
itemBuilder: (_, i) {
final c = children[i];
return AdminEnfantUserCard.fromSummary(
c,
onCardTap: () => onOpen(c),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => onOpen(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => onDetach(c),
),
],
);
},
),
);
}
}
@@ -1,138 +0,0 @@
import 'package:flutter/material.dart';
class AdminDetailField {
final String label;
final String value;
const AdminDetailField({
required this.label,
required this.value,
});
}
class AdminDetailModal extends StatelessWidget {
final String title;
final String? subtitle;
final List<AdminDetailField> fields;
final VoidCallback onEdit;
final VoidCallback onDelete;
const AdminDetailModal({
super.key,
required this.title,
this.subtitle,
required this.fields,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (subtitle != null && subtitle!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: const TextStyle(color: Colors.black54),
),
],
],
),
),
IconButton(
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
const Divider(height: 1),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
children: fields
.map(
(field) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 180,
child: Text(
field.label,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
Expanded(
child: Text(
field.value,
style: const TextStyle(color: Colors.black87),
),
),
],
),
),
)
.toList(),
),
),
),
const SizedBox(height: 14),
Align(
alignment: Alignment.centerRight,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton.icon(
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
label: const Text('Supprimer'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade700,
side: BorderSide(color: Colors.red.shade300),
),
),
const SizedBox(width: 10),
ElevatedButton.icon(
onPressed: onEdit,
icon: const Icon(Icons.edit),
label: const Text('Modifier'),
),
],
),
),
],
),
),
),
);
}
}
@@ -1,134 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/utils/enfant_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({
required String status,
String? birthDate,
String? dueDate,
String? gender,
List<String> extra = const [],
}) {
final lines = <String>[];
final age = formatChildAgeLabel(
birthDate: birthDate,
dueDate: dueDate,
status: normalizeEnfantStatus(status),
);
if (age.isNotEmpty) lines.add(age);
final normalized = normalizeEnfantStatus(status);
if (normalized.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}');
}
lines.addAll(extra);
return lines;
}
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
class AdminEnfantUserCard extends StatelessWidget {
final String title;
final String? photoUrl;
final List<String> subtitleLines;
final List<Widget> actions;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
final Color? borderColor;
final String? vigilanceTooltip;
const AdminEnfantUserCard({
super.key,
required this.title,
this.photoUrl,
required this.subtitleLines,
this.actions = const [],
this.onCardTap,
this.margin,
this.contentPadding,
this.borderColor,
this.vigilanceTooltip,
});
factory AdminEnfantUserCard.fromEnfant(
EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
Color? borderColor,
String? vigilanceTooltip,
}) {
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
.join(', ');
final orphan = enfantHasNoResponsable(enfant);
return AdminEnfantUserCard(
title: enfant.fullName,
photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: enfant.status,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
gender: enfant.gender,
extra: [
if (parents.isNotEmpty) 'Responsables : $parents',
if (orphan) 'Aucun responsable rattaché',
...extraSubtitleLines,
],
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor ??
(orphan ? Colors.red.shade300 : null),
vigilanceTooltip: vigilanceTooltip ??
enfantSansResponsableVigilanceMessage(enfant),
);
}
factory AdminEnfantUserCard.fromSummary(
ParentChildSummary child, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) {
return AdminEnfantUserCard(
title: child.fullName,
photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: child.status,
birthDate: child.birthDate,
dueDate: child.dueDate,
extra: extraSubtitleLines,
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
);
}
@override
Widget build(BuildContext context) {
return AdminUserCard(
title: title,
fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl,
subtitleLines: subtitleLines,
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
borderColor: borderColor,
vigilanceTooltip: vigilanceTooltip,
);
}
}
@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
class AdminListState extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final Widget list;
const AdminListState({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.list,
});
@override
Widget build(BuildContext context) {
if (isLoading) {
return const Expanded(
child: Center(child: CircularProgressIndicator()),
);
}
if (error != null) {
return Expanded(
child: Center(
child: Text(
'Erreur: $error',
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
);
}
if (isEmpty) {
return Expanded(
child: Center(
child: Text(emptyMessage),
),
);
}
return Expanded(child: list);
}
}
@@ -1,490 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_enfant_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
class AdminParentEditModal extends StatefulWidget {
final ParentModel parent;
final VoidCallback? onSaved;
const AdminParentEditModal({
super.key,
required this.parent,
this.onSaved,
});
@override
State<AdminParentEditModal> createState() => _AdminParentEditModalState();
}
class _AdminParentEditModalState extends State<AdminParentEditModal> {
late final TextEditingController _nomCtrl;
late final TextEditingController _prenomCtrl;
late final TextEditingController _emailCtrl;
late final TextEditingController _telCtrl;
late final TextEditingController _adresseCtrl;
late final TextEditingController _villeCtrl;
late final TextEditingController _cpCtrl;
late String _statut;
late List<ParentChildSummary> _children;
AppUser? _coParent;
late final ScrollController _childrenScrollCtrl;
bool _saving = false;
bool _dirty = false;
static const double _modalWidth = 930;
@override
void initState() {
super.initState();
final u = widget.parent.user;
_nomCtrl = TextEditingController(text: u.nom ?? '');
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
_emailCtrl = TextEditingController(text: u.email);
_telCtrl = TextEditingController(
text: formatPhoneForDisplay(u.telephone ?? ''),
);
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
_villeCtrl = TextEditingController(text: u.ville ?? '');
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
_statut = u.statut ?? 'en_attente';
_coParent = widget.parent.coParent;
_children = List.of(widget.parent.children);
_childrenScrollCtrl = ScrollController();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
]) {
c.addListener(_markDirty);
}
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_reloadChildren();
}
void _onNameFieldChanged() {
setState(() {});
}
@override
void dispose() {
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
]) {
c.dispose();
}
_childrenScrollCtrl.dispose();
super.dispose();
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
String _headerTitle() {
final fn = _prenomCtrl.text.trim();
final ln = _nomCtrl.text.trim();
if (fn.isEmpty && ln.isEmpty) {
final fallback = widget.parent.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Parent';
}
return '$fn $ln'.trim();
}
String? _coParentName() {
final name = _coParent?.fullName.trim() ?? '';
return name.isEmpty ? null : name;
}
Future<void> _openCoParent() async {
if (_saving) return;
final co = _coParent;
final id = (co?.id ?? '').trim();
if (co == null || id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
if (!mounted) return;
setState(() => _coParent = refreshed.coParent);
} catch (_) {}
widget.onSaved?.call();
},
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _coParentSubtitle() {
final name = _coParentName();
if (name == null) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Co-parent : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
InkWell(
onTap: _saving ? null : _openCoParent,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
),
],
);
}
Future<void> _save() async {
if (!_dirty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Confirmer'),
content: const Text(
'Enregistrer les modifications de la fiche parent ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Sauvegarder'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _saving = true);
try {
await UserService.updateParentFiche(
parentUserId: widget.parent.user.id,
body: {
'nom': _nomCtrl.text.trim(),
'prenom': _prenomCtrl.text.trim(),
'email': _emailCtrl.text.trim(),
'telephone': normalizePhone(_telCtrl.text),
'adresse': _adresseCtrl.text.trim(),
'ville': _villeCtrl.text.trim(),
'code_postal': _cpCtrl.text.trim(),
'statut': _statut,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche parent enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _openChild(ParentChildSummary child) async {
try {
final enfant = await UserService.getEnfant(child.id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _reloadChildren,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _reloadChildren() async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
final kids = refreshed.children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
if (!mounted) return;
setState(() {
_children = kids;
_coParent = refreshed.coParent;
});
} catch (_) {}
}
Future<void> _detachChild(ParentChildSummary child) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'enfant'),
content: Text(
'Retirer ${child.fullName} du foyer (tous les responsables) ?',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Détacher'),
),
],
),
);
if (confirmed != true || !mounted) return;
try {
await UserService.detachEnfantFromParent(
parentUserId: widget.parent.user.id,
enfantId: child.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant détaché du foyer')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _attachChild() async {
if (!mounted) return;
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
);
if (selected == null || !mounted) return;
try {
await UserService.attachEnfantToParent(
parentUserId: widget.parent.user.id,
enfantId: selected.id,
);
if (!mounted) return;
await _reloadChildren();
if (!mounted) return;
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Enfant rattaché au foyer')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget _childrenPanel() {
return AdminChildrenAffiliationPanel(
children: _children,
scrollController: _childrenScrollCtrl,
onOpen: _openChild,
onDetach: _detachChild,
);
}
Widget _identityFields() {
return IdentityBlock.editable(
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
);
}
Widget _buildFooter() {
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
const SizedBox(width: 12),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: !_dirty || _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
),
],
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_coParentSubtitle() != null) ...[
const SizedBox(height: 4),
_coParentSubtitle()!,
],
],
),
),
const SizedBox(width: 12),
SizedBox(
width: 160,
child: AdminStatusCapsule(
statut: _statut,
onChanged: (v) => setState(() {
_statut = v;
_dirty = true;
}),
),
),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 40,
minHeight: 40,
),
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_identityFields(),
const SizedBox(height: 10),
Text(
'Nombre d\'enfants : ${_children.length}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 6),
_childrenPanel(),
const SizedBox(height: 12),
_buildFooter(),
],
),
),
],
),
),
);
}
}
@@ -1,218 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
final zone = (am.residenceCity ?? '').trim();
if (zone.isNotEmpty) lines.add('Zone : $zone');
final agrement = (am.approvalNumber ?? '').trim();
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
final max = am.maxChildren;
final free = amExpectedPlacesAvailable(
maxChildren: max,
childrenCount: am.children.length,
) ??
am.placesAvailable;
if (free != null || max != null) {
lines.add('Places libres : ${free ?? ''} / capa. ${max ?? ''}');
}
lines.add('${am.children.length} enfant(s)');
return lines;
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
),
loadItems: () async {
final list = await UserService.getAssistantesMaternelles();
return list
.where((am) => !excludeIds.contains(am.user.id))
.toList()
..sort(
(a, b) => a.user.fullName
.toLowerCase()
.compareTo(b.user.fullName.toLowerCase()),
);
},
matchesQuery: (am, q) {
final u = am.user;
final name = u.fullName.toLowerCase();
final fn = (u.prenom ?? '').toLowerCase();
final ln = (u.nom ?? '').toLowerCase();
final zone = (am.residenceCity ?? '').toLowerCase();
final agrement = (am.approvalNumber ?? '').toLowerCase();
return name.contains(q) ||
fn.contains(q) ||
ln.contains(q) ||
zone.contains(q) ||
agrement.contains(q);
},
resolveSelect: (ctx, am, reload) =>
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: _amSelectSubtitleLines(am),
vigilanceTooltip: amPlacesVigilanceMessage(am),
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
borderColor: full ? Colors.red.shade200 : null,
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
BuildContext context,
AssistanteMaternelleModel am,
Future<void> Function() reloadList,
) async {
if (amHasFreePlace(am)) return am;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
);
await reloadList();
return selected;
}
}
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
class _AmNoPlaceWarningDialog extends StatefulWidget {
final AssistanteMaternelleModel initialAm;
const _AmNoPlaceWarningDialog({required this.initialAm});
@override
State<_AmNoPlaceWarningDialog> createState() =>
_AmNoPlaceWarningDialogState();
}
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
late AssistanteMaternelleModel _am;
TapGestureRecognizer? _linkRecognizer;
@override
void initState() {
super.initState();
_am = widget.initialAm;
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
}
@override
void dispose() {
_linkRecognizer?.dispose();
super.dispose();
}
Future<void> _openAmFiche() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: _am,
onSaved: () async {
try {
final fresh =
await UserService.getAssistanteMaternelle(_am.user.id);
if (mounted) setState(() => _am = fresh);
} catch (_) {}
},
),
);
if (!mounted) return;
try {
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
if (!mounted) return;
setState(() => _am = fresh);
if (amHasFreePlace(fresh)) {
Navigator.of(context).pop(fresh);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final name = _am.user.fullName.trim().isNotEmpty
? _am.user.fullName.trim()
: 'Cette assistante maternelle';
const linkColor = ValidationModalTheme.primaryActionBackground;
return AlertDialog(
title: const Text('Plus de place disponible'),
content: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
children: [
TextSpan(
text: '$name n\'a plus de place libre pour accueillir '
'un enfant supplémentaire.\n\n',
),
const TextSpan(text: 'Vous pouvez '),
TextSpan(
text: 'ouvrir sa fiche',
style: TextStyle(
color: linkColor,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
recognizer: _linkRecognizer,
),
const TextSpan(
text: ' pour modifier la capacité ou les places, '
'puis la sélectionner si une place se libère.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
}
@@ -1,72 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal {
AdminSelectEnfantModal._();
static Future<EnfantAdminModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Rattacher un enfant',
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false,
}) {
return AdminSelectListModal.show<EnfantAdminModel>(
context,
title: title,
searchHint: 'Rechercher par nom ou prénom…',
emptyMessage: 'Aucun enfant disponible à rattacher',
noResultsMessage: showSansGardeFilter
? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde',
initialValue: true,
whenEnabled: (e) =>
normalizeEnfantStatus(e.status) == 'sans_garde',
)
: null,
loadItems: () async {
final list = await UserService.getEnfants();
return list
.where((e) => !excludeIds.contains(e.id))
.toList()
..sort(
(a, b) =>
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
);
},
matchesQuery: (e, q) {
final name = e.fullName.toLowerCase();
final fn = (e.firstName ?? '').toLowerCase();
final ln = (e.lastName ?? '').toLowerCase();
return name.contains(q) || fn.contains(q) || ln.contains(q);
},
itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant(
e,
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -1,139 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
/// Foyer / famille sélectionnable pour rattacher un nouvel enfant (#132 / #157).
class AdminFamilleFoyer {
/// Parent pivot pour `POST /enfants` (`parent_user_id`).
final String pivotParentUserId;
/// Co-parent éventuel (rattachement foyer #157).
final String? coParentUserId;
final String? numeroDossier;
final String displayTitle;
final List<String> parentNames;
const AdminFamilleFoyer({
required this.pivotParentUserId,
required this.displayTitle,
required this.parentNames,
this.coParentUserId,
this.numeroDossier,
});
/// Parents du foyer à lier à lenfant (pivot puis co-parent).
List<String> get parentUserIds {
final ids = <String>[pivotParentUserId];
final co = (coParentUserId ?? '').trim();
if (co.isNotEmpty && co != pivotParentUserId) ids.add(co);
return ids;
}
String get subtitle {
final parts = <String>[];
final dossier = (numeroDossier ?? '').trim();
if (dossier.isNotEmpty) parts.add('Dossier $dossier');
if (parentNames.isNotEmpty) {
parts.add('Responsables : ${parentNames.join(', ')}');
}
return parts.join(' ');
}
}
/// Construit la liste des foyers uniques à partir de `GET /parents`.
List<AdminFamilleFoyer> buildFamilleFoyers(List<ParentModel> parents) {
final seenDossiers = <String>{};
final seenUserIds = <String>{};
final foyers = <AdminFamilleFoyer>[];
for (final p in parents) {
final dossier = (p.user.numeroDossier ?? '').trim();
if (dossier.isNotEmpty) {
if (seenDossiers.contains(dossier)) continue;
seenDossiers.add(dossier);
} else if (seenUserIds.contains(p.user.id)) {
continue;
}
seenUserIds.add(p.user.id);
final co = p.coParent;
if (co != null) seenUserIds.add(co.id);
final names = <String>[
if (p.user.fullName.trim().isNotEmpty) p.user.fullName.trim(),
if (co != null && co.fullName.trim().isNotEmpty) co.fullName.trim(),
];
final title = dossier.isNotEmpty
? 'Dossier $dossier'
: (names.isNotEmpty ? names.first : 'Famille');
foyers.add(
AdminFamilleFoyer(
pivotParentUserId: p.user.id,
coParentUserId: co?.id,
numeroDossier: dossier.isNotEmpty ? dossier : null,
displayTitle: title,
parentNames: names,
),
);
}
foyers.sort(
(a, b) => a.displayTitle.toLowerCase().compareTo(b.displayTitle.toLowerCase()),
);
return foyers;
}
/// Sélection d'une famille / dossier pour créer un enfant — ticket #132.
class AdminSelectFamilleModal {
AdminSelectFamilleModal._();
static Future<AdminFamilleFoyer?> show(
BuildContext context, {
String title = 'Choisir une famille',
}) {
return AdminSelectListModal.show<AdminFamilleFoyer>(
context,
title: title,
searchHint: 'Rechercher par dossier, nom…',
emptyMessage: 'Aucune famille disponible',
noResultsMessage: 'Aucun résultat pour cette recherche',
loadItems: () async {
final parents = await UserService.getParents();
return buildFamilleFoyers(parents);
},
matchesQuery: (f, q) {
final dossier = (f.numeroDossier ?? '').toLowerCase();
final title = f.displayTitle.toLowerCase();
final names = f.parentNames.join(' ').toLowerCase();
return dossier.contains(q) || title.contains(q) || names.contains(q);
},
itemBuilder: (context, f, onSelect) {
return AdminUserCard(
title: f.displayTitle,
fallbackIcon: Icons.family_restroom,
subtitleLines: [
if (f.parentNames.isNotEmpty) f.parentNames.join(', '),
if ((f.numeroDossier ?? '').isNotEmpty)
'Dossier ${f.numeroDossier}',
],
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.check_circle_outline),
tooltip: 'Choisir',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -1,367 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> {
final String label;
final bool initialValue;
/// Si le switch est activé, ne garde que les éléments pour lesquels
/// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({
required this.label,
required this.whenEnabled,
this.initialValue = true,
});
}
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget {
final String title;
final String searchHint;
final Future<List<T>> Function() loadItems;
final bool Function(T item, String query) matchesQuery;
final Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder;
final String emptyMessage;
final String noResultsMessage;
final double modalWidth;
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
final double cardExtent;
/// Nombre minimum de cartes visibles dans la zone scrollable.
final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
final Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({
super.key,
required this.title,
required this.loadItems,
required this.matchesQuery,
required this.itemBuilder,
this.searchHint = 'Rechercher…',
this.emptyMessage = 'Aucun élément disponible',
this.noResultsMessage = 'Aucun résultat pour cette recherche',
this.modalWidth = 930,
this.cardExtent = 52,
this.minVisibleCards = 8,
this.toggleFilter,
this.resolveSelect,
});
static Future<T?> show<T>(
BuildContext context, {
required String title,
required Future<List<T>> Function() loadItems,
required bool Function(T item, String query) matchesQuery,
required Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder,
String searchHint = 'Rechercher…',
String emptyMessage = 'Aucun élément disponible',
String noResultsMessage = 'Aucun résultat pour cette recherche',
double modalWidth = 930,
double cardExtent = 52,
int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect,
}) {
return showDialog<T>(
context: context,
builder: (ctx) => AdminSelectListModal<T>(
title: title,
loadItems: loadItems,
matchesQuery: matchesQuery,
itemBuilder: itemBuilder,
searchHint: searchHint,
emptyMessage: emptyMessage,
noResultsMessage: noResultsMessage,
modalWidth: modalWidth,
cardExtent: cardExtent,
minVisibleCards: minVisibleCards,
toggleFilter: toggleFilter,
resolveSelect: resolveSelect,
),
);
}
@override
State<AdminSelectListModal<T>> createState() =>
_AdminSelectListModalState<T>();
}
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
final _searchCtrl = TextEditingController();
List<T> _all = [];
bool _loading = true;
String? _error;
late bool _toggleOn;
bool _resolving = false;
double get _listHeight =>
widget.cardExtent * widget.minVisibleCards;
@override
void initState() {
super.initState();
_toggleOn = widget.toggleFilter?.initialValue ?? false;
_searchCtrl.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await widget.loadItems();
if (!mounted) return;
setState(() {
_all = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
Future<void> _handleSelect(T item) async {
if (_resolving) return;
final resolve = widget.resolveSelect;
if (resolve == null) {
Navigator.of(context).pop(item);
return;
}
setState(() => _resolving = true);
try {
final chosen = await resolve(context, item, _load);
if (!mounted || chosen == null) return;
Navigator.of(context).pop(chosen);
} finally {
if (mounted) setState(() => _resolving = false);
}
}
List<T> get _filtered {
var list = _all;
final toggle = widget.toggleFilter;
if (toggle != null && _toggleOn) {
list = list.where(toggle.whenEnabled).toList();
}
final q = _searchCtrl.text.trim().toLowerCase();
if (q.isEmpty) return list;
return list.where((item) => widget.matchesQuery(item, q)).toList();
}
@override
Widget build(BuildContext context) {
final toggle = widget.toggleFilter;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
isDense: true,
hintText: widget.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
),
if (toggle != null) ...[
const SizedBox(width: 12),
Text(
toggle.label,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 4),
Switch(
value: _toggleOn,
activeColor:
ValidationModalTheme.primaryActionBackground,
onChanged: (v) => setState(() => _toggleOn = v),
),
],
],
),
const SizedBox(height: 12),
SizedBox(
height: _listHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildBody(),
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: ValidationModalTheme.primaryActionBackground,
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
const SizedBox(height: 12),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
);
}
final items = _filtered;
if (_all.isEmpty) {
return Center(
child: Text(
widget.emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
if (items.isEmpty) {
return Center(
child: Text(
widget.noResultsMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: widget.cardExtent,
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
return widget.itemBuilder(
context,
item,
() => _handleSelect(item),
);
},
);
}
}
@@ -1,66 +0,0 @@
import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget {
final String statut;
final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({
super.key,
required this.statut,
this.onChanged,
});
static String displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
@override
Widget build(BuildContext context) {
final value = statuts.contains(statut) ? statut : statuts.first;
return Container(
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isExpanded: true,
isDense: true,
style: const TextStyle(fontSize: 13, color: Colors.black87),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(displayStatus(s)),
),
)
.toList(),
onChanged: onChanged == null
? null
: (v) {
if (v != null) onChanged!(v);
},
),
),
);
}
}
@@ -1,196 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class AdminUserCard extends StatefulWidget {
final String title;
final List<String> subtitleLines;
final String? avatarUrl;
final IconData fallbackIcon;
final List<Widget> actions;
final Color? borderColor;
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
/// Fond du cercle avatar / icône (défaut lavande admin).
final Color? avatarBackgroundColor;
/// Couleur de licône fallback (défaut violet admin).
final Color? avatarIconColor;
final String? vigilanceTooltip;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({
super.key,
required this.title,
required this.subtitleLines,
this.avatarUrl,
this.fallbackIcon = Icons.person,
this.actions = const [],
this.borderColor,
this.backgroundColor,
this.titleColor,
this.infoColor,
this.avatarBackgroundColor,
this.avatarIconColor,
this.vigilanceTooltip,
this.onCardTap,
this.margin,
this.contentPadding,
});
@override
State<AdminUserCard> createState() => _AdminUserCardState();
}
class _AdminUserCardState extends State<AdminUserCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final infoLine =
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: widget.onCardTap != null
? SystemMouseCursors.click
: MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: widget.onCardTap,
borderRadius: BorderRadius.circular(10),
hoverColor: const Color(0x149CC5C0),
child: Card(
margin: widget.margin ?? const EdgeInsets.only(bottom: 12),
elevation: 0,
color: widget.backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
),
child: Padding(
padding: widget.contentPadding ??
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 10),
if (widget.vigilanceTooltip != null) ...[
Tooltip(
message: widget.vigilanceTooltip!,
child: Icon(
Icons.error_outline,
size: 20,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 6),
],
Expanded(
child: Row(
children: [
// flex: 0 → largeur du nom ; le reste va aux infos
// (évite le partage 50/50 qui tronque « Responsables »).
Flexible(
flex: 0,
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
).copyWith(color: widget.titleColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
infoLine,
style: const TextStyle(
color: Colors.black54,
fontSize: 12,
).copyWith(color: widget.infoColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (widget.actions.isNotEmpty)
SizedBox(
width: actionsWidth,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _isHovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_isHovered,
child: IconTheme(
data: const IconThemeData(size: 17),
child: IconButtonTheme(
data: IconButtonThemeData(
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
minimumSize: const Size(28, 28),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: widget.actions,
),
),
),
),
),
),
],
),
),
),
),
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
final bg = widget.avatarBackgroundColor ?? const Color(0xFFEDE5FA);
final iconColor = widget.avatarIconColor ?? const Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
),
),
),
);
}
}
@@ -1,467 +0,0 @@
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,45 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
class UserList extends StatelessWidget {
final bool isLoading;
final String? error;
final bool isEmpty;
final String emptyMessage;
final int itemCount;
final Widget Function(BuildContext context, int index) itemBuilder;
final EdgeInsetsGeometry padding;
const UserList({
super.key,
required this.isLoading,
required this.error,
required this.isEmpty,
required this.emptyMessage,
required this.itemCount,
required this.itemBuilder,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AdminListState(
isLoading: isLoading,
error: error,
isEmpty: isEmpty,
emptyMessage: emptyMessage,
list: ListView.builder(
itemCount: itemCount,
itemBuilder: itemBuilder,
),
),
],
),
);
}
}
@@ -1,860 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/postal_utils.dart';
import 'admin_detail_modal.dart';
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
class ValidationFormMetrics {
ValidationFormMetrics._();
// --- 1. Titres de section ---
static const double sectionTitleFontSize = 16;
static const double sectionTitleGapBelow = 12;
// --- 2. TF : texte intérieur + padding vertical (= hauteur) ---
static const double fieldTextFontSize = 14;
static const double fieldContentPaddingV = 12;
static const double fieldContentPaddingH = 12;
/// Hauteur estimée du TF (texte + padding haut/bas + bordure).
static const double fieldHeight =
fieldTextFontSize + fieldContentPaddingV * 2 + 4;
// --- 3. Espace entre les lignes de TF ---
static const double rowGapBelow = 12;
// Libellé au-dessus du TF (titre du champ)
static const double fieldLabelFontSize = 13;
static const double fieldLabelGapBelow = 4;
static const TextStyle fieldTextStyle = TextStyle(
color: Colors.black87,
fontSize: fieldTextFontSize,
);
static double get sectionTitleBlockHeight =>
sectionTitleFontSize * 1.25 + sectionTitleGapBelow;
/// Libellé : marge au-dessus de [fieldLabelFontSize] (métriques police).
static double get labeledRowHeight =>
fieldLabelFontSize * 1.25 +
fieldLabelGapBelow +
fieldHeight +
rowGapBelow;
/// Corps modale AM / famille : padding wizard + titre + [rows] lignes + nav.
static double shellBodyHeightForRows(int rows) =>
20 * 2 + // padding wizard
4 + // espace haut
sectionTitleBlockHeight +
rows * labeledRowHeight +
24 + // avant nav
48; // boutons (+ marge anti-overflow)
}
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
/// [rowLayout] : même disposition que la création de compte, ex. [2, 2, 1, 2] = ligne de 2, ligne de 2, plein largeur, ligne de 2.
/// [rowFlex] : flex par index de ligne (optionnel). Ex. {3: [2, 5]} = 4e ligne : code postal étroit (2), ville large (5).
class ValidationDetailSection extends StatelessWidget {
/// Si null ou vide, pas de bandeau titre (gain de place vertical, ex. wizard AM).
final String? title;
final List<AdminDetailField> fields;
/// Nombre de champs par ligne (1 = plein largeur, 2 = deux côte à côte). Ex. [2, 2, 1, 2] pour identité.
final List<int>? rowLayout;
/// Flex par ligne (index de ligne -> [flex1, flex2, ...]). Ex. {3: [2, 5]} pour Code postal | Ville.
final Map<int, List<int>>? rowFlex;
/// Remplit la hauteur disponible (wizard AM étapes 12).
final bool expandVertically;
const ValidationDetailSection({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
this.expandVertically = false,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
expandVertically: expandVertically,
fields: fields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: ValidationReadOnlyField(value: f.value),
),
)
.toList(),
);
}
}
/// Grille label/champ réutilisable (validation, fiches admin).
class ValidationFormGrid extends StatelessWidget {
final String? title;
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
/// Répartit la hauteur dispo entre les lignes (remplit le blanc sans scroll).
final bool expandVertically;
const ValidationFormGrid({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
this.expandVertically = false,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
int index = 0;
int rowIndex = 0;
final rowWidgets = <Widget>[];
for (final count in layout) {
if (index >= fields.length) break;
final rowFields = fields.skip(index).take(count).toList();
index += count;
if (rowFields.isEmpty) continue;
final flexForRow = rowFlex?[rowIndex];
rowIndex++;
final labeled = rowFields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: f.field,
expand: expandVertically,
labelTrailing: f.labelTrailing,
),
)
.toList();
Widget row;
if (count == 1) {
row = labeled.first;
} else {
row = Row(
crossAxisAlignment: expandVertically
? CrossAxisAlignment.stretch
: CrossAxisAlignment.start,
children: [
for (int i = 0; i < labeled.length; i++) ...[
if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: labeled[i],
),
],
],
);
}
if (expandVertically) {
rowWidgets.add(Expanded(child: row));
} else {
rowWidgets.add(Padding(
padding: EdgeInsets.only(
bottom: compact ? 8 : ValidationFormMetrics.rowGapBelow,
),
child: row,
));
}
}
final showTitle = title != null && title!.trim().isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: expandVertically ? MainAxisSize.max : MainAxisSize.min,
children: [
if (showTitle) ...[
Text(
title!.trim(),
style: TextStyle(
fontSize: compact
? ValidationFormMetrics.sectionTitleFontSize - 1
: ValidationFormMetrics.sectionTitleFontSize,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(
height: compact
? 8
: ValidationFormMetrics.sectionTitleGapBelow,
),
],
...rowWidgets,
],
);
}
}
/// Décoration commune lecture seule / édition (modales validation, fiches admin).
class ValidationFieldDecoration {
ValidationFieldDecoration._();
static InputDecoration input({String? hint, bool compact = false}) {
return InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : ValidationFormMetrics.fieldContentPaddingH,
vertical: compact ? 7 : ValidationFormMetrics.fieldContentPaddingV,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade500),
),
);
}
static InputDecoration readOnly({bool error = false, bool compact = false}) {
final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300;
final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50;
return input(compact: compact).copyWith(
filled: true,
fillColor: fillColor,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
);
}
static BoxDecoration container({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
}
/// Libellé au-dessus dun champ (même typo que [ValidationDetailSection]).
class ValidationLabeledField extends StatelessWidget {
final String label;
final Widget field;
final bool expand;
/// Widget aligné à droite sur la ligne du libellé (ex. switch « Même adresse »).
final Widget? labelTrailing;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
this.expand = false,
this.labelTrailing,
});
@override
Widget build(BuildContext context) {
final labelStyle = TextStyle(
fontSize: ValidationFormMetrics.fieldLabelFontSize,
fontWeight: FontWeight.w500,
color: Colors.grey.shade700,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: expand ? MainAxisSize.max : MainAxisSize.min,
children: [
if (labelTrailing == null)
Text(label, style: labelStyle)
else
Row(
children: [
Expanded(child: Text(label, style: labelStyle)),
labelTrailing!,
],
),
SizedBox(height: ValidationFormMetrics.fieldLabelGapBelow),
if (expand) Expanded(child: field) else field,
],
);
}
}
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
class ValidationEditableField extends StatelessWidget {
final TextEditingController controller;
final TextInputType keyboardType;
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final int maxLines;
final bool compact;
final bool enabled;
const ValidationEditableField({
super.key,
required this.controller,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.hintText,
this.maxLines = 1,
this.compact = false,
this.enabled = true,
});
static const double _compactFieldHeight = 34;
static BoxDecoration _compactDecoration({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
static InputDecoration _compactInputDecoration({String? hint}) {
return InputDecoration(
isDense: true,
filled: false,
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
);
}
@override
Widget build(BuildContext context) {
if (maxLines > 1) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
if (!compact) {
return _validationFieldFillHeight(
TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(hint: hintText),
),
);
}
return SizedBox(
height: _compactFieldHeight,
child: DecoratedBox(
decoration: _compactDecoration(),
child: TextField(
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.0,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
);
}
}
/// Hauteur TF fixe ; en grille [expandVertically], étire jusqu’à la hauteur dispo.
Widget _validationFieldFillHeight(Widget field) {
return LayoutBuilder(
builder: (context, c) {
final h = (c.hasBoundedHeight && c.maxHeight.isFinite)
? c.maxHeight
: ValidationFormMetrics.fieldHeight;
return SizedBox(
height: h,
width: double.infinity,
child: field,
);
},
);
}
/// E-mail style validation — même supervision que login / création de compte :
/// [EmailMaxLengthFormatter] + à la perte de focus : trim/minuscules + validation.
class ValidationEmailField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationEmailField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationEmailField> createState() => _ValidationEmailFieldState();
}
class _ValidationEmailFieldState extends State<ValidationEmailField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final normalized = normalizeEmailText(c.text);
if (normalized != c.text) {
c.value = TextEditingValue(
text: normalized,
selection: TextSelection.collapsed(offset: normalized.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.email],
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: const [EmailMaxLengthFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateEmail(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Code postal FR — même supervision que création de compte :
/// chiffres uniquement (max 5) + validation à la perte de focus.
class ValidationPostalCodeField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
final bool enabled;
const ValidationPostalCodeField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
this.enabled = true,
});
@override
State<ValidationPostalCodeField> createState() =>
_ValidationPostalCodeFieldState();
}
class _ValidationPostalCodeFieldState extends State<ValidationPostalCodeField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final trimmed = c.text.trim();
if (trimmed != c.text) {
c.value = TextEditingValue(
text: trimmed,
selection: TextSelection.collapsed(offset: trimmed.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
enabled: widget.enabled,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: kFrenchPostalCodeInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '5 chiffres',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchPostalCode(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// Téléphone FR — formatters live + [validateFrenchNationalPhone] à la perte de focus.
class ValidationPhoneField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationPhoneField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationPhoneField> createState() => _ValidationPhoneFieldState();
}
class _ValidationPhoneFieldState extends State<ValidationPhoneField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
final c = widget.controller;
final digits = normalizePhone(c.text);
final formatted = digits.isEmpty ? '' : formatPhoneForDisplay(digits);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
inputFormatters: frenchPhoneInputFormatters,
style: ValidationFormMetrics.fieldTextStyle,
decoration:
ValidationFieldDecoration.input(hint: widget.hintText).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
validator: (value) =>
validateFrenchNationalPhone(value, allowEmpty: widget.allowEmpty),
),
);
}
}
/// NIR — formatage live ([NirInputFormatter]) + validation au fil de la saisie / blur.
class ValidationNirField extends StatefulWidget {
final TextEditingController controller;
final String? hintText;
final bool allowEmpty;
const ValidationNirField({
super.key,
required this.controller,
this.hintText,
this.allowEmpty = false,
});
@override
State<ValidationNirField> createState() => _ValidationNirFieldState();
}
class _ValidationNirFieldState extends State<ValidationNirField> {
final GlobalKey<FormFieldState<String>> _fieldKey =
GlobalKey<FormFieldState<String>>();
late final FocusNode _focusNode;
bool _blurred = false;
@override
void initState() {
super.initState();
_focusNode = FocusNode();
_focusNode.addListener(_onFocusChange);
}
void _onFocusChange() {
if (_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _focusNode.hasFocus) return;
setState(() => _blurred = true);
final c = widget.controller;
final raw = nirToRaw(c.text).toUpperCase();
final formatted = raw.isEmpty ? '' : formatNir(raw);
if (formatted != c.text) {
c.value = TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
_fieldKey.currentState?.validate();
});
}
@override
void dispose() {
_focusNode.removeListener(_onFocusChange);
_focusNode.dispose();
super.dispose();
}
String? _validator(String? value) {
if (_blurred) {
if (widget.allowEmpty && (value == null || value.trim().isEmpty)) {
return null;
}
return validateNir(value);
}
return validateNirTyping(value);
}
@override
Widget build(BuildContext context) {
return _validationFieldFillHeight(
TextFormField(
key: _fieldKey,
controller: widget.controller,
focusNode: _focusNode,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
textAlignVertical: TextAlignVertical.center,
autovalidateMode: AutovalidateMode.onUserInteraction,
inputFormatters: const [NirInputFormatter()],
style: ValidationFormMetrics.fieldTextStyle,
decoration: ValidationFieldDecoration.input(
hint: widget.hintText ?? '1 12 34 56 789 012 - 34',
).copyWith(
errorStyle: TextStyle(color: Colors.red.shade700, fontSize: 11),
errorMaxLines: 2,
),
onChanged: (_) => _fieldKey.currentState?.validate(),
validator: _validator,
),
);
}
}
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationEditableSection({
super.key,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
rowLayout: rowLayout,
rowFlex: rowFlex,
compact: compact,
fields: fields,
);
}
}
/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
class ValidationReadOnlyField extends StatefulWidget {
final String value;
final int? maxLines;
final bool compact;
final bool error;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
this.compact = false,
this.error = false,
});
@override
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
}
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
late final TextEditingController _controller;
static const double _compactFieldHeight = 34;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(ValidationReadOnlyField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) {
return _validationFieldFillHeight(
TextField(
controller: _controller,
readOnly: true,
enableInteractiveSelection: false,
textAlignVertical: TextAlignVertical.center,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: ValidationFormMetrics.fieldTextFontSize,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
),
);
}
return Container(
width: double.infinity,
height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
alignment: widget.compact ? Alignment.centerLeft : null,
padding: EdgeInsets.symmetric(
horizontal: widget.compact ? 10 : 12,
vertical: widget.compact ? 7 : 10,
),
decoration: ValidationFieldDecoration.container(error: widget.error),
child: Text(
widget.value,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: widget.compact ? 13 : 14,
height: widget.compact ? 1.0 : null,
fontWeight: widget.error ? FontWeight.w600 : null,
),
maxLines: widget.maxLines,
overflow: TextOverflow.ellipsis,
),
);
}
}
@@ -1,210 +0,0 @@
import 'package:flutter/material.dart';
/// Sous-barre : [À valider] | Gestionnaires | Parents | Assistantes maternelles | [Administrateurs].
/// [tabLabels] : liste des libellés d'onglets (ex. avec « À valider » en premier si dossiers en attente).
/// [subTabCount] = 3 pour masquer Administrateurs (dashboard gestionnaire).
class DashboardUserManagementSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
final TextEditingController searchController;
final String searchHint;
/// Infobulle au survol de la barre de recherche (ex. critères de recherche).
final String? searchTooltip;
final Widget? filterControl;
final VoidCallback? onAddPressed;
final String addLabel;
final int subTabCount;
/// Si non null, utilisé à la place des labels par défaut (ex. ['À valider', 'Parents', ...]).
final List<String>? tabLabels;
static const List<String> _defaultTabLabels = [
'Gestionnaires',
'Parents',
'Assistantes maternelles',
'Administrateurs',
];
/// Aligné sur la taille des libellés donglets.
static const double _searchFontSize = 13;
const DashboardUserManagementSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
required this.searchController,
required this.searchHint,
this.searchTooltip,
this.filterControl,
this.onAddPressed,
this.addLabel = '+ Ajouter',
this.subTabCount = 4,
this.tabLabels,
}) : super(key: key);
List<String> get _labels => tabLabels ?? _defaultTabLabels.sublist(0, subTabCount.clamp(1, _defaultTabLabels.length));
@override
Widget build(BuildContext context) {
final labels = _labels;
return Container(
height: 56,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Row(
children: [
for (int i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 12),
_buildSubNavItem(context, labels[i], i),
],
const SizedBox(width: 36),
_buildSearchField(),
if (filterControl != null) ...[
const SizedBox(width: 12),
_pillField(width: 150, child: filterControl!),
],
const Spacer(),
if (onAddPressed != null) _buildAddButton(),
],
),
);
}
Widget _buildSearchField() {
final field = _pillField(
width: 320,
child: TextField(
controller: searchController,
style: const TextStyle(fontSize: _searchFontSize),
decoration: InputDecoration(
hintText: searchHint,
hintStyle: TextStyle(
fontSize: _searchFontSize,
fontStyle: FontStyle.normal,
fontWeight: FontWeight.normal,
color: Colors.black45,
),
prefixIcon: const Icon(Icons.search, size: 18),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
),
),
);
final tip = (searchTooltip ?? '').trim();
if (tip.isEmpty) return field;
return Tooltip(
message: tip,
preferBelow: false,
waitDuration: const Duration(milliseconds: 400),
child: field,
);
}
Widget _pillField({required double width, required Widget child}) {
return Container(
width: width,
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
alignment: Alignment.centerLeft,
child: child,
);
}
Widget _buildAddButton() {
return ElevatedButton.icon(
onPressed: onAddPressed,
icon: const Icon(Icons.add),
label: Text(addLabel),
);
}
Widget _buildSubNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedSubIndex;
return InkWell(
onTap: () => onSubTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black87,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 13,
),
),
),
);
}
}
/// Sous-barre Paramètres : Paramètres généraux | Paramètres territoriaux.
class DashboardSettingsSubBar extends StatelessWidget {
final int selectedSubIndex;
final ValueChanged<int> onSubTabChange;
const DashboardSettingsSubBar({
Key? key,
required this.selectedSubIndex,
required this.onSubTabChange,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildSubNavItem(context, 'Paramètres généraux', 0),
const SizedBox(width: 16),
_buildSubNavItem(context, 'Paramètres territoriaux', 1),
],
),
),
);
}
Widget _buildSubNavItem(BuildContext context, String title, int index) {
final bool isActive = index == selectedSubIndex;
return InkWell(
onTap: () => onSubTabChange(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isActive ? const Color(0xFF9CC5C0) : Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: isActive ? null : Border.all(color: Colors.black26),
),
child: Text(
title,
style: TextStyle(
color: isActive ? Colors.white : Colors.black87,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
fontSize: 13,
),
),
),
);
}
}
@@ -1,83 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur licône.
class DossierListCard extends StatelessWidget {
final String numeroDossier;
final String namesLine;
final bool isFamille;
final VoidCallback onOpen;
/// Photo AM (si absente → icône fallback).
final String? photoUrl;
final VoidCallback? onDelete;
/// Warning vigilance (ex. dossier sans enfant #160).
final String? vigilanceTooltip;
/// Nombre denfants (famille) — affiché à côté des noms.
final int? enfantsCount;
final bool sansEnfant;
/// Lavande — Famille / Parents.
static const Color familleAccent = Color(0xFFB289C9);
/// Menthe logo — AM.
static const Color amAccent = Color(0xFF5A9D94);
const DossierListCard({
super.key,
required this.numeroDossier,
required this.namesLine,
required this.isFamille,
required this.onOpen,
this.photoUrl,
this.onDelete,
this.vigilanceTooltip,
this.enfantsCount,
this.sansEnfant = false,
});
@override
Widget build(BuildContext context) {
final accent = isFamille ? familleAccent : amAccent;
final num = numeroDossier.trim().isEmpty ? '' : numeroDossier.trim();
final names = namesLine.trim();
final avatar = (photoUrl ?? '').trim();
final emptyKids = isFamille && (sansEnfant || enfantsCount == 0);
final count = enfantsCount;
final countLabel = (!isFamille || count == null)
? null
: (count <= 1 ? '$count enfant' : '$count enfants');
final subtitle = <String>[
if (names.isNotEmpty) names,
if (emptyKids) 'Sans enfant',
if (!emptyKids && countLabel != null) countLabel,
];
return AdminUserCard(
title: num,
subtitleLines: subtitle,
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
fallbackIcon:
isFamille ? Icons.family_restroom_outlined : Icons.face,
// N° = titre neutre (comme Parents / AM) ; accent = icône seule.
avatarIconColor: accent,
infoColor: emptyKids ? Colors.red.shade700 : Colors.black87,
onCardTap: onOpen,
vigilanceTooltip: emptyKids
? (vigilanceTooltip ??
'Aucun enfant rattaché à ce dossier famille')
: vigilanceTooltip,
borderColor: emptyKids ? Colors.red.shade300 : null,
actions: [
IconButton(
tooltip: 'Ouvrir',
icon: Icon(Icons.open_in_new, size: 20, color: accent),
onPressed: onOpen,
),
if (onDelete != null)
suppressionIconButton(onPressed: onDelete),
],
);
}
}
@@ -1,329 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas.
class DossiersManagementWidget extends StatefulWidget {
final String searchQuery;
final VoidCallback? onRefresh;
const DossiersManagementWidget({
super.key,
this.searchQuery = '',
this.onRefresh,
});
@override
State<DossiersManagementWidget> createState() =>
_DossiersManagementWidgetState();
}
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
bool _loading = true;
String? _error;
List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0;
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadAll();
}
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadAll() async {
setState(() {
_loading = true;
_error = null;
});
try {
final parents = await UserService.getParents();
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;
final items = <DossierListItem>[
...DossierListItem.fromParents(parents),
...DossierListItem.fromAssistantes(ams),
].map((item) {
if (!item.isFamille) return item;
final apiFlag = sansEnfant[item.numeroDossier] == true;
final localEmpty = (item.enfantsCount ?? 0) == 0;
return item.copyWith(
sansEnfant: apiFlag || localEmpty || item.sansEnfant,
);
}).toList();
items.sort((a, b) {
// Dossiers sans enfant en tête (#160), comme orphelins #157.
final ae = a.sansEnfant ? 0 : 1;
final be = b.sansEnfant ? 0 : 1;
if (ae != be) return ae.compareTo(be);
final byNum = a.numeroDossier.compareTo(b.numeroDossier);
if (byNum != 0) return byNum;
return a.typeLabel.compareTo(b.typeLabel);
});
setState(() {
_all = items;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
Future<void> _refreshEverything() async {
setState(() => _pendingRefreshTick++);
await _loadAll();
widget.onRefresh?.call();
}
void _openDossier(String numeroDossier) {
final num = numeroDossier.trim();
if (num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
openAsEdit: true,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_refreshEverything();
},
),
);
}
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
Widget build(BuildContext context) {
final query = widget.searchQuery;
// Pending uniquement en haut — exclus de « Tous les dossiers ».
final filtered = _all
.where((d) => !_pendingNumeros.contains(d.numeroDossier))
.where((d) => (d.statut ?? '').toLowerCase() != 'en_attente')
.where((d) => d.matchesQuery(query))
.toList(growable: false);
return RefreshIndicator(
onRefresh: _refreshEverything,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: PendingValidationWidget(
key: ValueKey('pending-$_pendingRefreshTick'),
searchQuery: query,
compactWhenEmpty: true,
canDelete: _canDelete,
onPendingNumerosChanged: (nums) {
if (!mounted) return;
setState(() => _pendingNumeros = nums);
},
onRefresh: () {
_loadAll();
widget.onRefresh?.call();
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Tous les dossiers',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
),
if (_loading)
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator()),
)
else if (_error != null && _error!.isNotEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadAll,
child: const Text('Réessayer'),
),
],
),
),
),
)
else if (filtered.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 32),
child: Text(
query.trim().isEmpty
? 'Aucun dossier pour le moment.\n'
'Pour créer un dossier → onglet Parents (+ Parents) '
'ou Assistantes maternelles (+ Asmat).'
: 'Aucun dossier ne correspond à la recherche.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600, height: 1.4),
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = filtered[index];
return DossierListCard(
numeroDossier: item.numeroDossier,
namesLine: item.namesLine,
isFamille: item.isFamille,
photoUrl: item.photoUrl,
sansEnfant: item.sansEnfant,
enfantsCount: item.enfantsCount,
vigilanceTooltip: item.sansEnfant
? 'Aucun enfant rattaché à ce dossier famille'
: null,
onOpen: () => _openDossier(item.numeroDossier),
onDelete: _canDelete
? () => _confirmDeleteDossier(item)
: null,
);
},
childCount: filtered.length,
),
),
),
],
),
);
}
}
@@ -1,245 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
class EnfantManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const EnfantManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
}
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false;
String? _error;
List<EnfantAdminModel> _enfants = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadEnfants();
}
Future<void> _loadRights() async {
final user = await AuthService.getCurrentUser();
if (!mounted) return;
setState(() => _canDelete = canDeleteMetier(user?.role));
}
Future<void> _loadEnfants() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getEnfants();
if (!mounted) return;
setState(() {
_enfants = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
onDeleted: _loadEnfants,
),
);
}
Future<({String? numero, String famille, bool isLast, String? amLabel})>
_resolveContext(
EnfantAdminModel enfant,
) async {
String? amLabel;
try {
final am = await UserService.findAmForEnfant(enfant.id);
if (am != null) {
final label = formatDossierPersonLabel(
nom: am.user.nom,
prenom: am.user.prenom,
email: am.user.email,
);
if (label.isNotEmpty) amLabel = label;
}
} catch (_) {}
final parentId = enfant.parentLinks
.map((l) => l.parentId.trim())
.firstWhere((id) => id.isNotEmpty, orElse: () => '');
if (parentId.isEmpty) {
return (numero: null, famille: '', isLast: true, amLabel: amLabel);
}
try {
final parent = await UserService.getParent(parentId);
final num = (parent.user.numeroDossier ?? '').trim();
final famille = parent.user.fullName.isNotEmpty
? parent.user.fullName
: parent.user.email;
if (num.isEmpty) {
return (
numero: null,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) {
return (
numero: num,
famille: famille,
isLast: true,
amLabel: amLabel,
);
}
final n = dossier.asFamily.enfants.length;
final names = dossier.asFamily.parents
.map((p) => formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
))
.where((s) => s.isNotEmpty)
.join(' - ');
return (
numero: num,
famille: names.isNotEmpty ? names : famille,
isLast: n <= 1,
amLabel: amLabel,
);
} catch (_) {
return (numero: null, famille: '', isLast: false, amLabel: amLabel);
}
}
Future<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
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = widget.statusFilter == null ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList()
..sort((a, b) {
// Orphelins (#157) en tête, puis ordre alphabétique.
final ao = a.hasNoResponsable ? 0 : 1;
final bo = b.hasNoResponsable ? 0 : 1;
if (ao != bo) return ao.compareTo(bo);
return a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase());
});
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return AdminEnfantUserCard.fromEnfant(
enfant,
onCardTap: () => _openEnfant(enfant),
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
if (_canDelete)
suppressionIconButton(
onPressed: () => _confirmDelete(enfant),
),
],
);
},
);
}
}
@@ -1,169 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class GestionnaireManagementWidget extends StatefulWidget {
final String searchQuery;
const GestionnaireManagementWidget({
Key? key,
required this.searchQuery,
}) : super(key: key);
@override
State<GestionnaireManagementWidget> createState() =>
_GestionnaireManagementWidgetState();
}
class _GestionnaireManagementWidgetState
extends State<GestionnaireManagementWidget> {
bool _isLoading = false;
String? _error;
List<AppUser> _gestionnaires = [];
bool _canDelete = false;
String? _currentUserId;
@override
void initState() {
super.initState();
_loadRights();
_loadGestionnaires();
}
@override
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 {
setState(() {
_isLoading = true;
_error = null;
});
try {
final gestionnaires = await UserService.getGestionnaires();
if (!mounted) return;
setState(() {
_gestionnaires = gestionnaires;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openGestionnaireEditDialog(AppUser user) async {
final changed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminUserFormDialog(initialUser: user);
},
);
if (changed == true) {
await _loadGestionnaires();
}
}
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
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filteredGestionnaires = _gestionnaires.where((u) {
final name = u.fullName.toLowerCase();
final email = u.email.toLowerCase();
return name.contains(query) || email.contains(query);
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredGestionnaires.isEmpty,
emptyMessage: 'Aucun gestionnaire trouvé.',
itemCount: filteredGestionnaires.length,
itemBuilder: (context, index) {
final user = filteredGestionnaires[index];
final isSelf =
_currentUserId != null && _currentUserId == user.id;
return AdminUserCard(
title: user.fullName,
fallbackIcon: Icons.assignment_ind_outlined,
avatarUrl: user.photoUrl,
onCardTap: () => _openGestionnaireEditDialog(user),
subtitleLines: [
user.email,
'Statut : ${user.statut ?? 'Inconnu'}',
'Relais : ${user.relaisNom ?? 'Non rattaché'}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () {
_openGestionnaireEditDialog(user);
},
),
if (_canDelete && !isSelf)
suppressionIconButton(onPressed: () => _confirmDelete(user)),
],
);
},
);
}
}
@@ -1,527 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:p_tits_pas/services/configuration_service.dart';
import 'package:p_tits_pas/utils/email_utils.dart';
import 'package:p_tits_pas/widgets/admin/relais_management_panel.dart';
/// Panneau Paramètres admin : Email (SMTP), Personnalisation, Avancé.
class ParametresPanel extends StatefulWidget {
/// Si true, après sauvegarde on redirige vers le login (première config). Sinon on reste sur la page.
final bool redirectToLoginAfterSave;
final int selectedSettingsTabIndex;
const ParametresPanel({
super.key,
this.redirectToLoginAfterSave = false,
this.selectedSettingsTabIndex = 0,
});
@override
State<ParametresPanel> createState() => _ParametresPanelState();
}
class _ParametresPanelState extends State<ParametresPanel> {
final _formKey = GlobalKey<FormState>();
bool _isLoading = true;
String? _loadError;
bool _isSaving = false;
String? _message;
final Map<String, TextEditingController> _controllers = {};
bool _smtpSecure = false;
bool _smtpAuthRequired = false;
@override
void initState() {
super.initState();
_createControllers();
_loadConfiguration();
}
void _createControllers() {
final keys = [
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_password',
'email_from_name',
'email_from_address',
'app_name',
'app_url',
'app_logo_url',
'password_reset_token_expiry_days',
'jwt_expiry_hours',
'max_upload_size_mb',
];
for (final k in keys) {
_controllers[k] = TextEditingController();
}
}
Future<void> _loadConfiguration() async {
setState(() {
_isLoading = true;
_loadError = null;
});
try {
final list = await ConfigurationService.getAll();
if (!mounted) return;
for (final item in list) {
final c = _controllers[item.cle];
if (c != null && item.valeur != null && item.valeur != '***********') {
c.text = item.valeur!;
}
if (item.cle == 'smtp_secure') {
_smtpSecure = item.valeur == 'true';
}
if (item.cle == 'smtp_auth_required') {
_smtpAuthRequired = item.valeur == 'true';
}
}
setState(() {
_isLoading = false;
});
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_loadError = e.toString().replaceAll('Exception: ', '');
});
}
}
}
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
Map<String, dynamic> _buildPayload() {
final payload = <String, dynamic>{};
payload['smtp_host'] = _controllers['smtp_host']!.text.trim();
final port = int.tryParse(_controllers['smtp_port']!.text.trim());
if (port != null) payload['smtp_port'] = port;
payload['smtp_secure'] = _smtpSecure;
payload['smtp_auth_required'] = _smtpAuthRequired;
payload['smtp_user'] = _controllers['smtp_user']!.text.trim();
final pwd = _controllers['smtp_password']!.text.trim();
if (pwd.isNotEmpty && pwd != '***********') {
payload['smtp_password'] = pwd;
}
payload['email_from_name'] = _controllers['email_from_name']!.text.trim();
payload['email_from_address'] =
_controllers['email_from_address']!.text.trim();
payload['app_name'] = _controllers['app_name']!.text.trim();
payload['app_url'] = _controllers['app_url']!.text.trim();
payload['app_logo_url'] = _controllers['app_logo_url']!.text.trim();
final tokenDays = int.tryParse(
_controllers['password_reset_token_expiry_days']!.text.trim());
if (tokenDays != null) {
payload['password_reset_token_expiry_days'] = tokenDays;
}
final jwtHours =
int.tryParse(_controllers['jwt_expiry_hours']!.text.trim());
if (jwtHours != null) {
payload['jwt_expiry_hours'] = jwtHours;
}
final maxMb = int.tryParse(_controllers['max_upload_size_mb']!.text.trim());
if (maxMb != null) {
payload['max_upload_size_mb'] = maxMb;
}
return payload;
}
/// Sauvegarde en base sans completeSetup (utilisé avant test SMTP).
Future<void> _saveBulkOnly() async {
await ConfigurationService.updateBulk(_buildPayload());
}
/// Sauvegarde la config, marque le setup comme terminé. Si première config, redirige vers le login.
Future<void> _save() async {
final redirectAfter = widget.redirectToLoginAfterSave;
setState(() {
_message = null;
_isSaving = true;
});
try {
await ConfigurationService.updateBulk(_buildPayload());
if (!mounted) return;
await ConfigurationService.completeSetup();
if (!mounted) return;
setState(() {
_isSaving = false;
_message = 'Configuration enregistrée.';
});
if (!mounted) return;
if (redirectAfter) {
GoRouter.of(context).go('/login');
}
} catch (e) {
if (mounted) {
setState(() {
_isSaving = false;
_message = e.toString().replaceAll('Exception: ', '');
});
}
}
}
Future<void> _testSmtp() async {
final email = await showDialog<String>(
context: context,
builder: (ctx) {
final c = TextEditingController();
return AlertDialog(
title: const Text('Tester la connexion SMTP'),
content: TextField(
controller: c,
decoration: const InputDecoration(
labelText: 'Email pour recevoir le test',
hintText: 'admin@example.com',
),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
inputFormatters: const [EmailMaxLengthFormatter()],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler'),
),
FilledButton(
onPressed: () {
final t = c.text.trim();
if (t.isEmpty) {
return;
}
final err = validateEmail(t, allowEmpty: true);
if (err != null) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(err)),
);
return;
}
Navigator.pop(ctx, t);
},
child: const Text('Envoyer'),
),
],
);
},
);
if (email == null || !mounted) return;
setState(() => _message = null);
try {
await _saveBulkOnly();
if (!mounted) return;
final msg = await ConfigurationService.testSmtp(email);
if (!mounted) return;
setState(() => _message = msg);
} catch (e) {
if (mounted) {
setState(() => _message = e.toString().replaceAll('Exception: ', ''));
}
}
}
@override
Widget build(BuildContext context) {
if (widget.selectedSettingsTabIndex == 1) {
return const RelaisManagementPanel();
}
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_loadError != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_loadError!, style: TextStyle(color: Colors.red.shade700)),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadConfiguration,
child: const Text('Réessayer'),
),
],
),
),
);
}
final isSuccess = _message != null &&
(_message!.startsWith('Configuration') ||
_message!.startsWith('Connexion'));
return Form(
key: _formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_message != null) ...[
_MessageBanner(message: _message!, isSuccess: isSuccess),
const SizedBox(height: 20),
],
_buildSectionCard(
context,
icon: Icons.email_outlined,
title: 'Configuration Email (SMTP)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField(
'smtp_host',
'Serveur SMTP',
hint: 'mail.example.com',
),
const SizedBox(height: 14),
_buildField(
'smtp_port',
'Port SMTP',
keyboard: TextInputType.number,
hint: '25, 465, 587',
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Checkbox(
value: _smtpSecure,
onChanged: (v) =>
setState(() => _smtpSecure = v ?? false),
activeColor: const Color(0xFF9CC5C0),
),
const Text('SSL/TLS (secure)'),
const SizedBox(width: 24),
Checkbox(
value: _smtpAuthRequired,
onChanged: (v) => setState(
() => _smtpAuthRequired = v ?? false,
),
activeColor: const Color(0xFF9CC5C0),
),
const Text('Authentification requise'),
],
),
),
_buildField('smtp_user', 'Utilisateur SMTP'),
const SizedBox(height: 14),
_buildField(
'smtp_password',
'Mot de passe SMTP',
obscure: true,
),
const SizedBox(height: 14),
_buildField('email_from_name', 'Nom expéditeur'),
const SizedBox(height: 14),
_buildField(
'email_from_address',
'Email expéditeur',
hint: 'no-reply@example.com',
),
const SizedBox(height: 18),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: _isSaving ? null : _testSmtp,
icon: const Icon(Icons.send_outlined, size: 18),
label: const Text('Tester la connexion SMTP'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF2D6A4F),
side: const BorderSide(
color: Color(0xFF9CC5C0),
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
),
),
),
],
),
),
const SizedBox(height: 24),
_buildSectionCard(
context,
icon: Icons.palette_outlined,
title: 'Personnalisation',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField('app_name', 'Nom de l\'application'),
const SizedBox(height: 14),
_buildField(
'app_url',
'URL de l\'application',
hint: 'https://app.example.com',
),
const SizedBox(height: 14),
_buildField(
'app_logo_url',
'URL du logo',
hint: '/assets/logo.png',
),
],
),
),
const SizedBox(height: 24),
_buildSectionCard(
context,
icon: Icons.settings_outlined,
title: 'Paramètres avancés',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildField(
'password_reset_token_expiry_days',
'Validité token MDP (jours)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14),
_buildField(
'jwt_expiry_hours',
'Validité session JWT (heures)',
keyboard: TextInputType.number,
),
const SizedBox(height: 14),
_buildField(
'max_upload_size_mb',
'Taille max upload (MB)',
keyboard: TextInputType.number,
),
],
),
),
const SizedBox(height: 28),
SizedBox(
height: 48,
child: FilledButton(
onPressed: _isSaving ? null : _save,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF9CC5C0),
foregroundColor: Colors.white,
),
child: _isSaving
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Sauvegarder la configuration'),
),
),
],
),
),
),
),
);
}
Widget _buildSectionCard(BuildContext context,
{required IconData icon, required String title, required Widget child}) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 22, color: const Color(0xFF9CC5C0)),
const SizedBox(width: 10),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: const Color(0xFF2D6A4F),
),
),
],
),
const SizedBox(height: 20),
child,
],
),
),
);
}
Widget _buildField(String key, String label,
{bool obscure = false, TextInputType? keyboard, String? hint}) {
final c = _controllers[key];
if (c == null) return const SizedBox.shrink();
return TextFormField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
enabled: !_isSaving,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
);
}
}
class _MessageBanner extends StatelessWidget {
final String message;
final bool isSuccess;
const _MessageBanner({required this.message, required this.isSuccess});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSuccess ? Colors.green.shade50 : Colors.red.shade50,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSuccess ? Colors.green.shade200 : Colors.red.shade200,
),
),
child: Row(
children: [
Icon(
isSuccess ? Icons.check_circle_outline : Icons.error_outline,
size: 22,
color: isSuccess ? Colors.green.shade700 : Colors.red.shade700,
),
const SizedBox(width: 12),
Expanded(
child: Text(
message,
style: TextStyle(
color: isSuccess ? Colors.green.shade900 : Colors.red.shade900,
fontSize: 14,
),
),
),
],
),
);
}
}
@@ -1,93 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
/// Modale de création dossier famille (#129) — même shell que [AmDossierCreateModal].
class ParentDossierCreateModal extends StatefulWidget {
final VoidCallback onClose;
final VoidCallback? onSuccess;
const ParentDossierCreateModal({
super.key,
required this.onClose,
this.onSuccess,
});
@override
State<ParentDossierCreateModal> createState() =>
_ParentDossierCreateModalState();
}
class _ParentDossierCreateModalState extends State<ParentDossierCreateModal> {
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
void _onSuccess() {
widget.onSuccess?.call();
}
static const double _modalWidth = 930;
/// Hauteur calculée depuis 4 lignes de TF (voir [ParentDossierWizard.shellBodyHeight]).
static double get _bodyHeight => ParentDossierWizard.shellBodyHeight;
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
final showStep =
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Nouveau dossier famille',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (showStep) ...[
Text(
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.black54,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: ParentDossierWizard.create(
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,198 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/services/auth_service.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/staff_deletion_rights.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
class ParentManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const ParentManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<ParentManagementWidget> createState() => _ParentManagementWidgetState();
}
class _ParentManagementWidgetState extends State<ParentManagementWidget> {
bool _isLoading = false;
String? _error;
List<ParentModel> _parents = [];
bool _canDelete = false;
@override
void initState() {
super.initState();
_loadRights();
_loadParents();
}
@override
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 {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getParents();
if (!mounted) return;
setState(() {
_parents = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
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
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filteredParents = _parents.where((p) {
final matchesName = p.user.fullName.toLowerCase().contains(query) ||
p.user.email.toLowerCase().contains(query);
final matchesStatus =
widget.statusFilter == null || p.user.statut == widget.statusFilter;
return matchesName && matchesStatus;
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filteredParents.isEmpty,
emptyMessage: 'Aucun parent trouvé.',
itemCount: filteredParents.length,
itemBuilder: (context, index) {
final parent = filteredParents[index];
return AdminUserCard(
title: parent.user.fullName,
fallbackIcon: Icons.supervisor_account_outlined,
avatarUrl: parent.user.photoUrl,
onCardTap: () => _openParentDetails(parent),
subtitleLines: [
parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${ParentModel.foyerChildrenCount(parent, _parents)}',
],
actions: [
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Modifier',
onPressed: () {
_openParentDetails(parent);
},
),
if (_canDelete)
suppressionIconButton(onPressed: () => _confirmDelete(parent)),
],
);
},
);
}
String _displayStatus(String? status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return 'Inconnu';
}
}
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminParentEditModal(
parent: parent,
onSaved: _loadParents,
),
);
}
}
@@ -1,422 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_list_item.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart';
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart';
/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153.
class PendingValidationWidget extends StatefulWidget {
final VoidCallback? onRefresh;
/// Filtre client (n°, nom, email) — onglet Dossiers (#153).
final String searchQuery;
/// Si true et liste vide : message court (pas de grand vide centré).
final bool compactWhenEmpty;
/// Numéros des dossiers pending (pour exclure de « Tous les dossiers »).
final ValueChanged<Set<String>>? onPendingNumerosChanged;
/// Afficher la poubelle (#160) — mêmes règles que dossiers validés.
final bool canDelete;
const PendingValidationWidget({
super.key,
this.onRefresh,
this.searchQuery = '',
this.compactWhenEmpty = false,
this.onPendingNumerosChanged,
this.canDelete = false,
});
@override
State<PendingValidationWidget> createState() =>
_PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
bool _isLoading = true;
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
final Map<String, String> _familyNamesByNumero = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final am =
await UserService.getPendingUsers(role: 'assistante_maternelle');
final families = await UserService.getPendingFamilies();
final namesByNumero = await _enrichFamilyNames(families);
if (!mounted) return;
setState(() {
_pendingAM = am;
_pendingFamilies = families;
_familyNamesByNumero
..clear()
..addAll(namesByNumero);
_isLoading = false;
});
_emitPendingNumeros();
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_isLoading = false;
});
widget.onPendingNumerosChanged?.call(const {});
}
}
/// Complète `NOM Prénom` via le détail dossier (sans changer le back).
Future<Map<String, String>> _enrichFamilyNames(
List<PendingFamily> families,
) async {
final out = <String, String>{};
await Future.wait(families.map((f) async {
final num = (f.numeroDossier ?? '').trim();
if (num.isEmpty) return;
try {
final dossier = await UserService.getDossier(num);
if (!dossier.isFamily) return;
final labels = <String>[];
final seen = <String>{};
for (final p in dossier.asFamily.parents) {
final id = p.id.trim();
if (id.isNotEmpty && !seen.add(id)) continue;
final label = formatDossierPersonLabel(
nom: p.nom,
prenom: p.prenom,
email: p.email,
);
if (label.isNotEmpty) labels.add(label);
}
if (labels.isNotEmpty) out[num] = labels.join(' - ');
} catch (_) {
// Repli libellé API ci-dessous.
}
}));
return out;
}
void _emitPendingNumeros() {
final nums = <String>{};
for (final u in _pendingAM) {
final n = (u.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
for (final f in _pendingFamilies) {
final n = (f.numeroDossier ?? '').trim();
if (n.isNotEmpty) nums.add(n);
}
widget.onPendingNumerosChanged?.call(nums);
}
void _onOpenValidation({String? numeroDossier}) {
final num = numeroDossier?.trim();
if (num == null || num.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Numéro de dossier manquant.')),
);
return;
}
showDialog<void>(
context: context,
builder: (context) => ValidationDossierModal(
numeroDossier: num,
onClose: () => Navigator.of(context).pop(),
onSuccess: () {
Navigator.of(context).pop();
_load();
widget.onRefresh?.call();
},
),
);
}
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) {
final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true;
return haystack.toLowerCase().contains(q);
}
List<AppUser> get _filteredAM {
return _pendingAM.where((u) {
final bits = [
u.numeroDossier ?? '',
u.fullName,
u.email,
u.nom ?? '',
u.prenom ?? '',
].join(' ');
return _matchesQuery(bits);
}).toList();
}
List<PendingFamily> get _filteredFamilies {
return _pendingFamilies.where((f) {
final num = (f.numeroDossier ?? '').trim();
final enriched = _familyNamesByNumero[num] ?? '';
final bits = [
f.numeroDossier ?? '',
f.libelle,
enriched,
f.emails.join(' '),
].join(' ');
return _matchesQuery(bits);
}).toList();
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
if (widget.compactWhenEmpty) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
);
}
return const Center(child: CircularProgressIndicator());
}
if (_error != null && _error!.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _load,
child: const Text('Réessayer'),
),
],
),
),
);
}
final pendingAM = _filteredAM;
final pendingFamilies = _filteredFamilies;
final cards = <Widget>[
...pendingAM.map(_buildAMCard),
...pendingFamilies.map(_buildFamilyCard),
];
if (cards.isEmpty) {
if (widget.compactWhenEmpty) {
final searching = widget.searchQuery.trim().isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
searching
? 'Aucun dossier en attente ne correspond à la recherche.'
: 'Aucun dossier en attente.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
),
);
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle_outline,
size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
'Aucun dossier en attente de validation',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey.shade600,
),
),
],
),
);
}
final list = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Dossiers à valider',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
...cards,
],
);
if (widget.compactWhenEmpty) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: list,
);
}
return RefreshIndicator(
onRefresh: () async {
await _load();
widget.onRefresh?.call();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: list,
),
);
}
String _amNamesLine(AppUser user) {
return formatDossierPersonLabel(
nom: user.nom,
prenom: user.prenom,
email: user.email,
);
}
Widget _buildAMCard(AppUser user) {
final names = _amNamesLine(user);
final num = user.numeroDossier ?? '';
return DossierListCard(
numeroDossier: num,
namesLine: names,
isFamille: false,
photoUrl: user.photoUrl,
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: false,
)
: null,
);
}
Widget _buildFamilyCard(PendingFamily family) {
final num = (family.numeroDossier ?? '').trim();
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
final names = (enriched != null && enriched.isNotEmpty)
? enriched
: formatDossierFamilyNamesLine(family.libelle);
return DossierListCard(
numeroDossier: family.numeroDossier ?? '',
namesLine: names,
isFamille: true,
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
onDelete: widget.canDelete
? () => _confirmDeletePending(
numeroDossier: num,
namesLine: names,
isFamille: true,
)
: null,
);
}
}
File diff suppressed because it is too large Load Diff
@@ -1,421 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_create_modal.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
class UserManagementPanel extends StatefulWidget {
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
final bool showAdministrateursTab;
/// Création gestionnaire / admin (#161). False pour le dashboard gestionnaire.
final bool allowStaffAccountCreation;
const UserManagementPanel({
super.key,
this.showAdministrateursTab = true,
this.allowStaffAccountCreation = true,
});
@override
State<UserManagementPanel> createState() => _UserManagementPanelState();
}
class _UserManagementPanelState extends State<UserManagementPanel> {
int _subIndex = 0;
int _gestionnaireRefreshTick = 0;
int _parentRefreshTick = 0;
int _adminRefreshTick = 0;
int _enfantRefreshTick = 0;
int _amRefreshTick = 0;
int _dossiersRefreshTick = 0;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
String? _enfantStatus;
@override
void initState() {
super.initState();
_searchController.addListener(_onFilterChanged);
_amCapacityController.addListener(_onFilterChanged);
}
@override
void dispose() {
_searchController.removeListener(_onFilterChanged);
_amCapacityController.removeListener(_onFilterChanged);
_searchController.dispose();
_amCapacityController.dispose();
super.dispose();
}
void _onFilterChanged() {
if (!mounted) return;
setState(() {});
}
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
List<String> get _tabLabels {
const base = [
'Dossiers',
'Parents',
'Enfants',
'Assistantes maternelles',
'Gestionnaires',
];
if (widget.showAdministrateursTab) {
return [...base, 'Administrateurs'];
}
return base;
}
void _onSubTabChange(int index) {
final maxIndex = _tabLabels.length - 1;
setState(() {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
_parentStatus = null;
_enfantStatus = null;
_amCapacityController.clear();
});
}
bool get _isDossiersTab => _subIndex == 0;
bool get _isStaffAccountsTab =>
_subIndex == 4 || (widget.showAdministrateursTab && _subIndex == 5);
bool get _canShowAddButton {
if (_isDossiersTab) return false;
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) return false;
return true;
}
String _searchHintForTab() {
switch (_subIndex) {
case 0:
return 'Rechercher un dossier';
case 1:
return 'Rechercher un parent...';
case 2:
return 'Rechercher un enfant...';
case 3:
return 'Rechercher une assistante...';
case 4:
return 'Rechercher un gestionnaire...';
case 5:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
}
}
String? _searchTooltipForTab() {
if (_subIndex != 0) return null;
return 'Recherche possible : n° de dossier, nom, prénom ou e-mail.';
}
Widget? _subBarFilterControl() {
// Parents
if (_subIndex == 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'actif',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Actif', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'en_attente',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En attente', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'suspendu',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Suspendu', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'refuse',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Refusé', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_parentStatus = value;
});
},
),
);
}
// Enfants
if (_subIndex == 2) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'a_naitre',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('À naître', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'sans_garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Sans garde', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Gardé', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'scolarise',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Scolarisé', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_enfantStatus = value;
});
},
),
);
}
// AM
if (_subIndex == 3) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
hintText: 'Capacité min',
hintStyle: TextStyle(fontSize: 12),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
keyboardType: TextInputType.number,
);
}
return null;
}
Widget _buildBody() {
switch (_subIndex) {
case 0:
return DossiersManagementWidget(
key: ValueKey('dossiers-$_dossiersRefreshTick'),
searchQuery: _searchController.text,
);
case 1:
return ParentManagementWidget(
key: ValueKey('parents-$_parentRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _parentStatus,
);
case 2:
return EnfantManagementWidget(
key: ValueKey('enfants-$_enfantRefreshTick'),
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
case 3:
return AssistanteMaternelleManagementWidget(
key: ValueKey('ams-$_amRefreshTick'),
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 4:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 5:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
);
default:
return const Center(child: Text('Page non trouvée'));
}
}
@override
Widget build(BuildContext context) {
final labels = _tabLabels;
return Column(
children: [
DashboardUserManagementSubBar(
selectedSubIndex: _subIndex,
onSubTabChange: _onSubTabChange,
searchController: _searchController,
searchHint: _searchHintForTab(),
searchTooltip: _searchTooltipForTab(),
filterControl: _subBarFilterControl(),
// Pas de « Créer » sur longlet Dossiers (#153).
// Pas de création staff pour le dashboard gestionnaire (#161).
onAddPressed: _canShowAddButton ? _handleAddPressed : null,
addLabel: 'Ajouter',
subTabCount: labels.length,
tabLabels: labels,
),
Expanded(child: _buildBody()),
],
);
}
Future<void> _handleAddPressed() async {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_isStaffAccountsTab && !widget.allowStaffAccountCreation) {
return;
}
if (_subIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return ParentDossierCreateModal(
onClose: () => Navigator.of(dialogContext).pop(),
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() {
_parentRefreshTick++;
_dossiersRefreshTick++;
});
},
);
},
);
return;
}
if (_subIndex == 2) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AdminChildDetailModal.create(
onSaved: () {
if (!mounted) return;
setState(() => _enfantRefreshTick++);
},
);
},
);
return;
}
if (_subIndex == 3) {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AmDossierCreateModal(
onClose: () => Navigator.of(dialogContext).pop(),
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() {
_amRefreshTick++;
_dossiersRefreshTick++;
});
},
);
},
);
return;
}
if (_subIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog();
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_gestionnaireRefreshTick++;
});
}
return;
}
if (_subIndex == 5) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return const AdminUserFormDialog(
adminMode: true,
withRelais: false,
);
},
);
if (!mounted) return;
if (created == true) {
setState(() {
_adminRefreshTick++;
});
}
}
}
}
@@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
/// Wrapper historique (#107) — délègue à [AmDossierWizard.review].
class ValidationAmWizard extends StatelessWidget {
final DossierAM dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationAmWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
@override
Widget build(BuildContext context) {
return AmDossierWizard.review(
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
}
@@ -1,200 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/am_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_am_wizard.dart';
import 'package:p_tits_pas/widgets/admin/validation_family_wizard.dart';
/// Modale (dialog) : charge le dossier par numéro puis affiche le wizard AM ou Famille.
/// Ticket #107 / #119 (review), #135 (`openAsEdit`).
class ValidationDossierModal extends StatefulWidget {
final String numeroDossier;
final VoidCallback onClose;
final VoidCallback? onSuccess;
/// Liste Dossiers actifs → mode edit (#135). Pending reste en review (défaut).
final bool openAsEdit;
const ValidationDossierModal({
super.key,
required this.numeroDossier,
required this.onClose,
this.onSuccess,
this.openAsEdit = false,
});
@override
State<ValidationDossierModal> createState() => _ValidationDossierModalState();
}
class _ValidationDossierModalState extends State<ValidationDossierModal> {
bool _loading = true;
String? _error;
DossierUnifie? _dossier;
int? _stepIndex;
int? _stepTotal;
void _onStepChanged(int step, int total) {
// step = 0-based dans les wizards, affichage 1-based dans l'en-tête.
if (!mounted) return;
setState(() {
_stepIndex = step;
_stepTotal = total;
});
}
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
_dossier = null;
_stepIndex = null;
_stepTotal = null;
});
try {
final d = await UserService.getDossier(widget.numeroDossier);
if (!mounted) return;
setState(() {
_dossier = d;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e is Exception
? e.toString().replaceFirst('Exception: ', '')
: 'Erreur inconnue';
_loading = false;
});
}
}
void _onSuccess() {
widget.onSuccess?.call();
// La modale est fermée par lappelant dans onSuccess (Navigator.pop).
}
/// Largeur modale = 1,5 × 620.
static const double _modalWidth = 930; // 620 * 1.5
double get _bodyHeight {
final d = _dossier;
if (d != null && d.isAm) return AmDossierWizard.shellBodyHeight;
// Aligné create (#129) / edit (#135) — évite overflow IdentityBlock (8px).
return ParentDossierWizard.shellBodyHeight;
}
@override
Widget build(BuildContext context) {
final maxH = MediaQuery.of(context).size.height * 0.85;
final showStep =
_stepIndex != null && _stepTotal != null && (_stepTotal ?? 0) > 0;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _modalWidth, maxHeight: maxH),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 0, 12),
child: Text(
'Dossier ${widget.numeroDossier}',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700),
),
),
const Spacer(),
if (showStep) ...[
Text(
'Étape ${(_stepIndex ?? 0) + 1}/${_stepTotal ?? 1}',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.black54,
fontStyle: FontStyle.italic,
),
),
const SizedBox(width: 8),
],
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
tooltip: 'Fermer',
),
],
),
const Divider(height: 1),
SizedBox(
height: _bodyHeight,
child: _buildBody(),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Padding(
padding: EdgeInsets.all(48),
child: Center(child: CircularProgressIndicator()),
);
}
if (_error != null && _error!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center),
const SizedBox(height: 16),
ElevatedButton(onPressed: _load, child: const Text('Réessayer')),
const SizedBox(height: 8),
TextButton(onPressed: widget.onClose, child: const Text('Fermer')),
],
),
);
}
final d = _dossier!;
if (d.isAm) {
if (widget.openAsEdit) {
return AmDossierWizard.edit(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationAmWizard(
dossier: d.asAm,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
if (widget.openAsEdit) {
return ParentDossierWizard.edit(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
return ValidationFamilyWizard(
dossier: d.asFamily,
onClose: widget.onClose,
onSuccess: _onSuccess,
onStepChanged: _onStepChanged,
);
}
}
@@ -1,29 +0,0 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/widgets/admin/parent_dossier_wizard.dart';
/// Wrapper historique (#107) — délègue à [ParentDossierWizard.review].
class ValidationFamilyWizard extends StatelessWidget {
final DossierFamille dossier;
final VoidCallback onClose;
final VoidCallback onSuccess;
final void Function(int step, int total)? onStepChanged;
const ValidationFamilyWizard({
super.key,
required this.dossier,
required this.onClose,
required this.onSuccess,
this.onStepChanged,
});
@override
Widget build(BuildContext context) {
return ParentDossierWizard.review(
dossier: dossier,
onClose: onClose,
onSuccess: onSuccess,
onStepChanged: onStepChanged,
);
}
}
@@ -1,18 +0,0 @@
import 'package:flutter/material.dart';
/// Couleurs / styles communs aux modales de validation (cohérent avec le violet / lavande admin).
abstract final class ValidationModalTheme {
/// Violet pastel foncé (proche des cartes admin, ex. `0xFF6D4EA1`).
static const Color primaryActionBackground = Color(0xFF6D4EA1);
static const Color primaryActionForeground = Colors.white;
static ButtonStyle get primaryElevatedStyle {
return ElevatedButton.styleFrom(
backgroundColor: primaryActionBackground,
foregroundColor: primaryActionForeground,
disabledBackgroundColor: primaryActionBackground.withOpacity(0.45),
disabledForegroundColor: primaryActionForeground.withOpacity(0.7),
);
}
}
@@ -1,137 +0,0 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Page « Motifs du refus » : champ libre + Annuler (ferme la modale), Précédent (retour au choix Valider/Refuser), Envoyer. Ticket #107.
class ValidationRefusForm extends StatefulWidget {
/// Ferme la modale (abandon du flux).
final VoidCallback onCancel;
/// Retour à l’étape précédente du wizard (écran avec Valider / Refuser).
final VoidCallback onPrevious;
final ValueChanged<String?> onSubmit;
/// Pendant l'appel API : désactive les actions (ticket #110).
final bool isSubmitting;
const ValidationRefusForm({
super.key,
required this.onCancel,
required this.onPrevious,
required this.onSubmit,
this.isSubmitting = false,
});
@override
State<ValidationRefusForm> createState() => _ValidationRefusFormState();
}
class _ValidationRefusFormState extends State<ValidationRefusForm> {
final _controller = TextEditingController();
final _formKey = GlobalKey<FormState>();
static const int _minLength = 20;
String? _validateMotifs(String? value) {
final t = value?.trim() ?? '';
if (t.isEmpty) return 'Les motifs du refus sont obligatoires.';
if (t.length < _minLength) {
return 'Veuillez indiquer au moins $_minLength caractères (${t.length}/$_minLength).';
}
return null;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Indiquez les motifs du refus',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Container(
constraints: BoxConstraints.tight(Size(constraints.maxWidth, constraints.maxHeight)),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.grey.shade400),
),
child: TextFormField(
controller: _controller,
readOnly: widget.isSubmitting,
maxLines: null,
minLines: 1,
validator: _validateMotifs,
decoration: InputDecoration(
hintText: 'Saisissez les raisons du refus (minimum $_minLength caractères)',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
alignLabelWithHint: true,
filled: false,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
);
},
),
),
const SizedBox(height: 24),
Row(
children: [
TextButton(
onPressed: widget.isSubmitting ? null : widget.onCancel,
child: const Text('Annuler'),
),
const Spacer(),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: widget.isSubmitting ? null : widget.onPrevious,
child: const Text('Précédent'),
),
const SizedBox(width: 8),
if (widget.isSubmitting)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 12),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () {
if (_formKey.currentState!.validate()) {
widget.onSubmit(_controller.text.trim());
}
},
child: const Text('Envoyer'),
),
],
),
],
),
],
),
);
}
}
@@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'validation_modal_theme.dart';
/// Affiche une confirmation avant dappeler lAPI de validation du dossier.
/// Retourne `true` si lutilisateur confirme.
Future<bool> showValidationValiderConfirmDialog(
BuildContext context, {
required String body,
}) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Confirmer la validation'),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Annuler'),
),
ElevatedButton(
style: ValidationModalTheme.primaryElevatedStyle,
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Confirmer'),
),
],
);
},
);
return result == true;
}