17 Commits
Author SHA1 Message Date
Coyaud Anthony 6834985334 merge: intègre fix/retire-bouton-notifications-inoperant dans dev 2026-08-17 16:05:26 +02:00
Coyaud AnthonyandClaude Opus 5 c1950629f1 fix(ui): retire le bouton de notifications inopérant de l'en-tête
Le bouton cloche portait un aria-label « Notifications » mais aucun gestionnaire
de clic : il n'ouvrait rien et n'était relié à aucune fonctionnalité. Un contrôle
annoncé aux lecteurs d'écran comme actionnable alors qu'il ne fait rien dégrade
l'accessibilité plutôt qu'il ne l'améliore.

- suppression du bouton et de l'icône Bell dans Header.jsx
- retrait de Bell de l'import lucide-react
- suppression de la règle .iconBtn et de son sélecteur :focus-visible dans
  Header.module.css, devenus morts

Aucun test ne référençait ce contrôle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:05:26 +02:00
Coyaud Anthony e4271825f6 merge: intègre fix/coherence-pages-legales-et-documentation dans dev 2026-08-17 12:50:45 +02:00
Coyaud Anthony f9482d5b68 merge: intègre dev dans main (cohérence pages légales et documentation)
Build & Deploy / build (push) Successful in 21s
2026-08-17 12:50:45 +02:00
Coyaud AnthonyandClaude Opus 5 7508f86c8c fix(contenu): aligne les pages légales et la documentation sur la réalité livrée
Plusieurs pages statiques affirmaient des faits contredits par le code, le dump
SQL ou le dossier de projet. Corrections vérifiées une à une contre les sources.

Documentation :
- React 18 -> React 19 (package.json : react ^19.2.5)
- token « stocké en sessionStorage » -> localStorage (api.js, AuthContext.jsx),
  avec HS256 / 60 min / sans refresh token
- « jobs CRON » -> jobs SQL Server Agent (aucun CRON dans le dump)
- « Infrastructure NEXERN / hébergement souverain » -> conteneurs Docker derrière
  un reverse proxy ; NEXERN redevient la cible métier
- suppression du monitoring « Infocentre » qui n'existe pas : la nomenclature
  livrée compte 6 monitorings Sage et 1 CRM, répartis sur les pôles Contrat (5)
  et Fournisseur (2)
- « contrastes conformes » -> objectif WCAG 2.1 AA, audit des contrastes à mener
- rôle « Administrateur » -> « Admin » (valeur réelle de l'énumération)

Mentions légales et CGU :
- éditeur : projet académique de COYAUD Anthony ; XEFI SA devient commanditaire.
  L'instance de démonstration n'est ni éditée ni exploitée par XEFI SA
- hébergeur : environnement de démonstration auto-hébergé, NEXERN en cible métier
- section Cookies : l'application ne dépose aucun cookie. La session et le choix
  de consentement sont en localStorage, et aucun traceur tiers n'est utilisé
- conservation : la purge à 24 mois porte sur l'historique de monitoring
  (TABLE_FINAL) ; la mention « 24 mois à compter de la dernière connexion »
  n'était adossée à aucun traitement
- droits RGPD : mise en avant du libre-service réellement implémenté
  (GET /me/data-export art. 20, DELETE /me art. 17)
- accessibilité : la conformité WCAG 2.1 AA devient un objectif, avec la liste
  des mesures en place et celle des points restant à traiter
- contact dpo@xefi.fr remplacé par a.coyaud@xefi.fr (6 occurrences)
- CGV et modération de commentaires déclarées explicitement sans objet

Build :
- vite.config.js : build.target déclaré explicitement (chrome111, edge111,
  firefox114, safari16.4, ios16.4) au lieu de dépendre du défaut de Vite, pour
  que la matrice de compatibilité annoncée dans le README soit tracée au dépôt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:50:07 +02:00
Antho df9bcf4c4d merge: intègre fix/accessibilite-modale-annonces dans dev
Build & Deploy / build (push) Successful in 21s
2026-08-16 13:34:09 +02:00
Antho 766d853d30 fix(a11y): rend conformes les boîtes de dialogue et les messages dynamiques
Le dossier affirmait un focus trap sur les modales et des régions aria-live
pour les mises à jour dynamiques. Ni l'un ni l'autre n'existait : Modal.jsx
tenait en 19 lignes, sans role="dialog", sans aria-modal, sans gestion du
clavier, et aucune occurrence d'aria-live dans le front. Plutôt que retirer
ces affirmations, elles sont rendues vraies.

Modal :
- role="dialog", aria-modal et aria-labelledby relié au titre
- focus placé à l'ouverture sur le premier champ du corps, à défaut sur le
  premier élément focusable
- tabulation contenue dans la modale, dans les deux sens
- fermeture par Échap et restitution du focus à l'élément d'origine
- identifiant produit par useId : ni tirage aléatoire pendant le rendu, ni
  collision entre deux modales

ErrorMessage et LoadingSpinner reçoivent role="alert"/"status" avec le niveau
d'annonce correspondant ; les éléments purement décoratifs passent en
aria-hidden.

9 tests ajoutés pour que ces comportements soient vérifiables autrement qu'en
relisant le code (41 -> 50 tests front).
2026-08-16 13:34:09 +02:00
Antho fc3cde3d65 merge: intègre test/couverture-front dans dev
Build & Deploy / build (push) Successful in 19s
2026-08-15 15:02:01 +02:00
Antho af464cfcb7 test(front): étend la couverture de 8 à 41 tests
Les fonctionnalités ajoutées récemment n'étaient couvertes par aucun test,
et deux utilitaires historiques ne l'avaient jamais été.

- statutMonitoring : bornes du seuil testées de part et d'autre, là où une
  confusion entre < et <= se verrait.
- dates : protège la correction du décalage de fuseau (toISOString basculait
  en UTC, le 1er janvier local devenait le 31 décembre). Les helpers sont
  sortis de Historique.jsx vers utils/dates.js, un fichier de composant ne
  pouvant pas exporter autre chose sans casser le rafraîchissement à chaud.
- exportToCSV : échappement des virgules, guillemets et sauts de ligne, et
  conservation du zéro, qui est falsy alors que « 0 erreur » est justement
  l'information utile. Cet utilitaire sert quatre écrans et n'avait aucun test.
- Dashboard : les menus ne proposent que les valeurs ayant des résultats, les
  catégories se restreignent au service choisi, et une catégorie devenue sans
  résultat est réinitialisée. Vérifié par mutation : casser la cascade fait
  bien échouer deux tests.
- Admin : création, modification pré-remplie, confirmation avant suppression,
  remontée du refus 409 de l'API, désactivation et non suppression d'un
  monitoring, affichage des libellés à la place des identifiants.

Corrige un défaut révélé par ces tests : referentielsConfig capturait les
fonctions de l'API à l'import, figeant l'implémentation. Elles sont désormais
résolues à l'appel, ce qui découple la configuration du module d'API.
2026-08-15 15:02:00 +02:00
Antho b5e4741178 merge: intègre fix/tracabilite-build dans dev
Build & Deploy / build (push) Successful in 23s
2026-08-15 14:47:00 +02:00
Antho 4fd0f5dd37 chore(ci): expose le SHA du build sur /version.json
Rien ne permettait de vérifier quelle version du front était réellement
servie : un déploiement non appliqué était indiscernable d'un déploiement
réussi, d'autant que le cache navigateur masque le problème.

La CI injecte désormais le SHA du commit au build, écrit dans
dist/version.json et servi par nginx. Le pendant côté API est le champ
"build" de GET /health.
2026-08-15 14:47:00 +02:00
Antho 58674dde4d merge: intègre refactor/normes-xefi-admin-crud dans dev
Build & Deploy / build (push) Successful in 18s
- refactor(front): centralise la règle de statut et les colonnes dupliquées
- chore(front): extrait KpiCard, unifie la clé de session, retire du code mort
- feat(front): rend l'écran d'administration réellement fonctionnel
- fix(front): corrige les 4 erreurs eslint restantes
2026-08-15 14:32:31 +02:00
Antho 6ad76ca773 fix(front): corrige les 4 erreurs eslint restantes
Le dépôt sortait 7 erreurs eslint au début de cette série de correctifs,
dont certaines antérieures. Il n'en reste aucune.

- Dashboard et Users appelaient setState de façon synchrone dans un
  effet, ce qui provoque des rendus en cascade. Le chargement initial
  passe par une fonction asynchrone qui n'écrit qu'après l'await, avec
  annulation au démontage. Sur le Dashboard, l'indicateur de chargement
  est allumé par les gestionnaires d'événement, et le bouton Actualiser
  relance l'effet via un compteur plutôt qu'en appelant le chargement.
- L'import ExternalLink de Documentation.jsx n'était pas utilisé.
- AuthContext.jsx exportait à la fois un composant, le contexte et un
  hook, ce qui désactivait le rafraîchissement à chaud de Vite pour ce
  fichier. Le contexte et le hook vivent désormais dans leurs propres
  modules ; les 5 fichiers concernés sont mis à jour.
2026-08-15 14:32:02 +02:00
Antho 82a1b93211 feat(front): rend l'écran d'administration réellement fonctionnel
Les boutons Modifier et Désactiver de la page Administration
n'appelaient rien : handleDelete se contentait d'un console.log et le
bouton Enregistrer de la modale ne faisait que la refermer. L'écran
donnait l'illusion d'un CRUD.

Branche les 4 onglets sur les endpoints POST/PUT/DELETE ajoutés côté
API, avec confirmation avant suppression, rechargement après
enregistrement et remontée des erreurs de l'API (dont le refus 409
quand des enregistrements sont rattachés).

Les 4 blocs de colonnes de tableau, quasi identiques d'un onglet à
l'autre, laissent place à une description déclarative
(referentielsConfig.js) : un seul tableau et un seul formulaire
générique (ReferentielForm) servent les 4 référentiels. Les colonnes
qui portaient un identifiant brut affichent maintenant le libellé.

La modale, jusqu'ici déclarée dans Admin.jsx, devient un composant
commun réutilisable.
2026-08-15 14:29:29 +02:00
Antho ddd0197130 chore(front): extrait KpiCard, unifie la clé de session, retire du code mort
- KpiCard était déclaré dans Dashboard.jsx : sorti dans son propre
  fichier avec ses styles, les règles CSS devenues orphelines sont
  supprimées de Dashboard.module.css.
- La clé localStorage 'ds_user' était définie séparément dans api.js
  (USER_KEY) et AuthContext.jsx (STORAGE_KEY). AuthContext importe
  désormais la constante exportée par api.js : un renommage ne peut
  plus casser silencieusement la restauration de session.
- hooks/useApi.js n'était importé nulle part : supprimé.
2026-08-15 14:29:18 +02:00
Antho 8b8121ca1c refactor(front): centralise la règle de statut et les colonnes dupliquées
Le seuil de 10 erreurs qui fait basculer un monitoring en « Critique »
était réécrit dans trois fichiers (MonitoringCard, MonitoringDetail,
VueConso), la version de VueConso ne renvoyant qu'une couleur. Faire
évoluer ce seuil imposait de corriger trois endroits sans en oublier.
Il vit maintenant dans utils/statutMonitoring.js, avec la constante
SEUIL_ATTENTION nommée.

Les colonnes du tableau de monitorings étaient également identiques
entre VueConso et ServiceMonitorings : extraites dans
utils/monitoringColumns.jsx.
2026-08-15 14:29:09 +02:00
Antho 270283e96d merge: intègre feature/dashboard-filtres-et-historique dans dev
Build & Deploy / build (push) Successful in 36s
- feat(dashboard): filtres services/catégories restreints aux résultats
- fix(historique): période par défaut du 1er janvier à aujourd'hui
2026-08-15 13:35:50 +02:00
44 changed files with 1394 additions and 484 deletions
+1
View File
@@ -23,6 +23,7 @@ jobs:
set -e set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build --build-arg VITE_API_URL="${VITE_API_URL}" \ docker build --build-arg VITE_API_URL="${VITE_API_URL}" \
--build-arg APP_BUILD="${GITHUB_SHA::12}" \
-t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel -t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel
docker push --all-tags "${IMAGE}" docker push --all-tags "${IMAGE}"
echo "pushed ${IMAGE}" echo "pushed ${IMAGE}"
+5
View File
@@ -11,6 +11,11 @@ ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build RUN pnpm build
# SHA du commit construit, servi sur /version.json : permet de vérifier quelle
# version est réellement déployée sans se fier au cache du navigateur.
ARG APP_BUILD=local
RUN printf '{"build":"%s"}' "$APP_BUILD" > /app/dist/version.json
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
+2 -1
View File
@@ -1,7 +1,8 @@
// src/App.jsx // src/App.jsx
// Configuration du routing React Router v6 avec protection des routes // Configuration du routing React Router v6 avec protection des routes
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { useAuth } from './context/useAuth';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import Login from './pages/Login/Login'; import Login from './pages/Login/Login';
import Dashboard from './pages/Dashboard/Dashboard'; import Dashboard from './pages/Dashboard/Dashboard';
+174
View File
@@ -0,0 +1,174 @@
/* Écran d'administration des référentiels.
Avant correction, les boutons Modifier / Désactiver n'appelaient rien
(console.log) et « Enregistrer » se contentait de fermer la modale. Ces
tests vérifient que chaque action atteint réellement l'API et que les
erreurs métier remontent à l'utilisateur. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Admin from '../pages/Admin/Admin';
import * as api from '../services/api';
const SERVICES = [
{ id_service: 1, nom_service: 'Contrat' },
{ id_service: 2, nom_service: 'Fournisseur' },
];
const CATEGORIES = [{ id_categorie: 1, intitule_categorie: 'Tiers-payeurs' }];
const MONITORINGS = [{
id_monito: 1, monito_intitule: 'Erreurs tiers payeurs', id_service: 1,
id_categorie: 1, table_source: 'MONITO_TIERS_PAYEURS', bdd_source: 'Sage',
}];
function stubLectures() {
vi.spyOn(api, 'getServices').mockResolvedValue(SERVICES);
vi.spyOn(api, 'getCategories').mockResolvedValue(CATEGORIES);
vi.spyOn(api, 'getContacts').mockResolvedValue([]);
vi.spyOn(api, 'getMonitorings').mockResolvedValue(MONITORINGS);
}
async function ouvrirOnglet(utilisateur, nom) {
await utilisateur.click(screen.getByRole('button', { name: nom }));
}
describe('Admin — CRUD des référentiels', () => {
beforeEach(() => {
vi.restoreAllMocks();
stubLectures();
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
it('crée un service via lAPI et recharge la liste', async () => {
const creation = vi.spyOn(api, 'createService').mockResolvedValue({ id_service: 3 });
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
await utilisateur.click(screen.getByRole('button', { name: /Ajouter/ }));
await utilisateur.type(screen.getByLabelText('Nom du service'), 'Comptabilité');
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(creation).toHaveBeenCalledWith({ nom_service: 'Comptabilité' });
});
/* Rechargement après écriture : la liste doit être redemandée. */
expect(api.getServices).toHaveBeenCalledTimes(2);
});
it('pré-remplit le formulaire de modification avec la ligne choisie', async () => {
vi.spyOn(api, 'updateService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Fournisseur').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Modifier/ }));
expect(screen.getByLabelText('Nom du service')).toHaveValue('Fournisseur');
});
it('envoie lidentifiant de la ligne lors dune modification', async () => {
const modification = vi.spyOn(api, 'updateService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Fournisseur').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Modifier/ }));
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(modification).toHaveBeenCalledWith(2, { nom_service: 'Fournisseur' });
});
});
it('demande confirmation avant de supprimer, et nappelle rien si on refuse', async () => {
window.confirm.mockReturnValue(false);
const suppression = vi.spyOn(api, 'deleteService').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Contrat').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Supprimer/ }));
expect(window.confirm).toHaveBeenCalled();
expect(suppression).not.toHaveBeenCalled();
});
it('affiche le refus de lAPI quand des enregistrements sont rattachés', async () => {
/* Cas réel : l'API renvoie 409 avec le décompte des rattachements. */
vi.spyOn(api, 'deleteService').mockRejectedValue(
new Error('Erreur 409: Suppression impossible : 5 monitoring(s) rattachés.'),
);
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await ouvrirOnglet(utilisateur, 'Services');
const ligne = screen.getByText('Contrat').closest('tr');
await utilisateur.click(within(ligne).getByRole('button', { name: /Supprimer/ }));
expect(await screen.findByText(/5 monitoring\(s\) rattachés/)).toBeInTheDocument();
});
it('parle de désactivation, pas de suppression, pour un monitoring', async () => {
/* TABLE_FINAL référence le monitoring : il n'est jamais supprimé. */
const desactivation = vi.spyOn(api, 'desactiverMonitoring').mockResolvedValue({});
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
const ligne = screen.getByText('Erreurs tiers payeurs').closest('tr');
expect(within(ligne).getByRole('button', { name: /Désactiver/ })).toBeInTheDocument();
await utilisateur.click(within(ligne).getByRole('button', { name: /Désactiver/ }));
await waitFor(() => expect(desactivation).toHaveBeenCalledWith(1));
});
it('affiche le libellé du service plutôt que son identifiant brut', async () => {
render(<Admin />);
await screen.findByText('Administration');
const ligne = screen.getByText('Erreurs tiers payeurs').closest('tr');
/* id_service = 1 doit s'afficher « Contrat ». */
expect(within(ligne).getByText('Contrat')).toBeInTheDocument();
});
it('convertit en entier les identifiants issus des listes déroulantes', async () => {
/* Un <select> renvoie une chaîne ; l'API attend un entier. */
const creation = vi.spyOn(api, 'createMonitoring').mockResolvedValue({ id_monito: 9 });
const utilisateur = userEvent.setup();
render(<Admin />);
await screen.findByText('Administration');
await utilisateur.click(screen.getByRole('button', { name: /Ajouter/ }));
await utilisateur.type(screen.getByLabelText('Nom'), 'Nouveau monitoring');
await utilisateur.selectOptions(screen.getByLabelText('Service'), '2');
await utilisateur.selectOptions(screen.getByLabelText('Catégorie'), '1');
await utilisateur.type(screen.getByLabelText('Table source'), 'MONITO_TEST');
await utilisateur.type(screen.getByLabelText('Base source'), 'Sage');
await utilisateur.click(screen.getByRole('button', { name: 'Enregistrer' }));
await waitFor(() => {
expect(creation).toHaveBeenCalledWith({
monito_intitule: 'Nouveau monitoring',
id_service: 2,
id_categorie: 1,
table_source: 'MONITO_TEST',
bdd_source: 'Sage',
});
});
});
});
@@ -1,7 +1,8 @@
/* Tests du contexte d'authentification : login / logout */ /* Tests du contexte d'authentification : login / logout */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../context/AuthContext'; import { AuthProvider } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import * as api from '../services/api'; import * as api from '../services/api';
describe('AuthContext', () => { describe('AuthContext', () => {
@@ -0,0 +1,108 @@
/* Filtres du dashboard.
Le comportement testé ici est la correction apportée aux menus déroulants :
ils étaient alimentés par les référentiels SERVICE / CATEGORIE complets,
dont certaines entrées (« Business Intelligence ») n'ont aucun monitoring
rattaché et menaient sur un écran vide. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import Dashboard from '../pages/Dashboard/Dashboard';
import * as api from '../services/api';
/* Ce que renvoie GET /dashboard/filtres : uniquement les valeurs présentes
dans VUE_CONSO. Contrat porte 2 catégories, Fournisseur une seule. */
const FILTRES = {
services: ['Contrat', 'Fournisseur'],
categories: ['Comptes fournisseurs', 'DOM-TOM', 'Tiers-payeurs'],
combinaisons: [
{ service: 'Contrat', categorie: 'DOM-TOM' },
{ service: 'Contrat', categorie: 'Tiers-payeurs' },
{ service: 'Fournisseur', categorie: 'Comptes fournisseurs' },
],
};
function renduDashboard() {
return render(<MemoryRouter><Dashboard /></MemoryRouter>);
}
describe('Dashboard — menus de filtre', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'getDashboardFiltres').mockResolvedValue(FILTRES);
vi.spyOn(api, 'getDashboardSummary').mockResolvedValue({
total: 3, ok: 0, critical: 3, total_erreurs: 12,
});
vi.spyOn(api, 'getDashboard').mockResolvedValue([]);
});
it('ne propose que les services ayant au moins un monitoring', async () => {
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const options = [...selectService.options].map(o => o.value);
expect(options).toContain('Contrat');
expect(options).toContain('Fournisseur');
/* Le cœur du correctif : ces entrées du référentiel ne doivent pas
apparaître puisqu'elles ramèneraient un dashboard vide. */
expect(options).not.toContain('Business Intelligence');
expect(options).not.toContain('Comptabilité');
});
it('restreint les catégories au service sélectionné', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const selectCategorie = screen.getByDisplayValue('Toutes les catégories');
/* Sans filtre de service : toutes les catégories utilisées */
expect([...selectCategorie.options].map(o => o.value)).toEqual(
expect.arrayContaining(['DOM-TOM', 'Tiers-payeurs', 'Comptes fournisseurs']),
);
await utilisateur.selectOptions(selectService, 'Fournisseur');
await waitFor(() => {
const restantes = [...selectCategorie.options].map(o => o.value).filter(Boolean);
expect(restantes).toEqual(['Comptes fournisseurs']);
});
});
it('réinitialise la catégorie devenue sans résultat après changement de service', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
const selectCategorie = screen.getByDisplayValue('Toutes les catégories');
await utilisateur.selectOptions(selectCategorie, 'DOM-TOM');
expect(selectCategorie.value).toBe('DOM-TOM');
/* DOM-TOM n'existe pas pour Fournisseur : garder la sélection
produirait une combinaison vide. */
await utilisateur.selectOptions(selectService, 'Fournisseur');
await waitFor(() => expect(selectCategorie.value).toBe(''));
});
it('interroge lAPI avec les filtres sélectionnés', async () => {
const utilisateur = userEvent.setup();
renduDashboard();
const selectService = await screen.findByDisplayValue('Tous les services');
await utilisateur.selectOptions(selectService, 'Contrat');
await waitFor(() => {
expect(api.getDashboard).toHaveBeenLastCalledWith('Contrat', '');
});
});
it('affiche les KPI renvoyés par lAPI', async () => {
renduDashboard();
expect(await screen.findByText('12')).toBeInTheDocument();
expect(screen.getByText('Total erreurs')).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
/* Accessibilité des composants transverses.
Le dossier affirme que les boîtes de dialogue piègent le focus et que les
messages dynamiques sont annoncés aux lecteurs d'écran. Ces tests rendent ces
affirmations vérifiables : elles étaient auparavant invalidables autrement
qu'en relisant le code. */
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Modal from '../components/common/Modal';
import ErrorMessage from '../components/common/ErrorMessage';
import LoadingSpinner from '../components/common/LoadingSpinner';
function ouvrirModale(onClose = () => {}) {
return render(
<Modal isOpen onClose={onClose} title="Modifier le service">
<input aria-label="Nom" />
<button>Enregistrer</button>
</Modal>,
);
}
describe('Modal — accessibilité', () => {
it('est exposée comme boîte de dialogue modale', () => {
ouvrirModale();
const dialogue = screen.getByRole('dialog');
expect(dialogue).toHaveAttribute('aria-modal', 'true');
});
it('est nommée par son titre', () => {
ouvrirModale();
/* Le lecteur d'écran doit annoncer de quoi parle la modale. */
expect(screen.getByRole('dialog', { name: 'Modifier le service' })).toBeInTheDocument();
});
it('place le focus sur le premier champ à louverture', () => {
ouvrirModale();
expect(screen.getByLabelText('Nom')).toHaveFocus();
});
it('referme la modale à la touche Échap', async () => {
const onClose = vi.fn();
const utilisateur = userEvent.setup();
ouvrirModale(onClose);
await utilisateur.keyboard('{Escape}');
expect(onClose).toHaveBeenCalled();
});
it('retient le focus à lintérieur de la modale', async () => {
const utilisateur = userEvent.setup();
ouvrirModale();
/* Depuis le dernier élément, Tab doit revenir au premier et non filer
vers la page située derrière. */
const fermer = screen.getByRole('button', { name: 'Fermer' });
fermer.focus();
await utilisateur.tab();
expect(document.activeElement).not.toBe(document.body);
expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(true);
});
it('naffiche rien quand elle est fermée', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Titre">contenu</Modal>);
expect(screen.queryByRole('dialog')).toBeNull();
});
});
describe('Messages dynamiques — annonce aux lecteurs d’écran', () => {
it('annonce une erreur immédiatement', () => {
render(<ErrorMessage message="Erreur 500 : service indisponible" />);
const alerte = screen.getByRole('alert');
expect(alerte).toHaveAttribute('aria-live', 'assertive');
expect(alerte).toHaveTextContent('service indisponible');
});
it('annonce le chargement sans interrompre la lecture', () => {
render(<LoadingSpinner message="Chargement de l'historique..." />);
const statut = screen.getByRole('status');
expect(statut).toHaveAttribute('aria-live', 'polite');
});
it('masque les éléments purement décoratifs', () => {
/* L'animation du spinner n'apporte rien à l'oral : elle doit être ignorée. */
const { container } = render(<LoadingSpinner />);
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
});
});
+50
View File
@@ -0,0 +1,50 @@
/* Formatage des bornes de période de la page Historique.
Ces tests protègent une régression précise : la version d'origine utilisait
toISOString(), qui convertit en UTC. Depuis un fuseau en avance sur UTC
(Paris = UTC+1 en hiver), le 1er janvier local devient le 31 décembre de
l'année précédente, et la période affichée est décalée d'un jour. */
import { describe, it, expect } from 'vitest';
import { formatDateISO, periodeParDefaut } from '../utils/dates';
describe('formatDateISO', () => {
it('formate une date en YYYY-MM-DD', () => {
expect(formatDateISO(new Date(2026, 7, 15))).toBe('2026-08-15');
});
it('complète le mois et le jour à deux chiffres', () => {
expect(formatDateISO(new Date(2026, 0, 5))).toBe('2026-01-05');
});
it('ne décale pas la date à minuit en heure locale', () => {
/* Le cas qui cassait : minuit local est la veille en UTC dès UTC+1. */
const premierJanvierMinuit = new Date(2026, 0, 1, 0, 0, 0);
expect(formatDateISO(premierJanvierMinuit)).toBe('2026-01-01');
});
it('ne décale pas non plus en fin de journée', () => {
/* Symétrique : 23h local bascule au lendemain pour les fuseaux négatifs. */
const finDeJournee = new Date(2026, 11, 31, 23, 30, 0);
expect(formatDateISO(finDeJournee)).toBe('2026-12-31');
});
});
describe('periodeParDefaut', () => {
it('démarre au 1er janvier de lannée de la date fournie', () => {
expect(periodeParDefaut(new Date(2026, 7, 15)).debut).toBe('2026-01-01');
});
it('se termine à la date fournie', () => {
expect(periodeParDefaut(new Date(2026, 7, 15)).fin).toBe('2026-08-15');
});
it('reste cohérente le 1er janvier (début = fin)', () => {
const periode = periodeParDefaut(new Date(2026, 0, 1));
expect(periode.debut).toBe('2026-01-01');
expect(periode.fin).toBe('2026-01-01');
});
it('suit le changement dannée', () => {
expect(periodeParDefaut(new Date(2027, 2, 9)).debut).toBe('2027-01-01');
});
});
@@ -0,0 +1,83 @@
/* Export CSV, utilisé par les pages Vue consolidée, Contrat, Fournisseur,
Historique et Détail monitoring. Les données exportées viennent de Sage et
du CRM : elles contiennent des intitulés avec virgules et apostrophes, d'où
l'importance de l'échappement. */
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { exportToCSV } from '../services/api';
/* Capture le contenu du Blob passé à URL.createObjectURL, sans télécharger. */
function capturerCSV(data, filename) {
let contenu = '';
let nomFichier = '';
vi.stubGlobal('Blob', class {
constructor(parties) { contenu = parties.join(''); }
});
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(document, 'createElement').mockReturnValue({
set download(valeur) { nomFichier = valeur; },
get download() { return nomFichier; },
href: '',
click: () => {},
});
exportToCSV(data, filename);
return { contenu, nomFichier };
}
describe('exportToCSV', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('écrit une ligne den-têtes puis une ligne par enregistrement', () => {
const { contenu } = capturerCSV(
[{ nom: 'Tiers payeurs', erreurs: 5 }, { nom: 'DOM-TOM', erreurs: 3 }],
'export.csv',
);
const lignes = contenu.split('\n');
expect(lignes[0]).toBe('nom,erreurs');
expect(lignes[1]).toBe('Tiers payeurs,5');
expect(lignes[2]).toBe('DOM-TOM,3');
});
it('entoure de guillemets une valeur contenant une virgule', () => {
/* Sans échappement, la virgule créerait une colonne fantôme. */
const { contenu } = capturerCSV([{ intitule: 'XEFI Lyon, agence Est' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('"XEFI Lyon, agence Est"');
});
it('double les guillemets internes', () => {
const { contenu } = capturerCSV([{ intitule: 'Client "Test"' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('"Client ""Test"""');
});
it('protège une valeur contenant un saut de ligne', () => {
const { contenu } = capturerCSV([{ adresse: 'Ligne 1\nLigne 2' }], 'x.csv');
expect(contenu).toContain('"Ligne 1\nLigne 2"');
});
it('rend les valeurs nulles ou absentes par une cellule vide', () => {
const { contenu } = capturerCSV([{ a: null, b: undefined, c: 'ok' }], 'x.csv');
expect(contenu.split('\n')[1]).toBe(',,ok');
});
it('conserve le zéro au lieu de le traiter comme une valeur vide', () => {
/* 0 est falsy : un test de vérité naïf leffacerait alors que
« 0 erreur » est justement linformation utile. */
const { contenu } = capturerCSV([{ nb_erreurs: 0 }], 'x.csv');
expect(contenu.split('\n')[1]).toBe('0');
});
it('utilise le nom de fichier demandé', () => {
const { nomFichier } = capturerCSV([{ a: 1 }], 'historique_2026.csv');
expect(nomFichier).toBe('historique_2026.csv');
});
it('nexporte rien si la liste est vide', () => {
const creerElement = vi.spyOn(document, 'createElement');
exportToCSV([], 'vide.csv');
expect(creerElement).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,33 @@
/* Règle métier de classement d'un monitoring selon son nombre d'erreurs.
Elle était dupliquée dans trois composants avant d'être centralisée : ces
tests fixent le contrat pour que les trois écrans restent cohérents. */
import { describe, it, expect } from 'vitest';
import { getStatutMonitoring, SEUIL_ATTENTION } from '../utils/statutMonitoring';
describe('getStatutMonitoring', () => {
it('classe OK un monitoring sans erreur', () => {
expect(getStatutMonitoring(0).label).toBe('OK');
expect(getStatutMonitoring(0).variant).toBe('ok');
});
it('classe Attention en dessous du seuil', () => {
expect(getStatutMonitoring(1).label).toBe('Attention');
expect(getStatutMonitoring(SEUIL_ATTENTION - 1).label).toBe('Attention');
});
it('bascule en Critique à partir du seuil, pas avant', () => {
/* La borne est le point où une erreur de type < / <= se verrait. */
expect(getStatutMonitoring(SEUIL_ATTENTION - 1).label).toBe('Attention');
expect(getStatutMonitoring(SEUIL_ATTENTION).label).toBe('Critique');
expect(getStatutMonitoring(SEUIL_ATTENTION + 1).label).toBe('Critique');
});
it('fournit toujours un libellé, une variante et une couleur', () => {
for (const nb of [0, 5, 50]) {
const statut = getStatutMonitoring(nb);
expect(statut.label).toBeTruthy();
expect(statut.variant).toBeTruthy();
expect(statut.color).toMatch(/^var\(--/);
}
});
});
@@ -1,7 +1,7 @@
/* En-tête fixe de l'application Data Sentinel */ /* En-tête fixe de l'application Data Sentinel */
import { Bell, LogOut, Menu } from 'lucide-react'; import { LogOut, Menu } from 'lucide-react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import styles from './Header.module.css'; import styles from './Header.module.css';
@@ -40,9 +40,6 @@ export default function Header({ onToggleSidebar, sidebarOpen }) {
</div> </div>
<div className={styles.right}> <div className={styles.right}>
<button className={styles.iconBtn} aria-label="Notifications">
<Bell size={18} color="var(--color-white)" aria-hidden="true" />
</button>
{user && ( {user && (
<span className={styles.username} aria-label={`Connecté en tant que ${user.username}`}> <span className={styles.username} aria-label={`Connecté en tant que ${user.username}`}>
{user.username} {user.username}
@@ -68,15 +68,6 @@
gap: 16px; gap: 16px;
} }
.iconBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
}
.username { .username {
color: var(--color-white); color: var(--color-white);
font-size: 13px; font-size: 13px;
@@ -122,7 +113,6 @@
/* Focus visible sur tous les éléments interactifs */ /* Focus visible sur tous les éléments interactifs */
.burgerBtn:focus-visible, .burgerBtn:focus-visible,
.iconBtn:focus-visible,
.logoutBtn:focus-visible { .logoutBtn:focus-visible {
outline: 2px solid var(--color-white); outline: 2px solid var(--color-white);
outline-offset: 2px; outline-offset: 2px;
@@ -13,7 +13,7 @@ import {
FileText, FileText,
ScrollText, ScrollText,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
/* Un lien de navigation avec icône */ /* Un lien de navigation avec icône */
@@ -4,9 +4,12 @@ import { AlertCircle } from 'lucide-react';
import styles from './ErrorMessage.module.css'; import styles from './ErrorMessage.module.css';
export default function ErrorMessage({ message = 'Une erreur est survenue.', onRetry }) { export default function ErrorMessage({ message = 'Une erreur est survenue.', onRetry }) {
/* role="alert" annonce l'erreur immédiatement au lecteur d'écran : elle
apparaît après le chargement de la page, sans quoi elle passerait
inaperçue pour un utilisateur non voyant. */
return ( return (
<div className={styles.container}> <div className={styles.container} role="alert" aria-live="assertive">
<AlertCircle size={20} color="var(--color-primary)" /> <AlertCircle size={20} color="var(--color-primary)" aria-hidden="true" />
<p className={styles.text}>{message}</p> <p className={styles.text}>{message}</p>
{onRetry && ( {onRetry && (
<button className={styles.retryBtn} onClick={onRetry}> <button className={styles.retryBtn} onClick={onRetry}>
@@ -0,0 +1,10 @@
import styles from './KpiCard.module.css';
export default function KpiCard({ label, value, color }) {
return (
<div className={styles.kpiCard}>
<span className={styles.kpiValue} style={{ color }}>{value}</span>
<span className={styles.kpiLabel}>{label}</span>
</div>
);
}
@@ -0,0 +1,28 @@
.kpiCard {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: var(--color-white);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.kpiValue {
font-size: 36px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
}
.kpiLabel {
font-size: 12px;
color: var(--color-muted, #888);
text-align: center;
}
@media (max-width: 768px) {
.kpiValue {
font-size: 28px;
}
}
@@ -3,9 +3,11 @@
import styles from './LoadingSpinner.module.css'; import styles from './LoadingSpinner.module.css';
export default function LoadingSpinner({ message = 'Chargement...' }) { export default function LoadingSpinner({ message = 'Chargement...' }) {
/* aria-live="polite" fait annoncer la fin du chargement sans interrompre la
lecture en cours ; l'animation elle-même n'apporte rien à l'oral. */
return ( return (
<div className={styles.container}> <div className={styles.container} role="status" aria-live="polite">
<div className={styles.spinner} /> <div className={styles.spinner} aria-hidden="true" />
{message && <p className={styles.message}>{message}</p>} {message && <p className={styles.message}>{message}</p>}
</div> </div>
); );
@@ -0,0 +1,80 @@
import { useEffect, useId, useRef } from 'react';
import styles from './Modal.module.css';
/* Sélecteur des éléments qui peuvent recevoir le focus au clavier. */
const ELEMENTS_FOCUSABLES =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export default function Modal({ isOpen, onClose, title, children }) {
const conteneur = useRef(null);
/* useId fournit un identifiant stable et unique par instance, sans tirage
aléatoire pendant le rendu et sans collision si deux modales coexistent. */
const titreId = useId();
/* Piège le focus dans la modale et gère la touche Échap.
Sans cela, la tabulation continue de parcourir la page située derrière,
ce qui rend la boîte de dialogue inutilisable au clavier. */
useEffect(() => {
if (!isOpen) return undefined;
const elementActifAvant = document.activeElement;
/* On vise d'abord le contenu : sur une modale de formulaire, ouvrir sur le
premier champ évite à l'utilisateur une tabulation inutile. À défaut, on
se rabat sur le premier élément focusable, souvent le bouton de fermeture. */
const corps = conteneur.current?.querySelector(`.${styles.modalBody}`);
const premier = corps?.querySelector(ELEMENTS_FOCUSABLES)
?? conteneur.current?.querySelector(ELEMENTS_FOCUSABLES);
premier?.focus();
function handleKeyDown(evenement) {
if (evenement.key === 'Escape') {
onClose();
return;
}
if (evenement.key !== 'Tab') return;
const focusables = conteneur.current?.querySelectorAll(ELEMENTS_FOCUSABLES);
if (!focusables?.length) return;
const premier = focusables[0];
const dernier = focusables[focusables.length - 1];
if (evenement.shiftKey && document.activeElement === premier) {
evenement.preventDefault();
dernier.focus();
} else if (!evenement.shiftKey && document.activeElement === dernier) {
evenement.preventDefault();
premier.focus();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
elementActifAvant?.focus?.();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div
className={styles.modal}
ref={conteneur}
role="dialog"
aria-modal="true"
aria-labelledby={titreId}
onClick={e => e.stopPropagation()}
>
<div className={styles.modalHeader}>
<h2 id={titreId}>{title}</h2>
<button className={styles.closeBtn} onClick={onClose} aria-label="Fermer">×</button>
</div>
<div className={styles.modalBody}>
{children}
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: var(--color-white);
border-radius: var(--radius);
border-top: 4px solid var(--color-primary);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-gray-border);
}
.modalHeader h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text);
}
.closeBtn {
background: none;
border: none;
font-size: 24px;
color: var(--color-text-secondary);
cursor: pointer;
}
.modalBody {
padding: 24px;
}
@@ -2,20 +2,14 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Badge from './Badge'; import Badge from './Badge';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import styles from './MonitoringCard.module.css'; import styles from './MonitoringCard.module.css';
/* Détermine le statut visuel selon le nombre d'erreurs */
function getStatut(nbErreurs) {
if (nbErreurs === 0) return { label: 'OK', variant: 'ok', color: 'var(--color-ok)' };
if (nbErreurs < 10) return { label: 'Attention', variant: 'warning', color: 'var(--color-warning)' };
return { label: 'Critique', variant: 'critical', color: 'var(--color-primary)' };
}
export default function MonitoringCard({ monitoring }) { export default function MonitoringCard({ monitoring }) {
const navigate = useNavigate(); const navigate = useNavigate();
const nbErreurs = monitoring.nb_erreurs ?? monitoring.count ?? 0; const nbErreurs = monitoring.nb_erreurs ?? monitoring.count ?? 0;
const monitoringId = monitoring.id ?? monitoring.id_monito; const monitoringId = monitoring.id ?? monitoring.id_monito;
const statut = getStatut(nbErreurs); const statut = getStatutMonitoring(nbErreurs);
/* Calcul du pourcentage de la barre de progression (max 100%) */ /* Calcul du pourcentage de la barre de progression (max 100%) */
const progressPct = Math.min((nbErreurs / 20) * 100, 100); const progressPct = Math.min((nbErreurs / 20) * 100, 100);
+6 -16
View File
@@ -1,17 +1,15 @@
/* Contexte d'authentification global : gestion de l'utilisateur connecté */ /* Contexte d'authentification global : gestion de l'utilisateur connecté */
import { createContext, useContext, useState } from 'react'; import { useState } from 'react';
import * as api from '../services/api'; import * as api from '../services/api';
import { USER_KEY } from '../services/api';
const AuthContext = createContext(null); import { AuthContext } from './authContextObject';
const STORAGE_KEY = 'ds_user';
/* Fournisseur du contexte d'authentification */ /* Fournisseur du contexte d'authentification */
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(() => { const [user, setUser] = useState(() => {
/* Restauration de la session depuis le localStorage au montage */ /* Restauration de la session depuis le localStorage au montage */
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(USER_KEY);
return stored ? JSON.parse(stored) : null; return stored ? JSON.parse(stored) : null;
}); });
@@ -19,7 +17,7 @@ export function AuthProvider({ children }) {
async function login(username, password) { async function login(username, password) {
const connectedUser = await api.login(username, password); const connectedUser = await api.login(username, password);
setUser(connectedUser); setUser(connectedUser);
localStorage.setItem(STORAGE_KEY, JSON.stringify(connectedUser)); localStorage.setItem(USER_KEY, JSON.stringify(connectedUser));
return connectedUser; return connectedUser;
} }
@@ -27,7 +25,7 @@ export function AuthProvider({ children }) {
function logout() { function logout() {
api.clearToken(); api.clearToken();
setUser(null); setUser(null);
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(USER_KEY);
} }
/* Booléen pratique pour savoir si l'utilisateur est connecté */ /* Booléen pratique pour savoir si l'utilisateur est connecté */
@@ -40,11 +38,3 @@ export function AuthProvider({ children }) {
); );
} }
/* Hook personnalisé pour accéder au contexte d'authentification */
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
}
@@ -0,0 +1,7 @@
/* Objet de contexte isolé : un fichier qui exporte un composant ne doit
exporter que des composants, sans quoi le rafraîchissement à chaud de Vite
(react-refresh) cesse de fonctionner pour ce fichier. */
import { createContext } from 'react';
export const AuthContext = createContext(null);
+15
View File
@@ -0,0 +1,15 @@
/* Hook d'accès au contexte d'authentification.
Séparé de AuthContext.jsx : un fichier qui exporte à la fois un composant
et autre chose casse le rafraîchissement à chaud de Vite (react-refresh). */
import { useContext } from 'react';
import { AuthContext } from './authContextObject';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
}
-33
View File
@@ -1,33 +0,0 @@
/* Hook générique pour les appels API : retourne { data, loading, error, refetch } */
import { useState, useEffect, useCallback } from 'react';
/**
* @param {Function} fetchFn - Fonction async à appeler (depuis api.js)
* @param {Array} deps - Dépendances qui déclenchent un re-fetch (optionnel)
*/
export function useApi(fetchFn, deps = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchFn();
setData(result);
} catch (err) {
setError(err.message || 'Une erreur est survenue.');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
fetch();
}, [fetch]);
return { data, loading, error, refetch: fetch };
}
+131 -174
View File
@@ -1,230 +1,187 @@
// src/pages/Admin/Admin.jsx // src/pages/Admin/Admin.jsx
// Page Administration : onglets pour gérer monitorings, services, catégories, contacts // Page Administration : CRUD sur les référentiels (monitorings, services,
import { useState, useEffect } from 'react'; // catégories, contacts). Réservée au rôle Admin côté API.
import { useState, useEffect, useCallback } from 'react';
import { Plus, Edit, Trash2 } from 'lucide-react'; import { Plus, Edit, Trash2 } from 'lucide-react';
import { getMonitorings, getServices, getCategories, getContacts } from '../../services/api'; import { getMonitorings, getServices, getCategories, getContacts } from '../../services/api';
import DataTable from '../../components/common/DataTable'; import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import Modal from '../../components/common/Modal';
import ReferentielForm from './ReferentielForm';
import { REFERENTIELS, LIBELLES_REFERENTIEL } from './referentielsConfig';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
/* Modale générique pour ajouter/modifier */ async function chargerReferentiels() {
function Modal({ isOpen, onClose, title, children }) { const [monitorings, services, categories, contacts] = await Promise.all([
if (!isOpen) return null; getMonitorings(),
getServices(),
return ( getCategories(),
<div className={styles.modalOverlay} onClick={onClose}> getContacts(),
<div className={styles.modal} onClick={e => e.stopPropagation()}> ]);
<div className={styles.modalHeader}> return {
<h2>{title}</h2> monitorings: Array.isArray(monitorings) ? monitorings : [],
<button className={styles.closeBtn} onClick={onClose}>×</button> services: Array.isArray(services) ? services : [],
</div> categories: Array.isArray(categories) ? categories : [],
<div className={styles.modalBody}> contacts: Array.isArray(contacts) ? contacts : [],
{children} };
</div>
</div>
</div>
);
} }
export default function Admin() { export default function Admin() {
const [activeTab, setActiveTab] = useState('monitorings'); const [ongletActif, setOngletActif] = useState(REFERENTIELS[0].key);
const [data, setData] = useState({}); const [donnees, setDonnees] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [modaleOuverte, setModaleOuverte] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [elementEnEdition, setElementEnEdition] = useState(null);
useEffect(() => { const recharger = useCallback(async () => {
async function loadData() { setErreur('');
setLoading(true); try {
setErreur(''); setDonnees(await chargerReferentiels());
try { } catch (e) {
const [monitorings, services, categories, contacts] = await Promise.all([ setErreur(e.message || 'Erreur lors du chargement des données administratives.');
getMonitorings(),
getServices(),
getCategories(),
getContacts()
]);
setData({
monitorings: Array.isArray(monitorings) ? monitorings : [],
services: Array.isArray(services) ? services : [],
categories: Array.isArray(categories) ? categories : [],
contacts: Array.isArray(contacts) ? contacts : []
});
} catch (e) {
setErreur(e.message || 'Erreur lors du chargement des données administratives.');
} finally {
setLoading(false);
}
} }
loadData();
}, []); }, []);
function handleAdd() { useEffect(() => {
setEditingItem(null); let annule = false;
setModalOpen(true); async function chargerAuMontage() {
try {
const resultat = await chargerReferentiels();
if (!annule) setDonnees(resultat);
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement des données administratives.');
} finally {
if (!annule) setLoading(false);
}
}
chargerAuMontage();
return () => { annule = true; };
}, []);
const referentiel = REFERENTIELS.find(r => r.key === ongletActif);
function ouvrirCreation() {
setElementEnEdition(null);
setModaleOuverte(true);
} }
function handleEdit(item) { function ouvrirEdition(element) {
setEditingItem(item); setElementEnEdition(element);
setModalOpen(true); setModaleOuverte(true);
} }
function handleDelete(item) { async function handleEnregistrer(corps) {
// Simulation de suppression (pas d'API pour ça) if (elementEnEdition) {
console.log('Supprimer', item); await referentiel.modifier(elementEnEdition[referentiel.idField], corps);
} else {
await referentiel.creer(corps);
}
setModaleOuverte(false);
await recharger();
} }
const tabs = [ async function handleSupprimer(element) {
{ key: 'monitorings', label: 'Monitorings' }, const action = referentiel.suppressionEstDesactivation ? 'Désactiver' : 'Supprimer';
{ key: 'services', label: 'Services' }, const nom = element[referentiel.colonnes[0].key];
{ key: 'categories', label: 'Catégories' }, if (!window.confirm(`${action} « ${nom} » ?`)) return;
{ key: 'contacts', label: 'Contacts' }
setErreur('');
try {
await referentiel.supprimer(element[referentiel.idField]);
await recharger();
} catch (e) {
setErreur(e.message || 'La suppression a échoué.');
}
}
/* Affiche le libellé d'un référentiel lié plutôt que son identifiant brut. */
function libelleLie(nomReferentiel, id) {
const { idField, labelField } = LIBELLES_REFERENTIEL[nomReferentiel];
const trouve = (donnees[nomReferentiel] || []).find(item => item[idField] === id);
return trouve ? trouve[labelField] : id;
}
const colonnes = [
...referentiel.colonnes.map(colonne => ({
key: colonne.key,
label: colonne.label,
render: colonne.referentiel
? val => libelleLie(colonne.referentiel, val)
: undefined,
})),
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => ouvrirEdition(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleSupprimer(row)}>
<Trash2 size={14} />
{referentiel.suppressionEstDesactivation ? 'Désactiver' : 'Supprimer'}
</button>
</div>
),
},
]; ];
const columns = {
monitorings: [
{ key: 'monito_intitule', label: 'Nom' },
{ key: 'id_service', label: 'Service' },
{ key: 'id_categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'table_source', label: 'Table' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
services: [
{ key: 'nom_service', label: 'Nom' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
categories: [
{ key: 'intitule_categorie', label: 'Nom' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
contacts: [
{ key: 'intitule_contact', label: 'Intitulé' },
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'mail', label: 'Email' },
{ key: 'id_service', label: 'Service' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
]
};
if (loading) return <LoadingSpinner message="Chargement des données administratives..." />; if (loading) return <LoadingSpinner message="Chargement des données administratives..." />;
if (erreur) return <ErrorMessage message={erreur} />;
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1 className={styles.title}>Administration</h1> <h1 className={styles.title}>Administration</h1>
{/* Onglets */}
<div className={styles.tabs}> <div className={styles.tabs}>
{tabs.map(tab => ( {REFERENTIELS.map(item => (
<button <button
key={tab.key} key={item.key}
className={`${styles.tab} ${activeTab === tab.key ? styles.active : ''}`} className={`${styles.tab} ${ongletActif === item.key ? styles.active : ''}`}
onClick={() => setActiveTab(tab.key)} onClick={() => setOngletActif(item.key)}
> >
{tab.label} {item.label}
</button> </button>
))} ))}
</div> </div>
{/* Contenu de l'onglet actif */} {erreur && <ErrorMessage message={erreur} />}
<div className={styles.tabContent}> <div className={styles.tabContent}>
<div className={styles.header}> <div className={styles.header}>
<h2>{tabs.find(t => t.key === activeTab)?.label}</h2> <h2>{referentiel.label}</h2>
<Button variant="primary" onClick={handleAdd}> <Button variant="primary" onClick={ouvrirCreation}>
<Plus size={14} /> <Plus size={14} />
Ajouter Ajouter
</Button> </Button>
</div> </div>
<DataTable <DataTable
columns={columns[activeTab]} columns={colonnes}
data={data[activeTab] || []} data={donnees[ongletActif] || []}
pageSize={50} pageSize={50}
emptyMessage={`Aucun ${activeTab.slice(0, -1)} trouvé.`} emptyMessage={`Aucun ${referentiel.singulier} trouvé.`}
/> />
</div> </div>
{/* Modale */}
<Modal <Modal
isOpen={modalOpen} isOpen={modaleOuverte}
onClose={() => setModalOpen(false)} onClose={() => setModaleOuverte(false)}
title={`${editingItem ? 'Modifier' : 'Ajouter'} ${tabs.find(t => t.key === activeTab)?.label.slice(0, -1)}`} title={`${elementEnEdition ? 'Modifier' : 'Ajouter'} ${referentiel.singulier}`}
> >
<p>Formulaire à implémenter selon les besoins.</p> <ReferentielForm
<div className={styles.modalActions}> /* Remonte le formulaire à zéro quand on change d'onglet ou d'élément */
<Button variant="secondary" onClick={() => setModalOpen(false)}> key={`${referentiel.key}-${elementEnEdition?.[referentiel.idField] ?? 'nouveau'}`}
Annuler referentiel={referentiel}
</Button> valeurInitiale={elementEnEdition}
<Button variant="primary" onClick={() => setModalOpen(false)}> donnees={donnees}
Enregistrer onEnregistrer={handleEnregistrer}
</Button> onAnnuler={() => setModaleOuverte(false)}
</div> />
</Modal> </Modal>
</div> </div>
); );
} }
+20 -42
View File
@@ -83,55 +83,33 @@
color: var(--color-primary); color: var(--color-primary);
} }
/* Modale */ /* Formulaire de la modale (le conteneur vit dans Modal.module.css) */
.modalOverlay { .field {
position: fixed; margin-bottom: 16px;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
} }
.modal { .label {
background-color: var(--color-white); display: block;
border-radius: var(--radius); margin-bottom: 6px;
border-top: 4px solid var(--color-primary); font-size: 13px;
width: 90%; font-weight: 500;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-gray-border);
}
.modalHeader h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text); color: var(--color-text);
} }
.closeBtn { .input {
background: none; width: 100%;
border: none; padding: 8px 12px;
font-size: 24px; border: 1px solid var(--color-gray-border);
color: var(--color-text-secondary); border-radius: var(--radius);
cursor: pointer; font-size: 14px;
font-family: inherit;
color: var(--color-text);
background-color: var(--color-white);
} }
.modalBody { .input:focus {
padding: 24px; outline: 2px solid var(--color-primary);
outline-offset: -1px;
} }
.modalActions { .modalActions {
@@ -0,0 +1,91 @@
import { useState } from 'react';
import Button from '../../components/common/Button';
import ErrorMessage from '../../components/common/ErrorMessage';
import { LIBELLES_REFERENTIEL } from './referentielsConfig';
import styles from './Admin.module.css';
/* Formulaire générique piloté par la description des champs du référentiel
(voir referentielsConfig.js) : les 4 onglets partagent ce composant. */
export default function ReferentielForm({ referentiel, valeurInitiale, donnees, onEnregistrer, onAnnuler }) {
const [valeurs, setValeurs] = useState(() =>
Object.fromEntries(
referentiel.champs.map(champ => [champ.name, valeurInitiale?.[champ.name] ?? ''])
)
);
const [enCours, setEnCours] = useState(false);
const [erreur, setErreur] = useState('');
function handleChange(name, valeur) {
setValeurs(precedentes => ({ ...precedentes, [name]: valeur }));
}
async function handleSubmit(event) {
event.preventDefault();
setErreur('');
setEnCours(true);
try {
/* Les identifiants de référentiel arrivent en chaîne depuis <select> :
l'API attend des entiers. */
const corps = Object.fromEntries(
referentiel.champs.map(champ => [
champ.name,
champ.type === 'select' ? Number(valeurs[champ.name]) : valeurs[champ.name],
])
);
await onEnregistrer(corps);
} catch (e) {
setErreur(e.message || "L'enregistrement a échoué.");
} finally {
setEnCours(false);
}
}
return (
<form onSubmit={handleSubmit}>
{referentiel.champs.map(champ => (
<div key={champ.name} className={styles.field}>
<label className={styles.label} htmlFor={champ.name}>{champ.label}</label>
{champ.type === 'select' ? (
<select
id={champ.name}
className={styles.input}
value={valeurs[champ.name]}
onChange={e => handleChange(champ.name, e.target.value)}
required={champ.required}
>
<option value=""> Choisir </option>
{(donnees[champ.options] || []).map(option => {
const { idField, labelField } = LIBELLES_REFERENTIEL[champ.options];
return (
<option key={option[idField]} value={option[idField]}>
{option[labelField]}
</option>
);
})}
</select>
) : (
<input
id={champ.name}
type={champ.type}
className={styles.input}
value={valeurs[champ.name]}
onChange={e => handleChange(champ.name, e.target.value)}
required={champ.required}
/>
)}
</div>
))}
{erreur && <ErrorMessage message={erreur} />}
<div className={styles.modalActions}>
<Button variant="secondary" type="button" onClick={onAnnuler} disabled={enCours}>
Annuler
</Button>
<Button variant="primary" type="submit" disabled={enCours}>
{enCours ? 'Enregistrement…' : 'Enregistrer'}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,87 @@
/* Les appels API sont référencés via l'espace de noms et résolus au moment de
l'appel, et non capturés à l'import : la configuration ne fige donc pas une
implémentation, ce qui la découple du module d'API. */
import * as api from '../../services/api';
/* Description déclarative des 4 référentiels administrables.
Les colonnes du tableau, les champs du formulaire et les appels API sont
décrits ici plutôt que répétés onglet par onglet : les 4 onglets partagent
ainsi un seul tableau et un seul formulaire. */
export const REFERENTIELS = [
{
key: 'monitorings',
label: 'Monitorings',
singulier: 'monitoring',
idField: 'id_monito',
/* Désactivation logique : TABLE_FINAL référence le monitoring. */
suppressionEstDesactivation: true,
colonnes: [
{ key: 'monito_intitule', label: 'Nom' },
{ key: 'id_service', label: 'Service', referentiel: 'services' },
{ key: 'id_categorie', label: 'Catégorie', referentiel: 'categories' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'table_source', label: 'Table' },
],
champs: [
{ name: 'monito_intitule', label: 'Nom', type: 'text', required: true },
{ name: 'id_service', label: 'Service', type: 'select', options: 'services', required: true },
{ name: 'id_categorie', label: 'Catégorie', type: 'select', options: 'categories', required: true },
{ name: 'table_source', label: 'Table source', type: 'text', required: true },
{ name: 'bdd_source', label: 'Base source', type: 'text', required: true },
],
creer: body => api.createMonitoring(body),
modifier: (id, body) => api.updateMonitoring(id, body),
supprimer: id => api.desactiverMonitoring(id),
},
{
key: 'services',
label: 'Services',
singulier: 'service',
idField: 'id_service',
colonnes: [{ key: 'nom_service', label: 'Nom' }],
champs: [{ name: 'nom_service', label: 'Nom du service', type: 'text', required: true }],
creer: body => api.createService(body),
modifier: (id, body) => api.updateService(id, body),
supprimer: id => api.deleteService(id),
},
{
key: 'categories',
label: 'Catégories',
singulier: 'catégorie',
idField: 'id_categorie',
colonnes: [{ key: 'intitule_categorie', label: 'Nom' }],
champs: [{ name: 'intitule_categorie', label: 'Intitulé', type: 'text', required: true }],
creer: body => api.createCategorie(body),
modifier: (id, body) => api.updateCategorie(id, body),
supprimer: id => api.deleteCategorie(id),
},
{
key: 'contacts',
label: 'Contacts',
singulier: 'contact',
idField: 'id_contact',
colonnes: [
{ key: 'intitule_contact', label: 'Intitulé' },
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'mail', label: 'Email' },
{ key: 'id_service', label: 'Service', referentiel: 'services' },
],
champs: [
{ name: 'intitule_contact', label: 'Intitulé', type: 'text', required: true },
{ name: 'nom', label: 'Nom', type: 'text', required: true },
{ name: 'prenom', label: 'Prénom', type: 'text', required: true },
{ name: 'mail', label: 'Email', type: 'email', required: true },
{ name: 'id_service', label: 'Service', type: 'select', options: 'services', required: true },
],
creer: body => api.createContact(body),
modifier: (id, body) => api.updateContact(id, body),
supprimer: id => api.deleteContact(id),
},
];
/* Libellés des listes déroulantes et des colonnes qui affichent un identifiant. */
export const LIBELLES_REFERENTIEL = {
services: { idField: 'id_service', labelField: 'nom_service' },
categories: { idField: 'id_categorie', labelField: 'intitule_categorie' },
};
+60 -32
View File
@@ -27,14 +27,17 @@ export default function CGU() {
<h2 id="art1">Article 1 — Objet et champ d'application</h2> <h2 id="art1">Article 1 — Objet et champ d'application</h2>
<p> <p>
Les présentes Conditions Générales d'Utilisation (ci-après « CGU ») régissent l'accès et Les présentes Conditions Générales d'Utilisation (ci-après « CGU ») régissent l'accès et
l'utilisation de la plateforme <strong>Data Sentinel</strong>, outil interne de monitoring l'utilisation de la plateforme <strong>Data Sentinel</strong>, outil de monitoring de la
de la qualité des données développé par COYAUD Anthony pour le compte de <strong>XEFI SA</strong> qualité des données développé par <strong>COYAUD Anthony</strong> (ci-après « l'Éditeur »)
(ci-après « l'Éditeur »). dans le cadre de son projet annuel Nexa Digital School, en réponse à un besoin du service
Business Intelligence de <strong>XEFI SA</strong> (commanditaire).
</p> </p>
<p> <p>
Data Sentinel est une application web à usage professionnel interne, accessible uniquement Data Sentinel est une application web à usage professionnel interne. La présente instance
aux collaborateurs habilités de XEFI et de ses filiales. Toute utilisation de la plateforme est une <strong>démonstration académique</strong> : elle est accessible à des fins
implique l'acceptation pleine et entière des présentes CGU. d'évaluation au moyen de comptes de test et ne contient aucune donnée réelle
d'entreprise. Toute utilisation de la plateforme implique l'acceptation pleine et entière
des présentes CGU.
</p> </p>
</section> </section>
@@ -42,8 +45,9 @@ export default function CGU() {
<h2 id="art2">Article 2 — Accès au service</h2> <h2 id="art2">Article 2 — Accès au service</h2>
<p> <p>
L'accès à Data Sentinel est réservé aux utilisateurs disposant d'un compte valide créé L'accès à Data Sentinel est réservé aux utilisateurs disposant d'un compte valide créé
par un administrateur système XEFI. L'authentification s'effectue via un identifiant et par un administrateur de la plateforme. L'authentification s'effectue par identifiant et
un mot de passe fournis par l'entreprise. mot de passe, complétée par une vérification anti-robot. Les mots de passe ne sont jamais
stockés en clair.
</p> </p>
<ul className={styles.list}> <ul className={styles.list}>
<li>Chaque utilisateur est responsable de la confidentialité de ses identifiants.</li> <li>Chaque utilisateur est responsable de la confidentialité de ses identifiants.</li>
@@ -58,11 +62,17 @@ export default function CGU() {
<p>Data Sentinel offre les fonctionnalités suivantes :</p> <p>Data Sentinel offre les fonctionnalités suivantes :</p>
<ul className={styles.list}> <ul className={styles.list}>
<li><strong>Dashboard</strong> — Vue d'ensemble des indicateurs clés de qualité des données (KPI).</li> <li><strong>Dashboard</strong> — Vue d'ensemble des indicateurs clés de qualité des données (KPI).</li>
<li><strong>Monitoring</strong> — Suivi en temps réel des monitorings CRM, Sage et Infocentre.</li> <li><strong>Monitoring</strong> Suivi de 7 monitorings répartis sur deux pôles (Contrat, Fournisseur), alimentés par des extractions de Sage et du CRM.</li>
<li><strong>Vue consolidée</strong> Agrégation des résultats par service et catégorie.</li> <li><strong>Vue consolidée</strong> Agrégation des résultats par service et catégorie.</li>
<li><strong>Historique</strong> — Consultation et export des données historiques.</li> <li><strong>Historique</strong> Consultation et export des données historiques, à partir d'un instantané quotidien.</li>
<li><strong>Administration</strong> — Gestion des nomenclatures, utilisateurs et journaux.</li> <li><strong>Administration</strong> — Gestion des nomenclatures, utilisateurs et journaux.</li>
</ul> </ul>
<p>
Le service ne comporte <strong>ni vente, ni transaction, ni paiement en ligne</strong> :
aucune condition générale de vente n'est donc applicable. Il ne propose
<strong> aucun espace de contribution</strong> (commentaires, forum, avis), ce qui rend
sans objet toute politique de modération.
</p>
</section> </section>
<section className={styles.section} aria-labelledby="art4"> <section className={styles.section} aria-labelledby="art4">
@@ -80,33 +90,50 @@ export default function CGU() {
<section className={styles.section} aria-labelledby="art5"> <section className={styles.section} aria-labelledby="art5">
<h2 id="art5">Article 5 Protection des données personnelles (RGPD)</h2> <h2 id="art5">Article 5 Protection des données personnelles (RGPD)</h2>
<p> <p>
Conformément au Règlement Général sur la Protection des Données (UE 2016/679), XEFI SA Le traitement est conçu conformément au Règlement Général sur la Protection des Données
traite les données personnelles des utilisateurs dans le strict respect de la réglementation. (UE 2016/679). Les données personnelles traitées sont limitées au strict nécessaire pour
authentifier les utilisateurs et tracer les actions d'administration.
</p> </p>
<div className={styles.infoBox} role="note" aria-label="Informations RGPD"> <div className={styles.infoBox} role="note" aria-label="Informations RGPD">
<p><strong>Responsable du traitement :</strong> XEFI SA — 2507 Avenue de l'Europe, 69140 Rillieux-la-Pape</p> <p><strong>Responsable du traitement (instance de démonstration) :</strong> COYAUD Anthony — a.coyaud@xefi.fr</p>
<p><strong>Délégué à la Protection des Données (DPO) :</strong> dpo@xefi.fr</p> <p><strong>Données traitées :</strong> identifiant, e-mail, rôle, horodatage des connexions et des actions d'administration.</p>
<p><strong>Finalité :</strong> Authentification, traçabilité des actions, sécurité du système.</p> <p><strong>Finalité :</strong> Authentification, traçabilité des actions, sécurité du système.</p>
<p><strong>Durée de conservation :</strong> 24 mois à compter de la dernière connexion.</p> <p><strong>Base légale :</strong> intérêt légitime fiabilité des données et traçabilité des accès.</p>
<p><strong>Droits :</strong> Accès, rectification, effacement, portabilité à exercer auprès de dpo@xefi.fr.</p> <p><strong>Conservation :</strong> historique de monitoring purgé automatiquement au-delà de 24 mois. Les comptes et le journal d'audit sont conservés le temps de l'exploitation, puis supprimés avec l'instance.</p>
<p><strong>Droits :</strong> export de ses données et suppression de son compte directement dans l'application (portabilité art. 20, effacement art. 17 par désactivation et anonymisation). Pour toute autre demande accès, rectification, opposition : a.coyaud@xefi.fr.</p>
<p><strong>Transferts hors UE :</strong> aucun. <strong>Traceurs tiers :</strong> aucun.</p>
</div> </div>
<p>
Dans l'hypothèse d'un déploiement en production chez XEFI SA, la responsabilité du
traitement reviendrait à l'entreprise et une validation par son délégué à la protection
des données serait requise au préalable.
</p>
</section> </section>
<section className={styles.section} aria-labelledby="art6"> <section className={styles.section} aria-labelledby="art6">
<h2 id="art6">Article 6 — Accessibilité</h2> <h2 id="art6">Article 6 — Accessibilité</h2>
<p> <p>
XEFI s'engage à rendre Data Sentinel accessible conformément au Référentiel Général L'Éditeur s'est fixé pour <strong>objectif</strong> le niveau AA des WCAG 2.1, en
d'Amélioration de l'Accessibilité (RGAA). L'application respecte les principes WCAG 2.1 s'appuyant sur le Référentiel Général d'Amélioration de l'Accessibilité (RGAA).
niveau AA : perceptibilité, utilisabilité, compréhension et robustesse. Cet objectif est poursuivi par une démarche intégrée aux composants ; il ne fait pas
encore l'objet d'une déclaration de conformité, l'audit formel restant à mener.
</p> </p>
<p>Mesures effectivement en place :</p>
<ul className={styles.list}> <ul className={styles.list}>
<li>Navigation clavier intégrale supportée.</li> <li>Navigation clavier sur l'ensemble des contrôles ; les boîtes de dialogue retiennent le focus, se ferment par Échap et le restituent à l'élément appelant.</li>
<li>Compatibilité avec les lecteurs d'écran (ARIA labels, landmarks, roles).</li> <li>Attributs ARIA (labels, landmarks, rôles), langue déclarée, annonces des messages d'erreur et de chargement aux lecteurs d'écran.</li>
<li>Contrastes de couleurs conformes aux exigences WCAG 2.1 AA (ratio 4.5:1 minimum).</li> <li>Formulaires étiquetés ; la connexion est protégée par un captcha additif, sans image ni distorsion, donc restituable par un lecteur d'écran.</li>
<li>Aucune information portée par la seule couleur : les statuts sont doublés d'un libellé.</li>
<li>Interface responsive adaptée aux écrans de 320px à 1920px.</li> <li>Interface responsive adaptée aux écrans de 320px à 1920px.</li>
</ul> </ul>
<p>Points restant à traiter, énoncés par transparence :</p>
<ul className={styles.list}>
<li>Vérification systématique des contrastes (ratio 4.5:1) lors d'un audit dédié.</li>
<li>Tests sur lecteurs d'écran réels (NVDA, VoiceOver) non encore réalisés.</li>
<li>Aucune surcouche d'accessibilité n'est installée : ces widgets dégradent souvent la sémantique et la navigation clavier, l'accessibilité étant traitée à la source dans les composants.</li>
</ul>
<p> <p>
Pour signaler un problème d'accessibilité : <strong>dpo@xefi.fr</strong> Pour signaler un problème d'accessibilité : <strong>a.coyaud@xefi.fr</strong>
</p> </p>
</section> </section>
@@ -122,17 +149,18 @@ export default function CGU() {
<section className={styles.section} aria-labelledby="art8"> <section className={styles.section} aria-labelledby="art8">
<h2 id="art8">Article 8 Propriété intellectuelle</h2> <h2 id="art8">Article 8 Propriété intellectuelle</h2>
<p> <p>
L'ensemble des éléments composant Data Sentinel (code source, design, données, documentation) L'ensemble des éléments composant Data Sentinel (code source, design, documentation) a été
est la propriété exclusive de <strong>XEFI SA</strong>. Toute reproduction, modification réalisé par <strong>COYAUD Anthony</strong> dans le cadre de son alternance chez XEFI SA et
ou diffusion sans autorisation écrite préalable est interdite. de son projet annuel — Bachelor DBI, Nexa Digital School, 2026. Les règles de gestion et le
besoin métier appartiennent à XEFI SA.
</p> </p>
<p>Application développée par <strong>COYAUD Anthony</strong> Bachelor DBI, Nexa Digital School, 2026.</p> <p>Toute reproduction, modification ou diffusion sans autorisation préalable est interdite.</p>
</section> </section>
<section className={styles.section} aria-labelledby="art9"> <section className={styles.section} aria-labelledby="art9">
<h2 id="art9">Article 9 — Modification des CGU</h2> <h2 id="art9">Article 9 — Modification des CGU</h2>
<p> <p>
XEFI SA se réserve le droit de modifier les présentes CGU à tout moment. Les utilisateurs L'Éditeur se réserve le droit de modifier les présentes CGU à tout moment. Les utilisateurs
seront informés de toute modification substantielle. La poursuite de l'utilisation de la seront informés de toute modification substantielle. La poursuite de l'utilisation de la
plateforme après notification vaut acceptation des nouvelles conditions. plateforme après notification vaut acceptation des nouvelles conditions.
</p> </p>
@@ -142,15 +170,15 @@ export default function CGU() {
<h2 id="art10">Article 10 Droit applicable et contact</h2> <h2 id="art10">Article 10 Droit applicable et contact</h2>
<p>Les présentes CGU sont soumises au droit français. Pour toute question :</p> <p>Les présentes CGU sont soumises au droit français. Pour toute question :</p>
<div className={styles.infoBox} role="note" aria-label="Coordonnées de contact"> <div className={styles.infoBox} role="note" aria-label="Coordonnées de contact">
<p><strong>XEFI SA</strong></p> <p><strong>COYAUD Anthony</strong> Éditeur</p>
<p>2507 Avenue de l'Europe 69140 Rillieux-la-Pape</p> <p>Projet annuel Bachelor DBI Nexa Digital School, 2026</p>
<p>Email : dpo@xefi.fr</p> <p>Email : a.coyaud@xefi.fr</p>
</div> </div>
</section> </section>
</main> </main>
<footer className={styles.footer} role="contentinfo"> <footer className={styles.footer} role="contentinfo">
<p>Data Sentinel v1.0 © XEFI SA 2026</p> <p>Data Sentinel v1.0 COYAUD Anthony, Nexa Digital School 2026</p>
<nav aria-label="Liens légaux"> <nav aria-label="Liens légaux">
<a href="/mentions-legales">Mentions légales</a> <a href="/mentions-legales">Mentions légales</a>
</nav> </nav>
+41 -31
View File
@@ -4,20 +4,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api'; import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api';
import MonitoringCard from '../../components/common/MonitoringCard'; import MonitoringCard from '../../components/common/MonitoringCard';
import KpiCard from '../../components/common/KpiCard';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
/* Carte KPI affichant un indicateur clé */
function KpiCard({ label, value, color }) {
return (
<div className={styles.kpiCard}>
<span className={styles.kpiValue} style={{ color }}>{value}</span>
<span className={styles.kpiLabel}>{label}</span>
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [monitorings, setMonitorings] = useState([]); const [monitorings, setMonitorings] = useState([]);
@@ -27,6 +18,7 @@ export default function Dashboard() {
const [filtreCategorie, setFiltreCategorie] = useState(''); const [filtreCategorie, setFiltreCategorie] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
const [rafraichissement, setRafraichissement] = useState(0);
/* Chargement initial des filtres. /* Chargement initial des filtres.
On n'utilise pas les référentiels SERVICE / CATEGORIE complets : ils On n'utilise pas les référentiels SERVICE / CATEGORIE complets : ils
@@ -61,39 +53,57 @@ export default function Dashboard() {
/* Changer de service peut rendre la catégorie sélectionnée sans résultat : /* Changer de service peut rendre la catégorie sélectionnée sans résultat :
on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */ on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */
function handleServiceChange(service) { function handleServiceChange(service) {
setLoading(true);
setFiltreService(service); setFiltreService(service);
if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) { if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) {
setFiltreCategorie(''); setFiltreCategorie('');
} }
} }
/* Chargement des données dashboard */ function handleCategorieChange(categorie) {
const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setErreur(''); setFiltreCategorie(categorie);
try { }
const [sum, data] = await Promise.all([
getDashboardSummary(),
getDashboard(filtreService, filtreCategorie),
]);
setSummary(sum);
setMonitorings(Array.isArray(data) ? data : []);
} catch (e) {
setErreur(e.message || 'Erreur lors du chargement du dashboard.');
} finally {
setLoading(false);
}
}, [filtreService, filtreCategorie]);
/* Le bouton Actualiser relance l'effet en changeant cette valeur. */
function handleActualiser() {
setLoading(true);
setRafraichissement(compteur => compteur + 1);
}
/* Les données sont rechargées à chaque changement de filtre. L'indicateur de
chargement est allumé par les gestionnaires d'événement ci-dessus, jamais
dans l'effet : y appeler setState de façon synchrone provoquerait un rendu
en cascade. */
useEffect(() => { useEffect(() => {
loadData(); let annule = false;
}, [loadData]);
async function charger() {
try {
const [sum, data] = await Promise.all([
getDashboardSummary(),
getDashboard(filtreService, filtreCategorie),
]);
if (annule) return;
setSummary(sum);
setMonitorings(Array.isArray(data) ? data : []);
setErreur('');
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement du dashboard.');
} finally {
if (!annule) setLoading(false);
}
}
charger();
return () => { annule = true; };
}, [filtreService, filtreCategorie, rafraichissement]);
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<h1 className={styles.title}>Dashboard</h1> <h1 className={styles.title}>Dashboard</h1>
<button className={styles.refreshBtn} onClick={loadData} disabled={loading}> <button className={styles.refreshBtn} onClick={handleActualiser} disabled={loading}>
<RefreshCw size={14} className={loading ? styles.spinning : ''} /> <RefreshCw size={14} className={loading ? styles.spinning : ''} />
Actualiser Actualiser
</button> </button>
@@ -140,7 +150,7 @@ export default function Dashboard() {
<select <select
className={styles.select} className={styles.select}
value={filtreCategorie} value={filtreCategorie}
onChange={e => setFiltreCategorie(e.target.value)} onChange={e => handleCategorieChange(e.target.value)}
> >
<option value="">Toutes les catégories</option> <option value="">Toutes les catégories</option>
{categories.map(c => ( {categories.map(c => (
@@ -153,7 +163,7 @@ export default function Dashboard() {
{loading ? ( {loading ? (
<LoadingSpinner message="Chargement du dashboard..." /> <LoadingSpinner message="Chargement du dashboard..." />
) : erreur ? ( ) : erreur ? (
<ErrorMessage message={erreur} onRetry={loadData} /> <ErrorMessage message={erreur} onRetry={handleActualiser} />
) : ( ) : (
<> <>
{monitorings.length === 0 ? ( {monitorings.length === 0 ? (
@@ -55,29 +55,6 @@
margin-bottom: 32px; margin-bottom: 32px;
} }
.kpiCard {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: var(--color-white);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.kpiValue {
font-size: 36px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
}
.kpiLabel {
font-size: 12px;
color: var(--color-muted, #888);
text-align: center;
}
.filtres { .filtres {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -131,10 +108,6 @@
gap: 10px; gap: 10px;
} }
.kpiValue {
font-size: 28px;
}
.filtres { .filtres {
flex-direction: column; flex-direction: column;
} }
@@ -1,6 +1,6 @@
// src/pages/Documentation/Documentation.jsx // src/pages/Documentation/Documentation.jsx
// Page documentation technique de Data Sentinel // Page documentation technique de Data Sentinel
import { ExternalLink, Book, Code, Database, Shield } from 'lucide-react'; import { Book, Code, Database, Shield } from 'lucide-react';
import styles from './Documentation.module.css'; import styles from './Documentation.module.css';
const sections = [ const sections = [
@@ -18,29 +18,29 @@ const sections = [
icon: Code, icon: Code,
title: 'Architecture technique', title: 'Architecture technique',
items: [ items: [
{ label: 'Frontend — React 18 + Vite', desc: 'SPA React 18 avec CSS Modules, React Router v7, Recharts. Build Vite.' }, { label: 'Frontend — React 19 + Vite', desc: 'SPA React 19 avec CSS Modules, React Router v7, Recharts. Build Vite.' },
{ label: 'Backend — API REST', desc: 'API REST avec authentification JWT. Endpoints documentés via Swagger.' }, { label: 'Backend — API REST', desc: 'API REST FastAPI avec authentification JWT. Endpoints documentés via Swagger.' },
{ label: 'Base de données — SQL Server', desc: 'SQL Server avec vues SQL optimisées et jobs CRON pour les agrégations.' }, { label: 'Base de données — SQL Server', desc: 'SQL Server 2022 avec vues SQL d\'agrégation et jobs SQL Server Agent pour l\'historisation quotidienne.' },
{ label: 'Infrastructure — NEXERN (XEFI)', desc: 'Hébergement souverain français sur les datacenters NEXERN de XEFI.' }, { label: 'Infrastructure — Docker', desc: 'Conteneurs Docker derrière un reverse proxy HTTPS. Cible métier : datacenters souverains NEXERN de XEFI.' },
], ],
}, },
{ {
icon: Database, icon: Database,
title: 'Monitorings disponibles', title: 'Monitorings disponibles',
items: [ items: [
{ label: 'CRM — Données clients et prospects', desc: 'Surveillance de la cohérence et de la complétude des données CRM.' }, { label: 'Contrat — 5 monitorings', desc: 'Tiers-payeurs, prélèvements sans RIB, TVA DOM-TOM, zone de ventes nulle, modes de règlement multiples.' },
{ label: 'Sage — Données comptables et financières', desc: 'Contrôle qualité des flux comptables issus de Sage.' }, { label: 'Fournisseur — 2 monitorings', desc: 'Écritures sans SIRET et écritures sans compte de contrepartie.' },
{ label: 'Infocentre — Reporting décisionnel', desc: 'Validation des données alimentant les rapports de direction.' }, { label: 'Sources — Sage et CRM en V1', desc: 'Le périmètre V1 couvre Sage (6 monitorings) et le CRM (1). L\'Infocentre fait partie du périmètre cible.' },
], ],
}, },
{ {
icon: Shield, icon: Shield,
title: 'Sécurité et conformité', title: 'Sécurité et conformité',
items: [ items: [
{ label: 'Authentification JWT', desc: 'Token JWT stocké en sessionStorage, expiration automatique.' }, { label: 'Authentification JWT', desc: 'Token JWT (HS256) stocké en localStorage, expiration 60 min sans refresh token.' },
{ label: 'Contrôle d\'accès RBAC', desc: 'Rôles : Administrateur, Superviseur, Consultant. Accès aux fonctions selon le rôle.' }, { label: 'Contrôle d\'accès RBAC', desc: 'Rôles : Admin, Superviseur, Consultant. Accès aux fonctions selon le rôle.' },
{ label: 'Conformité RGPD', desc: 'Conservation 24 mois, DPO joignable sur dpo@xefi.fr, droits exercés à la demande.' }, { label: 'Conformité RGPD', desc: 'Historique conservé 24 mois, export et suppression de son compte en libre-service, droits exercés à la demande.' },
{ label: 'Accessibilité RGAA / WCAG 2.1 AA', desc: 'Navigation clavier, ARIA, contrastes conformes, responsive 320px1920px.' }, { label: 'Accessibilité — objectif WCAG 2.1 AA', desc: 'Navigation clavier, ARIA, responsive 320px1920px. Audit formel des contrastes à finaliser.' },
], ],
}, },
]; ];
@@ -8,27 +8,9 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { periodeParDefaut } from '../../utils/dates';
import styles from './Historique.module.css'; import styles from './Historique.module.css';
/* Formate une date au format attendu par <input type="date"> (YYYY-MM-DD).
On lit les composantes en heure locale plutôt que d'utiliser toISOString(),
qui bascule en UTC : un 1er janvier saisi depuis Paris (UTC+1) y devient le
31 décembre de l'année précédente. */
function formatDateISO(date) {
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mois}-${jour}`;
}
/* Période affichée par défaut : du 1er janvier de l'année courante à aujourd'hui. */
function periodeParDefaut() {
const aujourdhui = new Date();
return {
debut: formatDateISO(new Date(aujourdhui.getFullYear(), 0, 1)),
fin: formatDateISO(aujourdhui),
};
}
/* Récupère le tableau d'historique et la série du graphique pour les filtres /* Récupère le tableau d'historique et la série du graphique pour les filtres
donnés. Sans monitoring sélectionné, on prend la courbe globale. donnés. Sans monitoring sélectionné, on prend la courbe globale.
Fonction pure : elle ne touche à aucun état, ce qui permet de l'appeler Fonction pure : elle ne touche à aucun état, ce qui permet de l'appeler
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import styles from './Login.module.css'; import styles from './Login.module.css';
/* Génère deux entiers aléatoires pour le captcha additif */ /* Génère deux entiers aléatoires pour le captcha additif */
@@ -24,47 +24,82 @@ export default function MentionsLegales() {
<section className={styles.section}> <section className={styles.section}>
<h2>Éditeur</h2> <h2>Éditeur</h2>
<p><strong>XEFI SA</strong></p> <p><strong>COYAUD Anthony</strong></p>
<p>2507 Avenue de l'Europe</p> <p>Projet annuel Bachelor Data &amp; Business Intelligence</p>
<p>69140 Rillieux-la-Pape</p> <p>Nexa Digital School, 2026</p>
<p>dpo@xefi.fr</p> <p>a.coyaud@xefi.fr</p>
</section>
<section className={styles.section}>
<h2>Commanditaire</h2>
<p><strong>XEFI SA</strong> 2507 Avenue de l'Europe, 69140 Rillieux-la-Pape</p>
<p>
Le besoin métier et les règles de gestion sont ceux du service Business Intelligence
de XEFI. XEFI SA n'édite ni n'exploite la présente instance de démonstration.
</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Hébergeur</h2> <h2>Hébergeur</h2>
<p><strong>XEFI Infrastructure NEXERN</strong></p> <p><strong>Environnement de démonstration auto-hébergé</strong></p>
<p>Souveraineté française</p> <p>
Conteneurs Docker exposés en HTTPS via un reverse proxy, sur un serveur administré
par l'auteur du projet. Cible métier d'un déploiement réel : les datacenters
souverains NEXERN de XEFI.
</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Accès & Utilisation</h2> <h2>Accès &amp; Utilisation</h2>
<p>Usage interne exclusif aux collaborateurs habilités.</p> <p>
Application destinée à un usage professionnel interne. La présente instance est une
démonstration accessible à des fins d'évaluation académique, au moyen de comptes de
test ; elle ne contient aucune donnée réelle d'entreprise.
</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Données personnelles RGPD (UE 2016/679)</h2> <h2>Données personnelles RGPD (UE 2016/679)</h2>
<p>Conservation des données : 24 mois</p> <p>Données traitées : compte utilisateur (identifiant, e-mail, rôle) et journal des connexions.</p>
<p>Contact DPO : dpo@xefi.fr</p> <p>Conservation de l'historique de monitoring : 24 mois, par purge automatique en base.</p>
<p>
Droits exerçables directement dans l'application : export de ses données
(portabilité, art. 20) et suppression de son compte (effacement, art. 17, par
désactivation et anonymisation).
</p>
<p>Contact : a.coyaud@xefi.fr</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Cookies</h2> <h2>Cookies</h2>
<p>Session JWT uniquement, aucun tiers, bandeau de consentement.</p> <p>
Aucun cookie n'est déposé et aucun traceur tiers n'est utilisé. La session et le
choix de consentement sont conservés dans le stockage local du navigateur
(<code>localStorage</code>), effaçable à tout moment depuis les réglages du navigateur
ou par déconnexion.
</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Propriété intellectuelle</h2> <h2>Propriété intellectuelle</h2>
<p>© XEFI SA 2026, développé par COYAUD Anthony</p> <p>
Code source, design et documentation réalisés par <strong>COYAUD Anthony</strong>
{' '}dans le cadre de son alternance chez XEFI et de son projet annuel Nexa Digital
School (2026).
</p>
</section> </section>
<section className={styles.section}> <section className={styles.section}>
<h2>Responsabilité</h2> <h2>Responsabilité</h2>
<p>Données anonymisées pour rendu académique.</p> <p>
Jeu de données entièrement fictif, produit pour la démonstration. Aucune donnée
réelle de client, de fournisseur ou de collaborateur n'est exposée.
</p>
</section> </section>
</div> </div>
<footer className={styles.footer}> <footer className={styles.footer}>
<p>Data Sentinel v1.0 © XEFI SA 2026</p> <p>Data Sentinel v1.0 COYAUD Anthony, Nexa Digital School 2026</p>
</footer> </footer>
</div> </div>
</div> </div>
@@ -9,15 +9,9 @@ import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Badge from '../../components/common/Badge'; import Badge from '../../components/common/Badge';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import styles from './MonitoringDetail.module.css'; import styles from './MonitoringDetail.module.css';
/* Détermine le statut selon le nombre d'erreurs */
function getStatut(nbErreurs) {
if (nbErreurs === 0) return { label: 'OK', variant: 'ok' };
if (nbErreurs < 10) return { label: 'Attention', variant: 'warning' };
return { label: 'Critique', variant: 'critical' };
}
export default function MonitoringDetail() { export default function MonitoringDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -72,7 +66,7 @@ export default function MonitoringDetail() {
if (loading) return <LoadingSpinner message="Chargement du monitoring..." />; if (loading) return <LoadingSpinner message="Chargement du monitoring..." />;
if (erreur) return <ErrorMessage message={erreur} />; if (erreur) return <ErrorMessage message={erreur} />;
const statut = getStatut(count); const statut = getStatutMonitoring(count);
return ( return (
<div className={styles.page}> <div className={styles.page}>
@@ -7,6 +7,7 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { buildMonitoringColumns } from '../../utils/monitoringColumns';
import styles from './ServiceMonitorings.module.css'; import styles from './ServiceMonitorings.module.css';
export default function ServiceMonitorings({ serviceLabel, serviceName }) { export default function ServiceMonitorings({ serviceLabel, serviceName }) {
@@ -34,22 +35,7 @@ export default function ServiceMonitorings({ serviceLabel, serviceName }) {
if (loading) return <LoadingSpinner message={`Chargement des monitorings ${serviceLabel.toLowerCase()}...`} />; if (loading) return <LoadingSpinner message={`Chargement des monitorings ${serviceLabel.toLowerCase()}...`} />;
if (erreur) return <ErrorMessage message={erreur} />; if (erreur) return <ErrorMessage message={erreur} />;
const columns = [ const columns = buildMonitoringColumns(navigate);
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
return ( return (
<div className={styles.page}> <div className={styles.page}>
+18 -2
View File
@@ -43,9 +43,25 @@ export default function Users() {
} }
}, []); }, []);
/* Chargement initial. Le drapeau `annule` évite d'écrire dans un composant
déjà démonté si l'utilisateur quitte la page pendant la requête. */
useEffect(() => { useEffect(() => {
charger(); let annule = false;
}, [charger]);
async function chargerAuMontage() {
try {
const data = await getUsers();
if (!annule) setUsers(Array.isArray(data) ? data : []);
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement des utilisateurs.');
} finally {
if (!annule) setLoading(false);
}
}
chargerAuMontage();
return () => { annule = true; };
}, []);
/* Ouverture des modales */ /* Ouverture des modales */
function ouvrirCreation() { function ouvrirCreation() {
+4 -25
View File
@@ -9,15 +9,10 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import { buildMonitoringColumns } from '../../utils/monitoringColumns';
import styles from './VueConso.module.css'; import styles from './VueConso.module.css';
/* Détermine la couleur selon le statut */
function getStatusColor(nbErreurs) {
if (nbErreurs === 0) return 'var(--color-ok)';
if (nbErreurs < 10) return 'var(--color-warning)';
return 'var(--color-primary)';
}
export default function VueConso() { export default function VueConso() {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState([]); const [data, setData] = useState([]);
@@ -47,26 +42,10 @@ export default function VueConso() {
const chartData = data.map(item => ({ const chartData = data.map(item => ({
name: item.nom || item.nom_monito || item.libelle || 'Monitoring', name: item.nom || item.nom_monito || item.libelle || 'Monitoring',
erreurs: item.nb_erreurs ?? item.count ?? 0, erreurs: item.nb_erreurs ?? item.count ?? 0,
fill: getStatusColor(item.nb_erreurs ?? item.count ?? 0) fill: getStatutMonitoring(item.nb_erreurs ?? item.count ?? 0).color
})); }));
/* Colonnes du tableau */ const columns = buildMonitoringColumns(navigate);
const columns = [
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
return ( return (
<div className={styles.page}> <div className={styles.page}>
+56 -2
View File
@@ -3,9 +3,11 @@
/* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */ /* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
/* Clés de stockage local pour le token JWT et l'utilisateur connecté */ /* Clés de stockage local pour le token JWT et l'utilisateur connecté.
USER_KEY est exporté : AuthContext lit/écrit la même entrée, et une clé
dupliquée des deux côtés divergerait silencieusement au premier renommage. */
const TOKEN_KEY = 'ds_token'; const TOKEN_KEY = 'ds_token';
const USER_KEY = 'ds_user'; export const USER_KEY = 'ds_user';
/* Helpers de gestion du token JWT dans le localStorage */ /* Helpers de gestion du token JWT dans le localStorage */
export function getToken() { export function getToken() {
@@ -188,6 +190,58 @@ export async function getContacts(id_service) {
return fetchApi('/contacts', { id_service }); return fetchApi('/contacts', { id_service });
} }
/* ---------- Administration du référentiel (réservé Admin) ---------- */
export async function createService(body) {
return sendJson('/services', 'POST', body);
}
export async function updateService(id_service, body) {
return sendJson(`/services/${id_service}`, 'PUT', body);
}
export async function deleteService(id_service) {
return sendJson(`/services/${id_service}`, 'DELETE');
}
export async function createCategorie(body) {
return sendJson('/categories', 'POST', body);
}
export async function updateCategorie(id_categorie, body) {
return sendJson(`/categories/${id_categorie}`, 'PUT', body);
}
export async function deleteCategorie(id_categorie) {
return sendJson(`/categories/${id_categorie}`, 'DELETE');
}
export async function createContact(body) {
return sendJson('/contacts', 'POST', body);
}
export async function updateContact(id_contact, body) {
return sendJson(`/contacts/${id_contact}`, 'PUT', body);
}
export async function deleteContact(id_contact) {
return sendJson(`/contacts/${id_contact}`, 'DELETE');
}
export async function createMonitoring(body) {
return sendJson('/monitorings', 'POST', body);
}
export async function updateMonitoring(id_monito, body) {
return sendJson(`/monitorings/${id_monito}`, 'PUT', body);
}
/* Désactivation (actif = 0) : l'historique référence le monitoring,
il n'est jamais supprimé physiquement. */
export async function desactiverMonitoring(id_monito) {
return sendJson(`/monitorings/${id_monito}`, 'DELETE');
}
/* Récupère la nomenclature (monitorings), filtrable par service et catégorie */ /* Récupère la nomenclature (monitorings), filtrable par service et catégorie */
export async function getMonitorings(id_service, id_categorie) { export async function getMonitorings(id_service, id_categorie) {
const data = await fetchApi('/monitorings', { id_service, id_categorie }); const data = await fetchApi('/monitorings', { id_service, id_categorie });
+17
View File
@@ -0,0 +1,17 @@
/* Formate une date au format attendu par <input type="date"> (YYYY-MM-DD).
On lit les composantes en heure locale plutôt que d'utiliser toISOString(),
qui bascule en UTC : un 1er janvier saisi depuis Paris (UTC+1) y devient le
31 décembre de l'année précédente. */
export function formatDateISO(date) {
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mois}-${jour}`;
}
/* Période affichée par défaut : du 1er janvier de l'année courante à aujourd'hui. */
export function periodeParDefaut(aujourdhui = new Date()) {
return {
debut: formatDateISO(new Date(aujourdhui.getFullYear(), 0, 1)),
fin: formatDateISO(aujourdhui),
};
}
@@ -0,0 +1,24 @@
import Button from '../components/common/Button';
/* Colonnes du tableau de monitorings partagées par VueConso et
ServiceMonitorings (Contrat/Fournisseur) : même jeu de données
(GET /dashboard), même tableau. Centralisé pour qu'une évolution des
colonnes (ajout, renommage) ne soit faite qu'à un seul endroit. */
export function buildMonitoringColumns(navigate) {
return [
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
}
@@ -0,0 +1,16 @@
/* Règle de statut partagée : un monitoring est "OK" sans erreur, "Attention"
en-dessous du seuil, "Critique" au-delà. Centralisé ici pour que le seuil
ne soit corrigé qu'à un seul endroit (il était dupliqué dans MonitoringCard,
MonitoringDetail et VueConso, avec le risque que l'une des copies diverge). */
export const SEUIL_ATTENTION = 10;
export function getStatutMonitoring(nbErreurs) {
if (nbErreurs === 0) {
return { label: 'OK', variant: 'ok', color: 'var(--color-ok)' };
}
if (nbErreurs < SEUIL_ATTENTION) {
return { label: 'Attention', variant: 'warning', color: 'var(--color-warning)' };
}
return { label: 'Critique', variant: 'critical', color: 'var(--color-primary)' };
}
+6
View File
@@ -6,6 +6,12 @@ export default defineConfig({
plugins: [ plugins: [
react(), react(),
], ],
build: {
// Cible de compilation déclarée explicitement plutôt que laissée au défaut de
// Vite : la matrice de compatibilité annoncée dans le README est ainsi tracée
// dans le dépôt et ne bougera pas si Vite change sa baseline par défaut.
target: ['chrome111', 'edge111', 'firefox114', 'safari16.4', 'ios16.4'],
},
test: { test: {
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,