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:
2026-07-11 22:49:57 +02:00
co-authored by Cursor
parent b99745e0fe
commit 003fe6b762
69 changed files with 6290 additions and 417 deletions
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@@ -77,10 +77,12 @@ class _AssistanteMaternelleManagementWidgetState
itemCount: filteredAssistantes.length,
itemBuilder: (context, index) {
final assistante = filteredAssistantes[index];
final vigilance = amPlacesVigilanceMessage(assistante);
return AdminUserCard(
title: assistante.user.fullName,
avatarUrl: assistante.user.photoUrl,
fallbackIcon: Icons.face,
vigilanceTooltip: vigilance,
subtitleLines: [
assistante.user.email,
'Zone : ${assistante.residenceCity ?? 'N/A'} | Capacité : ${assistante.maxChildren ?? 0}',
@@ -102,55 +104,10 @@ class _AssistanteMaternelleManagementWidgetState
void _openAssistanteDetails(AssistanteMaternelleModel assistante) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: assistante.user.fullName.isEmpty
? 'Assistante maternelle'
: assistante.user.fullName,
subtitle: assistante.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(assistante.user.id)),
AdminDetailField(
label: 'Numero agrement',
value: _v(assistante.approvalNumber),
),
AdminDetailField(
label: 'Ville residence',
value: _v(assistante.residenceCity),
),
AdminDetailField(
label: 'Capacite max',
value: assistante.maxChildren?.toString() ?? '-',
),
AdminDetailField(
label: 'Places disponibles',
value: assistante.placesAvailable?.toString() ?? '-',
),
AdminDetailField(
label: 'Telephone',
value: _v(assistante.user.telephone) != '' ? formatPhoneForDisplay(_v(assistante.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(assistante.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(assistante.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(assistante.user.codePostal),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
builder: (context) => AdminAmEditModal(
assistante: assistante,
onSaved: _loadAssistantes,
),
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}
@@ -0,0 +1,301 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Grille 2×2 des places d'accueil AM (max 4, limitée à [capacity]).
class AdminAmChildrenCapacityGrid extends StatelessWidget {
static const int _gridSlots = 4;
static const double _slotHeight = 44;
static const double _gridPadding = 10;
static const double _gridGap = 8;
static const double _borderWidth = 1;
static const double fixedHeight = _borderWidth * 2 +
_gridPadding * 2 +
_slotHeight * 2 +
_gridGap;
final List<ParentChildSummary> children;
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
const AdminAmChildrenCapacityGrid({
super.key,
required this.children,
required this.capacity,
required this.onOpen,
required this.onDetach,
});
@override
Widget build(BuildContext context) {
final maxSlots = capacity.clamp(0, _gridSlots);
return Container(
height: fixedHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300, width: _borderWidth),
),
padding: const EdgeInsets.all(_gridPadding),
child: Column(
children: [
_buildRow(0, 1, maxSlots),
const SizedBox(height: _gridGap),
_buildRow(2, 3, maxSlots),
],
),
);
}
Widget _buildRow(int leftIndex, int rightIndex, int maxSlots) {
return Row(
children: [
Expanded(child: _buildSlot(leftIndex, maxSlots)),
const SizedBox(width: _gridGap),
Expanded(child: _buildSlot(rightIndex, maxSlots)),
],
);
}
Widget _buildSlot(int index, int maxSlots) {
return SizedBox(
height: _slotHeight,
child: _buildSlotContent(index, maxSlots),
);
}
Widget _buildSlotContent(int index, int maxSlots) {
if (index < children.length) {
return _OccupiedSlot(
child: children[index],
overCapacity: index >= maxSlots,
onOpen: () => onOpen(children[index]),
onDetach: () => onDetach(children[index]),
);
}
if (index < maxSlots) {
return const _EmptySlot();
}
return const _UnavailableSlot();
}
}
class _SlotShell extends StatelessWidget {
final Color backgroundColor;
final Color borderColor;
final Widget child;
const _SlotShell({
required this.backgroundColor,
required this.borderColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor),
),
child: child,
),
);
}
}
class _UnavailableSlot extends StatelessWidget {
const _UnavailableSlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade100,
borderColor: Colors.grey.shade200,
child: Center(
child: Icon(
Icons.block_outlined,
size: 20,
color: Colors.grey.shade400,
),
),
);
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
@override
Widget build(BuildContext context) {
return _SlotShell(
backgroundColor: Colors.grey.shade50,
borderColor: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
),
);
}
}
class _OccupiedSlot extends StatefulWidget {
final ParentChildSummary child;
final bool overCapacity;
final VoidCallback onOpen;
final VoidCallback onDetach;
const _OccupiedSlot({
required this.child,
required this.overCapacity,
required this.onOpen,
required this.onDetach,
});
@override
State<_OccupiedSlot> createState() => _OccupiedSlotState();
}
class _OccupiedSlotState extends State<_OccupiedSlot> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final age = formatChildAgeLabel(
birthDate: widget.child.birthDate,
dueDate: widget.child.dueDate,
status: widget.child.status,
);
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.child.photoUrl ?? '');
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: _SlotShell(
backgroundColor: widget.overCapacity
? Colors.red.shade50
: const Color(0xFFF8F5FC),
borderColor: widget.overCapacity
? Colors.red.shade300
: const Color(0xFFD8CCE8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildAvatar(avatarUrl),
const SizedBox(width: 8),
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Text(
widget.child.fullName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (age.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
age,
style: const TextStyle(
fontSize: 11,
color: Colors.black54,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 120),
opacity: _hovered ? 1 : 0,
child: IgnorePointer(
ignoring: !_hovered,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Voir / modifier',
icon: const Icon(Icons.visibility_outlined, size: 20),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onOpen,
),
IconButton(
tooltip: 'Détacher',
icon: Icon(
Icons.link_off,
size: 20,
color: Colors.orange.shade800,
),
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
onPressed: widget.onDetach,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: const Icon(Icons.child_care_outlined, size: 15, color: iconColor),
),
),
),
);
}
}
@@ -0,0 +1,841 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_children_capacity_grid.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_status_capsule.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Fiche AM éditable (ticket #131) — identité | fiche pro (photo) | enfants.
class AdminAmEditModal extends StatefulWidget {
final AssistanteMaternelleModel assistante;
final VoidCallback? onSaved;
const AdminAmEditModal({
super.key,
required this.assistante,
this.onSaved,
});
@override
State<AdminAmEditModal> createState() => _AdminAmEditModalState();
}
class _AdminAmEditModalState extends State<AdminAmEditModal>
with SingleTickerProviderStateMixin {
late final TabController _tabCtrl;
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 final TextEditingController _agrementCtrl;
late final TextEditingController _nirCtrl;
late final TextEditingController _dateNaissanceCtrl;
late final TextEditingController _lieuNaissanceVilleCtrl;
late final TextEditingController _lieuNaissancePaysCtrl;
late final TextEditingController _dateAgrementCtrl;
late final TextEditingController _capaciteCtrl;
late String _statut;
late bool _disponible;
late int? _placesAvailable;
late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds;
bool _saving = false;
bool _dirty = false;
static const double _modalWidth = 930;
static const double _photoProGap = 24;
static const double _proColumnMinWidth = 260;
static const double _photoColumnMinWidth = 160;
static const double _proTabHeight = 300;
static const List<int> _photoProRowLayout = [2, 2, 2];
/// Hauteur onglet enfants : champs + titre + grille 2×2 (+ alerte places si besoin).
double _childrenTabHeight() {
const capacityFields = 72.0;
const titleSection = 40.0;
const inconsistencyExtra = 46.0;
var h = capacityFields +
titleSection +
AdminAmChildrenCapacityGrid.fixedHeight;
if (_placesInconsistent()) h += inconsistencyExtra;
return h + 4;
}
double _tabViewHeight(int index) {
switch (index) {
case 1:
return _proTabHeight;
case 2:
return _childrenTabHeight();
default:
return 292;
}
}
@override
void initState() {
super.initState();
_tabCtrl = TabController(length: 3, vsync: this);
final u = widget.assistante.user;
final am = widget.assistante;
_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 ?? '');
_agrementCtrl = TextEditingController(text: am.approvalNumber ?? '');
_nirCtrl = TextEditingController(text: _formatNirDisplay());
_dateNaissanceCtrl = TextEditingController(
text: formatIsoDateFr(u.dateNaissance, ifEmpty: ''),
);
_lieuNaissanceVilleCtrl = TextEditingController(
text: u.lieuNaissanceVille ?? '',
);
_lieuNaissancePaysCtrl = TextEditingController(
text: u.lieuNaissancePays ?? '',
);
_dateAgrementCtrl = TextEditingController(
text: formatIsoDateFr(am.agreementDate, ifEmpty: ''),
);
_capaciteCtrl = TextEditingController(
text: am.maxChildren?.toString() ?? '',
);
_statut = u.statut ?? 'en_attente';
_disponible = am.available ?? true;
_placesAvailable = am.placesAvailable;
_children = List.of(am.children);
_baselineChildIds = _children.map((c) => c.id).toSet();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.addListener(_markDirty);
}
_capaciteCtrl.addListener(_onCapacityChanged);
_nomCtrl.addListener(_onNameFieldChanged);
_prenomCtrl.addListener(_onNameFieldChanged);
_tabCtrl.addListener(_onTabChanged);
_fetchChildrenFromServer();
}
bool _childrenChanged() {
final current = _children.map((c) => c.id).toSet();
return current.length != _baselineChildIds.length ||
!current.containsAll(_baselineChildIds);
}
void _syncPlacesAfterChildrenChange() {
final expected = _computedPlacesAvailable();
if (expected != null) _placesAvailable = expected;
}
void _onTabChanged() {
if (!_tabCtrl.indexIsChanging) setState(() {});
}
void _onNameFieldChanged() => setState(() {});
void _onCapacityChanged() => setState(() {});
@override
void dispose() {
_tabCtrl.dispose();
for (final c in [
_nomCtrl,
_prenomCtrl,
_emailCtrl,
_telCtrl,
_adresseCtrl,
_villeCtrl,
_cpCtrl,
_agrementCtrl,
_nirCtrl,
_dateNaissanceCtrl,
_lieuNaissanceVilleCtrl,
_lieuNaissancePaysCtrl,
_dateAgrementCtrl,
_capaciteCtrl,
]) {
c.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.assistante.user.fullName.trim();
return fallback.isNotEmpty ? fallback : 'Assistante maternelle';
}
return '$fn $ln'.trim();
}
String? _headerSubtitle() {
final parts = <String>[];
final zone = (widget.assistante.residenceCity ?? '').trim();
if (zone.isNotEmpty) parts.add('Zone : $zone');
final agrement = _agrementCtrl.text.trim();
if (agrement.isNotEmpty) parts.add('Agrément : $agrement');
final dossier = widget.assistante.user.numeroDossier?.trim();
if (dossier != null && dossier.isNotEmpty) {
parts.add('Dossier $dossier');
}
if (parts.isEmpty) return null;
return parts.join(' · ');
}
String _formatNirDisplay() {
final raw = widget.assistante.nir?.trim() ?? '';
if (raw.isEmpty) return '';
final digits = nirToRaw(raw).toUpperCase();
return digits.length == 15 ? formatNir(digits) : raw;
}
String? _frDateToIso(String text) => parseFrDateToIso(text);
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
);
bool _placesInconsistent() => amHasPlacesInconsistency(
maxChildren: _capaciteMax(),
placesAvailable: _placesAvailable,
childrenCount: _children.length,
);
String _placesDisplayValue() {
if (_placesAvailable != null) return _placesAvailable.toString();
return '';
}
void _applyPlacesCorrection() {
final expected = _computedPlacesAvailable();
if (expected == null) return;
setState(() {
_placesAvailable = expected;
_dirty = true;
});
}
String? _placesInconsistencyMessage() {
if (!_placesInconsistent()) return null;
final stored = _placesAvailable;
final expected = _computedPlacesAvailable();
final storedLabel = stored?.toString() ?? 'non renseigné';
final expectedLabel = expected?.toString() ?? '';
return 'Incohérence : l\'AM déclare $storedLabel place(s) disponible(s), '
'le calcul (capacité ${_capaciteMax() ?? ''} ${_children.length} '
'enfant(s) rattaché(s)) donne $expectedLabel.';
}
int? _parseIntField(TextEditingController c) {
final t = c.text.trim();
if (t.isEmpty) return null;
return int.tryParse(t);
}
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 assistante maternelle ?',
),
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 {
if (_childrenChanged()) _syncPlacesAfterChildrenChange();
final currentIds = _children.map((c) => c.id).toSet();
for (final id in _baselineChildIds.difference(currentIds)) {
await UserService.detachEnfantFromAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
for (final id in currentIds.difference(_baselineChildIds)) {
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: id,
);
}
await UserService.updateAmFiche(
amUserId: widget.assistante.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,
'approval_number': _agrementCtrl.text.trim(),
'nir': nirToRaw(_nirCtrl.text),
if (_frDateToIso(_dateNaissanceCtrl.text) != null)
'date_naissance': _frDateToIso(_dateNaissanceCtrl.text),
'lieu_naissance_ville': _lieuNaissanceVilleCtrl.text.trim(),
'lieu_naissance_pays': _lieuNaissancePaysCtrl.text.trim(),
if (_frDateToIso(_dateAgrementCtrl.text) != null)
'agreement_date': _frDateToIso(_dateAgrementCtrl.text),
if (_capaciteMax() != null) 'max_children': _capaciteMax(),
if (_placesAvailable != null) 'places_available': _placesAvailable,
'available': _disponible,
},
);
if (!mounted) return;
setState(() {
_dirty = false;
_saving = false;
_baselineChildIds = currentIds;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fiche assistante maternelle enregistrée')),
);
} catch (e) {
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Future<void> _fetchChildrenFromServer() async {
try {
final refreshed =
await UserService.getAssistanteMaternelle(widget.assistante.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;
_baselineChildIds = kids.map((c) => c.id).toSet();
});
} catch (_) {}
}
Future<void> _refreshChildrenDetails() async {
try {
final all = await UserService.getEnfants();
final byId = {for (final e in all) e.id: e};
if (!mounted) return;
setState(() {
_children = _children.map((c) {
final full = byId[c.id];
if (full != null) return ParentChildSummary.fromEnfant(full);
return c;
}).toList();
});
} catch (_) {}
}
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: _refreshChildrenDetails,
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
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 cette assistante ?\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;
setState(() {
_children = _children.where((c) => c.id != child.id).toList();
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
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;
setState(() {
_children = [
..._children,
ParentChildSummary.fromEnfant(selected),
];
_syncPlacesAfterChildrenChange();
_dirty = true;
});
}
Widget _identityTab() {
return SingleChildScrollView(
child: IdentityBlock.editable(
title: 'Identité et coordonnées',
nomController: _nomCtrl,
prenomController: _prenomCtrl,
telephoneController: _telCtrl,
emailController: _emailCtrl,
adresseController: _adresseCtrl,
codePostalController: _cpCtrl,
villeController: _villeCtrl,
),
);
}
Widget _proFieldsGrid() {
return ValidationFormGrid(
title: 'Dossier professionnel',
rowLayout: _photoProRowLayout,
fields: [
ValidationLabeledField(
label: 'NIR',
field: ValidationEditableField(
controller: _nirCtrl,
hintText: '15 chiffres',
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d\s]')),
],
),
),
ValidationLabeledField(
label: 'Date de naissance',
field: ValidationEditableField(
controller: _dateNaissanceCtrl,
hintText: 'jj/mm/aaaa',
),
),
ValidationLabeledField(
label: 'Ville de naissance',
field: ValidationEditableField(
controller: _lieuNaissanceVilleCtrl,
),
),
ValidationLabeledField(
label: 'Pays de naissance',
field: ValidationEditableField(
controller: _lieuNaissancePaysCtrl,
),
),
ValidationLabeledField(
label: 'N° Agrément',
field: ValidationEditableField(
controller: _agrementCtrl,
),
),
ValidationLabeledField(
label: 'Date d\'agrément',
field: ValidationEditableField(
controller: _dateAgrementCtrl,
hintText: 'jj/mm/aaaa',
),
),
],
);
}
Widget _proTab() {
return LayoutBuilder(
builder: (context, c) {
final maxRowW = c.maxWidth;
final maxRowH = c.maxHeight;
final bodyH = maxRowH;
final idealPhotoW = bodyH * AdminAmPhotoFrame.idPhotoAspectRatio + 16;
final maxPhotoW = (maxRowW - _photoProGap - _proColumnMinWidth)
.clamp(0.0, double.infinity);
var photoW = idealPhotoW.clamp(_photoColumnMinWidth, 220.0);
if (photoW > maxPhotoW) photoW = maxPhotoW;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: photoW,
child: AdminAmPhotoFrame(
photoUrl: widget.assistante.user.photoUrl,
),
),
const SizedBox(width: _photoProGap),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_proFieldsGrid(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
title: const Text(
'Disponible pour accueillir',
style: TextStyle(fontSize: 13),
),
value: _disponible,
onChanged: (v) => setState(() {
_disponible = v;
_dirty = true;
}),
),
],
),
),
),
],
);
},
);
}
Widget _childrenCapacityFields() {
final inconsistent = _placesInconsistent();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ValidationEditableSection(
rowLayout: const [2],
fields: [
ValidationLabeledField(
label: 'Capacité max (enfants)',
field: ValidationEditableField(
controller: _capaciteCtrl,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
ValidationLabeledField(
label: 'Places disponibles',
field: ValidationReadOnlyField(
value: _placesDisplayValue(),
error: inconsistent,
),
),
],
),
if (inconsistent) ...[
const SizedBox(height: 6),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
Text(
_placesInconsistencyMessage()!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
height: 1.3,
),
),
OutlinedButton(
onPressed: _saving ? null : _applyPlacesCorrection,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red.shade800,
disabledForegroundColor: Colors.red.shade300,
side: BorderSide(color: Colors.red.shade400),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
minimumSize: const Size(0, 28),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
child: const Text('Mettre à jour'),
),
],
),
],
],
);
}
Widget _childrenTab() {
final capacity = (_capaciteMax() ?? 4).clamp(1, 4);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_childrenCapacityFields(),
const SizedBox(height: 10),
Text(
'Enfants accueillis : ${_children.length}',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 8),
AdminAmChildrenCapacityGrid(
children: _children,
capacity: capacity,
onOpen: _openChild,
onDetach: _detachChild,
),
],
);
}
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
return Row(
children: [
TextButton(
onPressed: _saving ? null : () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
const Spacer(),
if (isChildrenTab)
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
if (isChildrenTab) 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, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_headerTitle(),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
if (_headerSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_headerSubtitle()!,
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',
),
],
),
),
TabBar(
controller: _tabCtrl,
onTap: (_) => setState(() {}),
labelColor: ValidationModalTheme.primaryActionBackground,
unselectedLabelColor: Colors.black54,
indicatorColor: ValidationModalTheme.primaryActionBackground,
tabs: const [
Tab(text: 'Identité'),
Tab(text: 'Fiche professionnelle'),
Tab(text: 'Enfants accueillis'),
],
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: SizedBox(
height: _tabViewHeight(_tabCtrl.index),
child: TabBarView(
controller: _tabCtrl,
children: [
_identityTab(),
_proTab(),
_childrenTab(),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
child: _buildFooter(),
),
],
),
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
/// Cadre photo identité AM (35×45 mm) — même logique que [ValidationAmWizard].
class AdminAmPhotoFrame extends StatelessWidget {
final String? photoUrl;
static const double idPhotoAspectRatio = 35 / 45;
const AdminAmPhotoFrame({super.key, this.photoUrl});
/// Largeur colonne photo pour remplir [height] (cadre inclus).
static double columnWidthForHeight(double height) {
const frame = 16.0;
final innerH = (height - frame).clamp(0.0, double.infinity);
return innerH * idPhotoAspectRatio + frame;
}
@override
Widget build(BuildContext context) {
final fullUrl = ApiConfig.absoluteMediaUrl(photoUrl);
return LayoutBuilder(
builder: (context, c) {
const uniformFrame = 8.0;
final maxPhotoW =
(c.maxWidth - 2 * uniformFrame).clamp(0.0, double.infinity);
final maxPhotoH =
(c.maxHeight - 2 * uniformFrame).clamp(0.0, double.infinity);
const ar = idPhotoAspectRatio;
double ph = maxPhotoH;
double pw = ph * ar;
if (pw > maxPhotoW) {
pw = maxPhotoW;
ph = pw / ar;
}
// Cadre gris = taille photo + padding uniforme ; centré dans la colonne
// (évite le vide blanc en bas quand le conteneur parent est plus haut).
return Align(
alignment: Alignment.topCenter,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(uniformFrame),
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
width: pw,
height: ph,
child: _photoContent(fullUrl, pw, ph),
),
),
),
),
);
},
);
}
Widget _photoContent(String fullUrl, double pw, double ph) {
if (fullUrl.isEmpty) {
return ColoredBox(
color: Colors.grey.shade200,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person_off_outlined, size: 36, color: Colors.grey.shade400),
const SizedBox(height: 6),
Text(
'Aucune photo',
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
],
),
);
}
return AuthNetworkImage(
url: fullUrl,
width: pw,
height: ph,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
loadingBuilder: (_, child, progress) {
if (progress == null) return child;
return ColoredBox(
color: Colors.grey.shade200,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress.expectedTotalBytes != null
? progress.cumulativeBytesLoaded /
(progress.expectedTotalBytes!)
: null,
),
),
),
);
},
errorBuilder: (_, __, ___) => ColoredBox(
color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined, size: 36, color: Colors.grey.shade400),
),
);
}
}
@@ -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;
}
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
/// Liste scrollable d'enfants rattachés (fiche parent / fiche AM).
class AdminChildrenAffiliationPanel extends StatelessWidget {
final List<ParentChildSummary> children;
final ScrollController scrollController;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
final String emptyMessage;
final double? height;
static const double _itemHeight = 58;
static const double defaultViewportHeight = _itemHeight * 2.5 + 8;
const AdminChildrenAffiliationPanel({
super.key,
required this.children,
required this.scrollController,
required this.onOpen,
required this.onDetach,
this.emptyMessage = 'Aucun enfant rattaché',
this.height,
});
@override
Widget build(BuildContext context) {
if (height != null) {
return _panel(height!);
}
return LayoutBuilder(
builder: (context, constraints) {
final h = constraints.maxHeight.isFinite && constraints.maxHeight > 0
? constraints.maxHeight
: defaultViewportHeight;
return _panel(h);
},
);
}
Widget _panel(double panelHeight) {
return Container(
height: panelHeight,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: children.isEmpty
? Center(
child: Text(
emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: _itemHeight,
itemCount: children.length,
itemBuilder: (_, i) {
final c = children[i];
return AdminEnfantUserCard.fromSummary(
c,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => onOpen(c),
),
IconButton(
tooltip: 'Détacher',
icon: Icon(Icons.link_off, color: Colors.orange.shade800),
onPressed: () => onDetach(c),
),
],
);
},
),
);
}
}
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/enfant_admin_model.dart';
import 'package:p_tits_pas/models/parent_child_summary.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
List<String> enfantAdminSubtitleLines({
required String status,
String? birthDate,
String? dueDate,
String? gender,
List<String> extra = const [],
}) {
final lines = <String>[];
final age = formatChildAgeLabel(
birthDate: birthDate,
dueDate: dueDate,
status: normalizeEnfantStatus(status),
);
if (age.isNotEmpty) lines.add(age);
final normalized = normalizeEnfantStatus(status);
if (normalized.isNotEmpty) {
lines.add('Statut : ${enfantStatusLabel(normalized, gender: gender)}');
}
lines.addAll(extra);
return lines;
}
/// Carte enfant admin (photo, nom, âge) — même rendu onglet Enfants / fiche parent.
class AdminEnfantUserCard extends StatelessWidget {
final String title;
final String? photoUrl;
final List<String> subtitleLines;
final List<Widget> actions;
const AdminEnfantUserCard({
super.key,
required this.title,
this.photoUrl,
required this.subtitleLines,
this.actions = const [],
});
factory AdminEnfantUserCard.fromEnfant(
EnfantAdminModel enfant, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
}) {
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
.join(', ');
return AdminEnfantUserCard(
title: enfant.fullName,
photoUrl: enfant.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: enfant.status,
birthDate: enfant.birthDate,
dueDate: enfant.dueDate,
gender: enfant.gender,
extra: [
if (parents.isNotEmpty) 'Responsables : $parents',
...extraSubtitleLines,
],
),
actions: actions,
);
}
factory AdminEnfantUserCard.fromSummary(
ParentChildSummary child, {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
}) {
return AdminEnfantUserCard(
title: child.fullName,
photoUrl: child.photoUrl,
subtitleLines: enfantAdminSubtitleLines(
status: child.status,
birthDate: child.birthDate,
dueDate: child.dueDate,
extra: extraSubtitleLines,
),
actions: actions,
);
}
@override
Widget build(BuildContext context) {
return AdminUserCard(
title: title,
fallbackIcon: Icons.child_care_outlined,
avatarUrl: photoUrl,
subtitleLines: subtitleLines,
actions: actions,
);
}
}
@@ -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(),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
/// Gélule de sélection du statut utilisateur (fiches admin parent / AM).
class AdminStatusCapsule extends StatelessWidget {
final String statut;
final ValueChanged<String>? onChanged;
static const statuts = ['actif', 'en_attente', 'suspendu', 'refuse'];
const AdminStatusCapsule({
super.key,
required this.statut,
this.onChanged,
});
static String displayStatus(String status) {
switch (status) {
case 'actif':
return 'Actif';
case 'en_attente':
return 'En attente';
case 'suspendu':
return 'Suspendu';
case 'refuse':
return 'Refusé';
default:
return status;
}
}
@override
Widget build(BuildContext context) {
final value = statuts.contains(statut) ? statut : statuts.first;
return Container(
height: 34,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.black26),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isExpanded: true,
isDense: true,
style: const TextStyle(fontSize: 13, color: Colors.black87),
icon: const Icon(Icons.arrow_drop_down, size: 20),
items: statuts
.map(
(s) => DropdownMenuItem(
value: s,
child: Text(displayStatus(s)),
),
)
.toList(),
onChanged: onChanged == null
? null
: (v) {
if (v != null) onChanged!(v);
},
),
),
);
}
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
class AdminUserCard extends StatefulWidget {
final String title;
@@ -10,6 +12,7 @@ class AdminUserCard extends StatefulWidget {
final Color? backgroundColor;
final Color? titleColor;
final Color? infoColor;
final String? vigilanceTooltip;
const AdminUserCard({
super.key,
@@ -22,6 +25,7 @@ class AdminUserCard extends StatefulWidget {
this.backgroundColor,
this.titleColor,
this.infoColor,
this.vigilanceTooltip,
});
@override
@@ -37,6 +41,7 @@ class _AdminUserCardState extends State<AdminUserCard> {
widget.subtitleLines.where((e) => e.trim().isNotEmpty).join(' ');
final actionsWidth =
widget.actions.isNotEmpty ? widget.actions.length * 30.0 : 0.0;
final avatarUrl = ApiConfig.absoluteMediaUrl(widget.avatarUrl);
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
@@ -60,21 +65,19 @@ class _AdminUserCardState extends State<AdminUserCard> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
CircleAvatar(
radius: 14,
backgroundColor: const Color(0xFFEDE5FA),
backgroundImage: widget.avatarUrl != null
? NetworkImage(widget.avatarUrl!)
: null,
child: widget.avatarUrl == null
? Icon(
widget.fallbackIcon,
size: 16,
color: const Color(0xFF6B3FA0),
)
: null,
),
_buildAvatar(avatarUrl),
const SizedBox(width: 10),
if (widget.vigilanceTooltip != null) ...[
Tooltip(
message: widget.vigilanceTooltip!,
child: Icon(
Icons.error_outline,
size: 20,
color: Colors.orange.shade800,
),
),
const SizedBox(width: 6),
],
Expanded(
child: Row(
children: [
@@ -140,4 +143,35 @@ class _AdminUserCardState extends State<AdminUserCard> {
),
);
}
Widget _buildAvatar(String url) {
const size = 28.0;
const bg = Color(0xFFEDE5FA);
const iconColor = Color(0xFF6B3FA0);
if (url.isEmpty) {
return CircleAvatar(
radius: 14,
backgroundColor: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
);
}
return ClipOval(
child: SizedBox(
width: size,
height: size,
child: AuthNetworkImage(
url: url,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => ColoredBox(
color: bg,
child: Icon(widget.fallbackIcon, size: 16, color: iconColor),
),
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'admin_detail_modal.dart';
/// Bloc type formulaire (titre de section + champs read-only) pour les modales de validation.
@@ -23,6 +24,41 @@ class ValidationDetailSection extends StatelessWidget {
this.rowFlex,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
title: title,
rowLayout: rowLayout,
rowFlex: rowFlex,
fields: fields
.map(
(f) => ValidationLabeledField(
label: f.label,
field: ValidationReadOnlyField(value: f.value),
),
)
.toList(),
);
}
}
/// Grille label/champ réutilisable (validation, fiches admin).
class ValidationFormGrid extends StatelessWidget {
final String? title;
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationFormGrid({
super.key,
this.title,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final layout = rowLayout ?? List.filled(fields.length, 1);
@@ -38,22 +74,22 @@ class ValidationDetailSection extends StatelessWidget {
rowIndex++;
if (count == 1) {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildFieldCell(rowFields.first),
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: rowFields.first,
));
} else {
rows.add(Padding(
padding: const EdgeInsets.only(bottom: 12),
padding: EdgeInsets.only(bottom: compact ? 8 : 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (int i = 0; i < rowFields.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
if (i > 0) SizedBox(width: compact ? 12 : 16),
Expanded(
flex: (flexForRow != null && i < flexForRow.length)
? flexForRow[i]
: 1,
child: _buildFieldCell(rowFields[i]),
child: rowFields[i],
),
],
],
@@ -69,26 +105,96 @@ class ValidationDetailSection extends StatelessWidget {
if (showTitle) ...[
Text(
title!.trim(),
style: const TextStyle(
fontSize: 16,
style: TextStyle(
fontSize: compact ? 15 : 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
SizedBox(height: compact ? 8 : 12),
],
...rows,
],
);
}
}
Widget _buildFieldCell(AdminDetailField field) {
/// Décoration commune lecture seule / édition (modales validation, fiches admin).
class ValidationFieldDecoration {
ValidationFieldDecoration._();
static InputDecoration input({String? hint, bool compact = false}) {
return InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
hintText: hint,
contentPadding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 7 : 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade500),
),
);
}
static InputDecoration readOnly({bool error = false, bool compact = false}) {
final borderColor = error ? Colors.red.shade400 : Colors.grey.shade300;
final fillColor = error ? Colors.red.shade50 : Colors.grey.shade50;
return input(compact: compact).copyWith(
filled: true,
fillColor: fillColor,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: borderColor),
),
);
}
static BoxDecoration container({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
}
/// Libellé au-dessus dun champ (même typo que [ValidationDetailSection]).
class ValidationLabeledField extends StatelessWidget {
final String label;
final Widget field;
const ValidationLabeledField({
super.key,
required this.label,
required this.field,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
field.label,
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
@@ -96,37 +202,203 @@ class ValidationDetailSection extends StatelessWidget {
),
),
const SizedBox(height: 4),
ValidationReadOnlyField(value: field.value),
field,
],
);
}
}
/// Champ texte en lecture seule, style formulaire (fond gris léger, bordure). Réutilisable en éditable plus tard.
class ValidationReadOnlyField extends StatelessWidget {
/// Champ texte éditable, même rendu que [ValidationReadOnlyField].
class ValidationEditableField extends StatelessWidget {
final TextEditingController controller;
final TextInputType keyboardType;
final List<TextInputFormatter>? inputFormatters;
final String? hintText;
final int maxLines;
final bool compact;
const ValidationEditableField({
super.key,
required this.controller,
this.keyboardType = TextInputType.text,
this.inputFormatters,
this.hintText,
this.maxLines = 1,
this.compact = false,
});
static const double _compactFieldHeight = 34;
static BoxDecoration _compactDecoration({bool error = false}) {
return BoxDecoration(
color: error ? Colors.red.shade50 : Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: error ? Colors.red.shade400 : Colors.grey.shade300,
),
);
}
static InputDecoration _compactInputDecoration({String? hint}) {
return InputDecoration(
isDense: true,
filled: false,
hintText: hint,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
);
}
@override
Widget build(BuildContext context) {
if (maxLines > 1) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: maxLines,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
if (!compact) {
return TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
style: const TextStyle(color: Colors.black87, fontSize: 14),
decoration: ValidationFieldDecoration.input(hint: hintText),
);
}
return SizedBox(
height: _compactFieldHeight,
child: DecoratedBox(
decoration: _compactDecoration(),
child: TextField(
controller: controller,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
color: Colors.black87,
fontSize: 13,
height: 1.0,
),
decoration: _compactInputDecoration(hint: hintText),
),
),
);
}
}
/// Grille label/champ éditable (délègue à [ValidationFormGrid]).
class ValidationEditableSection extends StatelessWidget {
final List<ValidationLabeledField> fields;
final List<int>? rowLayout;
final Map<int, List<int>>? rowFlex;
final bool compact;
const ValidationEditableSection({
super.key,
required this.fields,
this.rowLayout,
this.rowFlex,
this.compact = false,
});
@override
Widget build(BuildContext context) {
return ValidationFormGrid(
rowLayout: rowLayout,
rowFlex: rowFlex,
compact: compact,
fields: fields,
);
}
}
/// Champ texte en lecture seule, même coque [TextField] que [ValidationEditableField].
class ValidationReadOnlyField extends StatefulWidget {
final String value;
final int? maxLines;
final bool compact;
final bool error;
const ValidationReadOnlyField({
super.key,
required this.value,
this.maxLines = 1,
this.compact = false,
this.error = false,
});
@override
State<ValidationReadOnlyField> createState() => _ValidationReadOnlyFieldState();
}
class _ValidationReadOnlyFieldState extends State<ValidationReadOnlyField> {
late final TextEditingController _controller;
static const double _compactFieldHeight = 34;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
}
@override
void didUpdateWidget(ValidationReadOnlyField oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
_controller.text = widget.value;
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!widget.compact && widget.maxLines == 1) {
return TextField(
controller: _controller,
readOnly: true,
enableInteractiveSelection: false,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: 14,
fontWeight: widget.error ? FontWeight.w600 : null,
),
decoration: ValidationFieldDecoration.readOnly(error: widget.error),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade300),
height: widget.compact && widget.maxLines == 1 ? _compactFieldHeight : null,
alignment: widget.compact ? Alignment.centerLeft : null,
padding: EdgeInsets.symmetric(
horizontal: widget.compact ? 10 : 12,
vertical: widget.compact ? 7 : 10,
),
decoration: ValidationFieldDecoration.container(error: widget.error),
child: Text(
value,
style: const TextStyle(color: Colors.black87, fontSize: 14),
maxLines: maxLines,
widget.value,
style: TextStyle(
color: widget.error ? Colors.red.shade800 : Colors.black87,
fontSize: widget.compact ? 13 : 14,
height: widget.compact ? 1.0 : null,
fontWeight: widget.error ? FontWeight.w600 : null,
),
maxLines: widget.maxLines,
overflow: TextOverflow.ellipsis,
),
);
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.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/widgets/admin/common/admin_child_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
/// Onglet liste globale des enfants (doc 28 §6.2, ticket #137).
class EnfantManagementWidget extends StatefulWidget {
final String searchQuery;
final String? statusFilter;
const EnfantManagementWidget({
super.key,
required this.searchQuery,
this.statusFilter,
});
@override
State<EnfantManagementWidget> createState() => _EnfantManagementWidgetState();
}
class _EnfantManagementWidgetState extends State<EnfantManagementWidget> {
bool _isLoading = false;
String? _error;
List<EnfantAdminModel> _enfants = [];
@override
void initState() {
super.initState();
_loadEnfants();
}
Future<void> _loadEnfants() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await UserService.getEnfants();
if (!mounted) return;
setState(() {
_enfants = list;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _openEnfant(EnfantAdminModel enfant) async {
await showDialog<void>(
context: context,
builder: (ctx) => AdminChildDetailModal(
enfant: enfant,
onSaved: _loadEnfants,
),
);
}
@override
Widget build(BuildContext context) {
final query = widget.searchQuery.toLowerCase();
final filtered = _enfants.where((e) {
final matchesName = e.fullName.toLowerCase().contains(query);
final matchesStatus = widget.statusFilter == null ||
normalizeEnfantStatus(e.status) ==
normalizeEnfantStatus(widget.statusFilter);
return matchesName && matchesStatus;
}).toList();
return UserList(
isLoading: _isLoading,
error: _error,
isEmpty: filtered.isEmpty,
emptyMessage: 'Aucun enfant trouvé.',
itemCount: filtered.length,
itemBuilder: (context, index) {
final enfant = filtered[index];
return AdminEnfantUserCard.fromEnfant(
enfant,
actions: [
IconButton(
icon: const Icon(Icons.visibility_outlined),
tooltip: 'Voir / modifier',
onPressed: () => _openEnfant(enfant),
),
],
);
},
);
}
}
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/parent_model.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/user_list.dart';
@@ -80,7 +79,7 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
avatarUrl: parent.user.photoUrl,
subtitleLines: [
parent.user.email,
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.childrenCount}',
'Statut : ${_displayStatus(parent.user.statut)} | Enfants : ${parent.children.isNotEmpty ? parent.children.length : parent.childrenCount}',
],
actions: [
IconButton(
@@ -114,45 +113,10 @@ class _ParentManagementWidgetState extends State<ParentManagementWidget> {
void _openParentDetails(ParentModel parent) {
showDialog<void>(
context: context,
builder: (context) => AdminDetailModal(
title: parent.user.fullName.isEmpty ? 'Parent' : parent.user.fullName,
subtitle: parent.user.email,
fields: [
AdminDetailField(label: 'ID', value: _v(parent.user.id)),
AdminDetailField(
label: 'Statut',
value: _displayStatus(parent.user.statut),
),
AdminDetailField(
label: 'Telephone',
value: _v(parent.user.telephone) != '' ? formatPhoneForDisplay(_v(parent.user.telephone)) : '',
),
AdminDetailField(label: 'Adresse', value: _v(parent.user.adresse)),
AdminDetailField(label: 'Ville', value: _v(parent.user.ville)),
AdminDetailField(
label: 'Code postal',
value: _v(parent.user.codePostal),
),
AdminDetailField(
label: 'Nombre d\'enfants',
value: parent.childrenCount.toString(),
),
],
onEdit: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Modifier a implementer')),
);
},
onDelete: () {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Action Supprimer a implementer')),
);
},
builder: (context) => AdminParentEditModal(
parent: parent,
onSaved: _loadParents,
),
);
}
String _v(String? value) => (value == null || value.isEmpty) ? '-' : value;
}
@@ -4,6 +4,7 @@ import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/widgets/admin/admin_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart';
import 'package:p_tits_pas/widgets/admin/enfant_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/gestionnaire_management_widget.dart';
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart';
import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart';
@@ -28,6 +29,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
final TextEditingController _searchController = TextEditingController();
final TextEditingController _amCapacityController = TextEditingController();
String? _parentStatus;
String? _enfantStatus;
bool _hasPending = false;
bool _pendingLoading = true;
@@ -80,7 +82,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
List<String> get _tabLabels {
const base = ['Parents', 'Assistantes maternelles', 'Gestionnaires'];
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
final withAdmin = [...base, 'Administrateurs'];
final list = widget.showAdministrateursTab ? withAdmin : base;
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
@@ -94,11 +96,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
_subIndex = index.clamp(0, maxIndex);
_searchController.clear();
_parentStatus = null;
_enfantStatus = null;
_amCapacityController.clear();
});
}
/// Index du contenu : -1 = À valider (si visible), 0 = Parents, 1 = AM, 2 = Gestionnaires, 3 = Admin.
/// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin.
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
String _searchHintForTab() {
@@ -109,10 +112,12 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
case 0:
return 'Rechercher un parent...';
case 1:
return 'Rechercher une assistante...';
return 'Rechercher un enfant...';
case 2:
return 'Rechercher un gestionnaire...';
return 'Rechercher une assistante...';
case 3:
return 'Rechercher un gestionnaire...';
case 4:
return 'Rechercher un administrateur...';
default:
return 'Rechercher...';
@@ -176,6 +181,61 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
if (_subIndex == _contentIndexOffset + 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
isExpanded: true,
hint: const Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Statut', style: TextStyle(fontSize: 12)),
),
items: const [
DropdownMenuItem<String?>(
value: null,
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Tous', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'a_naitre',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('À naître', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'sans_garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Sans garde', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(
value: 'scolarise',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('Scolarisé', style: TextStyle(fontSize: 12)),
),
),
],
onChanged: (value) {
setState(() {
_enfantStatus = value;
});
},
),
);
}
if (_subIndex == _contentIndexOffset + 2) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@@ -203,16 +263,21 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
statusFilter: _parentStatus,
);
case 1:
return EnfantManagementWidget(
searchQuery: _searchController.text,
statusFilter: _enfantStatus,
);
case 2:
return AssistanteMaternelleManagementWidget(
searchQuery: _searchController.text,
capacityMin: int.tryParse(_amCapacityController.text),
);
case 2:
case 3:
return GestionnaireManagementWidget(
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
searchQuery: _searchController.text,
);
case 3:
case 4:
return AdminManagementWidget(
key: ValueKey('admins-$_adminRefreshTick'),
searchQuery: _searchController.text,
@@ -246,7 +311,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 2) {
if (contentIndex == 3) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -264,7 +329,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 3) {
if (contentIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -289,7 +354,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création est disponible pour les gestionnaires et administrateurs.',
'La création parent / enfant / AM sera disponible avec le ticket #129.',
),
),
);
@@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/nir_utils.dart';
import 'package:p_tits_pas/models/user.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
@@ -60,21 +60,6 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
/// Même ordre et disposition que le formulaire de création de compte (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _personalFields(AppUser u) => [
AdminDetailField(label: 'Nom', value: _v(u.nom)),
AdminDetailField(label: 'Prénom', value: _v(u.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(u.telephone) != ''
? formatPhoneForDisplay(_v(u.telephone))
: ''),
AdminDetailField(label: 'Email', value: _v(u.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(u.adresse)),
AdminDetailField(label: 'Code postal', value: _v(u.codePostal)),
AdminDetailField(label: 'Ville', value: _v(u.ville)),
];
/// Panneau photo + grille droite : NIR|naissance, ville|pays, agrément|date, capa|places.
List<AdminDetailField> _photoProFields(DossierAM d) {
final u = d.user;
@@ -110,10 +95,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
];
}
static const List<int> _personalRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _personalRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static const List<int> _photoProRowLayout = [2, 2, 2, 2];
/// Proportion photo didentité (35×45 mm).
static const double _idPhotoAspectRatio = 35 / 45;
@@ -266,11 +248,10 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: constraints.maxWidth),
child: ValidationDetailSection(
child: IdentityBlock.readOnlyFromUser(
u,
title: 'Identité et coordonnées',
fields: _personalFields(u),
rowLayout: _personalRowLayout,
rowFlex: _personalRowFlex,
emptyLabel: '',
),
),
);
@@ -311,7 +292,7 @@ class _ValidationAmWizardState extends State<ValidationAmWizard> {
child: ValidationDetailSection(
title: 'Dossier professionnel',
fields: _photoProFields(d),
rowLayout: const [2, 2, 2, 2],
rowLayout: _photoProRowLayout,
),
),
);
@@ -3,11 +3,11 @@ import 'package:flutter/gestures.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/utils/date_display_utils.dart';
import 'package:p_tits_pas/utils/phone_utils.dart';
import 'package:p_tits_pas/utils/enfant_status_utils.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/common/identity_block.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
import 'validation_modal_theme.dart';
import 'validation_refus_form.dart';
@@ -108,26 +108,6 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
static String _formatBirthDate(String? s) =>
formatIsoDateFr(s, ifEmpty: 'Non défini');
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
List<AdminDetailField> _parentFields(ParentDossier p) => [
AdminDetailField(label: 'Nom', value: _v(p.nom)),
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
AdminDetailField(
label: 'Téléphone',
value: _v(p.telephone) != 'Non défini'
? formatPhoneForDisplay(_v(p.telephone))
: 'Non défini'),
AdminDetailField(label: 'Email', value: _v(p.email)),
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
AdminDetailField(label: 'Ville', value: _v(p.ville)),
];
static const List<int> _parentRowLayout = [2, 2, 1, 2];
static const Map<int, List<int>> _parentRowFlex = {
3: [2, 5]
}; // Code postal étroit, Ville large
static String _fullPhotoUrl(String? url) => ApiConfig.absoluteMediaUrl(url);
@override
@@ -153,11 +133,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
final d = widget.dossier;
switch (_step) {
case 0:
return ValidationDetailSection(
return IdentityBlock.readOnlyFromParentDossier(
d.parents.first,
title: 'Parent principal',
fields: _parentFields(d.parents.first),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
case 1:
return _buildParent2Step();
@@ -178,11 +156,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
style: TextStyle(color: Colors.black87)),
);
}
return ValidationDetailSection(
return IdentityBlock.readOnlyFromParentDossier(
widget.dossier.parents[1],
title: 'Deuxième parent',
fields: _parentFields(widget.dossier.parents[1]),
rowLayout: _parentRowLayout,
rowFlex: _parentRowFlex,
);
}
@@ -422,20 +398,9 @@ class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
);
}
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
static String _scolariseAccordeAuGenre(String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
}
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
/// `actif` : pas de ligne statut.
/// Statut dans la colonne 2/3 (scolarisé·e, à naître, sans garde, en garde).
String? _enfantColumnStatusLabel(EnfantDossier e) {
final s = (e.status ?? '').trim().toLowerCase();
if (s == 'a_naitre') return 'À naître';
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
return null;
return enfantColumnStatusLabel(status: e.status, gender: e.gender);
}
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).