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>
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user