feat(materiel): connect catalogue and detail to api
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/app.js",
|
"start": "node dist/app.js",
|
||||||
"dev": "nodemon --exec ts-node src/app.ts",
|
"dev": "nodemon --exec ts-node --files src/app.ts",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { apiRouter } from './routes';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(cors({ origin: env.frontendUrl }));
|
// En développement, on accepte toute origine locale (le front Flutter tourne sur
|
||||||
|
// un port variable) ; en production, seule l'origine du frontend est autorisée.
|
||||||
|
app.use(cors({ origin: env.nodeEnv === 'development' ? true : env.frontendUrl }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(requestLogger);
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { AppError } from '../errors/app-error';
|
import { AppError } from '../errors/app-error';
|
||||||
import { listerCatalogue } from '../services/materiel.service';
|
import { listerCatalogue, obtenirDetailMateriel } from '../services/materiel.service';
|
||||||
import { toMaterielResponse } from '../dtos/materiel.dto';
|
import { toMaterielDetailResponse, toMaterielResponse } from '../dtos/materiel.dto';
|
||||||
|
|
||||||
function parseCategorieId(value: unknown): number | undefined {
|
function parseCategorieId(value: unknown): number | undefined {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
@@ -16,6 +16,18 @@ function parseCategorieId(value: unknown): number | undefined {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseId(value: unknown): number {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new AppError(400, 'id invalide');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -28,3 +40,13 @@ export async function getCatalogue(req: Request, res: Response): Promise<void> {
|
|||||||
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
const materiels = await listerCatalogue(user.campusId, { categorieId, recherche });
|
||||||
res.json({ data: materiels.map(toMaterielResponse) });
|
res.json({ data: materiels.map(toMaterielResponse) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getMaterielDetail(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(401, 'Authentification requise');
|
||||||
|
}
|
||||||
|
|
||||||
|
const materiel = await obtenirDetailMateriel(parseId(req.params.id), user.campusId);
|
||||||
|
res.json({ data: toMaterielDetailResponse(materiel) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MaterielAvecCategorie } from '../repositories/materiel.repository';
|
import { MaterielAvecCategorie, MaterielDetail } from '../repositories/materiel.repository';
|
||||||
|
|
||||||
export interface CategorieResponse {
|
export interface CategorieResponse {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,6 +16,15 @@ export interface MaterielResponse {
|
|||||||
categorie: CategorieResponse;
|
categorie: CategorieResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MaterielDetailResponse extends MaterielResponse {
|
||||||
|
campus: {
|
||||||
|
id: number;
|
||||||
|
nom: string;
|
||||||
|
ville: string;
|
||||||
|
};
|
||||||
|
accessoires: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielResponse {
|
||||||
return {
|
return {
|
||||||
id: materiel.id,
|
id: materiel.id,
|
||||||
@@ -31,3 +40,15 @@ export function toMaterielResponse(materiel: MaterielAvecCategorie): MaterielRes
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toMaterielDetailResponse(materiel: MaterielDetail): MaterielDetailResponse {
|
||||||
|
return {
|
||||||
|
...toMaterielResponse(materiel),
|
||||||
|
campus: {
|
||||||
|
id: materiel.campus.id,
|
||||||
|
nom: materiel.campus.nom,
|
||||||
|
ville: materiel.campus.ville,
|
||||||
|
},
|
||||||
|
accessoires: materiel.accessoires.map((liaison) => liaison.accessoire.nom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import { Materiel, Prisma } from '@prisma/client';
|
|||||||
import { prisma } from '../db/prisma';
|
import { prisma } from '../db/prisma';
|
||||||
|
|
||||||
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
export type MaterielAvecCategorie = Prisma.MaterielGetPayload<{ include: { categorie: true } }>;
|
||||||
|
export type MaterielDetail = Prisma.MaterielGetPayload<{
|
||||||
|
include: {
|
||||||
|
categorie: true;
|
||||||
|
campus: true;
|
||||||
|
accessoires: { include: { accessoire: true } };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
export interface CatalogueFiltres {
|
export interface CatalogueFiltres {
|
||||||
categorieId?: number;
|
categorieId?: number;
|
||||||
@@ -39,3 +46,17 @@ export function findDisponiblesParCampus(
|
|||||||
export function findById(id: number): Promise<Materiel | null> {
|
export function findById(id: number): Promise<Materiel | null> {
|
||||||
return prisma.materiel.findUnique({ where: { id } });
|
return prisma.materiel.findUnique({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findDetailParCampus(id: number, campusId: number): Promise<MaterielDetail | null> {
|
||||||
|
return prisma.materiel.findFirst({
|
||||||
|
where: { id, campusId, actif: true },
|
||||||
|
include: {
|
||||||
|
categorie: true,
|
||||||
|
campus: true,
|
||||||
|
accessoires: {
|
||||||
|
include: { accessoire: true },
|
||||||
|
orderBy: { accessoire: { nom: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { getCatalogue } from '../controllers/materiel.controller';
|
import { getCatalogue, getMaterielDetail } from '../controllers/materiel.controller';
|
||||||
|
|
||||||
export const materielRoutes: Router = Router();
|
export const materielRoutes: Router = Router();
|
||||||
|
|
||||||
materielRoutes.get('/', getCatalogue);
|
materielRoutes.get('/', getCatalogue);
|
||||||
|
materielRoutes.get('/:id', getMaterielDetail);
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
findDisponiblesParCampus,
|
findDisponiblesParCampus,
|
||||||
|
findDetailParCampus,
|
||||||
CatalogueFiltres,
|
CatalogueFiltres,
|
||||||
|
MaterielDetail,
|
||||||
MaterielAvecCategorie,
|
MaterielAvecCategorie,
|
||||||
} from '../repositories/materiel.repository';
|
} from '../repositories/materiel.repository';
|
||||||
|
import { AppError } from '../errors/app-error';
|
||||||
|
|
||||||
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
/* RG10 : après identification, seuls les matériels disponibles du campus de
|
||||||
l'étudiant sont affichés. */
|
l'étudiant sont affichés. */
|
||||||
@@ -12,3 +15,11 @@ export function listerCatalogue(
|
|||||||
): Promise<MaterielAvecCategorie[]> {
|
): Promise<MaterielAvecCategorie[]> {
|
||||||
return findDisponiblesParCampus(campusId, filtres);
|
return findDisponiblesParCampus(campusId, filtres);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function obtenirDetailMateriel(id: number, campusId: number): Promise<MaterielDetail> {
|
||||||
|
const materiel = await findDetailParCampus(id, campusId);
|
||||||
|
if (!materiel) {
|
||||||
|
throw new AppError(404, 'Materiel introuvable');
|
||||||
|
}
|
||||||
|
return materiel;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import "package:flutter/material.dart";
|
|||||||
/// (données statiques pour l'instant ; sera alimenté par l'API plus tard).
|
/// (données statiques pour l'instant ; sera alimenté par l'API plus tard).
|
||||||
class MaterielItem {
|
class MaterielItem {
|
||||||
const MaterielItem({
|
const MaterielItem({
|
||||||
|
required this.id,
|
||||||
required this.nom,
|
required this.nom,
|
||||||
required this.categorie,
|
required this.categorie,
|
||||||
required this.marque,
|
required this.marque,
|
||||||
@@ -16,6 +17,28 @@ class MaterielItem {
|
|||||||
required this.icon,
|
required this.icon,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Construit un MaterielItem depuis la réponse JSON du catalogue.
|
||||||
|
/// Les champs non fournis par cet endpoint (accessoires, icône) sont complétés.
|
||||||
|
factory MaterielItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final categorie = (json["categorie"] as Map<String, dynamic>)["nom"] as String;
|
||||||
|
final campus = json["campus"] as Map<String, dynamic>?;
|
||||||
|
final accessoires = json["accessoires"] as List<dynamic>?;
|
||||||
|
return MaterielItem(
|
||||||
|
id: json["id"] as int,
|
||||||
|
nom: json["nom"] as String,
|
||||||
|
categorie: categorie,
|
||||||
|
marque: json["marque"] as String,
|
||||||
|
modele: json["modele"] as String,
|
||||||
|
reference: json["reference"] as String,
|
||||||
|
campus: campus == null ? "Saint-Christophe · Cergy" : "${campus["nom"]} · ${campus["ville"]}",
|
||||||
|
etatGeneral: json["etatGeneral"] as String,
|
||||||
|
disponible: json["statut"] == "DISPONIBLE",
|
||||||
|
accessoires: accessoires == null ? const <String>[] : accessoires.cast<String>(),
|
||||||
|
icon: iconePourCategorie(categorie),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int id;
|
||||||
final String nom;
|
final String nom;
|
||||||
final String categorie;
|
final String categorie;
|
||||||
final String marque;
|
final String marque;
|
||||||
@@ -27,3 +50,23 @@ class MaterielItem {
|
|||||||
final List<String> accessoires;
|
final List<String> accessoires;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Déduit une icône à partir du nom de catégorie renvoyé par l'API.
|
||||||
|
IconData iconePourCategorie(String categorie) {
|
||||||
|
switch (categorie) {
|
||||||
|
case "Ordinateur portable":
|
||||||
|
return Icons.laptop_mac;
|
||||||
|
case "Vidéoprojecteur":
|
||||||
|
return Icons.videocam_outlined;
|
||||||
|
case "Tablette":
|
||||||
|
return Icons.tablet_mac;
|
||||||
|
case "Caméra":
|
||||||
|
return Icons.photo_camera_outlined;
|
||||||
|
case "Périphérique audio":
|
||||||
|
return Icons.headphones;
|
||||||
|
case "Câble / Adaptateur":
|
||||||
|
return Icons.cable;
|
||||||
|
default:
|
||||||
|
return Icons.inventory_2_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import "package:flutter/material.dart";
|
|||||||
import "package:google_fonts/google_fonts.dart";
|
import "package:google_fonts/google_fonts.dart";
|
||||||
import "../theme/ensup_colors.dart";
|
import "../theme/ensup_colors.dart";
|
||||||
import "../models/materiel_item.dart";
|
import "../models/materiel_item.dart";
|
||||||
|
import "../services/materiel_service.dart";
|
||||||
import "../widgets/ensup_top_bar.dart";
|
import "../widgets/ensup_top_bar.dart";
|
||||||
import "../widgets/brand_corners.dart";
|
import "../widgets/brand_corners.dart";
|
||||||
import "../widgets/materiel_card.dart";
|
import "../widgets/materiel_card.dart";
|
||||||
import "detail_screen.dart";
|
import "detail_screen.dart";
|
||||||
|
|
||||||
/// Catalogue matériel : recherche, filtres par catégorie et liste de matériels.
|
/// Catalogue matériel : recherche, filtres par catégorie et liste chargée
|
||||||
|
/// depuis l'API.
|
||||||
class CatalogueScreen extends StatefulWidget {
|
class CatalogueScreen extends StatefulWidget {
|
||||||
const CatalogueScreen({super.key});
|
const CatalogueScreen({super.key});
|
||||||
|
|
||||||
@@ -15,82 +17,62 @@ class CatalogueScreen extends StatefulWidget {
|
|||||||
State<CatalogueScreen> createState() => _CatalogueScreenState();
|
State<CatalogueScreen> createState() => _CatalogueScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const _materiels = <MaterielItem>[
|
|
||||||
MaterielItem(
|
|
||||||
nom: "PC Dell Latitude 5420",
|
|
||||||
categorie: "Ordinateur",
|
|
||||||
marque: "Dell",
|
|
||||||
modele: "Latitude 5420",
|
|
||||||
reference: "ENS-PC-001",
|
|
||||||
campus: "Paris · Ensitech",
|
|
||||||
etatGeneral: "Bon état",
|
|
||||||
disponible: true,
|
|
||||||
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection", "Câble HDMI"],
|
|
||||||
icon: Icons.laptop_mac,
|
|
||||||
),
|
|
||||||
MaterielItem(
|
|
||||||
nom: "Vidéoprojecteur Epson EB-X51",
|
|
||||||
categorie: "Projection",
|
|
||||||
marque: "Epson",
|
|
||||||
modele: "EB-X51",
|
|
||||||
reference: "ENS-VP-003",
|
|
||||||
campus: "Paris · Ensitech",
|
|
||||||
etatGeneral: "Bon état",
|
|
||||||
disponible: true,
|
|
||||||
accessoires: ["Télécommande", "Câble HDMI", "Câble VGA"],
|
|
||||||
icon: Icons.videocam_outlined,
|
|
||||||
),
|
|
||||||
MaterielItem(
|
|
||||||
nom: "Chargeur USB-C 65W",
|
|
||||||
categorie: "Accessoire",
|
|
||||||
marque: "Anker",
|
|
||||||
modele: "PowerPort III",
|
|
||||||
reference: "ENS-CH-012",
|
|
||||||
campus: "Paris · Ensitech",
|
|
||||||
etatGeneral: "Bon état",
|
|
||||||
disponible: false,
|
|
||||||
accessoires: [],
|
|
||||||
icon: Icons.power_outlined,
|
|
||||||
),
|
|
||||||
MaterielItem(
|
|
||||||
nom: "PC Lenovo ThinkPad E14",
|
|
||||||
categorie: "Ordinateur",
|
|
||||||
marque: "Lenovo",
|
|
||||||
modele: "ThinkPad E14",
|
|
||||||
reference: "ENS-PC-004",
|
|
||||||
campus: "Paris · Ensitech",
|
|
||||||
etatGeneral: "Bon état",
|
|
||||||
disponible: true,
|
|
||||||
accessoires: ["Chargeur 65W", "Souris sans fil", "Housse de protection"],
|
|
||||||
icon: Icons.laptop_mac,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
const _categories = ["Tous", "Ordinateurs", "Projection", "Câbles", "Accessoires"];
|
|
||||||
|
|
||||||
class _CatalogueScreenState extends State<CatalogueScreen> {
|
class _CatalogueScreenState extends State<CatalogueScreen> {
|
||||||
|
late Future<List<MaterielItem>> _future;
|
||||||
String _recherche = "";
|
String _recherche = "";
|
||||||
String _categorie = "Tous";
|
String _categorie = "Tous";
|
||||||
|
int? _selectionEnCoursId;
|
||||||
|
|
||||||
List<MaterielItem> get _resultats {
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_future = MaterielService.getCatalogue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recharger() {
|
||||||
|
setState(() => _future = MaterielService.getCatalogue());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _categories(List<MaterielItem> materiels) {
|
||||||
|
final distinctes = materiels.map((m) => m.categorie).toSet().toList()..sort();
|
||||||
|
return ["Tous", ...distinctes];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<MaterielItem> _filtrer(List<MaterielItem> materiels) {
|
||||||
final terme = _recherche.trim().toLowerCase();
|
final terme = _recherche.trim().toLowerCase();
|
||||||
return _materiels.where((materiel) {
|
return materiels.where((materiel) {
|
||||||
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
final matchTexte = terme.isEmpty || materiel.nom.toLowerCase().contains(terme);
|
||||||
final matchCategorie = _categorie == "Tous" || _categorie.startsWith(materiel.categorie);
|
final matchCategorie = _categorie == "Tous" || materiel.categorie == _categorie;
|
||||||
return matchTexte && matchCategorie;
|
return matchTexte && matchCategorie;
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _selectionner(BuildContext context, MaterielItem item) {
|
Future<void> _selectionner(MaterielItem item) async {
|
||||||
|
setState(() => _selectionEnCoursId = item.id);
|
||||||
|
try {
|
||||||
|
final detail = await MaterielService.getDetail(item.id);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => DetailScreen(item: item)),
|
MaterialPageRoute(builder: (_) => DetailScreen(item: detail)),
|
||||||
);
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error.toString())),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _selectionEnCoursId = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final resultats = _resultats;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: EnsupColors.soft,
|
backgroundColor: EnsupColors.soft,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@@ -131,32 +113,21 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
onChanged: (value) => setState(() => _recherche = value),
|
onChanged: (value) => setState(() => _recherche = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: _categories.map((categorie) {
|
|
||||||
return _CategoryChip(
|
|
||||||
label: categorie,
|
|
||||||
selected: categorie == _categorie,
|
|
||||||
onTap: () => setState(() => _categorie = categorie),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Divider(color: EnsupColors.line, height: 1),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: resultats.isEmpty
|
child: FutureBuilder<List<MaterielItem>>(
|
||||||
? _EmptyState()
|
future: _future,
|
||||||
: ListView.separated(
|
builder: (context, snapshot) {
|
||||||
itemCount: resultats.length,
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
return const Center(child: CircularProgressIndicator());
|
||||||
itemBuilder: (context, index) {
|
}
|
||||||
final item = resultats[index];
|
if (snapshot.hasError) {
|
||||||
return MaterielCard(
|
return _ErreurState(
|
||||||
item: item,
|
message: snapshot.error.toString(),
|
||||||
onSelect: () => _selectionner(context, item),
|
onRetry: _recharger,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
final materiels = snapshot.data ?? const [];
|
||||||
|
return _contenu(materiels);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -174,6 +145,53 @@ class _CatalogueScreenState extends State<CatalogueScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _contenu(List<MaterielItem> materiels) {
|
||||||
|
final resultats = _filtrer(materiels);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _categories(materiels).map((categorie) {
|
||||||
|
return _CategoryChip(
|
||||||
|
label: categorie,
|
||||||
|
selected: categorie == _categorie,
|
||||||
|
onTap: () => setState(() => _categorie = categorie),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Divider(color: EnsupColors.line, height: 1),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: resultats.isEmpty
|
||||||
|
? const _EmptyState()
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: resultats.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = resultats[index];
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
MaterielCard(item: item, onSelect: () => _selectionner(item)),
|
||||||
|
if (_selectionEnCoursId == item.id)
|
||||||
|
const Positioned.fill(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Color(0x66FFFFFF),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SearchField extends StatelessWidget {
|
class _SearchField extends StatelessWidget {
|
||||||
@@ -235,7 +253,7 @@ class _CategoryChip extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: GoogleFonts.darkerGrotesque(
|
style: GoogleFonts.darkerGrotesque(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: selected ? Colors.white : EnsupColors.muted,
|
color: selected ? Colors.white : EnsupColors.muted,
|
||||||
),
|
),
|
||||||
@@ -246,14 +264,16 @@ class _CategoryChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _EmptyState extends StatelessWidget {
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.search_off, size: 40, color: EnsupColors.muted),
|
const Icon(Icons.inventory_2_outlined, size: 40, color: EnsupColors.muted),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Text(
|
Text(
|
||||||
"Aucun matériel ne correspond",
|
"Aucun matériel ne correspond",
|
||||||
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.muted),
|
||||||
@@ -263,3 +283,36 @@ class _EmptyState extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ErreurState extends StatelessWidget {
|
||||||
|
const _ErreurState({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: [
|
||||||
|
const Icon(Icons.cloud_off_outlined, size: 40, color: EnsupColors.muted),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.titilliumWeb(fontSize: 14, color: EnsupColors.text2),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(
|
||||||
|
"Réessayer",
|
||||||
|
style: GoogleFonts.darkerGrotesque(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import "../widgets/brand_corners.dart";
|
|||||||
import "checklist_retour_screen.dart";
|
import "checklist_retour_screen.dart";
|
||||||
|
|
||||||
const _pcDell = MaterielItem(
|
const _pcDell = MaterielItem(
|
||||||
|
id: 0,
|
||||||
nom: "PC Dell Latitude 5420",
|
nom: "PC Dell Latitude 5420",
|
||||||
categorie: "Ordinateur",
|
categorie: "Ordinateur",
|
||||||
marque: "Dell",
|
marque: "Dell",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import "dart:convert";
|
||||||
|
import "package:http/http.dart" as http;
|
||||||
|
|
||||||
|
/// Erreur levée par le client API (serveur injoignable ou réponse en échec).
|
||||||
|
class ApiException implements Exception {
|
||||||
|
const ApiException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
class ApiClient {
|
||||||
|
ApiClient._();
|
||||||
|
|
||||||
|
static const String _baseUrl = "http://localhost:3000/api";
|
||||||
|
static const String _utilisateurEmail = "lucas.martin@ensitech.eu";
|
||||||
|
|
||||||
|
static Future<dynamic> get(String chemin) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$chemin");
|
||||||
|
|
||||||
|
late final http.Response reponse;
|
||||||
|
try {
|
||||||
|
reponse = await http.get(uri, headers: const {"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) {
|
||||||
|
return jsonDecode(reponse.body);
|
||||||
|
}
|
||||||
|
throw ApiException(_messageErreur(reponse));
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _messageErreur(http.Response reponse) {
|
||||||
|
try {
|
||||||
|
final corps = jsonDecode(reponse.body);
|
||||||
|
if (corps is Map && corps["error"] is Map) {
|
||||||
|
final message = (corps["error"] as Map)["message"];
|
||||||
|
if (message is String) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Corps non-JSON : on retombe sur le code HTTP.
|
||||||
|
}
|
||||||
|
return "Erreur ${reponse.statusCode}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "../models/materiel_item.dart";
|
||||||
|
import "api_client.dart";
|
||||||
|
|
||||||
|
/// Appels liés au catalogue matériel.
|
||||||
|
class MaterielService {
|
||||||
|
MaterielService._();
|
||||||
|
|
||||||
|
/// Récupère le catalogue (matériels disponibles du campus de l'utilisateur).
|
||||||
|
static Future<List<MaterielItem>> getCatalogue() async {
|
||||||
|
final reponse = await ApiClient.get("/materiels");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as List<dynamic>;
|
||||||
|
return data.map((json) => MaterielItem.fromJson(json as Map<String, dynamic>)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère le détail d'un matériel, notamment les accessoires du kit.
|
||||||
|
static Future<MaterielItem> getDetail(int id) async {
|
||||||
|
final reponse = await ApiClient.get("/materiels/$id");
|
||||||
|
final data = (reponse as Map<String, dynamic>)["data"] as Map<String, dynamic>;
|
||||||
|
return MaterielItem.fromJson(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,7 +108,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "8.1.0"
|
version: "8.1.0"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ dependencies:
|
|||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
google_fonts: ^8.1.0
|
google_fonts: ^8.1.0
|
||||||
|
http: ^1.6.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -207,4 +207,20 @@ En terminal interactif (VS Code), `migrate dev` fonctionne normalement (taper `y
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Dernière mise à jour : 2026-07-07 — Frontend : parcours étudiant complet (emprunt + restitution avec résultat conforme/anomalie).*
|
### Étape 26 — Connexion frontend ↔ API (catalogue)
|
||||||
|
- Package `http` ajouté. Couche `services/api_client.dart` : URL de base, en-tête d'auth simulée (`x-user-email: lucas.martin@ensitech.eu`), gestion des erreurs (serveur injoignable, message `{error:{message}}`).
|
||||||
|
- `services/materiel_service.dart` (`getCatalogue`) + `MaterielItem.fromJson` (icône déduite de la catégorie, champs absents complétés).
|
||||||
|
- Écran catalogue branché via `FutureBuilder` : chargement / données / erreur + bouton Réessayer. Filtres de catégorie déduits des vraies données.
|
||||||
|
- CORS backend assoupli en développement (`origin: true` si `NODE_ENV=development`), strict en production.
|
||||||
|
- Testé en réel côté backend : Docker SQL Server OK, backend compilé OK, `/health`, `/api/auth/me`, `/api/materiels` OK, 401 sans `x-user-email` OK.
|
||||||
|
|
||||||
|
### Étape 27 — Connexion frontend ↔ API (détail matériel)
|
||||||
|
- Correctif runtime backend : `npm run dev` lance maintenant `ts-node --files`, sinon l'augmentation `Express.Request.user` n'était pas chargée par `ts-node` malgré un build TypeScript vert.
|
||||||
|
- Endpoint `GET /api/materiels/:id` ajouté : détail filtré par campus utilisateur, avec catégorie, campus et accessoires du kit.
|
||||||
|
- Frontend : `MaterielItem` porte maintenant l'`id`, `MaterielService.getDetail(id)` consomme le nouvel endpoint, et le catalogue charge le détail réel avant de naviguer vers l'écran détail.
|
||||||
|
- Testé en réel côté backend : `/api/materiels/1` renvoie le PC avec accessoires (`Chargeur`, `Souris`) ; `/api/materiels/abc` renvoie 400.
|
||||||
|
- Limite environnement : `flutter analyze`, `flutter analyze --no-pub` et `dart format` restent bloqués jusqu'au timeout dans le sandbox. À relancer dans un terminal Flutter local.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Dernière mise à jour : 2026-07-07 — Catalogue et détail matériel branchés sur l'API backend ; backend testé avec SQL Server Docker.*
|
||||||
|
|||||||
Reference in New Issue
Block a user