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>
412 lines
14 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|