feat(#153): onglet permanent Dossiers (pending + liste unifiée).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-24 18:40:00 +02:00
co-authored by Cursor
parent 6708f73b06
commit 3fdd913367
5 changed files with 680 additions and 350 deletions
@@ -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,
),
],
);
}
}
@@ -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<DossiersManagementWidget> createState() =>
_DossiersManagementWidgetState();
}
class _DossiersManagementWidgetState extends State<DossiersManagementWidget> {
bool _loading = true;
String? _error;
List<DossierListItem> _all = [];
Set<String> _pendingNumeros = {};
int _pendingRefreshTick = 0;
@override
void initState() {
super.initState();
_loadAll();
}
Future<void> _loadAll() async {
setState(() {
_loading = true;
_error = null;
});
try {
final parents = await UserService.getParents();
final ams = await UserService.getAssistantesMaternelles();
if (!mounted) return;
final items = <DossierListItem>[
...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<void> _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<void>(
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,
),
),
),
],
),
);
}
}
@@ -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<Set<String>>? onPendingNumerosChanged;
const PendingValidationWidget({super.key, this.onRefresh});
const PendingValidationWidget({
super.key,
this.onRefresh,
this.searchQuery = '',
this.compactWhenEmpty = false,
this.onPendingNumerosChanged,
});
@override
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
State<PendingValidationWidget> createState() =>
_PendingValidationWidgetState();
}
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
@@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
String? _error;
List<AppUser> _pendingAM = [];
List<PendingFamily> _pendingFamilies = [];
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
final Map<String, String> _familyNamesByNumero = {};
@override
void initState() {
@@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
_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<Map<String, String>> _enrichFamilyNames(
List<PendingFamily> families,
) async {
final out = <String, String>{};
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 = <String>[];
final seen = <String>{};
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 = <String>{};
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<PendingValidationWidget> {
);
}
bool _matchesQuery(String haystack) {
final q = widget.searchQuery.trim().toLowerCase();
if (q.isEmpty) return true;
return haystack.toLowerCase().contains(q);
}
List<AppUser> get _filteredAM {
return _pendingAM.where((u) {
final bits = [
u.numeroDossier ?? '',
u.fullName,
u.email,
u.nom ?? '',
u.prenom ?? '',
].join(' ');
return _matchesQuery(bits);
}).toList();
}
List<PendingFamily> 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<PendingValidationWidget> {
);
}
final hasAM = _pendingAM.isNotEmpty;
final hasFamilies = _pendingFamilies.isNotEmpty;
if (!hasAM && !hasFamilies) {
final pendingAM = _filteredAM;
final pendingFamilies = _filteredFamilies;
final cards = <Widget>[
...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<PendingValidationWidget> {
);
}
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<PendingValidationWidget> {
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 = <String>[];
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 = <String>[];
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 = <String>[];
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',
),
),
),
),
),
),
],
),
),
),
),
),
);
}
}
@@ -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<UserManagementPanel> {
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<void> _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<UserManagementPanel> {
setState(() {});
}
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
List<String> 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<UserManagementPanel> {
});
}
/// 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<UserManagementPanel> {
}
Widget? _subBarFilterControl() {
if (_subIndex == _contentIndexOffset + 0) {
// Parents
if (_subIndex == 1) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _parentStatus,
@@ -186,7 +162,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
if (_subIndex == _contentIndexOffset + 1) {
// Enfants
if (_subIndex == 2) {
return DropdownButtonHideUnderline(
child: DropdownButton<String?>(
value: _enfantStatus,
@@ -241,7 +218,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
);
}
if (_subIndex == _contentIndexOffset + 2) {
// AM
if (_subIndex == 3) {
return TextField(
controller: _amCapacityController,
decoration: const InputDecoration(
@@ -258,35 +236,36 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
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<UserManagementPanel> {
@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<UserManagementPanel> {
searchController: _searchController,
searchHint: _searchHintForTab(),
filterControl: _subBarFilterControl(),
onAddPressed: isAValiderTab ? null : _handleAddPressed,
// Pas de « Créer » sur longlet Dossiers (#153).
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
addLabel: 'Ajouter',
subTabCount: labels.length,
tabLabels: labels,
@@ -319,9 +298,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
}
Future<void> _handleAddPressed() async {
final contentIndex = _subIndex - _contentIndexOffset;
if (contentIndex == 0) {
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
if (_subIndex == 1) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -331,7 +309,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() => _parentRefreshTick++);
setState(() {
_parentRefreshTick++;
_dossiersRefreshTick++;
});
},
);
},
@@ -339,7 +320,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 1) {
if (_subIndex == 2) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -355,7 +336,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 2) {
if (_subIndex == 3) {
await showDialog<void>(
context: context,
barrierDismissible: false,
@@ -365,7 +346,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
onSuccess: () {
Navigator.of(dialogContext).pop();
if (!mounted) return;
setState(() => _amRefreshTick++);
setState(() {
_amRefreshTick++;
_dossiersRefreshTick++;
});
},
);
},
@@ -373,7 +357,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 3) {
if (_subIndex == 4) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -391,7 +375,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
return;
}
if (contentIndex == 4) {
if (_subIndex == 5) {
final created = await showDialog<bool>(
context: context,
barrierDismissible: false,
@@ -409,16 +393,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
_adminRefreshTick++;
});
}
return;
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'La création parent sera disponible avec le ticket #129.',
),
),
);
}
}