feat(sprint-8 web 2026-04-25): pages PrimeFaces conformité (compliance dashboard + rapports trimestriels + PI-SPI readiness)

Front-end web JSF/PrimeFaces pour exposer les features backend Sprints 3, 5, 7 aux compliance officers et controleurs internes.

DTOs locaux (miroirs JSON, @JsonIgnoreProperties)
- ComplianceSnapshotDto + ConformiteIndicateurDto (P1-NEW-7)
- RapportTrimestrielDto + helpers estDraft/estSigne/estArchive (P2-NEW-3)
- PispiReadinessDto + CheckResultDto + helpers estReady/estBlocked (P1-NEW-15)

REST clients @RegisterRestClient configKey=unionflow-api
- ComplianceDashboardRestClient : getSnapshotCurrent + getSnapshotOf(orgId)
- RapportTrimestrielRestClient : lister, generer, signer, archiver, telechargerPdf
- PispiReadinessRestClient : getReadiness
- AuthHeaderFactory propage le token OIDC

Beans @ViewScoped
- ConformiteDashboardBean : init + rafraichir + couleurScore + hasAlertes
- RapportsTrimestrielsBean : lister, genererRapport, signerSelection, archiverSelection, telechargerPdf via ExternalContext
- PispiReadinessBean : rafraichir + gestion HTTP 503 BLOCKED + couleurStatus + couleurCheck

Pages XHTML PrimeFaces (template main-template.xhtml, classes Freya)
- /pages/secure/conformite/dashboard.xhtml — score global + 9 indicateurs en grille + alertes
- /pages/secure/conformite/rapports-trimestriels.xhtml — table DRAFT/SIGNE/ARCHIVE + bouton générer/signer/archiver/PDF
- /pages/secure/admin/pispi-readiness.xhtml — 8 checks + blocages/warnings dédiés + statut global

Tests (21/21 verts, JUnit5 natif puisque AssertJ non transitif)
- ConformiteDashboardBeanTest : 9 tests (couleur score success/warning/danger/secondary, hasAlertes 5 cas)
- PispiReadinessBeanTest : 8 tests (couleurStatus READY/DEGRADED/BLOCKED/null, couleurCheck PASS/FAIL × severity, DTO helpers)
- RapportTrimestrielDtoTest : 4 tests (estDraft/estSigne/estArchive/inconnu)
This commit is contained in:
dahoud
2026-04-25 11:02:48 +00:00
parent 0c5a027ec3
commit a7788036eb
15 changed files with 1134 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Miroir local du record {@code ComplianceSnapshot} backend (P1-NEW-7).
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ComplianceSnapshotDto(
UUID organisationId,
String organisationNom,
String referentielComptable,
boolean complianceOfficerDesigne,
ConformiteIndicateurDto agAnnuelle,
ConformiteIndicateurDto rapportAirms,
int dirigeantsAvecCmu,
BigDecimal tauxKycAJourPct,
BigDecimal tauxFormationLbcFtPct,
ConformiteIndicateurDto commissaireAuxComptes,
ConformiteIndicateurDto fomusCi,
BigDecimal couvertureUboPct,
int scoreGlobal
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ConformiteIndicateurDto(String statut, String message) {}
}

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* Miroir local du DTO {@code ReadinessReport} backend (P1-NEW-15).
*
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PispiReadinessDto(
String globalStatus, // READY, DEGRADED, BLOCKED
String baseUrl,
List<CheckResultDto> checks,
List<String> blockingIssues,
List<String> warnings
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record CheckResultDto(
String name,
String status, // PASS, FAIL
String severity, // BLOCKING, WARNING
String message
) {}
public boolean estReady() { return "READY".equals(globalStatus); }
public boolean estBlocked() { return "BLOCKED".equals(globalStatus); }
}

View File

@@ -0,0 +1,30 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Miroir local de l'entité {@code RapportTrimestrielControleurInterne} backend (P2-NEW-3).
*
* <p>Champs JSONB et byte[] omis — récupérés via endpoint PDF dédié.
*
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record RapportTrimestrielDto(
UUID id,
UUID organisationId,
Integer annee,
Integer trimestre,
LocalDateTime dateGeneration,
String statut,
Integer scoreConformite,
UUID signataireId,
LocalDateTime dateSignature,
String hashSha256
) {
public boolean estDraft() { return "DRAFT".equals(statut); }
public boolean estSigne() { return "SIGNE".equals(statut); }
public boolean estArchive() { return "ARCHIVE".equals(statut); }
}

View File

@@ -0,0 +1,30 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/compliance/dashboard")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface ComplianceDashboardRestClient {
/** Snapshot pour l'organisation active (résolue par le backend via header X-Active-Organisation-Id). */
@GET
ComplianceSnapshotDto getSnapshotCurrent();
/** Snapshot d'une organisation arbitraire — restreint SUPER_ADMIN backend. */
@GET
@Path("/{organisationId}")
ComplianceSnapshotDto getSnapshotOf(@PathParam("organisationId") UUID organisationId);
}

View File

@@ -0,0 +1,22 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.PispiReadinessDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/admin/pispi/readiness")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface PispiReadinessRestClient {
@GET
PispiReadinessDto getReadiness();
}

View File

@@ -0,0 +1,51 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.RapportTrimestrielDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/rapports/trimestriel")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface RapportTrimestrielRestClient {
@GET
List<RapportTrimestrielDto> lister(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") Integer annee);
@POST
@Path("/generer")
RapportTrimestrielDto generer(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") int annee,
@QueryParam("trimestre") int trimestre);
@POST
@Path("/{id}/signer")
RapportTrimestrielDto signer(
@PathParam("id") UUID id,
@QueryParam("signataireId") UUID signataireId);
@POST
@Path("/{id}/archiver")
RapportTrimestrielDto archiver(@PathParam("id") UUID id);
@GET
@Path("/{id}/pdf")
@Produces("application/pdf")
byte[] telechargerPdf(@PathParam("id") UUID id);
}

View File

@@ -0,0 +1,76 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto;
import dev.lions.unionflow.client.service.ComplianceDashboardRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean du tableau de bord de conformité (Sprint 8 ⇄ backend P1-NEW-7).
*
* <p>Affiche le snapshot de l'organisation active : score global, indicateurs AG/AIRMS/CMU/KYC/UBO,
* alertes critiques. Refresh manuel par bouton.
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class ConformiteDashboardBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(ConformiteDashboardBean.class);
@Inject @RestClient
ComplianceDashboardRestClient client;
private ComplianceSnapshotDto snapshot;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
snapshot = client.getSnapshotCurrent();
LOG.infof("Snapshot conformité chargé : score=%d", snapshot.scoreGlobal());
} catch (Exception e) {
LOG.warnf("Échec chargement compliance dashboard : %s", e.getMessage());
erreur = "Impossible de charger le tableau de bord : " + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
snapshot = null;
}
}
public String getCouleurScore() {
if (snapshot == null) return "secondary";
int s = snapshot.scoreGlobal();
if (s >= 80) return "success";
if (s >= 60) return "warning";
return "danger";
}
public boolean hasAlertes() {
if (snapshot == null) return false;
return !snapshot.complianceOfficerDesigne()
|| "RETARD".equals(snapshot.agAnnuelle().statut())
|| snapshot.scoreGlobal() < 60;
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
public ComplianceSnapshotDto getSnapshot() { return snapshot; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,85 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.PispiReadinessDto;
import dev.lions.unionflow.client.service.PispiReadinessRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean d'inspection PI-SPI Readiness (Sprint 8 ⇄ P1-NEW-15).
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class PispiReadinessBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(PispiReadinessBean.class);
@Inject @RestClient
PispiReadinessRestClient client;
private PispiReadinessDto readiness;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
readiness = client.getReadiness();
LOG.infof("PI-SPI readiness chargé : %s (%d blocking, %d warnings)",
readiness.globalStatus(),
readiness.blockingIssues() == null ? 0 : readiness.blockingIssues().size(),
readiness.warnings() == null ? 0 : readiness.warnings().size());
} catch (jakarta.ws.rs.WebApplicationException wae) {
// Backend renvoie 503 quand BLOCKED — on récupère quand même le body
try {
readiness = wae.getResponse().readEntity(PispiReadinessDto.class);
LOG.infof("PI-SPI readiness BLOCKED — body décodé");
} catch (Exception parseEx) {
handleError("Décodage réponse readiness échoué", parseEx);
readiness = null;
}
} catch (Exception e) {
handleError("Chargement PI-SPI readiness échoué", e);
readiness = null;
}
}
public String getCouleurStatus() {
if (readiness == null) return "secondary";
return switch (readiness.globalStatus()) {
case "READY" -> "success";
case "DEGRADED" -> "warning";
case "BLOCKED" -> "danger";
default -> "secondary";
};
}
public String getCouleurCheck(String severity, String status) {
if ("PASS".equals(status)) return "success";
return "BLOCKING".equals(severity) ? "danger" : "warning";
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur));
}
public PispiReadinessDto getReadiness() { return readiness; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,147 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.RapportTrimestrielDto;
import dev.lions.unionflow.client.service.RapportTrimestrielRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.time.Year;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean liste/actions sur les rapports trimestriels Contrôleur Interne (Sprint 8 ⇄ P2-NEW-3).
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class RapportsTrimestrielsBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(RapportsTrimestrielsBean.class);
@Inject @RestClient
RapportTrimestrielRestClient client;
private List<RapportTrimestrielDto> rapports = Collections.emptyList();
private int annee = Year.now().getValue();
private int trimestreCible = 1;
private RapportTrimestrielDto selection;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
rapports = client.lister(null, annee);
LOG.infof("Liste rapports trimestriels chargée pour année %d : %d entrée(s)",
annee, rapports.size());
} catch (Exception e) {
handleError("Chargement des rapports échoué", e);
rapports = Collections.emptyList();
}
}
public void genererRapport() {
erreur = null;
try {
var nouveau = client.generer(null, annee, trimestreCible);
addMessage(FacesMessage.SEVERITY_INFO, "Rapport généré",
"Trimestre " + nouveau.trimestre() + "/" + nouveau.annee()
+ " — score " + nouveau.scoreConformite());
rafraichir();
} catch (Exception e) {
handleError("Génération du rapport échouée", e);
}
}
public void signerSelection(UUID signataireId) {
if (selection == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Aucune sélection",
"Sélectionnez un rapport DRAFT à signer");
return;
}
try {
client.signer(selection.id(), signataireId);
addMessage(FacesMessage.SEVERITY_INFO, "Signé",
"Rapport " + selection.annee() + "/T" + selection.trimestre() + " signé");
rafraichir();
} catch (Exception e) {
handleError("Signature échouée", e);
}
}
public void archiverSelection() {
if (selection == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Aucune sélection",
"Sélectionnez un rapport SIGNE à archiver");
return;
}
try {
client.archiver(selection.id());
addMessage(FacesMessage.SEVERITY_INFO, "Archivé",
"Rapport " + selection.annee() + "/T" + selection.trimestre() + " archivé");
rafraichir();
} catch (Exception e) {
handleError("Archivage échoué", e);
}
}
public void telechargerPdf(RapportTrimestrielDto r) {
if (r == null) return;
try {
byte[] pdf = client.telechargerPdf(r.id());
String filename = String.format("rapport-trim-%d-T%d.pdf", r.annee(), r.trimestre());
FacesContext fc = FacesContext.getCurrentInstance();
ExternalContext ec = fc.getExternalContext();
ec.responseReset();
ec.setResponseContentType("application/pdf");
ec.setResponseHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
ec.setResponseContentLength(pdf.length);
try (OutputStream os = ec.getResponseOutputStream()) {
os.write(pdf);
}
fc.responseComplete();
} catch (IOException e) {
handleError("Téléchargement PDF échoué", e);
} catch (Exception e) {
handleError("Téléchargement PDF échoué", e);
}
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
public List<RapportTrimestrielDto> getRapports() { return rapports; }
public int getAnnee() { return annee; }
public void setAnnee(int annee) { this.annee = annee; }
public int getTrimestreCible() { return trimestreCible; }
public void setTrimestreCible(int trimestreCible) { this.trimestreCible = trimestreCible; }
public RapportTrimestrielDto getSelection() { return selection; }
public void setSelection(RapportTrimestrielDto selection) { this.selection = selection; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - PI-SPI Readiness</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<div>
<h2 class="text-900 font-medium text-3xl m-0">PI-SPI Readiness</h2>
<p class="text-600 mt-1 mb-0">
Vérification des pré-requis intégration PI-SPI BCEAO avant activation production.
</p>
</div>
<p:commandButton value="Rafraîchir"
icon="pi pi-refresh"
action="#{pispiReadinessBean.rafraichir}"
update=":form-readiness"
styleClass="p-button-outlined" />
</div>
<h:form id="form-readiness">
<p:messages closable="true" />
<ui:fragment rendered="#{pispiReadinessBean.readiness != null}">
<!-- Statut global -->
<div class="surface-100 border-round p-4 mb-4 text-center">
<div class="text-700 mb-2">Statut global</div>
<p:tag value="#{pispiReadinessBean.readiness.globalStatus}"
severity="#{pispiReadinessBean.couleurStatus}"
style="font-size: 1.5rem; padding: 0.5rem 1.5rem;" />
<div class="text-600 mt-3">
Base URL : <span class="font-mono">#{pispiReadinessBean.readiness.baseUrl}</span>
</div>
</div>
<!-- Blocages -->
<p:fieldset legend="Blocages critiques"
rendered="#{not empty pispiReadinessBean.readiness.blockingIssues}"
styleClass="mb-3 border-red-300">
<ul class="m-0">
<ui:repeat value="#{pispiReadinessBean.readiness.blockingIssues}" var="b">
<li class="text-red-600">#{b}</li>
</ui:repeat>
</ul>
</p:fieldset>
<!-- Warnings -->
<p:fieldset legend="Avertissements (non bloquants)"
rendered="#{not empty pispiReadinessBean.readiness.warnings}"
styleClass="mb-3 border-orange-300">
<ul class="m-0">
<ui:repeat value="#{pispiReadinessBean.readiness.warnings}" var="w">
<li class="text-orange-600">#{w}</li>
</ui:repeat>
</ul>
</p:fieldset>
<!-- Détail des checks -->
<p:dataTable value="#{pispiReadinessBean.readiness.checks}"
var="c"
emptyMessage="Aucun check disponible">
<p:column headerText="Vérification">
<h:outputText value="#{c.name}" styleClass="font-mono" />
</p:column>
<p:column headerText="Sévérité">
<p:tag value="#{c.severity}"
severity="#{c.severity eq 'BLOCKING' ? 'danger' : 'warning'}" />
</p:column>
<p:column headerText="Statut">
<p:tag value="#{c.status}"
severity="#{c.status eq 'PASS' ? 'success' : pispiReadinessBean.getCouleurCheck(c.severity, c.status)}"
icon="#{c.status eq 'PASS' ? 'pi pi-check' : 'pi pi-times'}" />
</p:column>
<p:column headerText="Message">
<h:outputText value="#{c.message}" />
</p:column>
</p:dataTable>
</ui:fragment>
<ui:fragment rendered="#{pispiReadinessBean.readiness == null}">
<div class="text-center text-600 p-5">
Impossible de récupérer le rapport readiness.
</div>
</ui:fragment>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - Tableau de bord conformité</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<div>
<h2 class="text-900 font-medium text-3xl m-0">Tableau de bord conformité</h2>
<p class="text-600 mt-1 mb-0">
Indicateurs BCEAO Instr. 001-03-2025 / ARTCI / OHADA / AIRMS
</p>
</div>
<p:commandButton value="Rafraîchir"
icon="pi pi-refresh"
action="#{conformiteDashboardBean.rafraichir}"
update=":form-conformite"
styleClass="p-button-outlined" />
</div>
<h:form id="form-conformite">
<p:messages closable="true" />
<ui:fragment rendered="#{conformiteDashboardBean.snapshot != null}">
<!-- Score global -->
<div class="grid mb-4">
<div class="col-12 md:col-4">
<div class="surface-100 border-round p-4 text-center">
<div class="text-700 mb-2">Score conformité global</div>
<div class="text-#{conformiteDashboardBean.couleurScore eq 'success' ? 'green' : (conformiteDashboardBean.couleurScore eq 'warning' ? 'orange' : 'red')}-600 font-bold"
style="font-size: 3.5rem; line-height: 1;">
#{conformiteDashboardBean.snapshot.scoreGlobal}
</div>
<div class="text-600 mt-1">/ 100</div>
</div>
</div>
<div class="col-12 md:col-8">
<div class="surface-100 border-round p-4 h-full">
<div class="text-700 mb-2 font-bold">Organisation</div>
<div class="text-900 text-xl">#{conformiteDashboardBean.snapshot.organisationNom}</div>
<div class="text-600 mt-2">
Référentiel comptable : <span class="font-semibold">#{conformiteDashboardBean.snapshot.referentielComptable}</span>
</div>
</div>
</div>
</div>
<!-- Indicateurs détaillés -->
<div class="grid">
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">Compliance Officer</span>
<p:tag value="#{conformiteDashboardBean.snapshot.complianceOfficerDesigne ? 'Désigné' : 'ABSENT'}"
severity="#{conformiteDashboardBean.snapshot.complianceOfficerDesigne ? 'success' : 'danger'}" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">AG annuelle</span>
<p:tag value="#{conformiteDashboardBean.snapshot.agAnnuelle.statut}"
severity="#{conformiteDashboardBean.snapshot.agAnnuelle.statut eq 'OK' ? 'success' : (conformiteDashboardBean.snapshot.agAnnuelle.statut eq 'RETARD' ? 'danger' : 'warning')}" />
</div>
<p class="text-600 text-sm mt-2 m-0">#{conformiteDashboardBean.snapshot.agAnnuelle.message}</p>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="flex justify-content-between">
<span class="text-700">Rapport AIRMS</span>
<p:tag value="#{conformiteDashboardBean.snapshot.rapportAirms.statut}"
severity="#{conformiteDashboardBean.snapshot.rapportAirms.statut eq 'OK' ? 'success' : 'warning'}" />
</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Dirigeants enrôlés CMU</div>
<div class="text-900 text-2xl mt-1">#{conformiteDashboardBean.snapshot.dirigeantsAvecCmu}</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Taux KYC à jour</div>
<div class="text-900 text-2xl mt-1">#{conformiteDashboardBean.snapshot.tauxKycAJourPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Taux formation LBC/FT</div>
<div class="text-900 text-2xl mt-1">#{conformiteDashboardBean.snapshot.tauxFormationLbcFtPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Couverture UBO</div>
<div class="text-900 text-2xl mt-1">#{conformiteDashboardBean.snapshot.couvertureUboPct} %</div>
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">Commissaire aux comptes</div>
<p:tag value="#{conformiteDashboardBean.snapshot.commissaireAuxComptes.statut}"
styleClass="mt-1" />
</div>
</div>
<div class="col-12 md:col-6 lg:col-4">
<div class="surface-50 border-round p-3 h-full">
<div class="text-700">FOMUS-CI</div>
<p:tag value="#{conformiteDashboardBean.snapshot.fomusCi.statut}"
styleClass="mt-1" />
<p class="text-600 text-sm mt-2 m-0">#{conformiteDashboardBean.snapshot.fomusCi.message}</p>
</div>
</div>
</div>
<p:fieldset legend="Alertes critiques"
rendered="#{conformiteDashboardBean.hasAlertes}"
styleClass="mt-4 surface-100">
<ul class="m-0">
<li class="text-red-600" jsf:rendered="#{not conformiteDashboardBean.snapshot.complianceOfficerDesigne}">
Compliance Officer non désigné — Instruction BCEAO 001-03-2025 obligatoire
</li>
<li class="text-red-600" jsf:rendered="#{conformiteDashboardBean.snapshot.agAnnuelle.statut eq 'RETARD'}">
AG annuelle en retard — échéance 30 juin
</li>
<li class="text-orange-600" jsf:rendered="#{conformiteDashboardBean.snapshot.scoreGlobal lt 60}">
Score global &lt; 60 % — risque inspection BCEAO/ARTCI
</li>
</ul>
</p:fieldset>
</ui:fragment>
<ui:fragment rendered="#{conformiteDashboardBean.snapshot == null}">
<p:messages />
<div class="text-center text-600 p-5">
Aucune donnée de conformité disponible.
</div>
</ui:fragment>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<ui:composition xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="http://xmlns.jcp.org/jsf/html"
xmlns:f="http://xmlns.jcp.org/jsf/core"
xmlns:ui="http://xmlns.jcp.org/jsf/facelets"
xmlns:p="http://primefaces.org/ui"
template="/templates/main-template.xhtml">
<ui:define name="title">UnionFlow - Rapports trimestriels Contrôleur Interne</ui:define>
<ui:define name="content">
<div class="grid">
<div class="col-12">
<div class="card">
<div class="flex justify-content-between align-items-center mb-4">
<div>
<h2 class="text-900 font-medium text-3xl m-0">Rapports trimestriels — Contrôleur Interne</h2>
<p class="text-600 mt-1 mb-0">
Source AG, inspections BCEAO/ARTCI. Cycle DRAFT → SIGNE → ARCHIVE.
</p>
</div>
</div>
<h:form id="form-rapports">
<p:messages closable="true" />
<!-- Filtres + génération -->
<p:panel header="Génération rapport" styleClass="mb-3">
<div class="grid">
<div class="col-12 md:col-3">
<p:outputLabel for="annee" value="Année" />
<p:inputNumber id="annee"
value="#{rapportsTrimestrielsBean.annee}"
minValue="2024" maxValue="2099"
thousandSeparator=""
styleClass="w-full" />
</div>
<div class="col-12 md:col-3">
<p:outputLabel for="trim" value="Trimestre" />
<p:selectOneMenu id="trim"
value="#{rapportsTrimestrielsBean.trimestreCible}"
styleClass="w-full">
<f:selectItem itemLabel="T1 (Jan-Mar)" itemValue="1" />
<f:selectItem itemLabel="T2 (Avr-Jun)" itemValue="2" />
<f:selectItem itemLabel="T3 (Jul-Sep)" itemValue="3" />
<f:selectItem itemLabel="T4 (Oct-Déc)" itemValue="4" />
</p:selectOneMenu>
</div>
<div class="col-12 md:col-3 flex align-items-end">
<p:commandButton value="Générer"
icon="pi pi-plus"
action="#{rapportsTrimestrielsBean.genererRapport}"
update="form-rapports"
styleClass="w-full" />
</div>
<div class="col-12 md:col-3 flex align-items-end">
<p:commandButton value="Rafraîchir"
icon="pi pi-refresh"
action="#{rapportsTrimestrielsBean.rafraichir}"
update="form-rapports"
styleClass="p-button-outlined w-full" />
</div>
</div>
</p:panel>
<!-- Tableau -->
<p:dataTable id="tbl-rapports"
value="#{rapportsTrimestrielsBean.rapports}"
var="r"
selection="#{rapportsTrimestrielsBean.selection}"
rowKey="#{r.id}"
selectionMode="single"
emptyMessage="Aucun rapport pour cette année"
styleClass="mb-3">
<p:column headerText="Trimestre">
<h:outputText value="T#{r.trimestre} / #{r.annee}" />
</p:column>
<p:column headerText="Date génération">
<h:outputText value="#{r.dateGeneration}" />
</p:column>
<p:column headerText="Score">
<p:tag value="#{r.scoreConformite}/100"
severity="#{r.scoreConformite ge 80 ? 'success' : (r.scoreConformite ge 60 ? 'warning' : 'danger')}" />
</p:column>
<p:column headerText="Statut">
<p:tag value="#{r.statut}"
severity="#{r.statut eq 'ARCHIVE' ? 'success' : (r.statut eq 'SIGNE' ? 'info' : 'warning')}" />
</p:column>
<p:column headerText="Hash signature">
<h:outputText value="#{r.hashSha256.substring(0, 16)}..." rendered="#{r.hashSha256 != null}" />
<h:outputText value="—" rendered="#{r.hashSha256 == null}" />
</p:column>
<p:column headerText="Actions" style="width: 120px;">
<p:commandButton icon="pi pi-download"
title="Télécharger PDF"
action="#{rapportsTrimestrielsBean.telechargerPdf(r)}"
ajax="false"
styleClass="p-button-text" />
</p:column>
</p:dataTable>
<div class="flex gap-2">
<p:commandButton value="Signer (sélection)"
icon="pi pi-check"
action="#{rapportsTrimestrielsBean.signerSelection(userSession.currentUser != null ? userSession.currentUser.id : null)}"
update="form-rapports"
styleClass="p-button-success" />
<p:commandButton value="Archiver (sélection)"
icon="pi pi-lock"
action="#{rapportsTrimestrielsBean.archiverSelection}"
update="form-rapports"
styleClass="p-button-secondary" />
</div>
</h:form>
</div>
</div>
</div>
</ui:define>
</ui:composition>

View File

@@ -0,0 +1,54 @@
package dev.lions.unionflow.client.api.dto;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDateTime;
import java.util.UUID;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class RapportTrimestrielDtoTest {
private RapportTrimestrielDto build(String statut) {
return new RapportTrimestrielDto(
UUID.randomUUID(), UUID.randomUUID(), 2026, 1,
LocalDateTime.now(), statut, 75, null, null, null);
}
@Test
@DisplayName("estDraft / estSigne / estArchive — DRAFT")
void draft() {
var r = build("DRAFT");
assertTrue(r.estDraft());
assertFalse(r.estSigne());
assertFalse(r.estArchive());
}
@Test
@DisplayName("estDraft / estSigne / estArchive — SIGNE")
void signe() {
var r = build("SIGNE");
assertFalse(r.estDraft());
assertTrue(r.estSigne());
assertFalse(r.estArchive());
}
@Test
@DisplayName("estDraft / estSigne / estArchive — ARCHIVE")
void archive() {
var r = build("ARCHIVE");
assertFalse(r.estDraft());
assertFalse(r.estSigne());
assertTrue(r.estArchive());
}
@Test
@DisplayName("Statut inconnu → tous false")
void inconnu() {
var r = build("AUTRE");
assertFalse(r.estDraft());
assertFalse(r.estSigne());
assertFalse(r.estArchive());
}
}

View File

@@ -0,0 +1,109 @@
package dev.lions.unionflow.client.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto.ConformiteIndicateurDto;
import java.lang.reflect.Field;
import java.math.BigDecimal;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Tests purs logique du bean conformité — pas d'invocation REST réelle.
*
* @since 2026-04-25 (Sprint 8)
*/
class ConformiteDashboardBeanTest {
private ConformiteDashboardBean bean;
@BeforeEach
void setUp() {
bean = new ConformiteDashboardBean();
}
private void setSnapshot(ConformiteDashboardBean b, ComplianceSnapshotDto s) throws Exception {
Field f = ConformiteDashboardBean.class.getDeclaredField("snapshot");
f.setAccessible(true);
f.set(b, s);
}
private ComplianceSnapshotDto snapshotAvec(int score, boolean officer, String agStatut) {
return new ComplianceSnapshotDto(
UUID.randomUUID(), "Test Mutuelle", "SYSCOHADA",
officer,
new ConformiteIndicateurDto(agStatut, ""),
new ConformiteIndicateurDto("OK", ""),
3, new BigDecimal("80"), new BigDecimal("70"),
new ConformiteIndicateurDto("OPTIONNEL", ""),
new ConformiteIndicateurDto("EN_VEILLE", ""),
new BigDecimal("60"),
score);
}
@Test
@DisplayName("couleurScore — score >=80 → success")
void couleurSuccess() throws Exception {
setSnapshot(bean, snapshotAvec(85, true, "OK"));
assertEquals("success", bean.getCouleurScore());
}
@Test
@DisplayName("couleurScore — 60..79 → warning")
void couleurWarning() throws Exception {
setSnapshot(bean, snapshotAvec(70, true, "OK"));
assertEquals("warning", bean.getCouleurScore());
}
@Test
@DisplayName("couleurScore — <60 → danger")
void couleurDanger() throws Exception {
setSnapshot(bean, snapshotAvec(45, true, "OK"));
assertEquals("danger", bean.getCouleurScore());
}
@Test
@DisplayName("couleurScore — snapshot null → secondary")
void couleurSecondaire() {
assertEquals("secondary", bean.getCouleurScore());
}
@Test
@DisplayName("hasAlertes — score 85 + officer OK + AG OK → false")
void pasDAlertes() throws Exception {
setSnapshot(bean, snapshotAvec(85, true, "OK"));
assertFalse(bean.hasAlertes());
}
@Test
@DisplayName("hasAlertes — officer absent → true")
void alerteOfficerAbsent() throws Exception {
setSnapshot(bean, snapshotAvec(85, false, "OK"));
assertTrue(bean.hasAlertes());
}
@Test
@DisplayName("hasAlertes — AG en RETARD → true")
void alerteAgRetard() throws Exception {
setSnapshot(bean, snapshotAvec(85, true, "RETARD"));
assertTrue(bean.hasAlertes());
}
@Test
@DisplayName("hasAlertes — score <60 → true")
void alerteScoreFaible() throws Exception {
setSnapshot(bean, snapshotAvec(50, true, "OK"));
assertTrue(bean.hasAlertes());
}
@Test
@DisplayName("hasAlertes — snapshot null → false")
void hasAlertesNull() {
assertFalse(bean.hasAlertes());
}
}

View File

@@ -0,0 +1,85 @@
package dev.lions.unionflow.client.view;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.lions.unionflow.client.api.dto.PispiReadinessDto;
import java.lang.reflect.Field;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class PispiReadinessBeanTest {
private PispiReadinessBean bean;
@BeforeEach
void setUp() {
bean = new PispiReadinessBean();
}
private void setReadiness(PispiReadinessDto r) throws Exception {
Field f = PispiReadinessBean.class.getDeclaredField("readiness");
f.setAccessible(true);
f.set(bean, r);
}
@Test
@DisplayName("couleurStatus — null → secondary")
void couleurNull() {
assertEquals("secondary", bean.getCouleurStatus());
}
@Test
@DisplayName("couleurStatus — READY → success")
void couleurReady() throws Exception {
setReadiness(new PispiReadinessDto("READY", "url", List.of(), List.of(), List.of()));
assertEquals("success", bean.getCouleurStatus());
}
@Test
@DisplayName("couleurStatus — DEGRADED → warning")
void couleurDegraded() throws Exception {
setReadiness(new PispiReadinessDto("DEGRADED", "url", List.of(), List.of(), List.of()));
assertEquals("warning", bean.getCouleurStatus());
}
@Test
@DisplayName("couleurStatus — BLOCKED → danger")
void couleurBlocked() throws Exception {
setReadiness(new PispiReadinessDto("BLOCKED", "url", List.of(), List.of(), List.of()));
assertEquals("danger", bean.getCouleurStatus());
}
@Test
@DisplayName("couleurCheck — PASS → success quel que soit severity")
void couleurCheckPass() {
assertEquals("success", bean.getCouleurCheck("BLOCKING", "PASS"));
assertEquals("success", bean.getCouleurCheck("WARNING", "PASS"));
}
@Test
@DisplayName("couleurCheck — FAIL BLOCKING → danger")
void couleurCheckFailBlocking() {
assertEquals("danger", bean.getCouleurCheck("BLOCKING", "FAIL"));
}
@Test
@DisplayName("couleurCheck — FAIL WARNING → warning")
void couleurCheckFailWarning() {
assertEquals("warning", bean.getCouleurCheck("WARNING", "FAIL"));
}
@Test
@DisplayName("DTO estReady / estBlocked")
void dtoStateHelpers() {
var ready = new PispiReadinessDto("READY", "u", List.of(), List.of(), List.of());
var blocked = new PispiReadinessDto("BLOCKED", "u", List.of(), List.of(), List.of());
assertTrue(ready.estReady());
assertFalse(ready.estBlocked());
assertFalse(blocked.estReady());
assertTrue(blocked.estBlocked());
}
}