Back: PATCH /parents/:id/fiche, attach/detach enfant, GET /enfants enrichi. Front: modale parent éditable, onglet Enfants, fiche enfant, UserService. Couvre doc 28 §6.1–6.2 ; tickets liés #115 #116 #130 #131 #137 #138. Hors scope: fiche AM (#131), création admin (#129). Co-authored-by: Cursor <cursoragent@cursor.com>
290 lines
9.4 KiB
Dart
290 lines
9.4 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';
|
|
|
|
/// 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 _statuses = ['a_naitre', 'actif', 'scolarise'];
|
|
static const _genders = ['H', 'F', 'Autre'];
|
|
|
|
@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 = _statuses.contains(e.status) ? e.status : 'actif';
|
|
_gender = _genders.contains(e.gender) ? e.gender! : 'H';
|
|
_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();
|
|
}
|
|
|
|
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: ', ''))),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final parents = widget.enfant.parentLinks
|
|
.map((l) => l.parentName ?? l.parentId)
|
|
.where((s) => s.isNotEmpty)
|
|
.join(', ');
|
|
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 560, maxHeight: 640),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
widget.enfant.fullName,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
],
|
|
),
|
|
if (parents.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text('Responsables : $parents', style: const TextStyle(color: Colors.black54)),
|
|
),
|
|
const Divider(),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
_rowField('Prénom', TextField(controller: _prenomCtrl, decoration: _decoration())),
|
|
_rowField('Nom', TextField(controller: _nomCtrl, decoration: _decoration())),
|
|
_rowDropdown(
|
|
'Statut',
|
|
_status,
|
|
_statuses.map((s) => MapEntry(s, _statusLabel(s))).toList(),
|
|
(v) => setState(() {
|
|
_status = v;
|
|
_dirty = true;
|
|
}),
|
|
),
|
|
_rowDropdown(
|
|
'Genre',
|
|
_gender,
|
|
_genders.map((g) => MapEntry(g, g)).toList(),
|
|
(v) => setState(() {
|
|
_gender = v;
|
|
_dirty = true;
|
|
}),
|
|
),
|
|
_rowField(
|
|
'Date naissance',
|
|
TextField(
|
|
controller: _birthCtrl,
|
|
decoration: _decoration(hint: 'AAAA-MM-JJ'),
|
|
),
|
|
),
|
|
_rowField(
|
|
'Date prévue',
|
|
TextField(
|
|
controller: _dueCtrl,
|
|
decoration: _decoration(hint: 'AAAA-MM-JJ'),
|
|
),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Consentement photo'),
|
|
value: _consentPhoto,
|
|
onChanged: (v) => setState(() {
|
|
_consentPhoto = v;
|
|
_dirty = true;
|
|
}),
|
|
),
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: const Text('Naissance multiple'),
|
|
value: _isMultiple,
|
|
onChanged: (v) => setState(() {
|
|
_isMultiple = v;
|
|
_dirty = true;
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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);
|
|
}
|
|
|
|
Widget _rowField(String label, Widget field) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
),
|
|
),
|
|
Expanded(child: field),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _rowDropdown(
|
|
String label,
|
|
String value,
|
|
List<MapEntry<String, String>> items,
|
|
ValueChanged<String> onChanged,
|
|
) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 120,
|
|
child: Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
),
|
|
Expanded(
|
|
child: DropdownButtonFormField<String>(
|
|
value: items.any((e) => e.key == value) ? value : items.first.key,
|
|
decoration: _decoration(),
|
|
items: items
|
|
.map((e) => DropdownMenuItem(value: e.key, child: Text(e.value)))
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v != null) onChanged(v);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _statusLabel(String status) {
|
|
switch (status) {
|
|
case 'a_naitre':
|
|
return 'À naître';
|
|
case 'actif':
|
|
return 'Actif';
|
|
case 'scolarise':
|
|
return 'Scolarisé';
|
|
default:
|
|
return status;
|
|
}
|
|
}
|
|
}
|