merge: responsable history csv export
This commit is contained in:
@@ -4,6 +4,7 @@ import "package:google_fonts/google_fonts.dart";
|
|||||||
import "../demo_identity.dart";
|
import "../demo_identity.dart";
|
||||||
import "../services/responsable_service.dart";
|
import "../services/responsable_service.dart";
|
||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
|
import "../utils/file_download.dart";
|
||||||
|
|
||||||
const _workspaceBackground = Color(0xFFF3F6F9);
|
const _workspaceBackground = Color(0xFFF3F6F9);
|
||||||
const _orange = Color(0xFFD97706);
|
const _orange = Color(0xFFD97706);
|
||||||
@@ -19,6 +20,7 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
late Future<_ResponsableData> _future = _charger();
|
late Future<_ResponsableData> _future = _charger();
|
||||||
int _onglet = 0;
|
int _onglet = 0;
|
||||||
bool _transitionEnCours = false;
|
bool _transitionEnCours = false;
|
||||||
|
bool _exportEnCours = false;
|
||||||
|
|
||||||
Future<_ResponsableData> _charger() async {
|
Future<_ResponsableData> _charger() async {
|
||||||
final dashboard = await ResponsableService.dashboard();
|
final dashboard = await ResponsableService.dashboard();
|
||||||
@@ -79,6 +81,33 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _exporterHistorique() async {
|
||||||
|
if (_exportEnCours) return;
|
||||||
|
|
||||||
|
setState(() => _exportEnCours = true);
|
||||||
|
try {
|
||||||
|
final file = await ResponsableService.exporterHistorique();
|
||||||
|
downloadFile(
|
||||||
|
bytes: file.bytes,
|
||||||
|
fileName: file.fileName,
|
||||||
|
contentType: file.contentType,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text("Export CSV téléchargé.")));
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _exportEnCours = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String? _prochainStatut(String? statut) {
|
String? _prochainStatut(String? statut) {
|
||||||
return switch (statut) {
|
return switch (statut) {
|
||||||
"DETECTEE" => "EN_COURS_TRAITEMENT",
|
"DETECTEE" => "EN_COURS_TRAITEMENT",
|
||||||
@@ -195,17 +224,15 @@ class _ResponsableScreenState extends State<ResponsableScreen> {
|
|||||||
items: data.historique,
|
items: data.historique,
|
||||||
builder: _historiqueTile,
|
builder: _historiqueTile,
|
||||||
action: OutlinedButton.icon(
|
action: OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _exportEnCours ? null : _exporterHistorique,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
icon: _exportEnCours
|
||||||
const SnackBar(
|
? const SizedBox(
|
||||||
content: Text(
|
width: 16,
|
||||||
"Export disponible via /api/responsable/historique/export.csv",
|
height: 16,
|
||||||
),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
)
|
||||||
);
|
: const Icon(Icons.download_outlined, size: 16),
|
||||||
},
|
label: Text(_exportEnCours ? "Export en cours" : "Export CSV"),
|
||||||
icon: const Icon(Icons.download_outlined, size: 16),
|
|
||||||
label: const Text("Export CSV"),
|
|
||||||
),
|
),
|
||||||
searchableText: (item) => "${item["action"]} ${item["description"]}",
|
searchableText: (item) => "${item["action"]} ${item["description"]}",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "dart:convert";
|
import "dart:convert";
|
||||||
|
import "dart:typed_data";
|
||||||
import "package:http/http.dart" as http;
|
import "package:http/http.dart" as http;
|
||||||
|
|
||||||
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
||||||
@@ -11,6 +12,18 @@ class ApiException implements Exception {
|
|||||||
String toString() => message;
|
String toString() => message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ApiFile {
|
||||||
|
const ApiFile({
|
||||||
|
required this.bytes,
|
||||||
|
required this.fileName,
|
||||||
|
required this.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Uint8List bytes;
|
||||||
|
final String fileName;
|
||||||
|
final String contentType;
|
||||||
|
}
|
||||||
|
|
||||||
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
|
/// Point d'accès unique au backend : centralise l'URL de base, l'en-tête
|
||||||
/// d'authentification (simulée) et la gestion des erreurs.
|
/// d'authentification (simulée) et la gestion des erreurs.
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@@ -44,6 +57,37 @@ class ApiClient {
|
|||||||
throw ApiException(_messageErreur(reponse));
|
throw ApiException(_messageErreur(reponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<ApiFile> getFile(
|
||||||
|
String chemin, {
|
||||||
|
required String fallbackFileName,
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {"x-user-email": utilisateurEmail},
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reponse.statusCode < 200 || reponse.statusCode >= 300) {
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiFile(
|
||||||
|
bytes: reponse.bodyBytes,
|
||||||
|
fileName: _fileName(reponse, fallbackFileName),
|
||||||
|
contentType:
|
||||||
|
reponse.headers["content-type"] ?? "application/octet-stream",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<dynamic> post(
|
static Future<dynamic> post(
|
||||||
String chemin,
|
String chemin,
|
||||||
Map<String, dynamic> body, {
|
Map<String, dynamic> body, {
|
||||||
@@ -116,4 +160,15 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
return "Erreur ${reponse.statusCode}";
|
return "Erreur ${reponse.statusCode}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _fileName(http.Response reponse, String fallback) {
|
||||||
|
final disposition = reponse.headers["content-disposition"];
|
||||||
|
if (disposition == null) return fallback;
|
||||||
|
|
||||||
|
final match = RegExp(
|
||||||
|
"filename=\"?([^\";]+)\"?",
|
||||||
|
caseSensitive: false,
|
||||||
|
).firstMatch(disposition);
|
||||||
|
return match?.group(1) ?? fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ class ResponsableService {
|
|||||||
static Future<List<Map<String, dynamic>>> historique() =>
|
static Future<List<Map<String, dynamic>>> historique() =>
|
||||||
_liste("/responsable/historique");
|
_liste("/responsable/historique");
|
||||||
|
|
||||||
|
static Future<ApiFile> exporterHistorique() => ApiClient.getFile(
|
||||||
|
"/responsable/historique/export.csv",
|
||||||
|
fallbackFileName: "historique-responsable.csv",
|
||||||
|
utilisateurEmail: _email,
|
||||||
|
);
|
||||||
|
|
||||||
static Future<int> marquerNotificationsLues() async {
|
static Future<int> marquerNotificationsLues() async {
|
||||||
final reponse = await ApiClient.patch(
|
final reponse = await ApiClient.patch(
|
||||||
"/responsable/notifications/lu-toutes",
|
"/responsable/notifications/lu-toutes",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
|
||||||
|
|
||||||
|
import "dart:html" as html;
|
||||||
|
import "dart:typed_data";
|
||||||
|
|
||||||
|
void downloadFile({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String fileName,
|
||||||
|
required String contentType,
|
||||||
|
}) {
|
||||||
|
final blob = html.Blob([bytes], contentType);
|
||||||
|
final url = html.Url.createObjectUrlFromBlob(blob);
|
||||||
|
final anchor = html.AnchorElement(href: url)
|
||||||
|
..download = fileName
|
||||||
|
..style.display = "none";
|
||||||
|
|
||||||
|
html.document.body?.children.add(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
html.Url.revokeObjectUrl(url);
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ l'historique complet, y compris des étapes devenues obsolètes après brancheme
|
|||||||
| Frontend étudiant | Fonctionnel pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel ; conservation de l'action avant identification à finaliser. |
|
| Frontend étudiant | Fonctionnel pour la V1 | Parcours emprunt et restitution branchés sur l'API et testés en réel ; conservation de l'action avant identification à finaliser. |
|
||||||
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
|
| Authentification | Simulée | `x-user-email` côté backend et identité démo côté frontend ; Azure AD reste à faire. |
|
||||||
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
|
| API responsable | Terminé pour la V1 | Dashboard, emprunts, stock, anomalies, notifications, historique et export CSV, avec contrôle du rôle et du campus. |
|
||||||
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier et vues API ; téléchargement CSV à finaliser. |
|
| Frontend responsable | Fonctionnel pour la V1 | Espace de supervision responsive avec navigation dédiée, dashboard métier, vues API et export CSV opérationnel. |
|
||||||
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
| Tests automatisés | Non démarré | Tests backend/frontend/E2E à ajouter ; tests runtime manuels effectués. |
|
||||||
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
|
| Documentation | Partielle | README principal et documents de conception présents ; OpenAPI, guides utilisateur et captures restent à produire. |
|
||||||
|
|
||||||
@@ -423,6 +423,14 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
|
- Les listes métier partagent désormais une hiérarchie visuelle, une recherche et des lignes responsives cohérentes avec la charte ENSUP.
|
||||||
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
|
- Aucun changement backend ni métier ; rendu validé manuellement dans le navigateur et `dart analyze` sans erreur.
|
||||||
|
|
||||||
|
### Étape 45 — Téléchargement CSV de l'historique responsable
|
||||||
|
- Branche dédiée `feat/responsable-historique-export` créée depuis `develop`.
|
||||||
|
- Client HTTP étendu pour récupérer un fichier binaire avec son type de contenu et son nom.
|
||||||
|
- Le bouton `Export CSV` appelle désormais l'endpoint responsable avec l'identité simulée de Karim et déclenche le téléchargement navigateur.
|
||||||
|
- Un état de chargement bloque les doubles clics et les erreurs API sont affichées dans l'interface.
|
||||||
|
- Endpoint vérifié au runtime : réponse `200`, type `text/csv`, nom `historique-responsable.csv` et contenu non vide.
|
||||||
|
- Vérification statique : `dart analyze` sans erreur.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-22 — Espace responsable restructuré en interface de supervision premium et responsive.*
|
*Dernière mise à jour : 2026-07-23 — Export CSV de l'historique responsable opérationnel depuis l'interface.*
|
||||||
|
|||||||
Reference in New Issue
Block a user