petitspas/frontend/lib/widgets/admin/common/admin_child_detail_modal.dart
Julien Martin 003fe6b762 feat(#131): fiches parent/AM éditable, placement AM↔enfant, statuts garde/sans_garde
Squash merge develop → master.

- Fiche parent éditable (co-parent, PATCH fiche, GET /parents)
- Fiche AM 3 onglets (PATCH fiche, rattacher/détacher enfants)
- Table enfants_assistantes_maternelles + enum garde/sans_garde
- Migration SQL + BDD.sql canonique
- Correctifs recette : @Get() parents, DTO fiche AM, fix NIR

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 22:49:57 +02:00

412 lines
14 KiB
Dart

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';
/// Fiche enfant consultation / édition (ticket #138).
class AdminChildDetailModal extends StatefulWidget {
final EnfantAdminModel enfant;
final VoidCallback? onSaved;
const AdminChildDetailModal({
super.key,
required this.enfant,
this.onSaved,
});
@override
State<AdminChildDetailModal> createState() => _AdminChildDetailModalState();
}
class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
late final TextEditingController _prenomCtrl;
late final TextEditingController _nomCtrl;
late final TextEditingController _birthCtrl;
late final TextEditingController _dueCtrl;
late String _status;
late String _gender;
late bool _consentPhoto;
late bool _isMultiple;
bool _dirty = false;
bool _saving = false;
static const _genders = ['H', 'F', 'Autre'];
static const _labelStyle = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.black87,
);
@override
void initState() {
super.initState();
final e = widget.enfant;
_prenomCtrl = TextEditingController(text: e.firstName ?? '');
_nomCtrl = TextEditingController(text: e.lastName ?? '');
_birthCtrl = TextEditingController(text: e.birthDate ?? '');
_dueCtrl = TextEditingController(text: e.dueDate ?? '');
_status = normalizeEnfantStatus(e.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_gender = _normalizeGender(e.gender);
_consentPhoto = e.consentPhoto;
_isMultiple = e.isMultiple;
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.addListener(_markDirty);
}
}
@override
void dispose() {
for (final c in [_prenomCtrl, _nomCtrl, _birthCtrl, _dueCtrl]) {
c.dispose();
}
super.dispose();
}
static String _normalizeGender(String? raw) {
final g = (raw ?? '').trim();
if (g == 'M') return 'H';
if (_genders.contains(g)) return g;
return 'H';
}
void _markDirty() {
if (!_dirty) setState(() => _dirty = true);
}
Future<void> _save() async {
if (!_dirty) return;
setState(() => _saving = true);
try {
await UserService.updateEnfant(
enfantId: widget.enfant.id,
body: {
'first_name': _prenomCtrl.text.trim(),
'last_name': _nomCtrl.text.trim(),
'status': _status,
'gender': _gender,
if (_birthCtrl.text.trim().isNotEmpty)
'birth_date': _birthCtrl.text.trim(),
if (_dueCtrl.text.trim().isNotEmpty) 'due_date': _dueCtrl.text.trim(),
'consent_photo': _consentPhoto,
'is_multiple': _isMultiple,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche enfant enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
String _parentsLine() {
final names = widget.enfant.parentLinks
.map((l) {
final n = (l.parentName ?? '').trim();
return n.isNotEmpty ? n : 'Parent rattaché';
})
.where((s) => s.isNotEmpty)
.toList();
if (names.isEmpty) return '';
return names.join(', ');
}
@override
Widget build(BuildContext context) {
final parents = _parentsLine();
final showDueDate = _status == 'a_naitre';
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: SizedBox(
width: 480,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 640),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.enfant.fullName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (parents.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Responsables : $parents',
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
],
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Fermer',
),
],
),
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledField(
'Prénom',
TextField(
controller: _prenomCtrl,
decoration: _decoration(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledField(
'Nom',
TextField(
controller: _nomCtrl,
decoration: _decoration(),
),
),
),
],
),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledDropdown(
'Statut',
_status,
enfantStatusValues
.map(
(s) => MapEntry(
s,
enfantStatusLabel(s, gender: _gender),
),
)
.toList(),
(v) => setState(() {
_status = v;
_dirty = true;
}),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledDropdown(
'Genre',
_gender,
_genders
.map((g) => MapEntry(g, _genderLabel(g)))
.toList(),
(v) => setState(() {
_gender = v;
_dirty = true;
}),
),
),
],
),
const SizedBox(height: 12),
if (showDueDate)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _labeledField(
'Date de naissance',
TextField(
controller: _birthCtrl,
decoration:
_decoration(hint: 'AAAA-MM-JJ'),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _labeledField(
'Date prévue',
TextField(
controller: _dueCtrl,
decoration:
_decoration(hint: 'AAAA-MM-JJ'),
),
),
),
],
)
else
_labeledField(
'Date de naissance',
TextField(
controller: _birthCtrl,
decoration: _decoration(hint: 'AAAA-MM-JJ'),
),
),
const SizedBox(height: 16),
_switchRow(
'Consentement photo',
_consentPhoto,
(v) => setState(() {
_consentPhoto = v;
_dirty = true;
}),
),
_switchRow(
'Naissance multiple',
_isMultiple,
(v) => setState(() {
_isMultiple = v;
_dirty = true;
}),
),
],
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: !_dirty || _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Sauvegarder'),
),
],
),
],
),
),
),
),
);
}
InputDecoration _decoration({String? hint}) {
return InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
hintText: hint,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
);
}
Widget _labeledField(String label, Widget field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: _labelStyle),
const SizedBox(height: 4),
field,
],
);
}
Widget _labeledDropdown(
String label,
String value,
List<MapEntry<String, String>> items,
ValueChanged<String> onChanged,
) {
final safeValue =
items.any((e) => e.key == value) ? value : items.first.key;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: _labelStyle),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
value: safeValue,
isExpanded: true,
decoration: _decoration(),
items: items
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (v) {
if (v != null) onChanged(v);
},
),
],
);
}
Widget _switchRow(String label, bool value, ValueChanged<bool> onChanged) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(child: Text(label, style: _labelStyle)),
Switch(
value: value,
onChanged: onChanged,
),
],
),
);
}
String _genderLabel(String gender) {
switch (gender) {
case 'H':
return 'Garçon';
case 'F':
return 'Fille';
case 'Autre':
return 'Autre';
default:
return gender;
}
}
}