feat(#153): onglet permanent Dossiers (pending + liste unifiée).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user