diff --git a/DataSentinel/src/App.jsx b/DataSentinel/src/App.jsx index 24306f8..94b658e 100644 --- a/DataSentinel/src/App.jsx +++ b/DataSentinel/src/App.jsx @@ -1,7 +1,8 @@ // src/App.jsx // Configuration du routing React Router v6 avec protection des routes 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 Login from './pages/Login/Login'; import Dashboard from './pages/Dashboard/Dashboard'; diff --git a/DataSentinel/src/__tests__/AuthContext.test.jsx b/DataSentinel/src/__tests__/AuthContext.test.jsx index 439cba2..5c596f4 100644 --- a/DataSentinel/src/__tests__/AuthContext.test.jsx +++ b/DataSentinel/src/__tests__/AuthContext.test.jsx @@ -1,7 +1,8 @@ /* Tests du contexte d'authentification : login / logout */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; 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'; describe('AuthContext', () => { diff --git a/DataSentinel/src/components/Layout/Header.jsx b/DataSentinel/src/components/Layout/Header.jsx index 3692e77..72c538d 100644 --- a/DataSentinel/src/components/Layout/Header.jsx +++ b/DataSentinel/src/components/Layout/Header.jsx @@ -1,7 +1,7 @@ /* En-tête fixe de l'application Data Sentinel */ import { Bell, LogOut, Menu } from 'lucide-react'; -import { useAuth } from '../../context/AuthContext'; +import { useAuth } from '../../context/useAuth'; import { useNavigate } from 'react-router-dom'; import styles from './Header.module.css'; diff --git a/DataSentinel/src/components/Layout/Sidebar.jsx b/DataSentinel/src/components/Layout/Sidebar.jsx index 082bd2d..8b6fba9 100644 --- a/DataSentinel/src/components/Layout/Sidebar.jsx +++ b/DataSentinel/src/components/Layout/Sidebar.jsx @@ -13,7 +13,7 @@ import { FileText, ScrollText, } from 'lucide-react'; -import { useAuth } from '../../context/AuthContext'; +import { useAuth } from '../../context/useAuth'; import styles from './Sidebar.module.css'; /* Un lien de navigation avec icône */ diff --git a/DataSentinel/src/components/common/KpiCard.jsx b/DataSentinel/src/components/common/KpiCard.jsx new file mode 100644 index 0000000..c12f4f1 --- /dev/null +++ b/DataSentinel/src/components/common/KpiCard.jsx @@ -0,0 +1,10 @@ +import styles from './KpiCard.module.css'; + +export default function KpiCard({ label, value, color }) { + return ( +
+ {value} + {label} +
+ ); +} diff --git a/DataSentinel/src/components/common/KpiCard.module.css b/DataSentinel/src/components/common/KpiCard.module.css new file mode 100644 index 0000000..cf8ef75 --- /dev/null +++ b/DataSentinel/src/components/common/KpiCard.module.css @@ -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; + } +} diff --git a/DataSentinel/src/components/common/Modal.jsx b/DataSentinel/src/components/common/Modal.jsx new file mode 100644 index 0000000..bb47a8a --- /dev/null +++ b/DataSentinel/src/components/common/Modal.jsx @@ -0,0 +1,19 @@ +import styles from './Modal.module.css'; + +export default function Modal({ isOpen, onClose, title, children }) { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+ {children} +
+
+
+ ); +} diff --git a/DataSentinel/src/components/common/Modal.module.css b/DataSentinel/src/components/common/Modal.module.css new file mode 100644 index 0000000..72d0cac --- /dev/null +++ b/DataSentinel/src/components/common/Modal.module.css @@ -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; +} diff --git a/DataSentinel/src/components/common/MonitoringCard.jsx b/DataSentinel/src/components/common/MonitoringCard.jsx index 2c43a36..d322a7f 100644 --- a/DataSentinel/src/components/common/MonitoringCard.jsx +++ b/DataSentinel/src/components/common/MonitoringCard.jsx @@ -2,20 +2,14 @@ import { useNavigate } from 'react-router-dom'; import Badge from './Badge'; +import { getStatutMonitoring } from '../../utils/statutMonitoring'; 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 }) { const navigate = useNavigate(); const nbErreurs = monitoring.nb_erreurs ?? monitoring.count ?? 0; 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%) */ const progressPct = Math.min((nbErreurs / 20) * 100, 100); diff --git a/DataSentinel/src/context/AuthContext.jsx b/DataSentinel/src/context/AuthContext.jsx index 931718e..dc3b5aa 100644 --- a/DataSentinel/src/context/AuthContext.jsx +++ b/DataSentinel/src/context/AuthContext.jsx @@ -1,17 +1,15 @@ /* 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'; - -const AuthContext = createContext(null); - -const STORAGE_KEY = 'ds_user'; +import { USER_KEY } from '../services/api'; +import { AuthContext } from './authContextObject'; /* Fournisseur du contexte d'authentification */ export function AuthProvider({ children }) { const [user, setUser] = useState(() => { /* 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; }); @@ -19,7 +17,7 @@ export function AuthProvider({ children }) { async function login(username, password) { const connectedUser = await api.login(username, password); setUser(connectedUser); - localStorage.setItem(STORAGE_KEY, JSON.stringify(connectedUser)); + localStorage.setItem(USER_KEY, JSON.stringify(connectedUser)); return connectedUser; } @@ -27,7 +25,7 @@ export function AuthProvider({ children }) { function logout() { api.clearToken(); setUser(null); - localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(USER_KEY); } /* 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; -} diff --git a/DataSentinel/src/context/authContextObject.js b/DataSentinel/src/context/authContextObject.js new file mode 100644 index 0000000..b1cba8b --- /dev/null +++ b/DataSentinel/src/context/authContextObject.js @@ -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); diff --git a/DataSentinel/src/context/useAuth.js b/DataSentinel/src/context/useAuth.js new file mode 100644 index 0000000..429359d --- /dev/null +++ b/DataSentinel/src/context/useAuth.js @@ -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; +} diff --git a/DataSentinel/src/hooks/useApi.js b/DataSentinel/src/hooks/useApi.js deleted file mode 100644 index 7239bb7..0000000 --- a/DataSentinel/src/hooks/useApi.js +++ /dev/null @@ -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 }; -} diff --git a/DataSentinel/src/pages/Admin/Admin.jsx b/DataSentinel/src/pages/Admin/Admin.jsx index 1f8ab4b..cd0d389 100644 --- a/DataSentinel/src/pages/Admin/Admin.jsx +++ b/DataSentinel/src/pages/Admin/Admin.jsx @@ -1,230 +1,187 @@ // src/pages/Admin/Admin.jsx -// Page Administration : onglets pour gérer monitorings, services, catégories, contacts -import { useState, useEffect } from 'react'; +// Page Administration : CRUD sur les référentiels (monitorings, services, +// 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 { getMonitorings, getServices, getCategories, getContacts } from '../../services/api'; import DataTable from '../../components/common/DataTable'; import LoadingSpinner from '../../components/common/LoadingSpinner'; import ErrorMessage from '../../components/common/ErrorMessage'; 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'; -/* Modale générique pour ajouter/modifier */ -function Modal({ isOpen, onClose, title, children }) { - if (!isOpen) return null; - - return ( -
-
e.stopPropagation()}> -
-

{title}

- -
-
- {children} -
-
-
- ); +async function chargerReferentiels() { + const [monitorings, services, categories, contacts] = await Promise.all([ + getMonitorings(), + getServices(), + getCategories(), + getContacts(), + ]); + return { + monitorings: Array.isArray(monitorings) ? monitorings : [], + services: Array.isArray(services) ? services : [], + categories: Array.isArray(categories) ? categories : [], + contacts: Array.isArray(contacts) ? contacts : [], + }; } export default function Admin() { - const [activeTab, setActiveTab] = useState('monitorings'); - const [data, setData] = useState({}); + const [ongletActif, setOngletActif] = useState(REFERENTIELS[0].key); + const [donnees, setDonnees] = useState({}); const [loading, setLoading] = useState(true); const [erreur, setErreur] = useState(''); - const [modalOpen, setModalOpen] = useState(false); - const [editingItem, setEditingItem] = useState(null); + const [modaleOuverte, setModaleOuverte] = useState(false); + const [elementEnEdition, setElementEnEdition] = useState(null); - useEffect(() => { - async function loadData() { - setLoading(true); - setErreur(''); - try { - const [monitorings, services, categories, contacts] = await Promise.all([ - 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); - } + const recharger = useCallback(async () => { + setErreur(''); + try { + setDonnees(await chargerReferentiels()); + } catch (e) { + setErreur(e.message || 'Erreur lors du chargement des données administratives.'); } - loadData(); }, []); - function handleAdd() { - setEditingItem(null); - setModalOpen(true); + useEffect(() => { + let annule = false; + 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) { - setEditingItem(item); - setModalOpen(true); + function ouvrirEdition(element) { + setElementEnEdition(element); + setModaleOuverte(true); } - function handleDelete(item) { - // Simulation de suppression (pas d'API pour ça) - console.log('Supprimer', item); + async function handleEnregistrer(corps) { + if (elementEnEdition) { + await referentiel.modifier(elementEnEdition[referentiel.idField], corps); + } else { + await referentiel.creer(corps); + } + setModaleOuverte(false); + await recharger(); } - const tabs = [ - { key: 'monitorings', label: 'Monitorings' }, - { key: 'services', label: 'Services' }, - { key: 'categories', label: 'Catégories' }, - { key: 'contacts', label: 'Contacts' } + async function handleSupprimer(element) { + const action = referentiel.suppressionEstDesactivation ? 'Désactiver' : 'Supprimer'; + const nom = element[referentiel.colonnes[0].key]; + if (!window.confirm(`${action} « ${nom} » ?`)) return; + + 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) => ( +
+ + +
+ ), + }, ]; - 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) => ( -
- - -
- ) - } - ], - services: [ - { key: 'nom_service', label: 'Nom' }, - { - key: 'actions', - label: 'Actions', - render: (val, row) => ( -
- - -
- ) - } - ], - categories: [ - { key: 'intitule_categorie', label: 'Nom' }, - { - key: 'actions', - label: 'Actions', - render: (val, row) => ( -
- - -
- ) - } - ], - 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) => ( -
- - -
- ) - } - ] - }; - if (loading) return ; - if (erreur) return ; return (

Administration

- {/* Onglets */}
- {tabs.map(tab => ( + {REFERENTIELS.map(item => ( ))}
- {/* Contenu de l'onglet actif */} + {erreur && } +
-

{tabs.find(t => t.key === activeTab)?.label}

-
- {/* Modale */} setModalOpen(false)} - title={`${editingItem ? 'Modifier' : 'Ajouter'} ${tabs.find(t => t.key === activeTab)?.label.slice(0, -1)}`} + isOpen={modaleOuverte} + onClose={() => setModaleOuverte(false)} + title={`${elementEnEdition ? 'Modifier' : 'Ajouter'} ${referentiel.singulier}`} > -

Formulaire à implémenter selon les besoins.

-
- - -
+ setModaleOuverte(false)} + />
); -} \ No newline at end of file +} diff --git a/DataSentinel/src/pages/Admin/Admin.module.css b/DataSentinel/src/pages/Admin/Admin.module.css index e441f23..d63f429 100644 --- a/DataSentinel/src/pages/Admin/Admin.module.css +++ b/DataSentinel/src/pages/Admin/Admin.module.css @@ -83,55 +83,33 @@ color: var(--color-primary); } -/* Modale */ -.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; +/* Formulaire de la modale (le conteneur vit dans Modal.module.css) */ +.field { + margin-bottom: 16px; } -.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; +.label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; color: var(--color-text); } -.closeBtn { - background: none; - border: none; - font-size: 24px; - color: var(--color-text-secondary); - cursor: pointer; +.input { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-gray-border); + border-radius: var(--radius); + font-size: 14px; + font-family: inherit; + color: var(--color-text); + background-color: var(--color-white); } -.modalBody { - padding: 24px; +.input:focus { + outline: 2px solid var(--color-primary); + outline-offset: -1px; } .modalActions { diff --git a/DataSentinel/src/pages/Admin/ReferentielForm.jsx b/DataSentinel/src/pages/Admin/ReferentielForm.jsx new file mode 100644 index 0000000..bc2c3fd --- /dev/null +++ b/DataSentinel/src/pages/Admin/ReferentielForm.jsx @@ -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 handleChange(champ.name, e.target.value)} + required={champ.required} + > + + {(donnees[champ.options] || []).map(option => { + const { idField, labelField } = LIBELLES_REFERENTIEL[champ.options]; + return ( + + ); + })} + + ) : ( + handleChange(champ.name, e.target.value)} + required={champ.required} + /> + )} + + ))} + + {erreur && } + +
+ + +
+ + ); +} diff --git a/DataSentinel/src/pages/Admin/referentielsConfig.js b/DataSentinel/src/pages/Admin/referentielsConfig.js new file mode 100644 index 0000000..042e1af --- /dev/null +++ b/DataSentinel/src/pages/Admin/referentielsConfig.js @@ -0,0 +1,89 @@ +import { + createService, updateService, deleteService, + createCategorie, updateCategorie, deleteCategorie, + createContact, updateContact, deleteContact, + createMonitoring, updateMonitoring, desactiverMonitoring, +} 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: createMonitoring, + modifier: updateMonitoring, + supprimer: desactiverMonitoring, + }, + { + 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: createService, + modifier: updateService, + supprimer: deleteService, + }, + { + 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: createCategorie, + modifier: updateCategorie, + supprimer: deleteCategorie, + }, + { + 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: createContact, + modifier: updateContact, + supprimer: deleteContact, + }, +]; + +/* 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' }, +}; diff --git a/DataSentinel/src/pages/Dashboard/Dashboard.jsx b/DataSentinel/src/pages/Dashboard/Dashboard.jsx index 79c30e2..ae98b6c 100644 --- a/DataSentinel/src/pages/Dashboard/Dashboard.jsx +++ b/DataSentinel/src/pages/Dashboard/Dashboard.jsx @@ -4,20 +4,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { RefreshCw } from 'lucide-react'; import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api'; import MonitoringCard from '../../components/common/MonitoringCard'; +import KpiCard from '../../components/common/KpiCard'; import LoadingSpinner from '../../components/common/LoadingSpinner'; import ErrorMessage from '../../components/common/ErrorMessage'; import styles from './Dashboard.module.css'; -/* Carte KPI affichant un indicateur clé */ -function KpiCard({ label, value, color }) { - return ( -
- {value} - {label} -
- ); -} - export default function Dashboard() { const [summary, setSummary] = useState(null); const [monitorings, setMonitorings] = useState([]); @@ -27,6 +18,7 @@ export default function Dashboard() { const [filtreCategorie, setFiltreCategorie] = useState(''); const [loading, setLoading] = useState(true); const [erreur, setErreur] = useState(''); + const [rafraichissement, setRafraichissement] = useState(0); /* Chargement initial des filtres. 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 : on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */ function handleServiceChange(service) { + setLoading(true); setFiltreService(service); if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) { setFiltreCategorie(''); } } - /* Chargement des données dashboard */ - const loadData = useCallback(async () => { + function handleCategorieChange(categorie) { setLoading(true); - setErreur(''); - 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]); + setFiltreCategorie(categorie); + } + /* 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(() => { - loadData(); - }, [loadData]); + let annule = false; + + 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 (

Dashboard

- @@ -140,7 +150,7 @@ export default function Dashboard() {