Files
EME_APP/eme-frontend/lib/screens/responsable_screen.dart
T
2026-07-17 13:58:30 +02:00

727 lines
21 KiB
Dart

import "package:flutter/material.dart";
import "package:google_fonts/google_fonts.dart";
import "../demo_identity.dart";
import "../services/responsable_service.dart";
import "../theme/ensup_colors.dart";
import "../widgets/brand_corners.dart";
import "../widgets/ensup_top_bar.dart";
class ResponsableScreen extends StatefulWidget {
const ResponsableScreen({super.key});
@override
State<ResponsableScreen> createState() => _ResponsableScreenState();
}
class _ResponsableScreenState extends State<ResponsableScreen> {
late Future<_ResponsableData> _future = _charger();
int _onglet = 0;
bool _transitionEnCours = false;
Future<_ResponsableData> _charger() async {
final dashboard = await ResponsableService.dashboard();
final emprunts = await ResponsableService.emprunts();
final materiels = await ResponsableService.materiels();
final anomalies = await ResponsableService.anomalies();
final notifications = await ResponsableService.notifications();
final historique = await ResponsableService.historique();
return _ResponsableData(
dashboard: dashboard,
emprunts: emprunts,
materiels: materiels,
anomalies: anomalies,
notifications: notifications,
historique: historique,
);
}
void _rafraichir() {
setState(() => _future = _charger());
}
Future<void> _changerStatut(Map<String, dynamic> anomalie) async {
final prochain = _prochainStatut(anomalie["statut"] as String?);
if (prochain == null || _transitionEnCours) {
return;
}
setState(() => _transitionEnCours = true);
try {
await ResponsableService.changerStatutAnomalie(
anomalie["id"] as int,
prochain,
);
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() => _transitionEnCours = false);
}
}
}
Future<void> _marquerNotificationsLues() async {
try {
await ResponsableService.marquerNotificationsLues();
_rafraichir();
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
String? _prochainStatut(String? statut) {
return switch (statut) {
"DETECTEE" => "EN_COURS_TRAITEMENT",
"EN_COURS_TRAITEMENT" => "RESOLUE",
"RESOLUE" => "CLOTUREE",
_ => null,
};
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EnsupColors.soft,
body: Stack(
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1320, maxHeight: 860),
child: Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: EnsupColors.text.withValues(alpha: 0.14),
blurRadius: 60,
offset: const Offset(0, 18),
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
EnsupTopBar(
brandText: "EME Responsable",
campusTag: DemoIdentity.loginCampusTag,
userName: "Karim Benali",
userInitials: "KB",
onBack: () => Navigator.of(context).pop(),
),
Expanded(
child: FutureBuilder<_ResponsableData>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState !=
ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (snapshot.hasError) {
return _Erreur(
message: snapshot.error.toString(),
onRetry: _rafraichir,
);
}
return _contenu(snapshot.data!);
},
),
),
],
),
),
),
),
const Positioned.fill(child: BrandCorners()),
],
),
);
}
Widget _contenu(_ResponsableData data) {
final vues = [
_VueDashboard(data: data),
_VueListe(
titre: "Emprunts",
items: data.emprunts,
builder: _empruntTile,
searchableText: (item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${materiel["nom"]} ${materiel["reference"]} ${etudiant["prenom"]} ${etudiant["nom"]} ${item["statut"]}";
},
),
_VueListe(
titre: "Stock",
items: data.materiels,
builder: _materielTile,
searchableText: (item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return "${item["nom"]} ${item["reference"]} ${item["statut"]} ${categorie["nom"]}";
},
),
_VueListe(
titre: "Anomalies",
items: data.anomalies,
builder: _anomalieTile,
searchableText: (item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return "${item["type"]} ${item["description"]} ${item["statut"]} ${materiel["nom"]}";
},
),
_VueListe(
titre: "Notifications",
items: data.notifications,
builder: _notificationTile,
action: OutlinedButton.icon(
onPressed: _marquerNotificationsLues,
icon: const Icon(Icons.done_all, size: 16),
label: const Text("Tout marquer lu"),
),
searchableText: (item) => "${item["titre"]} ${item["message"]}",
),
_VueListe(
titre: "Historique",
items: data.historique,
builder: _historiqueTile,
action: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Export disponible via /api/responsable/historique/export.csv",
),
),
);
},
icon: const Icon(Icons.download_outlined, size: 16),
label: const Text("Export CSV"),
),
searchableText: (item) => "${item["action"]} ${item["description"]}",
),
];
return Column(
children: [
_Onglets(
index: _onglet,
labels: const [
"Dashboard",
"Emprunts",
"Stock",
"Anomalies",
"Notifications",
"Historique",
],
onChanged: (index) => setState(() => _onglet = index),
),
Expanded(child: vues[_onglet]),
],
);
}
Widget _empruntTile(Map<String, dynamic> item) {
final etudiant = Map<String, dynamic>.from(item["etudiant"] as Map);
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
return _InfoTile(
icon: Icons.assignment_outlined,
title: "${materiel["nom"]}",
subtitle:
"${etudiant["prenom"]} ${etudiant["nom"]} · retour prévu ${_date(item["dateRetourPrevue"])}",
meta: "Emprunt #${item["id"]}",
trailing: _StatusBadge("${item["statut"]}"),
);
}
Widget _materielTile(Map<String, dynamic> item) {
final categorie = Map<String, dynamic>.from(item["categorie"] as Map);
return _InfoTile(
icon: Icons.inventory_2_outlined,
title: "${item["nom"]}",
subtitle:
"${item["reference"]} · ${categorie["nom"]} · ${item["etatGeneral"]}",
meta: "${item["marque"]} ${item["modele"]}",
trailing: _StatusBadge("${item["statut"]}"),
);
}
Widget _anomalieTile(Map<String, dynamic> item) {
final materiel = Map<String, dynamic>.from(item["materiel"] as Map);
final prochain = _prochainStatut(item["statut"] as String?);
return _InfoTile(
icon: Icons.report_problem_outlined,
title: "${item["type"]} · ${materiel["nom"]}",
subtitle: "${item["description"]}",
meta: "Détectée le ${_date(item["dateDetection"])}",
trailing: prochain == null
? _StatusBadge("${item["statut"]}")
: FilledButton(
onPressed: _transitionEnCours ? null : () => _changerStatut(item),
child: Text(_statusLabel(prochain)),
),
);
}
Widget _notificationTile(Map<String, dynamic> item) {
return _InfoTile(
icon: item["lu"] == true
? Icons.notifications_none
: Icons.notifications_active_outlined,
title: "${item["titre"]}",
subtitle: "${item["message"]}",
meta: _date(item["dateCreation"]),
trailing: _StatusBadge(item["lu"] == true ? "LUE" : "NON_LUE"),
);
}
Widget _historiqueTile(Map<String, dynamic> item) {
final utilisateur = Map<String, dynamic>.from(item["utilisateur"] as Map);
return _InfoTile(
icon: Icons.history,
title: _actionLabel("${item["action"]}"),
subtitle: "${item["description"]}",
meta:
"${_date(item["dateAction"])} · ${utilisateur["prenom"]} ${utilisateur["nom"]}",
trailing: const Icon(Icons.chevron_right, color: EnsupColors.muted),
);
}
String _date(Object? value) {
if (value == null) return "-";
final parsed = DateTime.tryParse(value.toString());
if (parsed == null) return value.toString();
return "${parsed.day.toString().padLeft(2, "0")}/"
"${parsed.month.toString().padLeft(2, "0")}/${parsed.year}";
}
}
String _statusLabel(String statut) {
return switch (statut) {
"DISPONIBLE" => "Disponible",
"EMPRUNTE" => "Emprunté",
"NON_CONFORME" => "Non conforme",
"DETERIORE" => "Détérioré",
"MAINTENANCE" => "Maintenance",
"INDISPONIBLE" => "Indisponible",
"EN_COURS" => "En cours",
"EN_RETARD" => "En retard",
"CLOTURE" => "Clôturé",
"RETOUR_NON_CONFORME" => "Retour non conforme",
"ANNULE" => "Annulé",
"DETECTEE" => "À traiter",
"EN_COURS_TRAITEMENT" => "En traitement",
"RESOLUE" => "Résolue",
"CLOTUREE" => "Clôturée",
"LUE" => "Lue",
"NON_LUE" => "Non lue",
_ => statut.replaceAll("_", " ").toLowerCase(),
};
}
String _actionLabel(String action) {
return switch (action) {
"CREATION_EMPRUNT" => "Création d'emprunt",
"CREATION_ANOMALIE" => "Création d'anomalie",
"TRAITEMENT_ANOMALIE" => "Traitement d'anomalie",
_ => action.replaceAll("_", " ").toLowerCase(),
};
}
Color _statusColor(String statut) {
return switch (statut) {
"DISPONIBLE" ||
"CLOTURE" ||
"RESOLUE" ||
"CLOTUREE" ||
"LUE" => EnsupColors.green,
"EN_RETARD" ||
"RETOUR_NON_CONFORME" ||
"NON_CONFORME" ||
"DETECTEE" ||
"NON_LUE" => EnsupColors.red,
"EN_COURS" || "EN_COURS_TRAITEMENT" || "EMPRUNTE" => EnsupColors.cyan,
"MAINTENANCE" || "INDISPONIBLE" || "DETERIORE" => EnsupColors.purple,
_ => EnsupColors.blue3,
};
}
class _ResponsableData {
const _ResponsableData({
required this.dashboard,
required this.emprunts,
required this.materiels,
required this.anomalies,
required this.notifications,
required this.historique,
});
final Map<String, dynamic> dashboard;
final List<Map<String, dynamic>> emprunts;
final List<Map<String, dynamic>> materiels;
final List<Map<String, dynamic>> anomalies;
final List<Map<String, dynamic>> notifications;
final List<Map<String, dynamic>> historique;
}
class _VueDashboard extends StatelessWidget {
const _VueDashboard({required this.data});
final _ResponsableData data;
@override
Widget build(BuildContext context) {
final kpis = Map<String, dynamic>.from(data.dashboard["kpis"] as Map);
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Wrap(
spacing: 14,
runSpacing: 14,
children: [
_KpiCard(
label: "Matériels",
value: _total(kpis["materiels"]),
icon: Icons.inventory_2_outlined,
),
_KpiCard(
label: "Emprunts",
value: _total(kpis["emprunts"]),
icon: Icons.assignment_outlined,
),
_KpiCard(
label: "Anomalies",
value: _total(kpis["anomalies"]),
icon: Icons.report_problem_outlined,
),
_KpiCard(
label: "Notifications",
value: "${kpis["notificationsNonLues"] ?? 0}",
icon: Icons.notifications_outlined,
),
],
),
);
}
String _total(Object? repartition) {
final map = Map<String, dynamic>.from(repartition as Map);
return "${map["total"] ?? 0}";
}
}
class _VueListe extends StatefulWidget {
const _VueListe({
required this.titre,
required this.items,
required this.builder,
required this.searchableText,
this.action,
});
final String titre;
final List<Map<String, dynamic>> items;
final Widget Function(Map<String, dynamic>) builder;
final String Function(Map<String, dynamic>) searchableText;
final Widget? action;
@override
State<_VueListe> createState() => _VueListeState();
}
class _VueListeState extends State<_VueListe> {
String _recherche = "";
@override
Widget build(BuildContext context) {
final terme = _recherche.trim().toLowerCase();
final resultats = terme.isEmpty
? widget.items
: widget.items
.where(
(item) =>
widget.searchableText(item).toLowerCase().contains(terme),
)
.toList();
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
"${widget.titre} (${resultats.length})",
style: GoogleFonts.darkerGrotesque(
fontSize: 26,
fontWeight: FontWeight.w900,
color: EnsupColors.text,
),
),
),
if (widget.action != null) widget.action!,
],
),
const SizedBox(height: 14),
TextField(
onChanged: (value) => setState(() => _recherche = value),
decoration: InputDecoration(
hintText: "Rechercher",
prefixIcon: const Icon(Icons.search),
isDense: true,
filled: true,
fillColor: EnsupColors.soft,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: EnsupColors.line),
),
),
),
const SizedBox(height: 14),
Expanded(
child: resultats.isEmpty
? const _EmptyState()
: ListView.separated(
itemCount: resultats.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => widget.builder(resultats[index]),
),
),
],
),
);
}
}
class _Onglets extends StatelessWidget {
const _Onglets({
required this.index,
required this.labels,
required this.onChanged,
});
final int index;
final List<String> labels;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
decoration: const BoxDecoration(
color: EnsupColors.soft,
border: Border(bottom: BorderSide(color: EnsupColors.line)),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(labels.length, (i) {
final selected = i == index;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
selected: selected,
label: Text(labels[i]),
onSelected: (_) => onChanged(i),
),
);
}),
),
),
);
}
}
class _KpiCard extends StatelessWidget {
const _KpiCard({
required this.label,
required this.value,
required this.icon,
});
final String label;
final String value;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
width: 210,
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
border: Border.all(color: EnsupColors.line),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: EnsupColors.blue3),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: GoogleFonts.darkerGrotesque(
fontSize: 30,
fontWeight: FontWeight.w900,
),
),
Text(
label,
style: GoogleFonts.titilliumWeb(
fontSize: 13,
color: EnsupColors.muted,
),
),
],
),
],
),
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({
required this.icon,
required this.title,
required this.subtitle,
required this.trailing,
this.meta,
});
final IconData icon;
final String title;
final String subtitle;
final Widget trailing;
final String? meta;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: EnsupColors.line),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(icon, color: EnsupColors.teal),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.darkerGrotesque(
fontSize: 19,
fontWeight: FontWeight.w800,
),
),
Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
if (meta != null) ...[
const SizedBox(height: 4),
Text(
meta!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.titilliumWeb(
fontSize: 12,
color: EnsupColors.muted,
),
),
],
],
),
),
const SizedBox(width: 12),
trailing,
],
),
);
}
}
class _StatusBadge extends StatelessWidget {
const _StatusBadge(this.statut);
final String statut;
@override
Widget build(BuildContext context) {
final color = _statusColor(statut);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: Text(
_statusLabel(statut),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState();
@override
Widget build(BuildContext context) {
return Center(
child: Text(
"Aucun résultat",
style: GoogleFonts.titilliumWeb(color: EnsupColors.muted),
),
);
}
}
class _Erreur extends StatelessWidget {
const _Erreur({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text("Réessayer"),
),
],
),
);
}
}