Compare commits

..
Author SHA1 Message Date
jmartinandCursor 6fe2b89a61 fix(#151): autoriser GET /relais pour gestionnaire + robustesse combo
- Back: RoleType.GESTIONNAIRE sur GET list/get (CRUD write admin-only)
- Front: ne plus effacer la sélection si le chargement échoue
- Fallback affichage depuis relaisNom de l'utilisateur

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:52:46 +02:00
jmartin d6a9b3fd66 Merge branch 'feature/149-clic-case-libre-rattacher-enfant' into develop
Clic case libre → rattacher enfant (#149).
2026-07-17 16:19:14 +02:00
jmartinandCursor 134b9781c8 feat(#149): clic sur case libre pour rattacher un enfant (fiche AM).
Les emplacements vides de la grille ouvrent le même flux que le lien footer, avec hover et respect de la capacité max.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:19:12 +02:00
jmartin b936d27445 Merge branch 'feature/148-rattacher-enfant-capacite-max' into develop
Fix capacité max — lien rattacher enfant (#148).
2026-07-17 16:12:54 +02:00
jmartinandCursor 18c1d1eba7 fix(#148): désactiver « Rattacher un enfant » si capacité AM pleine.
Le lien du footer est grisé avec tooltip quand enfants ≥ capacité max ; il se réactive après détachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:12:53 +02:00
jmartin f74b14c203 Merge branch 'feature/147-modale-selection-am-enfant' into develop
Modale de sélection d'AM (#147) depuis la fiche enfant.
2026-07-17 16:12:23 +02:00
jmartinandCursor f194b5f9e8 feat(#147): modale de sélection d'AM depuis la fiche enfant.
Réutilise AdminSelectListModal avec filtre Libre, cartes saturées en rouge, avertissement + lien fiche AM, et recalcul des places à chaque rattachement.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:11:55 +02:00
jmartin 5ab8ae3423 Merge branch 'feature/146-modale-selection-enfant-am' into develop
Modale de sélection d'enfant (#146) pour rattachement AM/parent.
2026-07-17 15:43:10 +02:00
jmartinandCursor 3277f77846 fix(#146): densifier la liste et corriger l'affichage des responsables.
Les cartes compactes montrent plus d'enfants d'un coup ; le nom ne monopolise plus la largeur au détriment des infos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:39:59 +02:00
jmartinandCursor 2d80ad0d7e feat(#146): modale de sélection d'enfant pour rattachement AM/parent.
Recherche dynamique, filtre Sans garde côté AM, confirmation de transfert et détachement préalable pour laisser le point de vigilance places sur l'AM d'origine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 15:31:47 +02:00
jmartinandCursor 09386f8aa6 feat(#145): lien co-parent cliquable dans la fiche parent.
Même UX que les responsables de la fiche enfant : ouverture de
AdminParentEditModal au clic sur le nom du co-parent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 14:56:32 +02:00
jmartinandCursor faa50f637c fix(#138): enrichir les noms des responsables sur GET enfant.
GET /enfants/:id ne joint pas parent.user ; on complète les noms via
GET parent pour l'affichage des liens (ex. ouverture depuis la fiche AM).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:19:09 +02:00
jmartinandCursor 267fe63aec fix(#138): différer le rattachement AM de la fiche enfant au Sauvegarder.
Attach/détach AM restent locaux comme sur la fiche AM ; la sync API
n'a lieu qu'à l'enregistrement, avec rechargement du statut au retour
de la fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:12:46 +02:00
jmartinandCursor 90b185740c feat(#138): ouvrir la fiche parent depuis les responsables de la modale enfant.
Les noms du sous-titre deviennent des liens cliquables vers
AdminParentEditModal, comme l'ouverture de fiche AM.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:54:32 +02:00
jmartinandCursor b903dbf60b fix(#138): libellé garde accordé au genre (Gardé / Gardée).
Remplace « En garde » par Gardé/Gardée selon le genre, comme
Scolarisé/Scolarisée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:51:07 +02:00
jmartinandCursor 291ea26b34 fix(#138): lire le consentement photo sans heuristique photo.
Admin et reprise utilisentent uniquement la valeur API ; plus de
pré-cochage implicite si une photo est présente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 12:46:54 +02:00
18 changed files with 1195 additions and 263 deletions
@@ -24,15 +24,19 @@ export class RelaisController {
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les relais' })
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Lister tous les relais',
description:
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
})
@ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() {
return this.relaisService.findAll();
}
@Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) {
+43 -5
View File
@@ -37,6 +37,34 @@ class EnfantAdminModel {
return '$fn $ln';
}
EnfantAdminModel copyWith({
String? id,
String? firstName,
String? lastName,
String? gender,
String? birthDate,
String? dueDate,
String? status,
String? photoUrl,
bool? consentPhoto,
bool? isMultiple,
List<EnfantParentLink>? parentLinks,
}) {
return EnfantAdminModel(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
lastName: lastName ?? this.lastName,
gender: gender ?? this.gender,
birthDate: birthDate ?? this.birthDate,
dueDate: dueDate ?? this.dueDate,
status: status ?? this.status,
photoUrl: photoUrl ?? this.photoUrl,
consentPhoto: consentPhoto ?? this.consentPhoto,
isMultiple: isMultiple ?? this.isMultiple,
parentLinks: parentLinks ?? this.parentLinks,
);
}
factory EnfantAdminModel.fromJson(Map<String, dynamic> json) {
final linksRaw = json['parentLinks'] as List?;
final links = <EnfantParentLink>[];
@@ -49,10 +77,7 @@ class EnfantAdminModel {
}
final photoUrl = json['photo_url'] as String? ?? json['photoUrl'] as String?;
final hasPhoto = (photoUrl ?? '').trim().isNotEmpty;
// L'inscription parent exigeait le consentement pour envoyer la photo, mais
// le back a longtemps forcé consent_photo=false (voir reprise_mapper).
final consentFromApi = _parseBool(json['consent_photo']) ||
final consentPhoto = _parseBool(json['consent_photo']) ||
_parseBool(json['consentement_photo']) ||
_parseBool(json['consentPhoto']);
@@ -67,7 +92,7 @@ class EnfantAdminModel {
(json['status'] ?? json['statut'])?.toString(),
),
photoUrl: photoUrl,
consentPhoto: consentFromApi || hasPhoto,
consentPhoto: consentPhoto,
isMultiple: _parseBool(json['is_multiple']) ||
_parseBool(json['est_multiple']),
parentLinks: links,
@@ -109,6 +134,13 @@ class EnfantParentLink {
EnfantParentLink({required this.parentId, this.parentName});
EnfantParentLink copyWith({String? parentId, String? parentName}) {
return EnfantParentLink(
parentId: parentId ?? this.parentId,
parentName: parentName ?? this.parentName,
);
}
factory EnfantParentLink.fromJson(Map<String, dynamic> json) {
final parentId =
(json['parentId'] ?? json['id_parent'] ?? '').toString();
@@ -119,6 +151,12 @@ class EnfantParentLink {
if (user is Map<String, dynamic>) {
final u = AppUser.fromJson(user);
name = u.fullName.isNotEmpty ? u.fullName : u.email;
} else {
// Parfois le parent est aplati (prenom/nom) sans nested user.
final prenom = (parent['prenom'] ?? parent['first_name'] ?? '').toString().trim();
final nom = (parent['nom'] ?? parent['last_name'] ?? '').toString().trim();
final flat = '$prenom $nom'.trim();
if (flat.isNotEmpty) name = flat;
}
}
return EnfantParentLink(
@@ -146,6 +146,21 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
super.dispose();
}
/// Fallback si GET /relais échoue : conserve le relais déjà connu sur l'utilisateur.
List<RelaisModel> _fallbackRelaisFromUser() {
final id = _selectedRelaisId?.trim();
if (id == null || id.isEmpty) return const [];
final nom = (widget.initialUser?.relaisNom ?? '').trim();
return [
RelaisModel(
id: id,
nom: nom.isNotEmpty ? nom : 'Relais actuel',
adresse: '',
actif: true,
),
];
}
Future<void> _loadRelais() async {
try {
final list = await RelaisService.getRelais();
@@ -162,7 +177,8 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
if (selected != null) {
filtered.add(selected);
} else {
_selectedRelaisId = null;
// Garder l'id sélectionné et afficher un item de secours (nom carte).
filtered.addAll(_fallbackRelaisFromUser());
}
}
@@ -172,9 +188,9 @@ class _AdminUserFormDialogState extends State<AdminUserFormDialog> {
});
} catch (_) {
if (!mounted) return;
// Ne pas nullifier _selectedRelaisId (#151) — la carte a déjà le bon libellé.
setState(() {
_selectedRelaisId = null;
_relais = [];
_relais = _fallbackRelaisFromUser();
_isLoadingRelais = false;
});
}
+57 -3
View File
@@ -8,6 +8,7 @@ import 'package:p_tits_pas/models/pending_family.dart';
import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
class DocumentActifInfo {
final String id;
@@ -460,7 +461,40 @@ class UserService {
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur chargement enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
// GET /enfants/:id ne joint pas toujours parent.user → noms manquants.
return enrichEnfantParentNames(enfant);
}
/// Complète [parentName] via GET parent quand l'API n'a pas fourni le nested user.
static Future<EnfantAdminModel> enrichEnfantParentNames(
EnfantAdminModel enfant,
) async {
final links = enfant.parentLinks;
if (links.isEmpty) return enfant;
if (links.every((l) => (l.parentName ?? '').trim().isNotEmpty)) {
return enfant;
}
final enriched = await Future.wait(
links.map((link) async {
if ((link.parentName ?? '').trim().isNotEmpty) return link;
final id = link.parentId.trim();
if (id.isEmpty) return link;
try {
final parent = await getParent(id);
final name = parent.user.fullName.trim();
if (name.isEmpty) return link;
return link.copyWith(parentName: name);
} catch (_) {
return link;
}
}),
);
return enfant.copyWith(parentLinks: enriched);
}
static Future<EnfantAdminModel> updateEnfant({
@@ -475,7 +509,10 @@ class UserService {
if (response.statusCode != 200) {
throw Exception(_extractErrorMessage(response.body, 'Erreur mise à jour enfant'));
}
return EnfantAdminModel.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
final enfant = EnfantAdminModel.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
return enrichEnfantParentNames(enfant);
}
static Future<void> deleteEnfant(String enfantId) async {
@@ -652,7 +689,24 @@ class UserService {
if (response.statusCode != 200 && response.statusCode != 201) {
throw Exception(_extractErrorMessage(response.body, 'Erreur rattachement enfant'));
}
return _amModelFromBody(response.body);
final am = _amModelFromBody(response.body);
// Recalcule toujours places_available (capacité enfants) après attachement.
return syncAmPlacesAvailable(am);
}
/// Aligne `places_available` sur capacité enfants rattachés.
static Future<AssistanteMaternelleModel> syncAmPlacesAvailable(
AssistanteMaternelleModel am,
) async {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected == null || am.placesAvailable == expected) return am;
return updateAmFiche(
amUserId: am.user.id,
body: {'places_available': expected},
);
}
static Future<AssistanteMaternelleModel> detachEnfantFromAm({
+12
View File
@@ -9,6 +9,18 @@ int? amExpectedPlacesAvailable({
return (maxChildren - childrenCount).clamp(0, maxChildren);
}
/// True s'il reste au moins une place d'accueil (capacité enfants).
bool amHasFreePlace(AssistanteMaternelleModel am) {
final expected = amExpectedPlacesAvailable(
maxChildren: am.maxChildren,
childrenCount: am.children.length,
);
if (expected != null) return expected > 0;
final places = am.placesAvailable;
if (places != null) return places > 0;
return true;
}
/// True si la valeur déclarée par l'AM ne correspond pas au calcul métier.
bool amHasPlacesInconsistency({
required int? maxChildren,
+5 -5
View File
@@ -14,10 +14,10 @@ String normalizeEnfantStatus(String? raw) {
return s;
}
String _scolariseAccordeAuGenre(String? gender) {
String _accordeAuGenre(String masculin, String feminin, String? gender) {
final g = (gender ?? '').trim().toUpperCase();
if (g == 'F') return 'Scolarisée';
return 'Scolarisé';
if (g == 'F') return feminin;
return masculin;
}
/// Libellé affiché pour un statut enfant.
@@ -28,9 +28,9 @@ String enfantStatusLabel(String? status, {String? gender}) {
case 'sans_garde':
return 'Sans garde';
case 'garde':
return 'En garde';
return _accordeAuGenre('Gardé', 'Gardée', gender);
case 'scolarise':
return _scolariseAccordeAuGenre(gender);
return _accordeAuGenre('Scolarisé', 'Scolarisée', gender);
default:
return status?.trim().isNotEmpty == true ? status!.trim() : '';
}
@@ -199,9 +199,6 @@ class ParentRegistrationPayload {
map['photo_base64'] = photo.$1;
map['photo_filename'] = photo.$2;
}
// Consentement photo enfant (le back l'ignore encore à l'inscription —
// à brancher côté API ; en attendant le front admin infère via photo).
map['consent_photo'] = c.photoConsent;
return map;
}
+2 -5
View File
@@ -38,15 +38,12 @@ class RepriseMapper {
? isoToDdMmYyyy(e.dueDate)
: isoToDdMmYyyy(e.birthDate);
final photo = e.photoUrl?.trim();
final hasPhoto = photo != null && photo.isNotEmpty;
return ChildData(
firstName: e.firstName ?? '',
lastName: e.lastName ?? '',
dob: dob,
genre: e.gender ?? '',
// Legacy : dossiers inscrits avant #144 avaient consent_photo=false malgré une photo.
// On pré-coche encore si photo en base pour ne pas bloquer la reprise.
photoConsent: e.consentPhoto || hasPhoto,
photoConsent: e.consentPhoto,
multipleBirth: e.estMultiple,
isUnbornChild: isUnborn,
cardColor: _childCardColors[index % _childCardColors.length],
@@ -200,7 +197,7 @@ class RepriseMapper {
postalCode: dossier.codePostal ?? '',
city: dossier.ville ?? '',
existingPhotoUrl: displayPhoto,
consentementPhoto: dossier.consentementPhoto || hasPhoto,
consentementPhoto: dossier.consentementPhoto,
dateOfBirth: parseIsoDate(dossier.dateNaissance),
birthCity: dossier.lieuNaissanceVille ?? '',
birthCountry: dossier.lieuNaissancePays ?? '',
@@ -20,6 +20,8 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
final int capacity;
final void Function(ParentChildSummary child) onOpen;
final void Function(ParentChildSummary child) onDetach;
/// Clic sur une case libre → même flux que « Rattacher un enfant » (#149).
final VoidCallback? onAttachEmpty;
const AdminAmChildrenCapacityGrid({
super.key,
@@ -27,6 +29,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
required this.capacity,
required this.onOpen,
required this.onDetach,
this.onAttachEmpty,
});
@override
@@ -78,7 +81,7 @@ class AdminAmChildrenCapacityGrid extends StatelessWidget {
);
}
if (index < maxSlots) {
return const _EmptySlot();
return _EmptySlot(onTap: onAttachEmpty);
}
return const _UnavailableSlot();
}
@@ -129,18 +132,52 @@ class _UnavailableSlot extends StatelessWidget {
}
}
class _EmptySlot extends StatelessWidget {
const _EmptySlot();
class _EmptySlot extends StatefulWidget {
final VoidCallback? onTap;
const _EmptySlot({this.onTap});
@override
State<_EmptySlot> createState() => _EmptySlotState();
}
class _EmptySlotState extends State<_EmptySlot> {
bool _hovered = false;
@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),
final clickable = widget.onTap != null;
return MouseRegion(
onEnter: clickable ? (_) => setState(() => _hovered = true) : null,
onExit: clickable ? (_) => setState(() => _hovered = false) : null,
cursor: clickable ? SystemMouseCursors.click : MouseCursor.defer,
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(8),
hoverColor: const Color(0x149CC5C0),
child: _SlotShell(
backgroundColor: _hovered
? const Color(0xFFF3F0FA)
: Colors.grey.shade50,
borderColor: _hovered
? const Color(0xFFB8A4D4)
: Colors.grey.shade300,
child: Center(
child: Text(
'Place libre',
style: TextStyle(
fontSize: 12,
color: _hovered
? const Color(0xFF6B3FA0)
: Colors.grey.shade500,
fontWeight: _hovered ? FontWeight.w600 : FontWeight.w400,
),
),
),
),
),
),
);
@@ -1,7 +1,6 @@
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';
@@ -11,6 +10,7 @@ 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_select_enfant_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';
@@ -55,6 +55,10 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
late List<ParentChildSummary> _children;
late Set<String> _baselineChildIds;
/// Enfants ajoutés localement qui étaient déjà chez une autre AM
/// (enfantId → amUserId d'origine). Au save : détacher puis rattacher.
final Map<String, String> _transferFromAmIds = {};
bool _saving = false;
bool _dirty = false;
@@ -234,6 +238,13 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
int? _capaciteMax() => _parseIntField(_capaciteCtrl);
/// True si plus aucune place d'accueil (enfants ≥ capacité max).
bool get _capacityFull {
final max = _capaciteMax();
if (max == null) return false;
return _children.length >= max;
}
int? _computedPlacesAvailable() => amExpectedPlacesAvailable(
maxChildren: _capaciteMax(),
childrenCount: _children.length,
@@ -311,6 +322,17 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
);
}
for (final id in currentIds.difference(_baselineChildIds)) {
// Transfert : détacher l'ancienne AM sans recalculer ses places
// (le ! de vigilance apparaît côté liste AM).
final previousAmId = _transferFromAmIds[id] ??
(await UserService.findAmForEnfant(id))?.user.id;
if (previousAmId != null &&
previousAmId != widget.assistante.user.id) {
await UserService.detachEnfantFromAm(
amUserId: previousAmId,
enfantId: id,
);
}
await UserService.attachEnfantToAm(
amUserId: widget.assistante.user.id,
enfantId: id,
@@ -346,6 +368,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
_dirty = false;
_saving = false;
_baselineChildIds = currentIds;
_transferFromAmIds.clear();
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
@@ -377,6 +400,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() {
_children = kids;
_baselineChildIds = kids.map((c) => c.id).toSet();
_transferFromAmIds.clear();
});
} catch (_) {}
}
@@ -442,55 +466,75 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
setState(() {
_children = _children.where((c) => c.id != child.id).toList();
_transferFromAmIds.remove(child.id);
_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 (_capacityFull || !mounted) return;
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
showSansGardeFilter: true,
);
if (selected == null || !mounted) return;
AssistanteMaternelleModel? previousAm;
try {
previousAm = await UserService.findAmForEnfant(selected.id);
} catch (_) {
previousAm = null;
}
if (!mounted) return;
final previousAmId = previousAm?.user.id;
final isTransfer = previousAmId != null &&
previousAmId != widget.assistante.user.id;
if (isTransfer) {
final amName = previousAm!.user.fullName.trim().isNotEmpty
? previousAm.user.fullName.trim()
: 'une autre assistante maternelle';
final gardeLabel =
(selected.gender ?? '').trim().toUpperCase() == 'F'
? 'déjà gardée'
: 'déjà gardé';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Changer d\'affectation'),
content: Text(
'${selected.fullName} est $gardeLabel par $amName.\n\n'
'Confirmer le transfert vers cette assistante ? '
'L\'enfant sera détaché de $amName.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ValidationModalTheme.primaryElevatedStyle,
child: const Text('Confirmer'),
),
],
),
);
if (confirmed != true || !mounted) return;
}
setState(() {
_children = [
..._children,
ParentChildSummary.fromEnfant(selected),
];
if (isTransfer) {
_transferFromAmIds[selected.id] = previousAmId!;
}
_syncPlacesAfterChildrenChange();
_dirty = true;
});
@@ -701,6 +745,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
capacity: capacity,
onOpen: _openChild,
onDetach: _detachChild,
onAttachEmpty: _capacityFull ? null : _attachChild,
),
],
);
@@ -708,6 +753,7 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
Widget _buildFooter() {
final isChildrenTab = _tabCtrl.index == 2;
final canAttachChild = !_saving && !_capacityFull;
return Row(
children: [
TextButton(
@@ -716,10 +762,15 @@ class _AdminAmEditModalState extends State<AdminAmEditModal>
),
const Spacer(),
if (isChildrenTab)
TextButton.icon(
onPressed: _attachChild,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
Tooltip(
message: _capacityFull
? 'Capacité maximale atteinte'
: 'Rattacher un enfant',
child: TextButton.icon(
onPressed: canAttachChild ? _attachChild : null,
icon: const Icon(Icons.link, size: 18),
label: const Text('Rattacher un enfant'),
),
),
if (isChildrenTab) const SizedBox(width: 12),
ElevatedButton(
@@ -8,6 +8,8 @@ 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_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_photo_frame.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_parent_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_am_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
import 'package:p_tits_pas/widgets/common/auth_network_image.dart';
@@ -41,10 +43,11 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
bool _dirty = false;
bool _saving = false;
bool _deleting = false;
bool _placementBusy = false;
bool _loadingAm = true;
String? _currentUserRole;
AssistanteMaternelleModel? _linkedAm;
/// AM rattachée au chargement (pour sync différé au Sauvegarder).
String? _baselineAmUserId;
static const double _modalWidth = 930;
static const double _mainRowHeight = 380;
@@ -73,7 +76,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
bool get _canDelete =>
(_currentUserRole ?? '').toLowerCase() == 'super_admin';
bool get _busy => _saving || _deleting || _placementBusy;
bool get _busy => _saving || _deleting;
@override
void initState() {
@@ -112,11 +115,15 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
if (!mounted) return;
setState(() {
_linkedAm = am;
_baselineAmUserId = am?.user.id;
_loadingAm = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingAm = false);
setState(() {
_baselineAmUserId = null;
_loadingAm = false;
});
}
}
@@ -170,22 +177,85 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
return '$fn $ln'.trim();
}
String? _parentsSubtitle() {
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 null;
return 'Responsables : ${names.join(', ')}';
List<EnfantParentLink> get _parentLinks => widget.enfant.parentLinks
.where((l) => l.parentId.trim().isNotEmpty)
.toList();
Future<void> _openParent(EnfantParentLink link) async {
if (_busy) return;
final id = link.parentId.trim();
if (id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () => widget.onSaved?.call(),
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _parentsSubtitle() {
final links = _parentLinks;
if (links.isEmpty) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Responsables : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
for (var i = 0; i < links.length; i++) ...[
if (i > 0)
const Text(
', ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
_parentNameLink(links[i]),
],
],
);
}
Widget _parentNameLink(EnfantParentLink link) {
final name = (link.parentName ?? '').trim();
final label = name.isNotEmpty ? name : 'Parent rattaché';
return InkWell(
onTap: _busy ? null : () => _openParent(link),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
);
}
Future<void> _save() async {
if (!_dirty) return;
setState(() => _saving = true);
try {
await _syncAmPlacement();
await UserService.updateEnfant(
enfantId: widget.enfant.id,
body: {
@@ -206,6 +276,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
setState(() {
_dirty = false;
_saving = false;
_baselineAmUserId = _linkedAm?.user.id;
});
widget.onSaved?.call();
ScaffoldMessenger.of(context).showSnackBar(
@@ -220,6 +291,25 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
}
}
/// Applique rattachement / détachement AM (différé jusqu'au Sauvegarder).
Future<void> _syncAmPlacement() async {
final baselineId = _baselineAmUserId;
final currentId = _linkedAm?.user.id;
if (baselineId != null && baselineId != currentId) {
await UserService.detachEnfantFromAm(
amUserId: baselineId,
enfantId: widget.enfant.id,
);
}
if (currentId != null && currentId != baselineId) {
await UserService.attachEnfantToAm(
amUserId: currentId,
enfantId: widget.enfant.id,
);
}
}
Future<void> _delete() async {
if (!_canDelete || _deleting) return;
@@ -276,97 +366,69 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: am,
onSaved: () {
_loadLinkedAm();
onSaved: () async {
await _reloadPlacementFromServer();
widget.onSaved?.call();
},
),
);
}
Future<void> _attachAm() async {
List<AssistanteMaternelleModel> all;
/// Recharge AM + statut après une modale externe (ex. fiche AM).
Future<void> _reloadPlacementFromServer() async {
try {
all = await UserService.getAssistantesMaternelles();
} catch (e) {
final results = await Future.wait([
UserService.findAmForEnfant(widget.enfant.id),
UserService.getEnfant(widget.enfant.id),
]);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
return;
final am = results[0] as AssistanteMaternelleModel?;
final enfant = results[1] as EnfantAdminModel;
setState(() {
_linkedAm = am;
_baselineAmUserId = am?.user.id;
_status = normalizeEnfantStatus(enfant.status);
if (!enfantStatusValues.contains(_status)) {
_status = 'sans_garde';
}
_coerceGenderForStatus();
});
} catch (_) {
if (!mounted) return;
await _loadLinkedAm();
}
}
Future<void> _attachAm() async {
if (_linkedAm != null && !_isSansGarde) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Détachez l\'assistante actuelle avant d\'en choisir une autre'),
content: Text(
'Détachez l\'assistante actuelle avant d\'en choisir une autre',
),
),
);
return;
}
final candidates = all;
if (candidates.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Aucune assistante maternelle disponible')),
);
return;
}
if (!mounted) return;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('Choisir une assistante maternelle'),
children: candidates
.map(
(am) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, am),
child: Text(am.user.fullName),
),
)
.toList(),
),
final selected = await AdminSelectAmModal.show(
context,
excludeIds: {
if (_linkedAm != null) _linkedAm!.user.id,
},
title: 'Choisir une assistante maternelle',
);
if (selected == null || !mounted) return;
setState(() => _placementBusy = true);
try {
// Sans garde : éventuel lien résiduel à clôturer avant rattachement.
final previousAm = _linkedAm;
if (previousAm != null) {
await UserService.detachEnfantFromAm(
amUserId: previousAm.user.id,
enfantId: widget.enfant.id,
);
setState(() {
_linkedAm = selected;
if (_status == 'sans_garde') {
_status = 'garde';
}
await UserService.attachEnfantToAm(
amUserId: selected.user.id,
enfantId: widget.enfant.id,
);
if (!mounted) return;
setState(() {
// Le back passe déjà sans_garde → garde à l'attach ; on aligne l'UI.
if (_status == 'sans_garde') {
_status = 'garde';
}
});
await _loadLinkedAm();
widget.onSaved?.call();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Assistante maternelle rattachée')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
if (mounted) setState(() => _placementBusy = false);
}
_dirty = true;
});
}
Future<void> _detachAm() async {
@@ -378,7 +440,8 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
builder: (ctx) => AlertDialog(
title: const Text('Détacher l\'assistante'),
content: Text(
'Retirer ${am.user.fullName} de la garde de cet enfant ?',
'Retirer ${am.user.fullName} de la garde de cet enfant ?\n'
'(Effectif après Sauvegarder.)',
),
actions: [
TextButton(
@@ -395,57 +458,22 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
);
if (confirmed != true || !mounted) return;
setState(() => _placementBusy = true);
try {
await UserService.detachEnfantFromAm(
amUserId: am.user.id,
enfantId: widget.enfant.id,
);
if (!mounted) return;
setState(() {
// Le back repasse en sans_garde sauf a_naitre / scolarise.
if (_status != 'a_naitre' && _status != 'scolarise') {
_status = 'sans_garde';
}
_linkedAm = null;
});
widget.onSaved?.call();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Assistante maternelle détachée')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
if (mounted) setState(() => _placementBusy = false);
}
setState(() {
_linkedAm = null;
if (_status != 'a_naitre' && _status != 'scolarise') {
_status = 'sans_garde';
}
_dirty = true;
});
}
/// Passe en « Sans garde » : cadre vide + détachement AM si besoin.
Future<void> _applySansGardeStatus() async {
final am = _linkedAm;
if (am == null) return;
setState(() => _placementBusy = true);
try {
await UserService.detachEnfantFromAm(
amUserId: am.user.id,
enfantId: widget.enfant.id,
);
if (!mounted) return;
setState(() => _linkedAm = null);
widget.onSaved?.call();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
} finally {
if (mounted) setState(() => _placementBusy = false);
}
/// Passe en « Sans garde » : cadre vide local (détachement au Sauvegarder).
void _applySansGardeStatus() {
if (_linkedAm == null) return;
setState(() {
_linkedAm = null;
_dirty = true;
});
}
InputDecoration _inputDecoration({String? hint}) {
@@ -668,7 +696,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Scolarisé',
enfantStatusLabel('scolarise', gender: _gender),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
@@ -890,7 +918,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
Widget _placementSection() {
if (!_showsAmPlacement) return _scolariseCard();
if (_loadingAm || _placementBusy) {
if (_loadingAm) {
return SizedBox(
height: _placementHeight,
width: double.infinity,
@@ -994,13 +1022,7 @@ class _AdminChildDetailModalState extends State<AdminChildDetailModal> {
),
if (_parentsSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_parentsSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
_parentsSubtitle()!,
],
],
),
@@ -34,6 +34,8 @@ class AdminEnfantUserCard extends StatelessWidget {
final List<String> subtitleLines;
final List<Widget> actions;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminEnfantUserCard({
super.key,
@@ -42,6 +44,8 @@ class AdminEnfantUserCard extends StatelessWidget {
required this.subtitleLines,
this.actions = const [],
this.onCardTap,
this.margin,
this.contentPadding,
});
factory AdminEnfantUserCard.fromEnfant(
@@ -49,6 +53,8 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) {
final parents = enfant.parentLinks
.map((l) => l.parentName ?? 'Parent')
@@ -68,6 +74,8 @@ class AdminEnfantUserCard extends StatelessWidget {
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
);
}
@@ -76,6 +84,8 @@ class AdminEnfantUserCard extends StatelessWidget {
List<String> extraSubtitleLines = const [],
List<Widget> actions = const [],
VoidCallback? onCardTap,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? contentPadding,
}) {
return AdminEnfantUserCard(
title: child.fullName,
@@ -88,6 +98,8 @@ class AdminEnfantUserCard extends StatelessWidget {
),
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
);
}
@@ -100,6 +112,8 @@ class AdminEnfantUserCard extends StatelessWidget {
subtitleLines: subtitleLines,
actions: actions,
onCardTap: onCardTap,
margin: margin,
contentPadding: contentPadding,
);
}
}
@@ -1,5 +1,4 @@
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';
@@ -7,6 +6,7 @@ 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_select_enfant_modal.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';
@@ -113,10 +113,73 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
return '$fn $ln'.trim();
}
String? _coParentSubtitle() {
String? _coParentName() {
final name = _coParent?.fullName.trim() ?? '';
if (name.isEmpty) return null;
return 'Co-parent : $name';
return name.isEmpty ? null : name;
}
Future<void> _openCoParent() async {
if (_saving) return;
final co = _coParent;
final id = (co?.id ?? '').trim();
if (co == null || id.isEmpty) return;
try {
final parent = await UserService.getParent(id);
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminParentEditModal(
parent: parent,
onSaved: () async {
try {
final refreshed = await UserService.getParent(widget.parent.user.id);
if (!mounted) return;
setState(() => _coParent = refreshed.coParent);
} catch (_) {}
widget.onSaved?.call();
},
),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString().replaceFirst('Exception: ', ''))),
);
}
}
Widget? _coParentSubtitle() {
final name = _coParentName();
if (name == null) return null;
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
const Text(
'Co-parent : ',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
InkWell(
onTap: _saving ? null : _openCoParent,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
child: Text(
name,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: ValidationModalTheme.primaryActionBackground,
decoration: TextDecoration.underline,
decorationColor: ValidationModalTheme.primaryActionBackground
.withValues(alpha: 0.5),
),
),
),
),
],
);
}
Future<void> _save() async {
@@ -258,41 +321,11 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
}
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(),
),
final selected = await AdminSelectEnfantModal.show(
context,
excludeIds: _children.map((c) => c.id).toSet(),
title: 'Rattacher un enfant',
);
if (selected == null || !mounted) return;
@@ -395,13 +428,7 @@ class _AdminParentEditModalState extends State<AdminParentEditModal> {
),
if (_coParentSubtitle() != null) ...[
const SizedBox(height: 4),
Text(
_coParentSubtitle()!,
style: const TextStyle(
fontSize: 13,
color: Colors.black54,
),
),
_coParentSubtitle()!,
],
],
),
@@ -0,0 +1,218 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:p_tits_pas/models/assistante_maternelle_model.dart';
import 'package:p_tits_pas/services/user_service.dart';
import 'package:p_tits_pas/utils/am_vigilance.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_am_edit_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
List<String> _amSelectSubtitleLines(AssistanteMaternelleModel am) {
final lines = <String>[];
final zone = (am.residenceCity ?? '').trim();
if (zone.isNotEmpty) lines.add('Zone : $zone');
final agrement = (am.approvalNumber ?? '').trim();
if (agrement.isNotEmpty) lines.add('Agrément : $agrement');
final max = am.maxChildren;
final free = amExpectedPlacesAvailable(
maxChildren: max,
childrenCount: am.children.length,
) ??
am.placesAvailable;
if (free != null || max != null) {
lines.add('Places libres : ${free ?? ''} / capa. ${max ?? ''}');
}
lines.add('${am.children.length} enfant(s)');
return lines;
}
/// Sélection d'une AM à rattacher (fiche enfant) — ticket #147.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #146).
class AdminSelectAmModal {
AdminSelectAmModal._();
static Future<AssistanteMaternelleModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Choisir une assistante maternelle',
}) {
return AdminSelectListModal.show<AssistanteMaternelleModel>(
context,
title: title,
searchHint: 'Rechercher par nom, prénom ou zone…',
emptyMessage: 'Aucune assistante maternelle disponible',
noResultsMessage: 'Aucune AM avec place libre pour cette recherche',
toggleFilter: const AdminSelectToggleFilter<AssistanteMaternelleModel>(
label: 'Libre',
initialValue: true,
whenEnabled: amHasFreePlace,
),
loadItems: () async {
final list = await UserService.getAssistantesMaternelles();
return list
.where((am) => !excludeIds.contains(am.user.id))
.toList()
..sort(
(a, b) => a.user.fullName
.toLowerCase()
.compareTo(b.user.fullName.toLowerCase()),
);
},
matchesQuery: (am, q) {
final u = am.user;
final name = u.fullName.toLowerCase();
final fn = (u.prenom ?? '').toLowerCase();
final ln = (u.nom ?? '').toLowerCase();
final zone = (am.residenceCity ?? '').toLowerCase();
final agrement = (am.approvalNumber ?? '').toLowerCase();
return name.contains(q) ||
fn.contains(q) ||
ln.contains(q) ||
zone.contains(q) ||
agrement.contains(q);
},
resolveSelect: (ctx, am, reload) =>
_resolveAmSelection(ctx, am, reload),
itemBuilder: (context, am, onSelect) {
final full = !amHasFreePlace(am);
return AdminUserCard(
title: am.user.fullName,
avatarUrl: am.user.photoUrl,
fallbackIcon: Icons.face,
subtitleLines: _amSelectSubtitleLines(am),
vigilanceTooltip: amPlacesVigilanceMessage(am),
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
backgroundColor: full ? const Color(0xFFFFEBEE) : null,
borderColor: full ? Colors.red.shade200 : null,
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
static Future<AssistanteMaternelleModel?> _resolveAmSelection(
BuildContext context,
AssistanteMaternelleModel am,
Future<void> Function() reloadList,
) async {
if (amHasFreePlace(am)) return am;
final selected = await showDialog<AssistanteMaternelleModel>(
context: context,
builder: (ctx) => _AmNoPlaceWarningDialog(initialAm: am),
);
await reloadList();
return selected;
}
}
/// Avertissement AM saturée + lien vers la fiche pour ajuster les places.
class _AmNoPlaceWarningDialog extends StatefulWidget {
final AssistanteMaternelleModel initialAm;
const _AmNoPlaceWarningDialog({required this.initialAm});
@override
State<_AmNoPlaceWarningDialog> createState() =>
_AmNoPlaceWarningDialogState();
}
class _AmNoPlaceWarningDialogState extends State<_AmNoPlaceWarningDialog> {
late AssistanteMaternelleModel _am;
TapGestureRecognizer? _linkRecognizer;
@override
void initState() {
super.initState();
_am = widget.initialAm;
_linkRecognizer = TapGestureRecognizer()..onTap = _openAmFiche;
}
@override
void dispose() {
_linkRecognizer?.dispose();
super.dispose();
}
Future<void> _openAmFiche() async {
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AdminAmEditModal(
assistante: _am,
onSaved: () async {
try {
final fresh =
await UserService.getAssistanteMaternelle(_am.user.id);
if (mounted) setState(() => _am = fresh);
} catch (_) {}
},
),
);
if (!mounted) return;
try {
final fresh = await UserService.getAssistanteMaternelle(_am.user.id);
if (!mounted) return;
setState(() => _am = fresh);
if (amHasFreePlace(fresh)) {
Navigator.of(context).pop(fresh);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final name = _am.user.fullName.trim().isNotEmpty
? _am.user.fullName.trim()
: 'Cette assistante maternelle';
const linkColor = ValidationModalTheme.primaryActionBackground;
return AlertDialog(
title: const Text('Plus de place disponible'),
content: Text.rich(
TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87, height: 1.4),
children: [
TextSpan(
text: '$name n\'a plus de place libre pour accueillir '
'un enfant supplémentaire.\n\n',
),
const TextSpan(text: 'Vous pouvez '),
TextSpan(
text: 'ouvrir sa fiche',
style: TextStyle(
color: linkColor,
decoration: TextDecoration.underline,
fontWeight: FontWeight.w600,
),
recognizer: _linkRecognizer,
),
const TextSpan(
text: ' pour modifier la capacité ou les places, '
'puis la sélectionner si une place se libère.',
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
}
@@ -0,0 +1,72 @@
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';
import 'package:p_tits_pas/widgets/admin/common/admin_enfant_user_card.dart';
import 'package:p_tits_pas/widgets/admin/common/admin_select_list_modal.dart';
/// Sélection d'un enfant à rattacher (fiche AM / fiche parent) — ticket #146.
/// S'appuie sur [AdminSelectListModal] (shell partagé avec #147).
class AdminSelectEnfantModal {
AdminSelectEnfantModal._();
static Future<EnfantAdminModel?> show(
BuildContext context, {
Set<String> excludeIds = const {},
String title = 'Rattacher un enfant',
/// Affiché uniquement depuis la fiche AM : filtre les enfants déjà en garde.
bool showSansGardeFilter = false,
}) {
return AdminSelectListModal.show<EnfantAdminModel>(
context,
title: title,
searchHint: 'Rechercher par nom ou prénom…',
emptyMessage: 'Aucun enfant disponible à rattacher',
noResultsMessage: showSansGardeFilter
? 'Aucun enfant sans garde pour cette recherche'
: 'Aucun résultat pour cette recherche',
toggleFilter: showSansGardeFilter
? AdminSelectToggleFilter<EnfantAdminModel>(
label: 'Sans garde',
initialValue: true,
whenEnabled: (e) =>
normalizeEnfantStatus(e.status) == 'sans_garde',
)
: null,
loadItems: () async {
final list = await UserService.getEnfants();
return list
.where((e) => !excludeIds.contains(e.id))
.toList()
..sort(
(a, b) =>
a.fullName.toLowerCase().compareTo(b.fullName.toLowerCase()),
);
},
matchesQuery: (e, q) {
final name = e.fullName.toLowerCase();
final fn = (e.firstName ?? '').toLowerCase();
final ln = (e.lastName ?? '').toLowerCase();
return name.contains(q) || fn.contains(q) || ln.contains(q);
},
itemBuilder: (context, e, onSelect) {
return AdminEnfantUserCard.fromEnfant(
e,
onCardTap: onSelect,
margin: const EdgeInsets.only(bottom: 4),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 5,
),
actions: [
IconButton(
icon: const Icon(Icons.add_link),
tooltip: 'Rattacher',
onPressed: onSelect,
),
],
);
},
);
}
}
@@ -0,0 +1,367 @@
import 'package:flutter/material.dart';
import 'package:p_tits_pas/widgets/admin/validation_modal_theme.dart';
/// Filtre optionnel (switch) sur la même ligne que la barre de recherche.
class AdminSelectToggleFilter<T> {
final String label;
final bool initialValue;
/// Si le switch est activé, ne garde que les éléments pour lesquels
/// [whenEnabled] renvoie `true`.
final bool Function(T item) whenEnabled;
const AdminSelectToggleFilter({
required this.label,
required this.whenEnabled,
this.initialValue = true,
});
}
/// Shell générique « rechercher + liste + sélection » pour les modales admin.
/// Utilisé par la sélection d'enfant (#146) et la sélection d'AM (#147).
class AdminSelectListModal<T> extends StatefulWidget {
final String title;
final String searchHint;
final Future<List<T>> Function() loadItems;
final bool Function(T item, String query) matchesQuery;
final Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder;
final String emptyMessage;
final String noResultsMessage;
final double modalWidth;
/// Hauteur d'une carte (pour dimensionner la liste à ≥ [minVisibleCards]).
final double cardExtent;
/// Nombre minimum de cartes visibles dans la zone scrollable.
final int minVisibleCards;
/// Switch optionnel à droite du champ de recherche (ex. « Sans garde », « Libre »).
final AdminSelectToggleFilter<T>? toggleFilter;
/// Si fourni, appelé avant de valider la sélection.
/// Retourne l'élément à pop (éventuellement rafraîchi), ou `null` pour annuler.
final Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect;
const AdminSelectListModal({
super.key,
required this.title,
required this.loadItems,
required this.matchesQuery,
required this.itemBuilder,
this.searchHint = 'Rechercher…',
this.emptyMessage = 'Aucun élément disponible',
this.noResultsMessage = 'Aucun résultat pour cette recherche',
this.modalWidth = 930,
this.cardExtent = 52,
this.minVisibleCards = 8,
this.toggleFilter,
this.resolveSelect,
});
static Future<T?> show<T>(
BuildContext context, {
required String title,
required Future<List<T>> Function() loadItems,
required bool Function(T item, String query) matchesQuery,
required Widget Function(
BuildContext context,
T item,
VoidCallback onSelect,
) itemBuilder,
String searchHint = 'Rechercher…',
String emptyMessage = 'Aucun élément disponible',
String noResultsMessage = 'Aucun résultat pour cette recherche',
double modalWidth = 930,
double cardExtent = 52,
int minVisibleCards = 8,
AdminSelectToggleFilter<T>? toggleFilter,
Future<T?> Function(
BuildContext context,
T item,
Future<void> Function() reload,
)? resolveSelect,
}) {
return showDialog<T>(
context: context,
builder: (ctx) => AdminSelectListModal<T>(
title: title,
loadItems: loadItems,
matchesQuery: matchesQuery,
itemBuilder: itemBuilder,
searchHint: searchHint,
emptyMessage: emptyMessage,
noResultsMessage: noResultsMessage,
modalWidth: modalWidth,
cardExtent: cardExtent,
minVisibleCards: minVisibleCards,
toggleFilter: toggleFilter,
resolveSelect: resolveSelect,
),
);
}
@override
State<AdminSelectListModal<T>> createState() =>
_AdminSelectListModalState<T>();
}
class _AdminSelectListModalState<T> extends State<AdminSelectListModal<T>> {
final _searchCtrl = TextEditingController();
List<T> _all = [];
bool _loading = true;
String? _error;
late bool _toggleOn;
bool _resolving = false;
double get _listHeight =>
widget.cardExtent * widget.minVisibleCards;
@override
void initState() {
super.initState();
_toggleOn = widget.toggleFilter?.initialValue ?? false;
_searchCtrl.addListener(() => setState(() {}));
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final list = await widget.loadItems();
if (!mounted) return;
setState(() {
_all = list;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
Future<void> _handleSelect(T item) async {
if (_resolving) return;
final resolve = widget.resolveSelect;
if (resolve == null) {
Navigator.of(context).pop(item);
return;
}
setState(() => _resolving = true);
try {
final chosen = await resolve(context, item, _load);
if (!mounted || chosen == null) return;
Navigator.of(context).pop(chosen);
} finally {
if (mounted) setState(() => _resolving = false);
}
}
List<T> get _filtered {
var list = _all;
final toggle = widget.toggleFilter;
if (toggle != null && _toggleOn) {
list = list.where(toggle.whenEnabled).toList();
}
final q = _searchCtrl.text.trim().toLowerCase();
if (q.isEmpty) return list;
return list.where((item) => widget.matchesQuery(item, q)).toList();
}
@override
Widget build(BuildContext context) {
final toggle = widget.toggleFilter;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: widget.modalWidth),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 4, 0),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
IconButton(
icon: const Icon(Icons.close),
tooltip: 'Fermer',
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
isDense: true,
hintText: widget.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
),
),
),
if (toggle != null) ...[
const SizedBox(width: 12),
Text(
toggle.label,
style: const TextStyle(fontSize: 13),
),
const SizedBox(width: 4),
Switch(
value: _toggleOn,
activeColor:
ValidationModalTheme.primaryActionBackground,
onChanged: (v) => setState(() => _toggleOn = v),
),
],
],
),
const SizedBox(height: 12),
SizedBox(
height: _listHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildBody(),
),
),
),
const SizedBox(height: 12),
Row(
children: [
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: ValidationModalTheme.primaryActionBackground,
),
);
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 40, color: Colors.red.shade400),
const SizedBox(height: 12),
Text(
_error!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.red.shade700),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
),
);
}
final items = _filtered;
if (_all.isEmpty) {
return Center(
child: Text(
widget.emptyMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
if (items.isEmpty) {
return Center(
child: Text(
widget.noResultsMessage,
style: const TextStyle(fontSize: 14, color: Colors.black54),
),
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
itemExtent: widget.cardExtent,
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[i];
return widget.itemBuilder(
context,
item,
() => _handleSelect(item),
);
},
);
}
}
@@ -15,6 +15,7 @@ class AdminUserCard extends StatefulWidget {
final String? vigilanceTooltip;
final VoidCallback? onCardTap;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? contentPadding;
const AdminUserCard({
super.key,
@@ -30,6 +31,7 @@ class AdminUserCard extends StatefulWidget {
this.vigilanceTooltip,
this.onCardTap,
this.margin,
this.contentPadding,
});
@override
@@ -69,7 +71,8 @@ class _AdminUserCardState extends State<AdminUserCard> {
side: BorderSide(color: widget.borderColor ?? Colors.grey.shade300),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
padding: widget.contentPadding ??
const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
child: Row(
children: [
_buildAvatar(avatarUrl),
@@ -88,7 +91,10 @@ class _AdminUserCardState extends State<AdminUserCard> {
Expanded(
child: Row(
children: [
// flex: 0 → largeur du nom ; le reste va aux infos
// (évite le partage 50/50 qui tronque « Responsables »).
Flexible(
flex: 0,
fit: FlexFit.loose,
child: Text(
widget.title.isNotEmpty ? widget.title : 'Sans nom',
@@ -215,7 +215,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
value: 'garde',
child: Padding(
padding: EdgeInsets.only(left: 10),
child: Text('En garde', style: TextStyle(fontSize: 12)),
child: Text('Gardé', style: TextStyle(fontSize: 12)),
),
),
DropdownMenuItem<String?>(