feat(frontend): add responsable dashboard screen
This commit is contained in:
@@ -6,6 +6,7 @@ import "../widgets/ensup_top_bar.dart";
|
|||||||
import "../widgets/identification_option.dart";
|
import "../widgets/identification_option.dart";
|
||||||
import "../widgets/brand_corners.dart";
|
import "../widgets/brand_corners.dart";
|
||||||
import "home_screen.dart";
|
import "home_screen.dart";
|
||||||
|
import "responsable_screen.dart";
|
||||||
|
|
||||||
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
/// Écran de démarrage : identification de l'étudiant (RG01/RG02).
|
||||||
class LoginScreen extends StatelessWidget {
|
class LoginScreen extends StatelessWidget {
|
||||||
@@ -60,7 +61,10 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 40),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 40,
|
||||||
|
),
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -71,18 +75,27 @@ class LoginScreen extends StatelessWidget {
|
|||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
colors: [EnsupColors.blue3, EnsupColors.cyan],
|
colors: [
|
||||||
|
EnsupColors.blue3,
|
||||||
|
EnsupColors.cyan,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: EnsupColors.blue3.withValues(alpha: 0.35),
|
color: EnsupColors.blue3.withValues(
|
||||||
|
alpha: 0.35,
|
||||||
|
),
|
||||||
blurRadius: 24,
|
blurRadius: 24,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.badge_outlined, color: Colors.white, size: 34),
|
child: const Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
@@ -115,16 +128,22 @@ class LoginScreen extends StatelessWidget {
|
|||||||
IdentificationOption(
|
IdentificationOption(
|
||||||
icon: Icons.mail_outline,
|
icon: Icons.mail_outline,
|
||||||
iconColor: EnsupColors.teal,
|
iconColor: EnsupColors.teal,
|
||||||
label: "Connexion compte professionnel ENSUP",
|
label:
|
||||||
description: "Utiliser mon adresse e-mail Microsoft 365",
|
"Connexion compte professionnel ENSUP",
|
||||||
|
description:
|
||||||
|
"Utiliser mon adresse e-mail Microsoft 365",
|
||||||
onTap: () => _identifier(context),
|
onTap: () => _identifier(context),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"ou",
|
"ou",
|
||||||
style: GoogleFonts.titilliumWeb(
|
style: GoogleFonts.titilliumWeb(
|
||||||
@@ -133,15 +152,29 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Expanded(child: Divider(color: EnsupColors.line)),
|
const Expanded(
|
||||||
|
child: Divider(color: EnsupColors.line),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () {},
|
onPressed: () =>
|
||||||
icon: const Icon(Icons.person_outline, size: 16),
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
const ResponsableScreen(),
|
||||||
|
settings: const RouteSettings(
|
||||||
|
name: "responsable",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
label: Text(
|
label: Text(
|
||||||
"Accès responsable matériel",
|
"Accès responsable matériel",
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
@@ -151,10 +184,17 @@ class LoginScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: EnsupColors.text2,
|
foregroundColor: EnsupColors.text2,
|
||||||
side: const BorderSide(color: EnsupColors.line, width: 1.5),
|
side: const BorderSide(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
color: EnsupColors.line,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(
|
||||||
|
10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,536 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
_VueListe(titre: "Stock", items: data.materiels, builder: _materielTile),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Anomalies",
|
||||||
|
items: data.anomalies,
|
||||||
|
builder: _anomalieTile,
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Notifications",
|
||||||
|
items: data.notifications,
|
||||||
|
builder: _notificationTile,
|
||||||
|
),
|
||||||
|
_VueListe(
|
||||||
|
titre: "Historique",
|
||||||
|
items: data.historique,
|
||||||
|
builder: _historiqueTile,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
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"])}",
|
||||||
|
trailing: _Badge("${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"]}",
|
||||||
|
trailing: _Badge("${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"]}",
|
||||||
|
trailing: prochain == null
|
||||||
|
? _Badge("${item["statut"]}")
|
||||||
|
: FilledButton(
|
||||||
|
onPressed: _transitionEnCours ? null : () => _changerStatut(item),
|
||||||
|
child: Text(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"]}",
|
||||||
|
trailing: _Badge(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: "${item["action"]}",
|
||||||
|
subtitle:
|
||||||
|
"${_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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 StatelessWidget {
|
||||||
|
const _VueListe({
|
||||||
|
required this.titre,
|
||||||
|
required this.items,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String titre;
|
||||||
|
final List<Map<String, dynamic>> items;
|
||||||
|
final Widget Function(Map<String, dynamic>) builder;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
"$titre (${items.length})",
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: EnsupColors.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.separated(
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (_, index) => builder(items[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,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Widget trailing;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
style: GoogleFonts.darkerGrotesque(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Badge extends StatelessWidget {
|
||||||
|
const _Badge(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: EnsupColors.blue3.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,20 @@ class ApiClient {
|
|||||||
|
|
||||||
static const String _baseUrl = "http://localhost:3000/api";
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
||||||
|
static const String responsableEmail = "karim.benali@ensup.eu";
|
||||||
|
|
||||||
static Future<dynamic> get(String chemin) async {
|
static Future<dynamic> get(
|
||||||
|
String chemin, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
reponse = await http.get(uri, headers: const {"x-user-email": _utilisateurEmail});
|
reponse = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {"x-user-email": utilisateurEmail},
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw const ApiException(
|
throw const ApiException(
|
||||||
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
@@ -37,16 +44,49 @@ class ApiClient {
|
|||||||
throw ApiException(_messageErreur(reponse));
|
throw ApiException(_messageErreur(reponse));
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<dynamic> post(String chemin, Map<String, dynamic> body) async {
|
static Future<dynamic> post(
|
||||||
|
String chemin,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
final uri = Uri.parse("$_baseUrl$chemin");
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
late final http.Response reponse;
|
late final http.Response reponse;
|
||||||
try {
|
try {
|
||||||
reponse = await http.post(
|
reponse = await http.post(
|
||||||
uri,
|
uri,
|
||||||
headers: const {
|
headers: {
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
"x-user-email": _utilisateurEmail,
|
"x-user-email": utilisateurEmail,
|
||||||
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
throw const ApiException(
|
||||||
|
"Impossible de joindre le serveur. Vérifiez que le backend est démarré.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reponse.statusCode >= 200 && reponse.statusCode < 300) {
|
||||||
|
return jsonDecode(reponse.body);
|
||||||
|
}
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<dynamic> patch(
|
||||||
|
String chemin,
|
||||||
|
Map<String, dynamic> body, {
|
||||||
|
String utilisateurEmail = _utilisateurEmail,
|
||||||
|
}) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.patch(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-user-email": utilisateurEmail,
|
||||||
},
|
},
|
||||||
body: jsonEncode(body),
|
body: jsonEncode(body),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels API du tableau responsable matériel.
|
||||||
|
class ResponsableService {
|
||||||
|
ResponsableService._();
|
||||||
|
|
||||||
|
static const String _email = ApiClient.responsableEmail;
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> dashboard() async {
|
||||||
|
final reponse = await ApiClient.get(
|
||||||
|
"/responsable/dashboard",
|
||||||
|
utilisateurEmail: _email,
|
||||||
|
);
|
||||||
|
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> emprunts() =>
|
||||||
|
_liste("/responsable/emprunts");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> materiels() =>
|
||||||
|
_liste("/responsable/materiels");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> anomalies() =>
|
||||||
|
_liste("/responsable/anomalies");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> notifications() =>
|
||||||
|
_liste("/responsable/notifications");
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> historique() =>
|
||||||
|
_liste("/responsable/historique");
|
||||||
|
|
||||||
|
static Future<Map<String, dynamic>> changerStatutAnomalie(
|
||||||
|
int id,
|
||||||
|
String statut,
|
||||||
|
) async {
|
||||||
|
final reponse = await ApiClient.patch("/responsable/anomalies/$id/statut", {
|
||||||
|
"statut": statut,
|
||||||
|
"observation": "Traitement depuis l'interface responsable",
|
||||||
|
}, utilisateurEmail: _email);
|
||||||
|
return Map<String, dynamic>.from(reponse["data"] as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Map<String, dynamic>>> _liste(String chemin) async {
|
||||||
|
final reponse = await ApiClient.get(chemin, utilisateurEmail: _email);
|
||||||
|
final data = reponse["data"] as List;
|
||||||
|
return data.map((item) => Map<String, dynamic>.from(item as Map)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -381,6 +381,15 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
|
- Le responsable connecté est enregistré dans `traiteeParId`; une observation optionnelle peut être conservée.
|
||||||
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
|
- Une entrée d'historique `TRAITEMENT_ANOMALIE` est créée à chaque transition.
|
||||||
|
|
||||||
|
### Étape 41 — Frontend responsable : premier branchement API
|
||||||
|
- Branche dédiée `feat/responsable-frontend-api` créée pour isoler le travail frontend responsable.
|
||||||
|
- Le bouton "Accès responsable matériel" de l'écran d'identification ouvre maintenant un écran responsable.
|
||||||
|
- Client API étendu pour accepter l'e-mail simulé du responsable (`karim.benali@ensup.eu`) et les requêtes `PATCH`.
|
||||||
|
- Service frontend responsable ajouté pour consommer dashboard, emprunts, stock, anomalies, notifications et historique.
|
||||||
|
- Écran responsable ajouté avec onglets : dashboard, emprunts, stock, anomalies, notifications, historique.
|
||||||
|
- Les anomalies peuvent être avancées au prochain statut autorisé via l'API.
|
||||||
|
- Vérification statique effectuée avec l'exécutable Dart direct : `dart analyze` OK. Le wrapper `flutter` reste instable dans cette session et bloque au lancement web automatisé.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-16 — Bloc API responsable complété côté consultation, notifications, historique/export et cycle anomalies.*
|
*Dernière mise à jour : 2026-07-17 — Premier écran frontend responsable branché aux APIs validées.*
|
||||||
|
|||||||
Reference in New Issue
Block a user