feat(#153): onglet permanent Dossiers (pending + liste unifiée).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<String> 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<DossierListItem> fromParents(List<ParentModel> parents) {
|
||||||
|
final byDossier = <String, List<ParentModel>>{};
|
||||||
|
for (final p in parents) {
|
||||||
|
final num = (p.user.numeroDossier ?? '').trim();
|
||||||
|
if (num.isEmpty) continue;
|
||||||
|
byDossier.putIfAbsent(num, () => []).add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = <DossierListItem>[];
|
||||||
|
for (final entry in byDossier.entries) {
|
||||||
|
final seenIds = <String>{};
|
||||||
|
final names = <String>[];
|
||||||
|
final emails = <String>[];
|
||||||
|
final statuts = <String>[];
|
||||||
|
|
||||||
|
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<DossierListItem> fromAssistantes(
|
||||||
|
List<AssistanteMaternelleModel> ams,
|
||||||
|
) {
|
||||||
|
final byDossier = <String, AssistanteMaternelleModel>{};
|
||||||
|
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<String> 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';
|
||||||
|
}
|
||||||
@@ -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: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/user.dart';
|
||||||
import 'package:p_tits_pas/models/pending_family.dart';
|
import 'package:p_tits_pas/models/pending_family.dart';
|
||||||
import 'package:p_tits_pas/services/user_service.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';
|
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 {
|
class PendingValidationWidget extends StatefulWidget {
|
||||||
final VoidCallback? onRefresh;
|
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
|
@override
|
||||||
State<PendingValidationWidget> createState() => _PendingValidationWidgetState();
|
State<PendingValidationWidget> createState() =>
|
||||||
|
_PendingValidationWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
||||||
@@ -21,6 +34,8 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<AppUser> _pendingAM = [];
|
List<AppUser> _pendingAM = [];
|
||||||
List<PendingFamily> _pendingFamilies = [];
|
List<PendingFamily> _pendingFamilies = [];
|
||||||
|
/// Noms enrichis via GET /dossiers/:numero (libelle API = noms seuls).
|
||||||
|
final Map<String, String> _familyNamesByNumero = {};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -34,24 +49,77 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final am = await UserService.getPendingUsers(role: 'assistante_maternelle');
|
final am =
|
||||||
|
await UserService.getPendingUsers(role: 'assistante_maternelle');
|
||||||
final families = await UserService.getPendingFamilies();
|
final families = await UserService.getPendingFamilies();
|
||||||
|
final namesByNumero = await _enrichFamilyNames(families);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingAM = am;
|
_pendingAM = am;
|
||||||
_pendingFamilies = families;
|
_pendingFamilies = families;
|
||||||
|
_familyNamesByNumero
|
||||||
|
..clear()
|
||||||
|
..addAll(namesByNumero);
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
|
_emitPendingNumeros();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e is Exception ? e.toString().replaceFirst('Exception: ', '') : 'Erreur inconnue';
|
_error = e is Exception
|
||||||
|
? e.toString().replaceFirst('Exception: ', '')
|
||||||
|
: 'Erreur inconnue';
|
||||||
_isLoading = false;
|
_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();
|
final num = numeroDossier?.trim();
|
||||||
if (num == null || num.isEmpty) {
|
if (num == null || num.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
|
if (widget.compactWhenEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
if (_error != null && _error!.isNotEmpty) {
|
if (_error != null && _error!.isNotEmpty) {
|
||||||
@@ -97,14 +204,32 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final hasAM = _pendingAM.isNotEmpty;
|
final pendingAM = _filteredAM;
|
||||||
final hasFamilies = _pendingFamilies.isNotEmpty;
|
final pendingFamilies = _filteredFamilies;
|
||||||
if (!hasAM && !hasFamilies) {
|
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(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'Aucun dossier en attente de validation',
|
'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(
|
return RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _load();
|
await _load();
|
||||||
@@ -125,275 +272,39 @@ class _PendingValidationWidgetState extends State<PendingValidationWidget> {
|
|||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: list,
|
||||||
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)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _sectionTitle(String title) {
|
String _amNamesLine(AppUser user) {
|
||||||
return Text(
|
return formatDossierPersonLabel(
|
||||||
title,
|
nom: user.nom,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
prenom: user.prenom,
|
||||||
fontWeight: FontWeight.w600,
|
email: user.email,
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 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) {
|
Widget _buildAMCard(AppUser user) {
|
||||||
final numDossier = user.numeroDossier ?? '–';
|
return DossierListCard(
|
||||||
final nameBold =
|
numeroDossier: user.numeroDossier ?? '',
|
||||||
user.fullName.isNotEmpty ? user.fullName : (user.email.isNotEmpty ? user.email : '–');
|
namesLine: _amNamesLine(user),
|
||||||
return _PendingValidationRow(
|
isFamille: false,
|
||||||
icon: Icons.person_outline,
|
onOpen: () => _onOpenValidation(numeroDossier: user.numeroDossier),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `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) {
|
Widget _buildFamilyCard(PendingFamily family) {
|
||||||
final numDossier = family.numeroDossier ?? '–';
|
final num = (family.numeroDossier ?? '').trim();
|
||||||
final nameBold = family.libelle.isNotEmpty ? family.libelle : 'Famille';
|
final enriched = num.isNotEmpty ? _familyNamesByNumero[num] : null;
|
||||||
return _PendingValidationRow(
|
final names = (enriched != null && enriched.isNotEmpty)
|
||||||
icon: Icons.family_restroom_outlined,
|
? enriched
|
||||||
title: Text.rich(
|
: formatDossierFamilyNamesLine(family.libelle);
|
||||||
TextSpan(
|
return DossierListCard(
|
||||||
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
numeroDossier: family.numeroDossier ?? '',
|
||||||
children: [
|
namesLine: names,
|
||||||
TextSpan(
|
isFamille: true,
|
||||||
text: nameBold,
|
onOpen: () => _onOpenValidation(numeroDossier: family.numeroDossier),
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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:flutter/material.dart';
|
||||||
import 'package:p_tits_pas/screens/administrateurs/creation/gestionnaires_create.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/admin_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/am_dossier_create_modal.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/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/common/admin_child_detail_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/dashboard_admin.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/enfant_management_widget.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/gestionnaire_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_dossier_create_modal.dart';
|
||||||
import 'package:p_tits_pas/widgets/admin/parent_managmant_widget.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 {
|
class UserManagementPanel extends StatefulWidget {
|
||||||
/// Afficher l'onglet Administrateurs (sinon 3 onglets : Gestionnaires, Parents, AM).
|
/// Afficher l'onglet Administrateurs (sinon sans Administrateurs).
|
||||||
final bool showAdministrateursTab;
|
final bool showAdministrateursTab;
|
||||||
|
|
||||||
const UserManagementPanel({
|
const UserManagementPanel({
|
||||||
@@ -32,45 +31,17 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
int _adminRefreshTick = 0;
|
int _adminRefreshTick = 0;
|
||||||
int _enfantRefreshTick = 0;
|
int _enfantRefreshTick = 0;
|
||||||
int _amRefreshTick = 0;
|
int _amRefreshTick = 0;
|
||||||
|
int _dossiersRefreshTick = 0;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final TextEditingController _amCapacityController = TextEditingController();
|
final TextEditingController _amCapacityController = TextEditingController();
|
||||||
String? _parentStatus;
|
String? _parentStatus;
|
||||||
String? _enfantStatus;
|
String? _enfantStatus;
|
||||||
bool _hasPending = false;
|
|
||||||
bool _pendingLoading = true;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_searchController.addListener(_onFilterChanged);
|
_searchController.addListener(_onFilterChanged);
|
||||||
_amCapacityController.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
|
@override
|
||||||
@@ -87,13 +58,19 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ordre #153 : Dossiers | Parents | Enfants | AM | Gestionnaires | (Admin).
|
||||||
List<String> get _tabLabels {
|
List<String> get _tabLabels {
|
||||||
const base = ['Parents', 'Enfants', 'Assistantes maternelles', 'Gestionnaires'];
|
const base = [
|
||||||
final withAdmin = [...base, 'Administrateurs'];
|
'Dossiers',
|
||||||
final list = widget.showAdministrateursTab ? withAdmin : base;
|
'Parents',
|
||||||
// Onglet « À valider » visible seulement s'il y a des dossiers en attente (ticket #107).
|
'Enfants',
|
||||||
if (!_pendingLoading && _hasPending) return ['À valider', ...list];
|
'Assistantes maternelles',
|
||||||
return list;
|
'Gestionnaires',
|
||||||
|
];
|
||||||
|
if (widget.showAdministrateursTab) {
|
||||||
|
return [...base, 'Administrateurs'];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSubTabChange(int index) {
|
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.
|
bool get _isDossiersTab => _subIndex == 0;
|
||||||
int get _contentIndexOffset => (_hasPending && !_pendingLoading) ? 1 : 0;
|
|
||||||
|
|
||||||
String _searchHintForTab() {
|
String _searchHintForTab() {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
switch (_subIndex) {
|
||||||
switch (contentIndex) {
|
|
||||||
case -1:
|
|
||||||
return 'À valider (pas de recherche)';
|
|
||||||
case 0:
|
case 0:
|
||||||
return 'Rechercher un parent...';
|
return 'Rechercher un dossier (n°, nom, email)…';
|
||||||
case 1:
|
case 1:
|
||||||
return 'Rechercher un enfant...';
|
return 'Rechercher un parent...';
|
||||||
case 2:
|
case 2:
|
||||||
return 'Rechercher une assistante...';
|
return 'Rechercher un enfant...';
|
||||||
case 3:
|
case 3:
|
||||||
return 'Rechercher un gestionnaire...';
|
return 'Rechercher une assistante...';
|
||||||
case 4:
|
case 4:
|
||||||
|
return 'Rechercher un gestionnaire...';
|
||||||
|
case 5:
|
||||||
return 'Rechercher un administrateur...';
|
return 'Rechercher un administrateur...';
|
||||||
default:
|
default:
|
||||||
return 'Rechercher...';
|
return 'Rechercher...';
|
||||||
@@ -131,7 +106,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget? _subBarFilterControl() {
|
Widget? _subBarFilterControl() {
|
||||||
if (_subIndex == _contentIndexOffset + 0) {
|
// Parents
|
||||||
|
if (_subIndex == 1) {
|
||||||
return DropdownButtonHideUnderline(
|
return DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
value: _parentStatus,
|
value: _parentStatus,
|
||||||
@@ -186,7 +162,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 1) {
|
// Enfants
|
||||||
|
if (_subIndex == 2) {
|
||||||
return DropdownButtonHideUnderline(
|
return DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
value: _enfantStatus,
|
value: _enfantStatus,
|
||||||
@@ -241,7 +218,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_subIndex == _contentIndexOffset + 2) {
|
// AM
|
||||||
|
if (_subIndex == 3) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: _amCapacityController,
|
controller: _amCapacityController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -258,35 +236,36 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
switch (_subIndex) {
|
||||||
if (_hasPending && !_pendingLoading && contentIndex == -1) {
|
|
||||||
return PendingValidationWidget(onRefresh: _loadPending);
|
|
||||||
}
|
|
||||||
switch (contentIndex) {
|
|
||||||
case 0:
|
case 0:
|
||||||
|
return DossiersManagementWidget(
|
||||||
|
key: ValueKey('dossiers-$_dossiersRefreshTick'),
|
||||||
|
searchQuery: _searchController.text,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
return ParentManagementWidget(
|
return ParentManagementWidget(
|
||||||
key: ValueKey('parents-$_parentRefreshTick'),
|
key: ValueKey('parents-$_parentRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _parentStatus,
|
statusFilter: _parentStatus,
|
||||||
);
|
);
|
||||||
case 1:
|
case 2:
|
||||||
return EnfantManagementWidget(
|
return EnfantManagementWidget(
|
||||||
key: ValueKey('enfants-$_enfantRefreshTick'),
|
key: ValueKey('enfants-$_enfantRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
statusFilter: _enfantStatus,
|
statusFilter: _enfantStatus,
|
||||||
);
|
);
|
||||||
case 2:
|
case 3:
|
||||||
return AssistanteMaternelleManagementWidget(
|
return AssistanteMaternelleManagementWidget(
|
||||||
key: ValueKey('ams-$_amRefreshTick'),
|
key: ValueKey('ams-$_amRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
capacityMin: int.tryParse(_amCapacityController.text),
|
capacityMin: int.tryParse(_amCapacityController.text),
|
||||||
);
|
);
|
||||||
case 3:
|
case 4:
|
||||||
return GestionnaireManagementWidget(
|
return GestionnaireManagementWidget(
|
||||||
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
key: ValueKey('gestionnaires-$_gestionnaireRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
);
|
);
|
||||||
case 4:
|
case 5:
|
||||||
return AdminManagementWidget(
|
return AdminManagementWidget(
|
||||||
key: ValueKey('admins-$_adminRefreshTick'),
|
key: ValueKey('admins-$_adminRefreshTick'),
|
||||||
searchQuery: _searchController.text,
|
searchQuery: _searchController.text,
|
||||||
@@ -299,7 +278,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final labels = _tabLabels;
|
final labels = _tabLabels;
|
||||||
final isAValiderTab = _hasPending && !_pendingLoading && _subIndex == 0;
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
DashboardUserManagementSubBar(
|
DashboardUserManagementSubBar(
|
||||||
@@ -308,7 +286,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
searchController: _searchController,
|
searchController: _searchController,
|
||||||
searchHint: _searchHintForTab(),
|
searchHint: _searchHintForTab(),
|
||||||
filterControl: _subBarFilterControl(),
|
filterControl: _subBarFilterControl(),
|
||||||
onAddPressed: isAValiderTab ? null : _handleAddPressed,
|
// Pas de « Créer » sur l’onglet Dossiers (#153).
|
||||||
|
onAddPressed: _isDossiersTab ? null : _handleAddPressed,
|
||||||
addLabel: 'Ajouter',
|
addLabel: 'Ajouter',
|
||||||
subTabCount: labels.length,
|
subTabCount: labels.length,
|
||||||
tabLabels: labels,
|
tabLabels: labels,
|
||||||
@@ -319,9 +298,8 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleAddPressed() async {
|
Future<void> _handleAddPressed() async {
|
||||||
final contentIndex = _subIndex - _contentIndexOffset;
|
// 1 Parents, 2 Enfants, 3 AM, 4 Gestionnaires, 5 Admin
|
||||||
|
if (_subIndex == 1) {
|
||||||
if (contentIndex == 0) {
|
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -331,7 +309,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _parentRefreshTick++);
|
setState(() {
|
||||||
|
_parentRefreshTick++;
|
||||||
|
_dossiersRefreshTick++;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -339,7 +320,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 1) {
|
if (_subIndex == 2) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -355,7 +336,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 2) {
|
if (_subIndex == 3) {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -365,7 +346,10 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
Navigator.of(dialogContext).pop();
|
Navigator.of(dialogContext).pop();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _amRefreshTick++);
|
setState(() {
|
||||||
|
_amRefreshTick++;
|
||||||
|
_dossiersRefreshTick++;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -373,7 +357,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 3) {
|
if (_subIndex == 4) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -391,7 +375,7 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentIndex == 4) {
|
if (_subIndex == 5) {
|
||||||
final created = await showDialog<bool>(
|
final created = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -409,16 +393,6 @@ class _UserManagementPanelState extends State<UserManagementPanel> {
|
|||||||
_adminRefreshTick++;
|
_adminRefreshTick++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'La création parent sera disponible avec le ticket #129.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user