feat(#140): dashboard admin ch.6 — fiche parent, enfants, affiliation
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>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user