feat(#132): création enfant admin — modale + sélection famille.
Onglet Enfants « Ajouter » ouvre la fiche en mode création ; bloc Famille/dossier ; client POST /enfants avec parent_user_id (contrat back à livrer). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,22 +10,32 @@ import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_select_famille_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
|
||||
|
||||
/// Fiche enfant consultation / édition (ticket #138) — format paysage comme fiche AM.
|
||||
/// Fiche enfant consultation / édition (#138) ou création (#132).
|
||||
class AdminChildDetailModal extends StatefulWidget {
|
||||
final EnfantAdminModel enfant;
|
||||
final EnfantAdminModel? enfant;
|
||||
final VoidCallback? onSaved;
|
||||
final VoidCallback? onDeleted;
|
||||
final bool isCreating;
|
||||
|
||||
const AdminChildDetailModal({
|
||||
super.key,
|
||||
required this.enfant,
|
||||
required EnfantAdminModel this.enfant,
|
||||
this.onSaved,
|
||||
this.onDeleted,
|
||||
});
|
||||
}) : isCreating = false;
|
||||
|
||||
/// Création depuis l'onglet Enfants (#132).
|
||||
const AdminChildDetailModal.create({
|
||||
super.key,
|
||||
this.onSaved,
|
||||
}) : enfant = null,
|
||||
onDeleted = null,
|
||||
isCreating = true;
|
||||
|
||||
@override
|
||||
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
|
||||
@@ -49,6 +59,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
|
||||
String? _baselineAmUserId;
|
||||
|
||||
/// Famille choisie en mode création (#132).
|
||||
AdminFamilleFoyer? _selectedFamily;
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
static const double _mainRowHeight = 380;
|
||||
static const double _placementHeight = 96;
|
||||
@@ -74,6 +87,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
_isUnborn ? 'Date prévisionnelle' : 'Date de naissance';
|
||||
|
||||
bool get _canDelete =>
|
||||
!widget.isCreating &&
|
||||
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
|
||||
|
||||
bool get _busy => _saving || _deleting;
|
||||
@@ -82,36 +96,50 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final e = widget.enfant;
|
||||
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
|
||||
_nomCtrl = TextEditingController(text: e.lastName ?? '');
|
||||
_prenomCtrl = TextEditingController(text: e?.firstName ?? '');
|
||||
_nomCtrl = TextEditingController(text: e?.lastName ?? '');
|
||||
_birthCtrl = TextEditingController(
|
||||
text: formatIsoDateFr(e.birthDate, ifEmpty: ''),
|
||||
text: formatIsoDateFr(e?.birthDate, ifEmpty: ''),
|
||||
);
|
||||
_dueCtrl = TextEditingController(
|
||||
text: formatIsoDateFr(e.dueDate, ifEmpty: ''),
|
||||
text: formatIsoDateFr(e?.dueDate, ifEmpty: ''),
|
||||
);
|
||||
_status = normalizeEnfantStatus(e.status);
|
||||
_status = normalizeEnfantStatus(e?.status);
|
||||
if (!enfantStatusValues.contains(_status)) {
|
||||
_status = 'sans_garde';
|
||||
}
|
||||
_gender = _normalizeGender(e.gender, allowUnknown: _isUnborn);
|
||||
_consentPhoto = e.consentPhoto;
|
||||
_isMultiple = e.isMultiple;
|
||||
_gender = _normalizeGender(e?.gender, allowUnknown: _isUnborn);
|
||||
_consentPhoto = e?.consentPhoto ?? false;
|
||||
_isMultiple = e?.isMultiple ?? false;
|
||||
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
|
||||
c.addListener(_markDirty);
|
||||
}
|
||||
_prenomCtrl.addListener(_onNameChanged);
|
||||
_nomCtrl.addListener(_onNameChanged);
|
||||
_loadCurrentUserRole();
|
||||
_loadLinkedAm();
|
||||
if (widget.isCreating) {
|
||||
_loadingAm = false;
|
||||
_dirty = true;
|
||||
} else {
|
||||
_loadLinkedAm();
|
||||
}
|
||||
}
|
||||
|
||||
void _onNameChanged() => setState(() {});
|
||||
|
||||
Future<void> _loadLinkedAm() async {
|
||||
if (widget.isCreating) {
|
||||
setState(() => _loadingAm = false);
|
||||
return;
|
||||
}
|
||||
final enfantId = widget.enfant?.id;
|
||||
if (enfantId == null || enfantId.isEmpty) {
|
||||
setState(() => _loadingAm = false);
|
||||
return;
|
||||
}
|
||||
setState(() => _loadingAm = true);
|
||||
try {
|
||||
final am = await UserService.findAmForEnfant(widget.enfant.id);
|
||||
final am = await UserService.findAmForEnfant(enfantId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_linkedAm = am;
|
||||
@@ -168,18 +196,25 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
String? _dateToIso(String text) => parseFrDateToIso(text);
|
||||
|
||||
String _headerTitle() {
|
||||
if (widget.isCreating) {
|
||||
final fn = _prenomCtrl.text.trim();
|
||||
final ln = _nomCtrl.text.trim();
|
||||
if (fn.isEmpty && ln.isEmpty) return 'Nouvel enfant';
|
||||
return '$fn $ln'.trim();
|
||||
}
|
||||
final fn = _prenomCtrl.text.trim();
|
||||
final ln = _nomCtrl.text.trim();
|
||||
if (fn.isEmpty && ln.isEmpty) {
|
||||
final fallback = widget.enfant.fullName.trim();
|
||||
final fallback = widget.enfant?.fullName.trim() ?? '';
|
||||
return fallback.isNotEmpty ? fallback : 'Enfant';
|
||||
}
|
||||
return '$fn $ln'.trim();
|
||||
}
|
||||
|
||||
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
|
||||
.where((l) => l.parentId.trim().isNotEmpty)
|
||||
.toList();
|
||||
List<EnfantParentLink> get _parentLinks =>
|
||||
(widget.enfant?.parentLinks ?? const <EnfantParentLink>[])
|
||||
.where((l) => l.parentId.trim().isNotEmpty)
|
||||
.toList();
|
||||
|
||||
Future<void> _openParent(EnfantParentLink link) async {
|
||||
if (_busy) return;
|
||||
@@ -205,6 +240,17 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
|
||||
Widget? _parentsSubtitle() {
|
||||
if (widget.isCreating) {
|
||||
final family = _selectedFamily;
|
||||
if (family == null) return null;
|
||||
return Text(
|
||||
family.parentNames.isNotEmpty
|
||||
? 'Famille : ${family.parentNames.join(', ')}'
|
||||
: family.displayTitle,
|
||||
style: const TextStyle(fontSize: 13, color: Colors.black54),
|
||||
);
|
||||
}
|
||||
|
||||
final links = _parentLinks;
|
||||
if (links.isEmpty) return null;
|
||||
|
||||
@@ -252,12 +298,21 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_dirty) return;
|
||||
|
||||
if (widget.isCreating) {
|
||||
await _saveCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
final enfantId = widget.enfant?.id;
|
||||
if (enfantId == null || enfantId.isEmpty) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await _syncAmPlacement();
|
||||
|
||||
await UserService.updateEnfant(
|
||||
enfantId: widget.enfant.id,
|
||||
enfantId: enfantId,
|
||||
body: {
|
||||
'first_name': _prenomCtrl.text.trim(),
|
||||
'last_name': _nomCtrl.text.trim(),
|
||||
@@ -291,21 +346,86 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveCreate() async {
|
||||
final family = _selectedFamily;
|
||||
if (family == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Choisissez une famille / un dossier avant d\'enregistrer'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final prenom = _prenomCtrl.text.trim();
|
||||
final nom = _nomCtrl.text.trim();
|
||||
if (prenom.isEmpty || nom.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Prénom et nom sont obligatoires')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_isUnborn && _dateToIso(_birthCtrl.text) == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Date de naissance invalide ou manquante')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await UserService.createEnfant(
|
||||
parentUserId: family.pivotParentUserId,
|
||||
body: {
|
||||
'first_name': prenom,
|
||||
'last_name': nom,
|
||||
'status': _status,
|
||||
'gender': _gender,
|
||||
if (_isUnborn)
|
||||
if (_dateToIso(_dueCtrl.text) != null)
|
||||
'due_date': _dateToIso(_dueCtrl.text)
|
||||
else if (_dateToIso(_birthCtrl.text) != null)
|
||||
'birth_date': _dateToIso(_birthCtrl.text),
|
||||
'consent_photo': _consentPhoto,
|
||||
'is_multiple': _isMultiple,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dirty = false;
|
||||
_saving = false;
|
||||
});
|
||||
widget.onSaved?.call();
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant créé')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
|
||||
Future<void> _syncAmPlacement() async {
|
||||
final enfantId = widget.enfant?.id;
|
||||
if (enfantId == null || enfantId.isEmpty) return;
|
||||
|
||||
final baselineId = _baselineAmUserId;
|
||||
final currentId = _linkedAm?.user.id;
|
||||
|
||||
if (baselineId != null && baselineId != currentId) {
|
||||
await UserService.detachEnfantFromAm(
|
||||
amUserId: baselineId,
|
||||
enfantId: widget.enfant.id,
|
||||
enfantId: enfantId,
|
||||
);
|
||||
}
|
||||
if (currentId != null && currentId != baselineId) {
|
||||
await UserService.attachEnfantToAm(
|
||||
amUserId: currentId,
|
||||
enfantId: widget.enfant.id,
|
||||
enfantId: enfantId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -342,7 +462,9 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
|
||||
setState(() => _deleting = true);
|
||||
try {
|
||||
await UserService.deleteEnfant(widget.enfant.id);
|
||||
final id = widget.enfant?.id;
|
||||
if (id == null || id.isEmpty) return;
|
||||
await UserService.deleteEnfant(id);
|
||||
if (!mounted) return;
|
||||
widget.onDeleted?.call();
|
||||
widget.onSaved?.call();
|
||||
@@ -376,10 +498,12 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
|
||||
/// Recharge AM + statut après une modale externe (ex. fiche AM).
|
||||
Future<void> _reloadPlacementFromServer() async {
|
||||
final id = widget.enfant?.id;
|
||||
if (id == null || id.isEmpty) return;
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
UserService.findAmForEnfant(widget.enfant.id),
|
||||
UserService.getEnfant(widget.enfant.id),
|
||||
UserService.findAmForEnfant(id),
|
||||
UserService.getEnfant(id),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final am = results[0] as AssistanteMaternelleModel?;
|
||||
@@ -625,7 +749,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: AdminAmPhotoFrame(
|
||||
photoUrl: widget.enfant.photoUrl,
|
||||
photoUrl: widget.enfant?.photoUrl,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -893,8 +1017,10 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
);
|
||||
}
|
||||
|
||||
String get _placementTitle =>
|
||||
_isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
||||
String get _placementTitle {
|
||||
if (widget.isCreating) return 'Famille / dossier';
|
||||
return _isScolarise ? 'Scolarisation' : 'Assistante maternelle';
|
||||
}
|
||||
|
||||
Widget _placementBlock() {
|
||||
return Column(
|
||||
@@ -910,11 +1036,161 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_placementSection(),
|
||||
if (widget.isCreating) _familyPlacementSection() else _placementSection(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickFamily() async {
|
||||
if (_busy) return;
|
||||
final selected = await AdminSelectFamilleModal.show(
|
||||
context,
|
||||
title: 'Choisir une famille',
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedFamily = selected;
|
||||
_dirty = true;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _familyPlacementSection() {
|
||||
final family = _selectedFamily;
|
||||
if (family == null) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _busy ? null : _pickFamily,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
height: _placementHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.family_restroom,
|
||||
size: 30,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Aucune famille sélectionnée',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Cliquer pour choisir un dossier / foyer',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(Icons.add_circle_outline, color: Colors.grey.shade500),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: _busy ? null : _pickFamily,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
height: _placementHeight,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F5FC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFFD8CCE8)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDE5FA),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.family_restroom,
|
||||
size: 30,
|
||||
color: Color(0xFF6B3FA0),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
family.displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (family.parentNames.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
family.parentNames.join(', '),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.swap_horiz, color: Colors.grey.shade700),
|
||||
tooltip: 'Changer de famille',
|
||||
onPressed: _busy ? null : _pickFamily,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _placementSection() {
|
||||
if (!_showsAmPlacement) return _scolariseCard();
|
||||
|
||||
@@ -976,7 +1252,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||
: Text(
|
||||
widget.isCreating
|
||||
? 'Créer'
|
||||
: (_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user