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,462 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:p_tits_pas/models/enfant_admin_model.dart';
|
||||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||||
import 'package:p_tits_pas/models/parent_child_summary.dart';
|
||||
import 'package:p_tits_pas/models/parent_model.dart';
|
||||
import 'package:p_tits_pas/models/user.dart';
|
||||
import 'package:p_tits_pas/services/user_service.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_children_affiliation_panel.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
|
||||
import 'package:p_tits_pas/widgets/common/identity_block.dart';
|
||||
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
|
||||
|
||||
/// Fiche parent éditable (doc 28 §6.1, tickets #131 / #138).
|
||||
/// Shell et typo alignés sur [ValidationDossierModal] / wizards validation.
|
||||
class AdminParentEditModal extends StatefulWidget {
|
||||
final ParentModel parent;
|
||||
final VoidCallback? onSaved;
|
||||
|
||||
const AdminParentEditModal({
|
||||
super.key,
|
||||
required this.parent,
|
||||
this.onSaved,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AdminParentEditModal> createState() => _AdminParentEditModalState();
|
||||
}
|
||||
|
||||
class _AdminParentEditModalState extends State<AdminParentEditModal> {
|
||||
late final TextEditingController _nomCtrl;
|
||||
late final TextEditingController _prenomCtrl;
|
||||
late final TextEditingController _emailCtrl;
|
||||
late final TextEditingController _telCtrl;
|
||||
late final TextEditingController _adresseCtrl;
|
||||
late final TextEditingController _villeCtrl;
|
||||
late final TextEditingController _cpCtrl;
|
||||
|
||||
late String _statut;
|
||||
late List<ParentChildSummary> _children;
|
||||
AppUser? _coParent;
|
||||
late final ScrollController _childrenScrollCtrl;
|
||||
bool _saving = false;
|
||||
bool _dirty = false;
|
||||
|
||||
static const double _modalWidth = 930;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final u = widget.parent.user;
|
||||
_nomCtrl = TextEditingController(text: u.nom ?? '');
|
||||
_prenomCtrl = TextEditingController(text: u.prenom ?? '');
|
||||
_emailCtrl = TextEditingController(text: u.email);
|
||||
_telCtrl = TextEditingController(
|
||||
text: formatPhoneForDisplay(u.telephone ?? ''),
|
||||
);
|
||||
_adresseCtrl = TextEditingController(text: u.adresse ?? '');
|
||||
_villeCtrl = TextEditingController(text: u.ville ?? '');
|
||||
_cpCtrl = TextEditingController(text: u.codePostal ?? '');
|
||||
_statut = u.statut ?? 'en_attente';
|
||||
_coParent = widget.parent.coParent;
|
||||
_children = List.of(widget.parent.children);
|
||||
_childrenScrollCtrl = ScrollController();
|
||||
for (final c in [
|
||||
_nomCtrl,
|
||||
_prenomCtrl,
|
||||
_emailCtrl,
|
||||
_telCtrl,
|
||||
_adresseCtrl,
|
||||
_villeCtrl,
|
||||
_cpCtrl,
|
||||
]) {
|
||||
c.addListener(_markDirty);
|
||||
}
|
||||
_nomCtrl.addListener(_onNameFieldChanged);
|
||||
_prenomCtrl.addListener(_onNameFieldChanged);
|
||||
_reloadChildren();
|
||||
}
|
||||
|
||||
void _onNameFieldChanged() {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [
|
||||
_nomCtrl,
|
||||
_prenomCtrl,
|
||||
_emailCtrl,
|
||||
_telCtrl,
|
||||
_adresseCtrl,
|
||||
_villeCtrl,
|
||||
_cpCtrl,
|
||||
]) {
|
||||
c.dispose();
|
||||
}
|
||||
_childrenScrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _markDirty() {
|
||||
if (!_dirty) setState(() => _dirty = true);
|
||||
}
|
||||
|
||||
String _headerTitle() {
|
||||
final fn = _prenomCtrl.text.trim();
|
||||
final ln = _nomCtrl.text.trim();
|
||||
if (fn.isEmpty && ln.isEmpty) {
|
||||
final fallback = widget.parent.user.fullName.trim();
|
||||
return fallback.isNotEmpty ? fallback : 'Parent';
|
||||
}
|
||||
return '$fn $ln'.trim();
|
||||
}
|
||||
|
||||
String? _coParentSubtitle() {
|
||||
final name = _coParent?.fullName.trim() ?? '';
|
||||
if (name.isEmpty) return null;
|
||||
return 'Co-parent : $name';
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_dirty) return;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Confirmer'),
|
||||
content: const Text(
|
||||
'Enregistrer les modifications de la fiche parent ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('Sauvegarder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await UserService.updateParentFiche(
|
||||
parentUserId: widget.parent.user.id,
|
||||
body: {
|
||||
'nom': _nomCtrl.text.trim(),
|
||||
'prenom': _prenomCtrl.text.trim(),
|
||||
'email': _emailCtrl.text.trim(),
|
||||
'telephone': normalizePhone(_telCtrl.text),
|
||||
'adresse': _adresseCtrl.text.trim(),
|
||||
'ville': _villeCtrl.text.trim(),
|
||||
'code_postal': _cpCtrl.text.trim(),
|
||||
'statut': _statut,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_dirty = false;
|
||||
_saving = false;
|
||||
});
|
||||
widget.onSaved?.call();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Fiche parent enregistrée')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _saving = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openChild(ParentChildSummary child) async {
|
||||
try {
|
||||
final enfant = await UserService.getEnfant(child.id);
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AdminChildDetailModal(
|
||||
enfant: enfant,
|
||||
onSaved: _reloadChildren,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reloadChildren() async {
|
||||
try {
|
||||
final refreshed = await UserService.getParent(widget.parent.user.id);
|
||||
final all = await UserService.getEnfants();
|
||||
final byId = {for (final e in all) e.id: e};
|
||||
|
||||
final kids = refreshed.children.map((c) {
|
||||
final full = byId[c.id];
|
||||
if (full != null) return ParentChildSummary.fromEnfant(full);
|
||||
return c;
|
||||
}).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_children = kids;
|
||||
_coParent = refreshed.coParent;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _detachChild(ParentChildSummary child) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Détacher l\'enfant'),
|
||||
content: Text(
|
||||
'Retirer ${child.fullName} de la fiche de ce parent ?\n'
|
||||
'(L\'enfant ne sera pas supprimé.)',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
child: const Text('Détacher'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await UserService.detachEnfantFromParent(
|
||||
parentUserId: widget.parent.user.id,
|
||||
enfantId: child.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant détaché')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _attachChild() async {
|
||||
List<EnfantAdminModel> all;
|
||||
try {
|
||||
all = await UserService.getEnfants();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final linkedIds = _children.map((c) => c.id).toSet();
|
||||
final candidates = all.where((e) => !linkedIds.contains(e.id)).toList();
|
||||
if (candidates.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Aucun enfant disponible à rattacher')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final selected = await showDialog<EnfantAdminModel>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('Rattacher un enfant'),
|
||||
children: candidates
|
||||
.map(
|
||||
(e) => SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, e),
|
||||
child: Text(e.fullName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
if (selected == null || !mounted) return;
|
||||
|
||||
try {
|
||||
await UserService.attachEnfantToParent(
|
||||
parentUserId: widget.parent.user.id,
|
||||
enfantId: selected.id,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _reloadChildren();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Enfant rattaché')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _childrenPanel() {
|
||||
return AdminChildrenAffiliationPanel(
|
||||
children: _children,
|
||||
scrollController: _childrenScrollCtrl,
|
||||
onOpen: _openChild,
|
||||
onDetach: _detachChild,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _identityFields() {
|
||||
return IdentityBlock.editable(
|
||||
nomController: _nomCtrl,
|
||||
prenomController: _prenomCtrl,
|
||||
telephoneController: _telCtrl,
|
||||
emailController: _emailCtrl,
|
||||
adresseController: _adresseCtrl,
|
||||
codePostalController: _cpCtrl,
|
||||
villeController: _villeCtrl,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
return Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: _attachChild,
|
||||
icon: const Icon(Icons.link, size: 18),
|
||||
label: const Text('Rattacher un enfant'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
style: ValidationModalTheme.primaryElevatedStyle,
|
||||
onPressed: !_dirty || _saving ? null : _save,
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(_dirty ? 'Sauvegarder' : 'Aucune modification'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _modalWidth),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 4, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_headerTitle(),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (_coParentSubtitle() != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_coParentSubtitle()!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: AdminStatusCapsule(
|
||||
statut: _statut,
|
||||
onChanged: (v) => setState(() {
|
||||
_statut = v;
|
||||
_dirty = true;
|
||||
}),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 40,
|
||||
minHeight: 40,
|
||||
),
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: 'Fermer',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_identityFields(),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Nombre d\'enfants : ${_children.length}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_childrenPanel(),
|
||||
const SizedBox(height: 12),
|
||||
_buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user