From 82a1b93211faa01e656e19f41ca220fa9ff68a77 Mon Sep 17 00:00:00 2001 From: Anthony Coyaud Date: Sat, 15 Aug 2026 14:29:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(front):=20rend=20l'=C3=A9cran=20d'administ?= =?UTF-8?q?ration=20r=C3=A9ellement=20fonctionnel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- DataSentinel/src/components/common/Modal.jsx | 19 ++ .../src/components/common/Modal.module.css | 49 +++ DataSentinel/src/pages/Admin/Admin.jsx | 305 ++++++++---------- DataSentinel/src/pages/Admin/Admin.module.css | 62 ++-- .../src/pages/Admin/ReferentielForm.jsx | 91 ++++++ .../src/pages/Admin/referentielsConfig.js | 89 +++++ DataSentinel/src/services/api.js | 58 +++- 7 files changed, 455 insertions(+), 218 deletions(-) create mode 100644 DataSentinel/src/components/common/Modal.jsx create mode 100644 DataSentinel/src/components/common/Modal.module.css create mode 100644 DataSentinel/src/pages/Admin/ReferentielForm.jsx create mode 100644 DataSentinel/src/pages/Admin/referentielsConfig.js 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/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/services/api.js b/DataSentinel/src/services/api.js index bbd1116..912a6ec 100644 --- a/DataSentinel/src/services/api.js +++ b/DataSentinel/src/services/api.js @@ -3,9 +3,11 @@ /* 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'; -/* 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 USER_KEY = 'ds_user'; +export const USER_KEY = 'ds_user'; /* Helpers de gestion du token JWT dans le localStorage */ export function getToken() { @@ -188,6 +190,58 @@ export async function getContacts(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 */ export async function getMonitorings(id_service, id_categorie) { const data = await fetchApi('/monitorings', { id_service, id_categorie });