refactor(#155): option C — widgets partagés sans préfixe Admin.
Déplace les composants Admin* du dashboard partagé vers widgets/dashboard/ (ChildDetailModal, AmEditModal, UserCard, Select*, …). Conserve AdminManagementWidget et screens/administrateurs. Rename mécanique, aucun changement de comportement. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@ 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/dashboard/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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,8 +14,8 @@ import 'package:p_tits_pas/utils/name_format_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 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||
@@ -221,32 +221,32 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
|
||||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||||
|
||||
List<AdminDetailField> _photoProFields(DossierAM d) {
|
||||
List<DetailField> _photoProFields(DossierAM d) {
|
||||
final u = d.user;
|
||||
return [
|
||||
AdminDetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||
AdminDetailField(
|
||||
DetailField(label: 'NIR', value: _formatNirForDisplay(d.nir)),
|
||||
DetailField(
|
||||
label: 'Date de naissance',
|
||||
value: formatIsoDateFr(u.dateNaissance),
|
||||
),
|
||||
AdminDetailField(
|
||||
DetailField(
|
||||
label: 'Ville de naissance',
|
||||
value: _v(u.lieuNaissanceVille),
|
||||
),
|
||||
AdminDetailField(
|
||||
DetailField(
|
||||
label: 'Pays de naissance',
|
||||
value: _v(u.lieuNaissancePays),
|
||||
),
|
||||
AdminDetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||
AdminDetailField(
|
||||
DetailField(label: 'N° Agrément', value: _v(d.numeroAgrement)),
|
||||
DetailField(
|
||||
label: 'Date d’agrément',
|
||||
value: formatIsoDateFr(d.dateAgrement),
|
||||
),
|
||||
AdminDetailField(
|
||||
DetailField(
|
||||
label: 'Capacité max (enfants)',
|
||||
value: d.nbMaxEnfants != null ? d.nbMaxEnfants.toString() : '–',
|
||||
),
|
||||
AdminDetailField(
|
||||
DetailField(
|
||||
label: 'Places disponibles',
|
||||
value: d.placesDisponibles != null
|
||||
? d.placesDisponibles.toString()
|
||||
@@ -741,14 +741,14 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
|
||||
Widget _buildStep1() {
|
||||
// Modale calée sur les TF ; photo étirée sur toute la hauteur utile
|
||||
// (largeur = [AdminAmPhotoFrame.columnWidthForHeight], cadre inclus).
|
||||
// (largeur = [AmPhotoFrame.columnWidthForHeight], cadre inclus).
|
||||
return LayoutBuilder(
|
||||
builder: (context, c) {
|
||||
final maxRowW = c.maxWidth;
|
||||
final maxRowH = c.maxHeight.clamp(0.0, double.infinity);
|
||||
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
|
||||
.clamp(0.0, double.infinity);
|
||||
var photoW = AdminAmPhotoFrame.columnWidthForHeight(maxRowH)
|
||||
var photoW = AmPhotoFrame.columnWidthForHeight(maxRowH)
|
||||
.clamp(_photoColumnMinWidth, 360.0);
|
||||
if (photoW > maxPhotoW) photoW = maxPhotoW;
|
||||
|
||||
@@ -762,7 +762,7 @@ class _AmDossierWizardState extends State<AmDossierWizard> {
|
||||
|
||||
final Widget photo;
|
||||
if (_isCreate) {
|
||||
photo = AdminAmPhotoFrame(
|
||||
photo = AmPhotoFrame(
|
||||
imageBytes: _photoBytes,
|
||||
onTap: _pickPhoto,
|
||||
onClear: _photoBytes != null ? _clearPhoto : null,
|
||||
|
||||
@@ -5,8 +5,8 @@ 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/dashboard/am_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/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';
|
||||
|
||||
@@ -130,7 +130,7 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
itemBuilder: (context, index) {
|
||||
final assistante = filteredAssistantes[index];
|
||||
final vigilance = amPlacesVigilanceMessage(assistante);
|
||||
return AdminUserCard(
|
||||
return UserCard(
|
||||
title: assistante.user.fullName,
|
||||
avatarUrl: assistante.user.photoUrl,
|
||||
fallbackIcon: Icons.face,
|
||||
@@ -161,7 +161,7 @@ class _AssistanteMaternelleManagementWidgetState
|
||||
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AdminAmEditModal(
|
||||
builder: (context) => AmEditModal(
|
||||
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 : l’onglet 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 l’onglet 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 à l’enfant (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 l’icô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,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_list_state.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/user_list_state.dart';
|
||||
|
||||
class UserList extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
@@ -28,7 +28,7 @@ class UserList extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AdminListState(
|
||||
UserListState(
|
||||
isLoading: isLoading,
|
||||
error: error,
|
||||
isEmpty: isEmpty,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import 'package:p_tits_pas/widgets/dashboard/detail_modal.dart';
|
||||
|
||||
/// Réglages des formulaires validation / wizard AM — **jouer sur ces 3 leviers**.
|
||||
class ValidationFormMetrics {
|
||||
@@ -60,7 +60,7 @@ class ValidationFormMetrics {
|
||||
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;
|
||||
final List<DetailField> 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;
|
||||
|
||||
@@ -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 d’onglets.
|
||||
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,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/user_card.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/suppression_confirm_dialog.dart';
|
||||
|
||||
/// Carte dossier unifiée (#153) — fond neutre, accent couleur sur l’icône.
|
||||
@@ -54,7 +54,7 @@ class DossierListCard extends StatelessWidget {
|
||||
if (!emptyKids && countLabel != null) countLabel,
|
||||
];
|
||||
|
||||
return AdminUserCard(
|
||||
return UserCard(
|
||||
title: num,
|
||||
subtitleLines: subtitle,
|
||||
avatarUrl: !isFamille && avatar.isNotEmpty ? avatar : null,
|
||||
|
||||
@@ -5,8 +5,8 @@ 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/dashboard/child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/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';
|
||||
|
||||
@@ -68,7 +68,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
Future<void> _openEnfant(EnfantAdminModel enfant) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AdminChildDetailModal(
|
||||
builder: (ctx) => ChildDetailModal(
|
||||
enfant: enfant,
|
||||
onSaved: _loadEnfants,
|
||||
onDeleted: _loadEnfants,
|
||||
@@ -224,7 +224,7 @@ class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final enfant = filtered[index];
|
||||
return AdminEnfantUserCard.fromEnfant(
|
||||
return EnfantUserCard.fromEnfant(
|
||||
enfant,
|
||||
onCardTap: () => _openEnfant(enfant),
|
||||
actions: [
|
||||
|
||||
@@ -4,7 +4,7 @@ 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/dashboard/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';
|
||||
|
||||
@@ -141,7 +141,7 @@ class _GestionnaireManagementWidgetState
|
||||
final user = filteredGestionnaires[index];
|
||||
final isSelf =
|
||||
_currentUserId != null && _currentUserId == user.id;
|
||||
return AdminUserCard(
|
||||
return UserCard(
|
||||
title: user.fullName,
|
||||
fallbackIcon: Icons.assignment_ind_outlined,
|
||||
avatarUrl: user.photoUrl,
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:p_tits_pas/utils/enfant_status_utils.dart';
|
||||
import 'package:p_tits_pas/utils/name_format_utils.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/utils/postal_utils.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_refus_form.dart';
|
||||
@@ -860,7 +860,7 @@ class _ParentDossierWizardState extends State<ParentDossierWizard> {
|
||||
child: SizedBox(
|
||||
width: pw,
|
||||
height: ph,
|
||||
child: AdminAmPhotoFrame(
|
||||
child: AmPhotoFrame(
|
||||
photoUrl: child.photoBytes == null
|
||||
? child.existingPhotoUrl
|
||||
: null,
|
||||
|
||||
@@ -4,8 +4,8 @@ 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/dashboard/parent_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/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';
|
||||
|
||||
@@ -146,7 +146,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
itemCount: filteredParents.length,
|
||||
itemBuilder: (context, index) {
|
||||
final parent = filteredParents[index];
|
||||
return AdminUserCard(
|
||||
return UserCard(
|
||||
title: parent.user.fullName,
|
||||
fallbackIcon: Icons.supervisor_account_outlined,
|
||||
avatarUrl: parent.user.photoUrl,
|
||||
@@ -189,7 +189,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
|
||||
void _openParentDetails(ParentModel parent) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AdminParentEditModal(
|
||||
builder: (context) => ParentEditModal(
|
||||
parent: parent,
|
||||
onSaved: _loadParents,
|
||||
),
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create
|
||||
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/dashboard/child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/dashboard/user_management_sub_bar.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';
|
||||
@@ -348,7 +348,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AdminChildDetailModal.create(
|
||||
return ChildDetailModal.create(
|
||||
onSaved: () {
|
||||
if (!mounted) return;
|
||||
setState(() => _enfantRefreshTick++);
|
||||
|
||||
Reference in New Issue
Block a user