diff --git a/frontend/lib/models/dossier_list_item.dart b/frontend/lib/models/dossier_list_item.dart new file mode 100644 index 0000000..375c817 --- /dev/null +++ b/frontend/lib/models/dossier_list_item.dart @@ -0,0 +1,191 @@ +import 'package:p_tits_pas/models/assistante_maternelle_model.dart'; +import 'package:p_tits_pas/models/parent_model.dart'; +import 'package:p_tits_pas/utils/name_format_utils.dart'; + +/// Ligne de liste unifiée dossiers (famille ou AM) — ticket #153. +enum DossierListType { famille, assistanteMaternelle } + +class DossierListItem { + final DossierListType type; + final String numeroDossier; + final String libelle; + final List emails; + final String? statut; + + const DossierListItem({ + required this.type, + required this.numeroDossier, + required this.libelle, + this.emails = const [], + this.statut, + }); + + bool get isFamille => type == DossierListType.famille; + bool get isAm => type == DossierListType.assistanteMaternelle; + + String get typeLabel => isFamille ? 'Famille' : 'AM'; + + /// Sous-titre carte : `NOM Prénom` ou `NOM Prénom - NOM Prénom`. + String get namesLine => libelle; + + String get emailsLine => emails.where((e) => e.trim().isNotEmpty).join(' · '); + + /// Titre carte : numéro de dossier seul. + String get titleLine => numeroDossier; + + bool matchesQuery(String query) { + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + if (numeroDossier.toLowerCase().contains(q)) return true; + if (libelle.toLowerCase().contains(q)) return true; + for (final e in emails) { + if (e.toLowerCase().contains(q)) return true; + } + return false; + } + + /// Une ligne par `numero_dossier` (foyer dédupliqué). + static List fromParents(List parents) { + final byDossier = >{}; + for (final p in parents) { + final num = (p.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => []).add(p); + } + + final items = []; + for (final entry in byDossier.entries) { + final seenIds = {}; + final names = []; + final emails = []; + final statuts = []; + + void consider( + String? id, + String? nom, + String? prenom, + String? email, + String? statut, + ) { + final uid = (id ?? '').trim(); + if (uid.isEmpty || !seenIds.add(uid)) return; + final label = formatDossierPersonLabel( + nom: nom, + prenom: prenom, + email: email, + ); + if (label.isNotEmpty) names.add(label); + final e = (email ?? '').trim(); + if (e.isNotEmpty) emails.add(e); + final s = (statut ?? '').trim(); + if (s.isNotEmpty) statuts.add(s); + } + + for (final p in entry.value) { + consider( + p.user.id, + p.user.nom, + p.user.prenom, + p.user.email, + p.user.statut, + ); + final co = p.coParent; + if (co != null) { + consider(co.id, co.nom, co.prenom, co.email, co.statut); + } + } + + items.add( + DossierListItem( + type: DossierListType.famille, + numeroDossier: entry.key, + libelle: names.isNotEmpty ? names.join(' - ') : 'Famille', + emails: emails, + statut: _preferStatut(statuts), + ), + ); + } + return items; + } + + static List fromAssistantes( + List ams, + ) { + final byDossier = {}; + for (final am in ams) { + final num = (am.user.numeroDossier ?? '').trim(); + if (num.isEmpty) continue; + byDossier.putIfAbsent(num, () => am); + } + + return byDossier.entries.map((e) { + final u = e.value.user; + final name = formatDossierPersonLabel( + nom: u.nom, + prenom: u.prenom, + email: u.email, + ); + return DossierListItem( + type: DossierListType.assistanteMaternelle, + numeroDossier: e.key, + libelle: name.isNotEmpty ? name : 'AM', + emails: u.email.trim().isEmpty ? const [] : [u.email.trim()], + statut: u.statut?.trim(), + ); + }).toList(); + } + + /// Priorité affichage : en_attente > suspendu > refuse > actif > autre. + static String? _preferStatut(List raw) { + if (raw.isEmpty) return null; + const order = ['en_attente', 'suspendu', 'refuse', 'actif']; + for (final wanted in order) { + for (final s in raw) { + if (s.toLowerCase() == wanted) return s; + } + } + return raw.first; + } +} + +/// Affichage carte dossier : `NOM Prénom` (repli email). +String formatDossierPersonLabel({ + String? nom, + String? prenom, + String? email, +}) { + final n = (nom ?? '').trim().toUpperCase(); + final p = formatPersonNameCase(prenom ?? ''); + if (n.isNotEmpty && p.isNotEmpty) return '$n $p'; + if (n.isNotEmpty) return n; + if (p.isNotEmpty) return p; + return (email ?? '').trim(); +} + +/// Reformate un libellé famille API (`A & B` / `Famille …`) en `NOM Prénom - …`. +String formatDossierFamilyNamesLine(String libelle) { + var raw = libelle.trim(); + if (raw.isEmpty) return ''; + raw = raw.replaceFirst(RegExp(r'^famille\s+', caseSensitive: false), ''); + raw = raw + .replaceAll(RegExp(r'\s+&\s+'), ' - ') + .replaceAll(RegExp(r'\s+et\s+', caseSensitive: false), ' - '); + final parts = raw + .split(RegExp(r'\s+-\s+')) + .map((part) => _formatLoosePersonSegment(part.trim())) + .where((s) => s.isNotEmpty) + .toList(); + return parts.join(' - '); +} + +/// Segment libre type « martin sophie » ou « DURAND Amélie » → `NOM Prénom`. +String _formatLoosePersonSegment(String segment) { + final words = + segment.split(RegExp(r'\s+')).where((w) => w.isNotEmpty).toList(); + if (words.isEmpty) return ''; + if (words.length == 1) return words.first.toUpperCase(); + // Convention affichage : premier mot = NOM, reste = prénom(s). + final nom = words.first.toUpperCase(); + final prenom = formatPersonNameCase(words.sublist(1).join(' ')); + return '$nom $prenom'; +} diff --git a/frontend/lib/widgets/admin/dossier_list_card.dart b/frontend/lib/widgets/admin/dossier_list_card.dart new file mode 100644 index 0000000..d0e3f67 --- /dev/null +++ b/frontend/lib/widgets/admin/dossier_list_card.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/widgets/admin/common/admin_user_card.dart'; + +/// Carte dossier unifiée (#153) — pending et liste complète. +class DossierListCard extends StatelessWidget { + final String numeroDossier; + final String namesLine; + final bool isFamille; + final VoidCallback onOpen; + + static const Color familleBg = Color(0xFFF3EEF8); + static const Color familleBorder = Color(0xFFB39DDB); + static const Color familleAccent = Color(0xFF7E57C2); + static const Color amBg = Color(0xFFE8F5F3); + static const Color amBorder = Color(0xFF80CBC4); + static const Color amAccent = Color(0xFF00897B); + + const DossierListCard({ + super.key, + required this.numeroDossier, + required this.namesLine, + required this.isFamille, + required this.onOpen, + }); + + @override + Widget build(BuildContext context) { + final bg = isFamille ? familleBg : amBg; + final border = isFamille ? familleBorder : amBorder; + final accent = isFamille ? familleAccent : amAccent; + final num = numeroDossier.trim().isEmpty ? '–' : numeroDossier.trim(); + final names = namesLine.trim(); + + return AdminUserCard( + title: num, + subtitleLines: names.isEmpty ? const [] : [names], + fallbackIcon: + isFamille ? Icons.family_restroom_outlined : Icons.person_outline, + backgroundColor: bg, + borderColor: border, + titleColor: accent, + infoColor: Colors.black87, + onCardTap: onOpen, + actions: [ + IconButton( + tooltip: 'Ouvrir', + icon: Icon(Icons.open_in_new, size: 20, color: accent), + onPressed: onOpen, + ), + ], + ); + } +} diff --git a/frontend/lib/widgets/admin/dossiers_management_widget.dart b/frontend/lib/widgets/admin/dossiers_management_widget.dart new file mode 100644 index 0000000..06f6113 --- /dev/null +++ b/frontend/lib/widgets/admin/dossiers_management_widget.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; +import 'package:p_tits_pas/services/user_service.dart'; +import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; +import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; +import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; + +/// Onglet permanent « Dossiers » (#153) : pending en haut + liste unifiée en bas. +class DossiersManagementWidget extends StatefulWidget { + final String searchQuery; + final VoidCallback? onRefresh; + + const DossiersManagementWidget({ + super.key, + this.searchQuery = '', + this.onRefresh, + }); + + @override + State createState() => + _DossiersManagementWidgetState(); +} + +class _DossiersManagementWidgetState extends State { + bool _loading = true; + String? _error; + List _all = []; + Set _pendingNumeros = {}; + int _pendingRefreshTick = 0; + + @override + void initState() { + super.initState(); + _loadAll(); + } + + Future _loadAll() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final parents = await UserService.getParents(); + final ams = await UserService.getAssistantesMaternelles(); + if (!mounted) return; + final items = [ + ...DossierListItem.fromParents(parents), + ...DossierListItem.fromAssistantes(ams), + ]; + items.sort((a, b) { + final byNum = a.numeroDossier.compareTo(b.numeroDossier); + if (byNum != 0) return byNum; + return a.typeLabel.compareTo(b.typeLabel); + }); + setState(() { + _all = items; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; + _loading = false; + }); + } + } + + Future _refreshEverything() async { + setState(() => _pendingRefreshTick++); + await _loadAll(); + widget.onRefresh?.call(); + } + + void _openDossier(String numeroDossier) { + final num = numeroDossier.trim(); + if (num.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Numéro de dossier manquant.')), + ); + return; + } + showDialog( + context: context, + builder: (context) => ValidationDossierModal( + numeroDossier: num, + onClose: () => Navigator.of(context).pop(), + onSuccess: () { + Navigator.of(context).pop(); + _refreshEverything(); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + final query = widget.searchQuery; + // Pending uniquement en haut — exclus de « Tous les dossiers ». + final filtered = _all + .where((d) => !_pendingNumeros.contains(d.numeroDossier)) + .where((d) => (d.statut ?? '').toLowerCase() != 'en_attente') + .where((d) => d.matchesQuery(query)) + .toList(growable: false); + + return RefreshIndicator( + onRefresh: _refreshEverything, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: PendingValidationWidget( + key: ValueKey('pending-$_pendingRefreshTick'), + searchQuery: query, + compactWhenEmpty: true, + onPendingNumerosChanged: (nums) { + if (!mounted) return; + setState(() => _pendingNumeros = nums); + }, + onRefresh: () { + _loadAll(); + widget.onRefresh?.call(); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Tous les dossiers', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + if (_loading) + const SliverFillRemaining( + hasScrollBody: false, + child: Center(child: CircularProgressIndicator()), + ) + else if (_error != null && _error!.isNotEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadAll, + child: const Text('Réessayer'), + ), + ], + ), + ), + ), + ) + else if (filtered.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + child: Text( + query.trim().isEmpty + ? 'Aucun dossier pour le moment.\n' + 'Pour créer un dossier → onglet Parents (+ Parents) ' + 'ou Assistantes maternelles (+ Asmat).' + : 'Aucun dossier ne correspond à la recherche.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600, height: 1.4), + ), + ), + ) + else + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final item = filtered[index]; + return DossierListCard( + numeroDossier: item.numeroDossier, + namesLine: item.namesLine, + isFamille: item.isFamille, + onOpen: () => _openDossier(item.numeroDossier), + ); + }, + childCount: filtered.length, + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/admin/pending_validation_widget.dart b/frontend/lib/widgets/admin/pending_validation_widget.dart index 361a54c..db30a71 100644 --- a/frontend/lib/widgets/admin/pending_validation_widget.dart +++ b/frontend/lib/widgets/admin/pending_validation_widget.dart @@ -1,19 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:p_tits_pas/models/dossier_list_item.dart'; import 'package:p_tits_pas/models/user.dart'; import 'package:p_tits_pas/models/pending_family.dart'; import 'package:p_tits_pas/services/user_service.dart'; -import 'package:p_tits_pas/utils/phone_utils.dart'; +import 'package:p_tits_pas/widgets/admin/dossier_list_card.dart'; import 'package:p_tits_pas/widgets/admin/validation_dossier_modal.dart'; -/// Onglet « À valider » : deux listes (AM en attente, familles en attente). Ticket #107. +/// Section « dossiers à valider » (liste unifiée AM + familles). Ticket #107 / #153. class PendingValidationWidget extends StatefulWidget { final VoidCallback? onRefresh; + /// Filtre client (n°, nom, email) — onglet Dossiers (#153). + final String searchQuery; + /// Si true et liste vide : message court (pas de grand vide centré). + final bool compactWhenEmpty; + /// Numéros des dossiers pending (pour exclure de « Tous les dossiers »). + final ValueChanged>? onPendingNumerosChanged; - const PendingValidationWidget({super.key, this.onRefresh}); + const PendingValidationWidget({ + super.key, + this.onRefresh, + this.searchQuery = '', + this.compactWhenEmpty = false, + this.onPendingNumerosChanged, + }); @override - State createState() => _PendingValidationWidgetState(); + State createState() => + _PendingValidationWidgetState(); } class _PendingValidationWidgetState extends State { @@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State { String? _error; List _pendingAM = []; List _pendingFamilies = []; + /// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls). + final Map _familyNamesByNumero = {}; @override void initState() { @@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State { _error = null; }); try { - final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); + final am = + await UserService.getPendingUsers(role: 'assistante_maternelle'); final families = await UserService.getPendingFamilies(); + final namesByNumero = await _enrichFamilyNames(families); if (!mounted) return; setState(() { _pendingAM = am; _pendingFamilies = families; + _familyNamesByNumero + ..clear() + ..addAll(namesByNumero); _isLoading = false; }); + _emitPendingNumeros(); } catch (e) { if (!mounted) return; setState(() { - _error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue'; + _error = e is Exception + ? e.toString().replaceFirst('Exception: ', '') + : 'Erreur inconnue'; _isLoading = false; }); + widget.onPendingNumerosChanged?.call(const {}); } } - void _onOpenValidation({String? type, String? id, String? numeroDossier}) { + /// Complète `NOM Prénom` via le détail dossier (sans changer le back). + Future> _enrichFamilyNames( + List families, + ) async { + final out = {}; + await Future.wait(families.map((f) async { + final num = (f.numeroDossier ?? '').trim(); + if (num.isEmpty) return; + try { + final dossier = await UserService.getDossier(num); + if (!dossier.isFamily) return; + final labels = []; + final seen = {}; + for (final p in dossier.asFamily.parents) { + final id = p.id.trim(); + if (id.isNotEmpty && !seen.add(id)) continue; + final label = formatDossierPersonLabel( + nom: p.nom, + prenom: p.prenom, + email: p.email, + ); + if (label.isNotEmpty) labels.add(label); + } + if (labels.isNotEmpty) out[num] = labels.join(' - '); + } catch (_) { + // Repli libellé API ci-dessous. + } + })); + return out; + } + + void _emitPendingNumeros() { + final nums = {}; + for (final u in _pendingAM) { + final n = (u.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + for (final f in _pendingFamilies) { + final n = (f.numeroDossier ?? '').trim(); + if (n.isNotEmpty) nums.add(n); + } + widget.onPendingNumerosChanged?.call(nums); + } + + void _onOpenValidation({String? numeroDossier}) { final num = numeroDossier?.trim(); if (num == null || num.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -73,9 +141,48 @@ class _PendingValidationWidgetState extends State { ); } + bool _matchesQuery(String haystack) { + final q = widget.searchQuery.trim().toLowerCase(); + if (q.isEmpty) return true; + return haystack.toLowerCase().contains(q); + } + + List get _filteredAM { + return _pendingAM.where((u) { + final bits = [ + u.numeroDossier ?? '', + u.fullName, + u.email, + u.nom ?? '', + u.prenom ?? '', + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + + List get _filteredFamilies { + return _pendingFamilies.where((f) { + final num = (f.numeroDossier ?? '').trim(); + final enriched = _familyNamesByNumero[num] ?? ''; + final bits = [ + f.numeroDossier ?? '', + f.libelle, + enriched, + f.emails.join(' '), + ].join(' '); + return _matchesQuery(bits); + }).toList(); + } + @override Widget build(BuildContext context) { if (_isLoading) { + if (widget.compactWhenEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } return const Center(child: CircularProgressIndicator()); } if (_error != null && _error!.isNotEmpty) { @@ -97,14 +204,32 @@ class _PendingValidationWidgetState extends State { ); } - final hasAM = _pendingAM.isNotEmpty; - final hasFamilies = _pendingFamilies.isNotEmpty; - if (!hasAM && !hasFamilies) { + final pendingAM = _filteredAM; + final pendingFamilies = _filteredFamilies; + final cards = [ + ...pendingAM.map(_buildAMCard), + ...pendingFamilies.map(_buildFamilyCard), + ]; + + if (cards.isEmpty) { + if (widget.compactWhenEmpty) { + final searching = widget.searchQuery.trim().isNotEmpty; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + searching + ? 'Aucun dossier en attente ne correspond à la recherche.' + : 'Aucun dossier en attente.', + style: TextStyle(color: Colors.grey.shade600, fontSize: 13), + ), + ); + } return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: Colors.grey.shade400), + Icon(Icons.check_circle_outline, + size: 64, color: Colors.grey.shade400), const SizedBox(height: 16), Text( 'Aucun dossier en attente de validation', @@ -117,6 +242,28 @@ class _PendingValidationWidgetState extends State { ); } + final list = Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Dossiers à valider', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + ...cards, + ], + ); + + if (widget.compactWhenEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: list, + ); + } + return RefreshIndicator( onRefresh: () async { await _load(); @@ -125,275 +272,39 @@ class _PendingValidationWidgetState extends State { child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (hasAM) ...[ - _sectionTitle('Assistantes maternelles en attente'), - const SizedBox(height: 8), - ..._pendingAM.map((u) => _buildAMCard(u)), - const SizedBox(height: 24), - ], - if (hasFamilies) ...[ - _sectionTitle('Familles en attente'), - const SizedBox(height: 8), - ..._pendingFamilies.map((f) => _buildFamilyCard(f)), - ], - ], - ), + child: list, ), ); } - Widget _sectionTitle(String title) { - return Text( - title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + String _amNamesLine(AppUser user) { + return formatDossierPersonLabel( + nom: user.nom, + prenom: user.prenom, + email: user.email, ); } - - /// Sous-titre AM : `email - date • tél. • CP ville` (plan affichage lignes À valider). - String _amSubtitleLine(AppUser user) { - final email = user.email.trim(); - final bits = []; - bits.add(DateFormat('dd/MM/yyyy').format(user.createdAt.toLocal())); - final tel = user.telephone?.trim(); - if (tel != null && tel.isNotEmpty) { - bits.add(formatPhoneForDisplay(tel)); - } - final cp = user.codePostal?.trim(); - final ville = user.ville?.trim(); - final loc = [if (cp != null && cp.isNotEmpty) cp, if (ville != null && ville.isNotEmpty) ville] - .join(' ') - .trim(); - if (loc.isNotEmpty) bits.add(loc); - final infos = bits.join(' • '); - if (email.isEmpty) return infos; - return '$email - $infos'; - } - Widget _buildAMCard(AppUser user) { - final numDossier = user.numeroDossier ?? '–'; - final nameBold = - user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–'); - return _PendingValidationRow( - icon: Icons.person_outline, - title: Text.rich( - TextSpan( - style: const TextStyle(fontSize: 14, color: Colors.black87), - children: [ - TextSpan( - text: nameBold, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' - $numDossier', - style: const TextStyle(fontWeight: FontWeight.w400), - ), - ], - ), - ), - subtitle: _amSubtitleLine(user), - subtitleStyle: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: Colors.grey.shade600, - ), - onOpen: () => _onOpenValidation( - type: 'AM', - id: user.id, - numeroDossier: user.numeroDossier, - ), + return DossierListCard( + numeroDossier: user.numeroDossier ?? '', + namesLine: _amNamesLine(user), + isFamille: false, + onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier), ); } - /// `email, tél., localisation` par parent, puis `date soumission`, puis `nb enfants`. - String _familyParentSegment(PendingParentLine p) { - final parts = []; - final e = p.email?.trim(); - if (e != null && e.isNotEmpty) parts.add(e); - final t = p.telephone?.trim(); - if (t != null && t.isNotEmpty) parts.add(formatPhoneForDisplay(t)); - final cp = p.codePostal?.trim(); - final v = p.ville?.trim(); - final loc = [if (cp != null && cp.isNotEmpty) cp, if (v != null && v.isNotEmpty) v] - .join(' ') - .trim(); - if (loc.isNotEmpty) parts.add(loc); - return parts.join(', '); - } - - String _familySubtitleLine(PendingFamily family) { - final blocks = family.parentLines - .map(_familyParentSegment) - .where((s) => s.isNotEmpty) - .join(' - '); - - final tail = []; - final date = family.dateSoumission; - if (date != null) { - tail.add(DateFormat('dd/MM/yyyy').format(date.toLocal())); - } - if (family.nombreEnfants > 0) { - tail.add( - family.nombreEnfants > 1 - ? '${family.nombreEnfants} enfants' - : '1 enfant', - ); - } - final right = tail.join(' - '); - - if (blocks.isEmpty && right.isEmpty) return ''; - if (blocks.isEmpty) return right; - if (right.isEmpty) return blocks; - return '$blocks - $right'; - } - Widget _buildFamilyCard(PendingFamily family) { - final numDossier = family.numeroDossier ?? '–'; - final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille'; - return _PendingValidationRow( - icon: Icons.family_restroom_outlined, - title: Text.rich( - TextSpan( - style: const TextStyle(fontSize: 14, color: Colors.black87), - children: [ - TextSpan( - text: nameBold, - style: const TextStyle(fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' - $numDossier', - style: const TextStyle(fontWeight: FontWeight.w400), - ), - ], - ), - ), - subtitle: _familySubtitleLine(family), - subtitleStyle: TextStyle( - fontSize: 12, - fontStyle: FontStyle.italic, - color: Colors.grey.shade600, - ), - onOpen: () => _onOpenValidation( - type: 'famille', - id: family.parentIds.isNotEmpty ? family.parentIds.first : null, - numeroDossier: family.numeroDossier, - ), + final num = (family.numeroDossier ?? '').trim(); + final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null; + final names = (enriched != null && enriched.isNotEmpty) + ? enriched + : formatDossierFamilyNamesLine(family.libelle); + return DossierListCard( + numeroDossier: family.numeroDossier ?? '', + namesLine: names, + isFamille: true, + onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier), ); } } - -/// Ligne « À valider » : survol comme [AdminUserCard], icône « Ouvrir » visible au hover uniquement. -class _PendingValidationRow extends StatefulWidget { - final IconData icon; - final Widget title; - final String? subtitle; - final TextStyle? subtitleStyle; - final VoidCallback onOpen; - - const _PendingValidationRow({ - required this.icon, - required this.title, - this.subtitle, - this.subtitleStyle, - required this.onOpen, - }); - - @override - State<_PendingValidationRow> createState() => _PendingValidationRowState(); -} - -class _PendingValidationRowState extends State<_PendingValidationRow> { - bool _isHovered = false; - - @override - Widget build(BuildContext context) { - final subStyle = widget.subtitleStyle ?? - TextStyle( - fontSize: 12, - color: Colors.grey.shade600, - ); - return MouseRegion( - onEnter: (_) => setState(() => _isHovered = true), - onExit: (_) => setState(() => _isHovered = false), - cursor: SystemMouseCursors.click, - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(10), - child: InkWell( - onTap: widget.onOpen, - borderRadius: BorderRadius.circular(10), - hoverColor: const Color(0x149CC5C0), - child: Card( - margin: const EdgeInsets.only(bottom: 12), - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: BorderSide(color: Colors.grey.shade300), - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(widget.icon, color: Colors.grey.shade600, size: 28), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - widget.title, - if (widget.subtitle != null && - widget.subtitle!.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - widget.subtitle!, - style: subStyle, - ), - ], - ], - ), - ), - SizedBox( - width: 52, - child: Center( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 120), - opacity: _isHovered ? 1 : 0, - child: IgnorePointer( - ignoring: !_isHovered, - child: IconButtonTheme( - data: IconButtonThemeData( - style: IconButton.styleFrom( - padding: const EdgeInsets.all(0), - minimumSize: const Size(48, 48), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - child: IconButton( - onPressed: widget.onOpen, - icon: const Icon(Icons.open_in_new), - iconSize: 34, - tooltip: 'Ouvrir', - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} - diff --git a/frontend/lib/widgets/admin/user_management_panel.dart b/frontend/lib/widgets/admin/user_management_panel.dart index 7be38c7..6f4cb82 100644 --- a/frontend/lib/widgets/admin/user_management_panel.dart +++ b/frontend/lib/widgets/admin/user_management_panel.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.dart'; -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/am_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/assistante_maternelle_management_widget.dart'; import 'package:p_tits_pas/widgets/admin/common/admin_child_detail_modal.dart'; import 'package:p_tits_pas/widgets/admin/dashboard_admin.dart'; +import 'package:p_tits_pas/widgets/admin/dossiers_management_widget.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_dossier_create_modal.dart'; import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.dart'; -import 'package:p_tits_pas/widgets/admin/pending_validation_widget.dart'; class UserManagementPanel extends StatefulWidget { - /// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM). + /// Afficher l'onglet Administrateurs (sinon sans Administrateurs). final bool showAdministrateursTab; const UserManagementPanel({ @@ -32,45 +31,17 @@ class _UserManagementPanelState extends State { int _adminRefreshTick = 0; int _enfantRefreshTick = 0; int _amRefreshTick = 0; + int _dossiersRefreshTick = 0; final TextEditingController _searchController = TextEditingController(); final TextEditingController _amCapacityController = TextEditingController(); String? _parentStatus; String? _enfantStatus; - bool _hasPending = false; - bool _pendingLoading = true; @override void initState() { super.initState(); _searchController.addListener(_onFilterChanged); _amCapacityController.addListener(_onFilterChanged); - _loadPending(); - } - - Future _loadPending() async { - try { - final am = await UserService.getPendingUsers(role: 'assistante_maternelle'); - final families = await UserService.getPendingFamilies(); - if (!mounted) return; - final hasPending = am.isNotEmpty || families.isNotEmpty; - setState(() { - final hadPending = _hasPending; - _hasPending = hasPending; - _pendingLoading = false; - // Si on passe à "plus de dossiers", recaler l'index (onglet À valider disparaît). - if (hadPending && !hasPending) { - _subIndex = (_subIndex > 0 ? _subIndex - 1 : 0).clamp(0, _tabLabels.length - 1); - } else if (!hadPending && hasPending) { - _subIndex = 0; // Afficher l'onglet À valider - } - }); - } catch (_) { - if (!mounted) return; - setState(() { - _hasPending = false; - _pendingLoading = false; - }); - } } @override @@ -87,13 +58,19 @@ class _UserManagementPanelState extends State { setState(() {}); } + /// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin). List get _tabLabels { - 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). - if (!_pendingLoading && _hasPending) return ['À valider', ...list]; - return list; + const base = [ + 'Dossiers', + 'Parents', + 'Enfants', + 'Assistantes maternelles', + 'Gestionnaires', + ]; + if (widget.showAdministrateursTab) { + return [...base, 'Administrateurs']; + } + return base; } void _onSubTabChange(int index) { @@ -107,23 +84,21 @@ class _UserManagementPanelState extends State { }); } - /// Index du contenu : -1 = À valider, 0 = Parents, 1 = Enfants, 2 = AM, 3 = Gestionnaires, 4 = Admin. - int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0; + bool get _isDossiersTab => _subIndex == 0; String _searchHintForTab() { - final contentIndex = _subIndex - _contentIndexOffset; - switch (contentIndex) { - case -1: - return 'À valider (pas de recherche)'; + switch (_subIndex) { case 0: - return 'Rechercher un parent...'; + return 'Rechercher un dossier (n°, nom, email)…'; case 1: - return 'Rechercher un enfant...'; + return 'Rechercher un parent...'; case 2: - return 'Rechercher une assistante...'; + return 'Rechercher un enfant...'; case 3: - return 'Rechercher un gestionnaire...'; + return 'Rechercher une assistante...'; case 4: + return 'Rechercher un gestionnaire...'; + case 5: return 'Rechercher un administrateur...'; default: return 'Rechercher...'; @@ -131,7 +106,8 @@ class _UserManagementPanelState extends State { } Widget? _subBarFilterControl() { - if (_subIndex == _contentIndexOffset + 0) { + // Parents + if (_subIndex == 1) { return DropdownButtonHideUnderline( child: DropdownButton( value: _parentStatus, @@ -186,7 +162,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 1) { + // Enfants + if (_subIndex == 2) { return DropdownButtonHideUnderline( child: DropdownButton( value: _enfantStatus, @@ -241,7 +218,8 @@ class _UserManagementPanelState extends State { ); } - if (_subIndex == _contentIndexOffset + 2) { + // AM + if (_subIndex == 3) { return TextField( controller: _amCapacityController, decoration: const InputDecoration( @@ -258,35 +236,36 @@ class _UserManagementPanelState extends State { } Widget _buildBody() { - final contentIndex = _subIndex - _contentIndexOffset; - if (_hasPending && !_pendingLoading && contentIndex == -1) { - return PendingValidationWidget(onRefresh: _loadPending); - } - switch (contentIndex) { + switch (_subIndex) { case 0: + return DossiersManagementWidget( + key: ValueKey('dossiers-$_dossiersRefreshTick'), + searchQuery: _searchController.text, + ); + case 1: return ParentManagementWidget( key: ValueKey('parents-$_parentRefreshTick'), searchQuery: _searchController.text, statusFilter: _parentStatus, ); - case 1: + case 2: return EnfantManagementWidget( key: ValueKey('enfants-$_enfantRefreshTick'), searchQuery: _searchController.text, statusFilter: _enfantStatus, ); - case 2: + case 3: return AssistanteMaternelleManagementWidget( key: ValueKey('ams-$_amRefreshTick'), searchQuery: _searchController.text, capacityMin: int.tryParse(_amCapacityController.text), ); - case 3: + case 4: return GestionnaireManagementWidget( key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'), searchQuery: _searchController.text, ); - case 4: + case 5: return AdminManagementWidget( key: ValueKey('admins-$_adminRefreshTick'), searchQuery: _searchController.text, @@ -299,7 +278,6 @@ class _UserManagementPanelState extends State { @override Widget build(BuildContext context) { final labels = _tabLabels; - final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0; return Column( children: [ DashboardUserManagementSubBar( @@ -308,7 +286,8 @@ class _UserManagementPanelState extends State { searchController: _searchController, searchHint: _searchHintForTab(), filterControl: _subBarFilterControl(), - onAddPressed: isAValiderTab ? null : _handleAddPressed, + // Pas de « Créer » sur l’onglet Dossiers (#153). + onAddPressed: _isDossiersTab ? null : _handleAddPressed, addLabel: 'Ajouter', subTabCount: labels.length, tabLabels: labels, @@ -319,9 +298,8 @@ class _UserManagementPanelState extends State { } Future _handleAddPressed() async { - final contentIndex = _subIndex - _contentIndexOffset; - - if (contentIndex == 0) { + // 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin + if (_subIndex == 1) { await showDialog( context: context, barrierDismissible: false, @@ -331,7 +309,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _parentRefreshTick++); + setState(() { + _parentRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -339,7 +320,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 1) { + if (_subIndex == 2) { await showDialog( context: context, barrierDismissible: false, @@ -355,7 +336,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 2) { + if (_subIndex == 3) { await showDialog( context: context, barrierDismissible: false, @@ -365,7 +346,10 @@ class _UserManagementPanelState extends State { onSuccess: () { Navigator.of(dialogContext).pop(); if (!mounted) return; - setState(() => _amRefreshTick++); + setState(() { + _amRefreshTick++; + _dossiersRefreshTick++; + }); }, ); }, @@ -373,7 +357,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 3) { + if (_subIndex == 4) { final created = await showDialog( context: context, barrierDismissible: false, @@ -391,7 +375,7 @@ class _UserManagementPanelState extends State { return; } - if (contentIndex == 4) { + if (_subIndex == 5) { final created = await showDialog( context: context, barrierDismissible: false, @@ -409,16 +393,6 @@ class _UserManagementPanelState extends State { _adminRefreshTick++; }); } - return; } - - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'La création parent sera disponible avec le ticket #129.', - ), - ), - ); } }