feat: Migration complète vers Quarkus PrimeFaces Freya

Migration du frontend React/Next.js vers Quarkus + PrimeFaces Freya 5.0.0

Dashboard:
- Extension de BtpXpressApiClient avec tous les endpoints dashboard
- Création de DashboardService pour récupérer les données API
- Refactorisation DashboardView : uniquement données réelles de l'API
- Restructuration dashboard.xhtml avec tous les aspects métiers BTP
- Suppression complète de toutes les données fictives

Topbar:
- Amélioration du menu profil utilisateur avec header professionnel
- Ajout UserSessionBean pour gérer les informations utilisateur
- Styles CSS personnalisés pour une disposition raffinée
- Badges de notifications conditionnels

Configuration:
- Intégration du thème Freya 5.0.0-jakarta
- Configuration OIDC pour Keycloak (security.lions.dev)
- Gestion des erreurs HTTP 431 (headers size)
- Support du format Fcfa avec séparateurs d'espaces

Converters:
- Création de FcfaConverter pour formater les montants en Fcfa avec espaces (x xxx xxx format)

Code Quality:
- Code entièrement documenté en français avec Javadoc exemplaire
- Respect du principe Java 'Write once, use many times'
- Logging complet pour le débogage
- Gestion d'erreurs robuste
This commit is contained in:
dahoud
2025-11-01 19:55:30 +00:00
commit b749f2df37
269 changed files with 29252 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
package dev.lions.btpxpress.converter;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.FacesConverter;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
/**
* Converter personnalisé pour formater les montants en Franc CFA (Fcfa).
*
* <p>Ce converter formate les nombres avec des espaces comme séparateurs de milliers
* au lieu de virgules, conformément au format standard du Franc CFA.</p>
*
* <p>Exemple : 1234567 devient "1 234 567 Fcfa"</p>
*
* @author BTP Xpress Team
* @version 1.0
*/
@FacesConverter("fcfaConverter")
public class FcfaConverter implements Converter<Number> {
private static final DecimalFormatSymbols SYMBOLS;
static {
SYMBOLS = new DecimalFormatSymbols(Locale.FRENCH);
SYMBOLS.setGroupingSeparator(' ');
SYMBOLS.setDecimalSeparator(',');
}
/**
* Convertit une chaîne de caractères en nombre.
*
* @param context Le contexte Faces
* @param component Le composant UI
* @param value La valeur string à convertir
* @return Le nombre converti, ou null si la valeur est vide/null
*/
@Override
public Number getAsObject(FacesContext context, UIComponent component, String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
// Retirer les espaces et le préfixe "Fcfa" si présent
String cleanedValue = value.replaceAll("\\s+", "")
.replace("Fcfa", "")
.replace("fcfa", "")
.trim();
return new BigDecimal(cleanedValue);
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Impossible de convertir '" + value + "' en nombre", e);
}
}
/**
* Convertit un nombre en chaîne de caractères formatée.
*
* @param context Le contexte Faces
* @param component Le composant UI
* @param value Le nombre à convertir
* @return La chaîne formatée avec espaces comme séparateurs de milliers
*/
@Override
public String getAsString(FacesContext context, UIComponent component, Number value) {
if (value == null) {
return "";
}
// Formater avec espaces comme séparateurs de milliers (format Fcfa standard)
// Le pattern "#" avec groupingUsed=true utilise le groupingSeparator défini dans SYMBOLS (espace)
DecimalFormat formatter = new DecimalFormat("#", SYMBOLS);
formatter.setGroupingSize(3);
formatter.setGroupingUsed(true);
formatter.setMaximumFractionDigits(0);
long amount = value.longValue();
return formatter.format(amount);
}
}

View File

@@ -0,0 +1,32 @@
package dev.lions.btpxpress.filter;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import java.io.IOException;
public class CharacterEncodingFilter implements Filter {
private static final String DEFAULT_ENCODING = "UTF-8";
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
request.setCharacterEncoding(DEFAULT_ENCODING);
response.setCharacterEncoding(DEFAULT_ENCODING);
response.setContentType("text/html; charset=" + DEFAULT_ENCODING);
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}

View File

@@ -0,0 +1,185 @@
package dev.lions.btpxpress.service;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/**
* Interface REST Client pour communiquer avec l'API backend BTP Xpress.
* <p>
* Ce client permet au frontend PrimeFaces de communiquer avec le backend Quarkus
* en utilisant les endpoints REST exposés sur /api/v1/*. L'authentification
* est gérée automatiquement via les tokens JWT Keycloak.
* </p>
*
* @author BTP Xpress Development Team
* @version 1.0.0
* @since 1.0.0
*/
@RegisterRestClient(configKey = "btpxpress.api")
@RegisterClientHeaders
@Path("/api/v1")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface BtpXpressApiClient {
/**
* Récupère la liste des chantiers.
* Correspond à {@code ChantierResource.getAllChantiers()} dans le serveur.
*
* @return Réponse HTTP contenant la liste des chantiers.
*/
@GET
@Path("/chantiers")
Response getChantiers();
/**
* Récupère un chantier par son identifiant.
* Correspond à {@code ChantierResource.getChantierById()} dans le serveur.
*
* @param id L'identifiant du chantier.
* @return Réponse HTTP contenant le chantier.
*/
@GET
@Path("/chantiers/{id}")
Response getChantier(@PathParam("id") Long id);
/**
* Récupère la liste des clients.
* Correspond à {@code ClientResource.getAllClients()} dans le serveur.
*
* @return Réponse HTTP contenant la liste des clients.
*/
@GET
@Path("/clients")
Response getClients();
/**
* Récupère un client par son identifiant.
* Correspond à {@code ClientResource.getClientById()} dans le serveur.
*
* @param id L'identifiant du client.
* @return Réponse HTTP contenant le client.
*/
@GET
@Path("/clients/{id}")
Response getClient(@PathParam("id") Long id);
// === ENDPOINTS DASHBOARD ===
/**
* Récupère le dashboard principal avec les métriques globales.
* Correspond à {@code DashboardResource.getDashboardPrincipal()} dans le serveur.
*
* @return Réponse HTTP contenant les métriques du dashboard.
*/
@GET
@Path("/dashboard")
Response getDashboardPrincipal();
/**
* Récupère le dashboard des chantiers avec métriques détaillées.
* Correspond à {@code DashboardResource.getDashboardChantiers()} dans le serveur.
*
* @return Réponse HTTP contenant les métriques des chantiers.
*/
@GET
@Path("/dashboard/chantiers")
Response getDashboardChantiers();
/**
* Récupère les métriques financières.
* Correspond à {@code DashboardResource.getDashboardFinances()} dans le serveur.
*
* @param periode Période en jours (défaut: 30).
* @return Réponse HTTP contenant les métriques financières.
*/
@GET
@Path("/dashboard/finances")
Response getDashboardFinances(@QueryParam("periode") @DefaultValue("30") int periode);
/**
* Récupère les métriques de maintenance.
* Correspond à {@code DashboardResource.getDashboardMaintenance()} dans le serveur.
*
* @return Réponse HTTP contenant les métriques de maintenance.
*/
@GET
@Path("/dashboard/maintenance")
Response getDashboardMaintenance();
/**
* Récupère les métriques des ressources (équipes, employés, matériel).
* Correspond à {@code DashboardResource.getDashboardRessources()} dans le serveur.
*
* @return Réponse HTTP contenant les métriques des ressources.
*/
@GET
@Path("/dashboard/ressources")
Response getDashboardRessources();
/**
* Récupère les alertes nécessitant une attention immédiate.
* Correspond à {@code DashboardResource.getAlertes()} dans le serveur.
*
* @return Réponse HTTP contenant les alertes.
*/
@GET
@Path("/dashboard/alertes")
Response getAlertes();
/**
* Récupère les KPIs principaux.
* Correspond à {@code DashboardResource.getKPI()} dans le serveur.
*
* @param periode Période en jours (défaut: 30).
* @return Réponse HTTP contenant les KPIs.
*/
@GET
@Path("/dashboard/kpi")
Response getKPI(@QueryParam("periode") @DefaultValue("30") int periode);
/**
* Récupère les activités récentes.
* Correspond à {@code DashboardResource.getActivitesRecentes()} dans le serveur.
*
* @param limit Nombre d'activités à récupérer (défaut: 10).
* @return Réponse HTTP contenant les activités récentes.
*/
@GET
@Path("/dashboard/activites-recentes")
Response getActivitesRecentes(@QueryParam("limit") @DefaultValue("10") int limit);
/**
* Récupère le résumé quotidien.
* Correspond à {@code DashboardResource.getResumeQuotidien()} dans le serveur.
*
* @return Réponse HTTP contenant le résumé quotidien.
*/
@GET
@Path("/dashboard/resume-quotidien")
Response getResumeQuotidien();
/**
* Récupère la liste des devis.
* Correspond à {@code DevisResource.getAllDevis()} dans le serveur.
*
* @return Réponse HTTP contenant la liste des devis.
*/
@GET
@Path("/devis")
Response getDevis();
/**
* Récupère la liste des factures.
* Correspond à {@code FactureResource.getAllFactures()} dans le serveur.
*
* @return Réponse HTTP contenant la liste des factures.
*/
@GET
@Path("/factures")
Response getFactures();
}

View File

@@ -0,0 +1,84 @@
package dev.lions.btpxpress.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Service de gestion des chantiers côté client.
* <p>
* Ce service encapsule la communication avec l'API backend pour les opérations
* liées aux chantiers. Il utilise le REST Client pour effectuer les appels HTTP
* vers le backend.
* </p>
*
* @author BTP Xpress Development Team
* @version 1.0.0
* @since 1.0.0
*/
@ApplicationScoped
public class ChantierService {
private static final Logger LOG = LoggerFactory.getLogger(ChantierService.class);
@Inject
@RestClient
BtpXpressApiClient apiClient;
/**
* Récupère tous les chantiers depuis l'API backend.
*
* @return Liste des chantiers, ou liste vide en cas d'erreur.
*/
public List<Map<String, Object>> getAllChantiers() {
try {
LOG.debug("Récupération de la liste des chantiers depuis l'API backend.");
Response response = apiClient.getChantiers();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Map<String, Object> data = response.readEntity(Map.class);
@SuppressWarnings("unchecked")
List<Map<String, Object>> chantiers = (List<Map<String, Object>>) data.get("chantiers");
LOG.debug("Chantiers récupérés avec succès : {} élément(s)", chantiers != null ? chantiers.size() : 0);
return chantiers != null ? chantiers : new ArrayList<>();
} else {
LOG.warn("Erreur lors de la récupération des chantiers. Code HTTP : {}", response.getStatus());
return new ArrayList<>();
}
} catch (Exception e) {
LOG.error("Erreur lors de la communication avec l'API backend pour récupérer les chantiers : {}", e.getMessage(), e);
return new ArrayList<>();
}
}
/**
* Récupère un chantier par son identifiant depuis l'API backend.
*
* @param id L'identifiant du chantier.
* @return Le chantier sous forme de Map, ou null en cas d'erreur.
*/
public Map<String, Object> getChantierById(Long id) {
try {
LOG.debug("Récupération du chantier avec ID : {}", id);
Response response = apiClient.getChantier(id);
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Map<String, Object> chantier = response.readEntity(Map.class);
LOG.debug("Chantier récupéré avec succès.");
return chantier;
} else {
LOG.warn("Erreur lors de la récupération du chantier. Code HTTP : {}", response.getStatus());
return null;
}
} catch (Exception e) {
LOG.error("Erreur lors de la communication avec l'API backend pour récupérer le chantier : {}", e.getMessage(), e);
return null;
}
}
}

View File

@@ -0,0 +1,311 @@
package dev.lions.btpxpress.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.core.Response;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.List;
import java.util.Map;
/**
* Service pour récupérer et transformer les données du dashboard depuis l'API backend.
*
* <p>Ce service encapsule tous les appels à l'API dashboard et transforme
* les réponses JSON en objets Java utilisables par les vues JSF.</p>
*
* @author BTP Xpress Team
* @version 1.0
*/
@ApplicationScoped
public class DashboardService {
private static final Logger logger = LoggerFactory.getLogger(DashboardService.class);
@Inject
@RestClient
BtpXpressApiClient apiClient;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* Récupère les métriques du dashboard principal.
*
* @return JsonNode contenant les métriques ou null en cas d'erreur
*/
public JsonNode getDashboardPrincipal() {
try {
Response response = apiClient.getDashboardPrincipal();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
if (entity == null) {
logger.warn("Réponse vide du dashboard principal");
return null;
}
// REST Client avec Jackson désérialise déjà en Map/Object
return convertToJsonNode(entity);
} else {
logger.error("Erreur API dashboard principal: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du dashboard principal", e);
return null;
}
}
/**
* Convertit un objet en JsonNode, quel que soit son type (String, Map, Object, etc.).
*/
private JsonNode convertToJsonNode(Object entity) {
try {
if (entity instanceof String) {
return objectMapper.readTree((String) entity);
} else if (entity instanceof Map || entity instanceof List) {
// Map ou List sont déjà désérialisés par REST Client
return objectMapper.valueToTree(entity);
} else {
// Pour les autres objets, conversion via ObjectMapper
return objectMapper.valueToTree(entity);
}
} catch (Exception e) {
logger.error("Erreur lors de la conversion en JsonNode", e);
return null;
}
}
/**
* Récupère les métriques des chantiers.
*
* @return JsonNode contenant les métriques des chantiers ou null en cas d'erreur
*/
public JsonNode getDashboardChantiers() {
try {
Response response = apiClient.getDashboardChantiers();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return convertToJsonNode(entity);
} else {
logger.error("Erreur API dashboard chantiers: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du dashboard chantiers", e);
return null;
}
}
/**
* Récupère les métriques financières.
*
* @param periode Période en jours (défaut: 30)
* @return JsonNode contenant les métriques financières ou null en cas d'erreur
*/
public JsonNode getDashboardFinances(int periode) {
try {
Response response = apiClient.getDashboardFinances(periode);
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API dashboard finances: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du dashboard finances", e);
return null;
}
}
/**
* Récupère les métriques de maintenance.
*
* @return JsonNode contenant les métriques de maintenance ou null en cas d'erreur
*/
public JsonNode getDashboardMaintenance() {
try {
Response response = apiClient.getDashboardMaintenance();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API dashboard maintenance: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du dashboard maintenance", e);
return null;
}
}
/**
* Récupère les métriques des ressources.
*
* @return JsonNode contenant les métriques des ressources ou null en cas d'erreur
*/
public JsonNode getDashboardRessources() {
try {
Response response = apiClient.getDashboardRessources();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API dashboard ressources: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du dashboard ressources", e);
return null;
}
}
/**
* Récupère les alertes.
*
* @return JsonNode contenant les alertes ou null en cas d'erreur
*/
public JsonNode getAlertes() {
try {
Response response = apiClient.getAlertes();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API alertes: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération des alertes", e);
return null;
}
}
/**
* Récupère les KPIs.
*
* @param periode Période en jours (défaut: 30)
* @return JsonNode contenant les KPIs ou null en cas d'erreur
*/
public JsonNode getKPI(int periode) {
try {
Response response = apiClient.getKPI(periode);
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API KPI: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération des KPIs", e);
return null;
}
}
/**
* Récupère les activités récentes.
*
* @param limit Nombre d'activités à récupérer
* @return JsonNode contenant les activités récentes ou null en cas d'erreur
*/
public JsonNode getActivitesRecentes(int limit) {
try {
Response response = apiClient.getActivitesRecentes(limit);
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API activités récentes: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération des activités récentes", e);
return null;
}
}
/**
* Récupère le résumé quotidien.
*
* @return JsonNode contenant le résumé quotidien ou null en cas d'erreur
*/
public JsonNode getResumeQuotidien() {
try {
Response response = apiClient.getResumeQuotidien();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
Object entity = response.getEntity();
return entity instanceof String ? objectMapper.readTree((String) entity) : objectMapper.valueToTree(entity);
} else {
logger.error("Erreur API résumé quotidien: status {}", response.getStatus());
return null;
}
} catch (Exception e) {
logger.error("Erreur lors de la récupération du résumé quotidien", e);
return null;
}
}
/**
* Récupère le nombre de clients.
*
* @return Nombre de clients ou 0 en cas d'erreur
*/
public int getNombreClients() {
try {
Response response = apiClient.getClients();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
List<?> clients = (List<?>) response.getEntity();
return clients != null ? clients.size() : 0;
}
return 0;
} catch (Exception e) {
logger.error("Erreur lors de la récupération du nombre de clients", e);
return 0;
}
}
/**
* Récupère le nombre de devis en attente.
*
* @return Nombre de devis en attente ou 0 en cas d'erreur
*/
public int getNombreDevisEnAttente() {
try {
Response response = apiClient.getDevis();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
List<?> devis = (List<?>) response.getEntity();
// TODO: Filtrer par statut EN_ATTENTE si l'API le permet
return devis != null ? devis.size() : 0;
}
return 0;
} catch (Exception e) {
logger.error("Erreur lors de la récupération du nombre de devis", e);
return 0;
}
}
/**
* Récupère le nombre de factures impayées.
*
* @return Nombre de factures impayées ou 0 en cas d'erreur
*/
public int getNombreFacturesImpayees() {
try {
Response response = apiClient.getFactures();
if (response.getStatus() == Response.Status.OK.getStatusCode()) {
List<?> factures = (List<?>) response.getEntity();
// TODO: Filtrer par statut IMPAYEE si l'API le permet
return factures != null ? factures.size() : 0;
}
return 0;
} catch (Exception e) {
logger.error("Erreur lors de la récupération du nombre de factures", e);
return 0;
}
}
}

View File

@@ -0,0 +1,72 @@
package dev.lions.btpxpress.view;
import jakarta.faces.view.ViewScoped;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.util.List;
import java.util.function.Predicate;
@Getter
@Setter
public abstract class BaseListView<T, ID> implements Serializable {
protected static final Logger LOG = LoggerFactory.getLogger(BaseListView.class);
private static final long serialVersionUID = 1L;
protected List<T> items = new java.util.ArrayList<>();
protected T selectedItem;
protected boolean loading = false;
public abstract void loadItems();
protected void applyFilters(List<T> items, List<Predicate<T>> filters) {
if (filters != null && !filters.isEmpty()) {
filters.stream()
.filter(p -> p != null)
.forEach(filter -> items.removeIf(filter.negate()));
}
}
public void search() {
LOG.debug("Recherche lancée pour {}", getClass().getSimpleName());
loadItems();
}
public void resetFilters() {
LOG.debug("Réinitialisation des filtres pour {}", getClass().getSimpleName());
resetFilterFields();
loadItems();
}
protected abstract void resetFilterFields();
public String viewDetails(ID id) {
LOG.debug("Redirection vers détails : {}", id);
return getDetailsPath() + id + "?faces-redirect=true";
}
protected abstract String getDetailsPath();
public String createNew() {
LOG.debug("Redirection vers création");
return getCreatePath() + "?faces-redirect=true";
}
protected abstract String getCreatePath();
public void delete() {
if (selectedItem != null) {
LOG.info("Suppression : {}", selectedItem);
performDelete();
items.remove(selectedItem);
selectedItem = null;
}
}
protected abstract void performDelete();
}

View File

@@ -0,0 +1,182 @@
package dev.lions.btpxpress.view;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
@Named("chantiersView")
@ViewScoped
@Getter
@Setter
public class ChantiersView extends BaseListView<ChantiersView.Chantier, Long> implements Serializable {
private static final Logger LOG = LoggerFactory.getLogger(ChantiersView.class);
private String filtreNom;
private String filtreClient;
private String filtreStatut;
private Long chantierId;
@PostConstruct
public void init() {
if (filtreStatut == null) {
filtreStatut = "TOUS";
}
loadItems();
}
/**
* Définit le filtre de statut (utilisé depuis les pages filtrées).
*/
public void setFiltreStatut(String statut) {
this.filtreStatut = statut;
}
@Override
public void loadItems() {
loading = true;
try {
items = new ArrayList<>();
for (int i = 1; i <= 20; i++) {
Chantier c = new Chantier();
c.setId((long) i);
c.setNom("Chantier " + i);
c.setClient("Client " + (i % 5 + 1));
c.setAdresse("123 Rue Exemple " + i + ", 75001 Paris");
c.setDateDebut(LocalDate.now().minusDays(i * 10));
c.setDateFinPrevue(LocalDate.now().plusDays((20 - i) * 10));
c.setStatut(i % 3 == 0 ? "TERMINE" : (i % 3 == 1 ? "EN_COURS" : "PLANIFIE"));
c.setAvancement(i * 5);
c.setBudget(i * 15000.0);
c.setCoutReel(i * 12000.0);
items.add(c);
}
applyFilters(items, buildFilters());
} catch (Exception e) {
LOG.error("Erreur chargement chantiers", e);
} finally {
loading = false;
}
}
private List<Predicate<Chantier>> buildFilters() {
List<Predicate<Chantier>> filters = new ArrayList<>();
if (filtreNom != null && !filtreNom.trim().isEmpty()) {
filters.add(c -> c.getNom().toLowerCase().contains(filtreNom.toLowerCase()));
}
if (filtreClient != null && !filtreClient.trim().isEmpty()) {
filters.add(c -> c.getClient().toLowerCase().contains(filtreClient.toLowerCase()));
}
if (filtreStatut != null && !filtreStatut.trim().isEmpty() && !"TOUS".equals(filtreStatut)) {
filters.add(c -> c.getStatut().equals(filtreStatut));
}
return filters;
}
@Override
protected void resetFilterFields() {
filtreNom = null;
filtreClient = null;
filtreStatut = "TOUS";
}
@Override
protected String getDetailsPath() {
return "/chantiers/";
}
@Override
protected String getCreatePath() {
return "/chantiers/nouveau";
}
@Override
protected void performDelete() {
LOG.info("Suppression chantier : {}", selectedItem.getId());
}
/**
* Initialise un nouveau chantier pour la création.
*/
@Override
public String createNew() {
selectedItem = new Chantier();
selectedItem.setStatut("PLANIFIE");
selectedItem.setAvancement(0);
selectedItem.setDateDebut(LocalDate.now());
return getCreatePath() + "?faces-redirect=true";
}
/**
* Sauvegarde un nouveau chantier.
*/
public String saveNew() {
if (selectedItem == null) {
selectedItem = new Chantier();
}
selectedItem.setId(System.currentTimeMillis()); // Simulation ID
selectedItem.setDateCreation(LocalDateTime.now());
selectedItem.setDateModification(LocalDateTime.now());
items.add(selectedItem);
LOG.info("Nouveau chantier créé : {}", selectedItem.getNom());
return "/chantiers?faces-redirect=true";
}
/**
* Charge un chantier par son ID depuis les paramètres de la requête.
*/
public void loadChantierById() {
if (chantierId != null) {
loadItems(); // S'assurer que les items sont chargés
selectedItem = items.stream()
.filter(c -> c.getId().equals(chantierId))
.findFirst()
.orElse(null);
if (selectedItem == null) {
LOG.warn("Chantier avec ID {} non trouvé", chantierId);
}
}
}
/**
* Affiche les détails d'un chantier.
*/
public String viewDetails(Long id) {
selectedItem = items.stream()
.filter(c -> c.getId().equals(id))
.findFirst()
.orElse(null);
if (selectedItem != null) {
return getDetailsPath() + "details?id=" + id + "&faces-redirect=true";
}
return "/chantiers?faces-redirect=true";
}
@lombok.Getter
@lombok.Setter
public static class Chantier {
private Long id;
private String nom;
private String client;
private String adresse;
private LocalDate dateDebut;
private LocalDate dateFinPrevue;
private String statut;
private int avancement;
private double budget;
private double coutReel;
private LocalDateTime dateCreation;
private LocalDateTime dateModification;
}
}

View File

@@ -0,0 +1,176 @@
package dev.lions.btpxpress.view;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;
@Named("clientsView")
@ViewScoped
@Getter
@Setter
public class ClientsView extends BaseListView<ClientsView.Client, Long> implements Serializable {
private static final Logger LOG = LoggerFactory.getLogger(ClientsView.class);
private String filtreNom;
private String filtreEmail;
private String filtreVille;
private Long clientId;
@PostConstruct
public void init() {
loadItems();
}
@Override
public void loadItems() {
loading = true;
try {
items = new ArrayList<>();
for (int i = 1; i <= 25; i++) {
Client c = new Client();
c.setId((long) i);
c.setRaisonSociale("Entreprise " + i);
c.setNomContact("Contact " + i);
c.setEmail("contact" + i + "@example.com");
c.setTelephone("+33 1 " + String.format("%02d", i) + " " +
String.format("%02d", i * 2) + " " +
String.format("%02d", i * 3) + " " +
String.format("%02d", i * 4));
c.setAdresse(i + " Rue Client, " + (75000 + i) + " Paris");
c.setVille("Paris");
c.setCodePostal(String.valueOf(75000 + i));
c.setNombreChantiers(i % 5 + 1);
c.setChiffreAffairesTotal(i * 25000.0);
c.setDateCreation(LocalDateTime.now().minusDays(i * 30));
items.add(c);
}
applyFilters(items, buildFilters());
} catch (Exception e) {
LOG.error("Erreur chargement clients", e);
} finally {
loading = false;
}
}
private List<Predicate<Client>> buildFilters() {
List<Predicate<Client>> filters = new ArrayList<>();
if (filtreNom != null && !filtreNom.trim().isEmpty()) {
filters.add(c -> c.getRaisonSociale().toLowerCase().contains(filtreNom.toLowerCase()) ||
c.getNomContact().toLowerCase().contains(filtreNom.toLowerCase()));
}
if (filtreEmail != null && !filtreEmail.trim().isEmpty()) {
filters.add(c -> c.getEmail().toLowerCase().contains(filtreEmail.toLowerCase()));
}
if (filtreVille != null && !filtreVille.trim().isEmpty()) {
filters.add(c -> c.getVille().toLowerCase().contains(filtreVille.toLowerCase()));
}
return filters;
}
@Override
protected void resetFilterFields() {
filtreNom = null;
filtreEmail = null;
filtreVille = null;
}
@Override
protected String getDetailsPath() {
return "/clients/";
}
@Override
protected String getCreatePath() {
return "/clients/nouveau";
}
@Override
protected void performDelete() {
LOG.info("Suppression client : {}", selectedItem.getId());
}
/**
* Initialise un nouveau client pour la création.
*/
@Override
public String createNew() {
selectedItem = new Client();
return getCreatePath() + "?faces-redirect=true";
}
/**
* Sauvegarde un nouveau client.
*/
public String saveNew() {
if (selectedItem == null) {
selectedItem = new Client();
}
selectedItem.setId(System.currentTimeMillis());
selectedItem.setDateCreation(LocalDateTime.now());
selectedItem.setDateModification(LocalDateTime.now());
selectedItem.setNombreChantiers(0);
selectedItem.setChiffreAffairesTotal(0.0);
items.add(selectedItem);
LOG.info("Nouveau client créé : {}", selectedItem.getRaisonSociale());
return "/clients?faces-redirect=true";
}
/**
* Affiche les détails d'un client.
*/
public String viewDetails(Long id) {
selectedItem = items.stream()
.filter(c -> c.getId().equals(id))
.findFirst()
.orElse(null);
if (selectedItem != null) {
return getDetailsPath() + "details?id=" + id + "&faces-redirect=true";
}
return "/clients?faces-redirect=true";
}
/**
* Charge un client par son ID depuis les paramètres de la requête.
*/
public void loadClientById() {
if (clientId != null) {
loadItems(); // S'assurer que les items sont chargés
selectedItem = items.stream()
.filter(c -> c.getId().equals(clientId))
.findFirst()
.orElse(null);
if (selectedItem == null) {
LOG.warn("Client avec ID {} non trouvé", clientId);
}
}
}
@lombok.Getter
@lombok.Setter
public static class Client {
private Long id;
private String raisonSociale;
private String nomContact;
private String email;
private String telephone;
private String adresse;
private String ville;
private String codePostal;
private String pays;
private int nombreChantiers;
private double chiffreAffairesTotal;
private LocalDateTime dateCreation;
private LocalDateTime dateModification;
}
}

View File

@@ -0,0 +1,324 @@
package dev.lions.btpxpress.view;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import dev.lions.btpxpress.service.DashboardService;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.Serializable;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* Bean de vue pour le tableau de bord principal.
*
* <p>Ce bean charge et affiche les métriques réelles provenant de l'API backend.
* Toutes les données sont récupérées depuis l'API, aucune donnée fictive n'est utilisée.</p>
*
* @author BTP Xpress Team
* @version 1.0
*/
@Named("dashboardView")
@ViewScoped
@Getter
@Setter
public class DashboardView implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger logger = LoggerFactory.getLogger(DashboardView.class);
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy");
@Inject
private DashboardService dashboardService;
// Métriques principales
private long nombreChantiers = 0;
private long chantiersActifs = 0;
private long nombreClients = 0;
private long nombreDevis = 0;
private long facturesImpayees = 0;
private double chiffreAffairesMois = 0.0;
private double budgetTotal = 0.0;
private double budgetConsomme = 0.0;
// Métriques ressources
private long nombreEquipes = 0;
private long equipesDisponibles = 0;
private long nombreEmployes = 0;
private long nombreMateriel = 0;
private long materielDisponible = 0;
// Métriques maintenance
private long maintenancesEnRetard = 0;
private long maintenancesPlanifiees = 0;
// Alertes
private long totalAlertes = 0;
private boolean alerteCritique = false;
// Chantiers récents
private List<ChantierResume> chantiersRecents = new ArrayList<>();
private List<ChantierResume> chantiersEnRetard = new ArrayList<>();
/**
* Initialise le dashboard en chargeant toutes les données depuis l'API.
*/
@PostConstruct
public void init() {
logger.info("Initialisation du dashboard avec données réelles de l'API");
loadDashboardPrincipal();
loadDashboardChantiers();
loadDashboardFinances();
loadDashboardRessources();
loadDashboardMaintenance();
loadAlertes();
loadNombreClients();
loadNombreDevis();
loadNombreFacturesImpayees();
}
/**
* Charge les métriques du dashboard principal.
*/
private void loadDashboardPrincipal() {
try {
JsonNode dashboard = dashboardService.getDashboardPrincipal();
if (dashboard != null) {
JsonNode chantiers = dashboard.get("chantiers");
if (chantiers != null) {
nombreChantiers = chantiers.get("total").asLong(0);
chantiersActifs = chantiers.get("actifs").asLong(0);
}
}
} catch (Exception e) {
logger.error("Erreur lors du chargement du dashboard principal", e);
}
}
/**
* Charge les métriques des chantiers.
*/
private void loadDashboardChantiers() {
try {
JsonNode dashboard = dashboardService.getDashboardChantiers();
if (dashboard != null) {
JsonNode chantiersActifsNode = dashboard.get("chantiersActifs");
if (chantiersActifsNode != null && chantiersActifsNode.isArray()) {
chantiersRecents.clear();
Iterator<JsonNode> iterator = chantiersActifsNode.elements();
int count = 0;
while (iterator.hasNext() && count < 5) {
JsonNode chantier = iterator.next();
ChantierResume c = new ChantierResume();
c.setId(chantier.get("id").asText());
c.setNom(chantier.get("nom").asText(""));
c.setClient(chantier.get("client").asText("Non assigné"));
if (chantier.has("dateDebut") && !chantier.get("dateDebut").isNull()) {
String dateStr = chantier.get("dateDebut").asText();
try {
c.setDateDebut(LocalDate.parse(dateStr));
} catch (Exception e) {
logger.warn("Erreur parsing date: {}", dateStr);
}
}
c.setAvancement(chantier.get("avancement").asInt(0));
c.setBudget(chantier.get("budget").asDouble(0.0));
c.setStatut(chantier.get("statut").asText(""));
chantiersRecents.add(c);
count++;
}
}
JsonNode chantiersEnRetardNode = dashboard.get("chantiersEnRetard");
if (chantiersEnRetardNode != null && chantiersEnRetardNode.isArray()) {
chantiersEnRetard.clear();
Iterator<JsonNode> iterator = chantiersEnRetardNode.elements();
while (iterator.hasNext()) {
JsonNode chantier = iterator.next();
ChantierResume c = new ChantierResume();
c.setId(chantier.get("id").asText());
c.setNom(chantier.get("nom").asText(""));
if (chantier.has("dateFinPrevue") && !chantier.get("dateFinPrevue").isNull()) {
String dateStr = chantier.get("dateFinPrevue").asText();
try {
c.setDateFinPrevue(LocalDate.parse(dateStr));
} catch (Exception e) {
logger.warn("Erreur parsing date: {}", dateStr);
}
}
c.setJoursRetard(chantier.get("joursRetard").asLong(0));
chantiersEnRetard.add(c);
}
}
}
} catch (Exception e) {
logger.error("Erreur lors du chargement du dashboard chantiers", e);
}
}
/**
* Charge les métriques financières.
*/
private void loadDashboardFinances() {
try {
JsonNode finances = dashboardService.getDashboardFinances(30); // 30 jours
if (finances != null) {
JsonNode budget = finances.get("budget");
if (budget != null) {
budgetTotal = budget.get("total").asDouble(0.0);
budgetConsomme = budget.get("realise").asDouble(0.0);
}
JsonNode chiffreAffaires = finances.get("chiffreAffaires");
if (chiffreAffaires != null) {
chiffreAffairesMois = chiffreAffaires.get("realise").asDouble(0.0);
}
}
} catch (Exception e) {
logger.error("Erreur lors du chargement des métriques financières", e);
}
}
/**
* Charge les métriques des ressources.
*/
private void loadDashboardRessources() {
try {
JsonNode ressources = dashboardService.getDashboardRessources();
if (ressources != null) {
JsonNode equipes = ressources.get("equipes");
if (equipes != null && equipes.has("total")) {
nombreEquipes = equipes.get("total").asLong(0);
equipesDisponibles = equipes.get("disponibles").asLong(0);
}
JsonNode employes = ressources.get("employes");
if (employes != null && employes.has("total")) {
nombreEmployes = employes.get("total").asLong(0);
}
JsonNode materiel = ressources.get("materiel");
if (materiel != null && materiel.has("total")) {
nombreMateriel = materiel.get("total").asLong(0);
materielDisponible = materiel.get("disponible").asLong(0);
}
}
} catch (Exception e) {
logger.error("Erreur lors du chargement des métriques ressources", e);
}
}
/**
* Charge les métriques de maintenance.
*/
private void loadDashboardMaintenance() {
try {
JsonNode maintenance = dashboardService.getDashboardMaintenance();
if (maintenance != null) {
JsonNode stats = maintenance.get("statistiques");
if (stats != null) {
maintenancesEnRetard = stats.has("enRetard") ? stats.get("enRetard").asLong(0) : 0;
maintenancesPlanifiees = stats.has("planifiees") ? stats.get("planifiees").asLong(0) : 0;
}
}
} catch (Exception e) {
logger.error("Erreur lors du chargement des métriques maintenance", e);
}
}
/**
* Charge les alertes.
*/
private void loadAlertes() {
try {
JsonNode alertes = dashboardService.getAlertes();
if (alertes != null) {
totalAlertes = alertes.get("totalAlertes").asLong(0);
alerteCritique = alertes.get("alerteCritique").asBoolean(false);
}
} catch (Exception e) {
logger.error("Erreur lors du chargement des alertes", e);
}
}
/**
* Charge le nombre de clients.
*/
private void loadNombreClients() {
nombreClients = dashboardService.getNombreClients();
}
/**
* Charge le nombre de devis en attente.
*/
private void loadNombreDevis() {
nombreDevis = dashboardService.getNombreDevisEnAttente();
}
/**
* Charge le nombre de factures impayées.
*/
private void loadNombreFacturesImpayees() {
facturesImpayees = dashboardService.getNombreFacturesImpayees();
}
/**
* Rafraîchit toutes les données du dashboard.
*/
public void rafraichir() {
init();
}
/**
* Retourne le taux de consommation du budget en pourcentage.
*
* @return Le taux de consommation (0-100)
*/
public double getTauxConsommationBudget() {
if (budgetTotal > 0) {
return (budgetConsomme / budgetTotal) * 100;
}
return 0;
}
/**
* Classe interne représentant un résumé de chantier.
*/
@lombok.Getter
@lombok.Setter
public static class ChantierResume implements Serializable {
private String id;
private String nom;
private String client;
private LocalDate dateDebut;
private LocalDate dateFinPrevue;
private int avancement;
private double budget;
private String statut;
private long joursRetard;
/**
* Retourne la date de début formatée pour l'affichage.
*
* @return La date au format dd/MM/yyyy
*/
public String getDateDebutFormatee() {
return dateDebut != null ? dateDebut.format(DATE_FORMATTER) : "";
}
/**
* Retourne la date de fin prévue formatée pour l'affichage.
*
* @return La date au format dd/MM/yyyy
*/
public String getDateFinPrevueFormatee() {
return dateFinPrevue != null ? dateFinPrevue.format(DATE_FORMATTER) : "";
}
}
}

View File

@@ -0,0 +1,90 @@
package dev.lions.btpxpress.view;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.Setter;
import org.primefaces.PrimeFaces;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Named("guestPreferences")
@SessionScoped
@Getter
@Setter
public class GuestPreferences implements Serializable {
private static final long serialVersionUID = 1L;
private String menuMode = "layout-sidebar";
private String darkMode = "light";
private String componentTheme = "purple";
private String topbarTheme = "light";
private String menuTheme = "light";
private String inputStyle = "outlined";
private boolean lightLogo = false;
private List<ComponentTheme> componentThemes = new ArrayList<>();
@PostConstruct
public void init() {
componentThemes.add(new ComponentTheme("Bleu", "blue", "#2c84d8"));
componentThemes.add(new ComponentTheme("Vert", "green", "#34B56F"));
componentThemes.add(new ComponentTheme("Orange", "orange", "#FF810E"));
componentThemes.add(new ComponentTheme("Turquoise", "turquoise", "#58AED3"));
componentThemes.add(new ComponentTheme("Avocat", "avocado", "#AEC523"));
componentThemes.add(new ComponentTheme("Violet", "purple", "#464DF2"));
componentThemes.add(new ComponentTheme("Rouge", "red", "#FF9B7B"));
componentThemes.add(new ComponentTheme("Jaune", "yellow", "#FFB340"));
}
public void setDarkMode(String darkMode) {
this.darkMode = darkMode;
this.menuTheme = darkMode;
this.topbarTheme = darkMode;
this.lightLogo = !this.topbarTheme.equals("light");
}
public String getLayout() {
return "layout-" + this.darkMode;
}
public String getTheme() {
return this.componentTheme + '-' + this.darkMode;
}
public void setTopbarTheme(String topbarTheme) {
this.topbarTheme = topbarTheme;
this.lightLogo = !this.topbarTheme.equals("light");
}
public String getInputStyleClass() {
return this.inputStyle.equals("filled") ? "ui-input-filled" : "";
}
public void onMenuTypeChange() {
if ("layout-horizontal".equals(menuMode)) {
menuTheme = topbarTheme;
PrimeFaces.current().executeScript(
"PrimeFaces.FreyaConfigurator.changeSectionTheme('" + menuTheme + "' , 'layout-menu')"
);
}
}
@lombok.Getter
@lombok.Setter
public static class ComponentTheme implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String file;
private String color;
public ComponentTheme(String name, String file, String color) {
this.name = name;
this.file = file;
this.color = color;
}
}
}

View File

@@ -0,0 +1,53 @@
package dev.lions.btpxpress.view;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
@Named("loginView")
@RequestScoped
@Getter
@Setter
public class LoginView implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private String password;
private boolean rememberMe = false;
public String login() {
if (username == null || username.trim().isEmpty()) {
addErrorMessage("Le nom d'utilisateur est requis");
return null;
}
if (password == null || password.trim().isEmpty()) {
addErrorMessage("Le mot de passe est requis");
return null;
}
if ("admin".equals(username) && "admin".equals(password)) {
addInfoMessage("Connexion réussie !");
return "/dashboard?faces-redirect=true";
} else {
addErrorMessage("Nom d'utilisateur ou mot de passe incorrect");
return null;
}
}
private void addErrorMessage(String message) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_ERROR, "Erreur", message));
}
private void addInfoMessage(String message) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_INFO, "Information", message));
}
}

View File

@@ -0,0 +1,79 @@
package dev.lions.btpxpress.view;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Named;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
/**
* Bean de session pour gérer les informations de l'utilisateur connecté.
*
* <p>Ce bean stocke les informations de session de l'utilisateur authentifié,
* telles que le nom, l'email, l'avatar, et les statistiques rapides.</p>
*
* @author BTP Xpress Team
* @version 1.0
*/
@Named("userSession")
@SessionScoped
@Getter
@Setter
public class UserSessionBean implements Serializable {
private static final long serialVersionUID = 1L;
private String nomComplet;
private String email;
private String avatarUrl;
private String role;
private int nombreNotificationsNonLues;
private int nombreMessagesNonLus;
/**
* Initialise les données de l'utilisateur connecté.
*/
@PostConstruct
public void init() {
// TODO: Récupérer depuis le token JWT ou la session OIDC
nomComplet = "Jean Dupont";
email = "jean.dupont@btpxpress.com";
avatarUrl = "/resources/freya-layout/images/avatar-profilemenu.png";
role = "Gestionnaire de Projets";
nombreNotificationsNonLues = 5;
nombreMessagesNonLus = 3;
}
/**
* Retourne les initiales de l'utilisateur pour l'avatar.
*
* @return Les initiales (ex: "JD" pour "Jean Dupont")
*/
public String getInitiales() {
if (nomComplet == null || nomComplet.trim().isEmpty()) {
return "U";
}
String[] parts = nomComplet.trim().split("\\s+");
if (parts.length >= 2) {
return String.valueOf(parts[0].charAt(0)).toUpperCase() +
String.valueOf(parts[1].charAt(0)).toUpperCase();
} else if (parts.length == 1) {
return parts[0].substring(0, Math.min(2, parts[0].length())).toUpperCase();
}
return "U";
}
/**
* Action de déconnexion.
*
* @return La page de login
*/
public String deconnecter() {
// TODO: Implémenter la déconnexion OIDC/Keycloak
return "/login?faces-redirect=true";
}
}