From 7ce654092439210a1aaf62a4bde9fee81fb2289c Mon Sep 17 00:00:00 2001 From: maxsoch Date: Tue, 14 Jul 2026 19:06:37 +0200 Subject: [PATCH] =?UTF-8?q?Refonte=20des=20listings=20et=20arborescence=20?= =?UTF-8?q?de=20cat=C3=A9gories=20=C3=A0=20profondeur=20illimit=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Catégorie/SousCategorie fusionnées en un seul modèle auto-référencé (parent_id), pour permettre une imbrication sans limite de niveaux. Les listings matériels/catégories/destinataires passent en lignes repliées (
) avec recherche côté serveur, ce qui corrige le débordement horizontal du tableau tout-en-input et reste utilisable sur de longues listes. Style de l'écran de scan également retravaillé (frontend-design). --- CLAUDE.md | 25 +- app/categories_arbre.py | 25 ++ app/models.py | 46 ++- app/routers/categories.py | 74 ++-- app/routers/destinataires.py | 15 +- app/routers/materiels.py | 34 +- app/static/style.css | 565 ++++++++++++++++++++++++++++--- app/templates/categories.html | 96 ++++-- app/templates/destinataires.html | 13 +- app/templates/materiels.html | 106 +++--- app/templates/scan.html | 87 +++-- scripts/seed.py | 61 ++-- tests/test_categories.py | 91 +++++ tests/test_materiels.py | 31 +- 14 files changed, 1020 insertions(+), 249 deletions(-) create mode 100644 app/categories_arbre.py create mode 100644 tests/test_categories.py diff --git a/CLAUDE.md b/CLAUDE.md index 578bfe0..25877b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Projet Application web interne pour le département IT d'une clinique privée : gestion -du stock de matériel informatique (catégories / sous-catégories / matériels), +du stock de matériel informatique (arborescence de catégories + matériels), avec recherche de matériel par scan de code-barres et alertes email quand le stock passe sous un seuil défini. @@ -19,6 +19,15 @@ Contraintes du domaine, volontairement strictes : clavier (elles tapent le code + Entrée). Aucune intégration matérielle n'est nécessaire côté app : un simple `` seul dans un `
` suffit, le navigateur soumet au Entrée. +- **Catégories à profondeur illimitée.** `Categorie` est auto-référencée + (`parent_id`), pas un modèle Catégorie/Sous-catégorie à deux niveaux fixes : + une catégorie peut avoir des sous-catégories qui en ont elles-mêmes, sans + limite (ex: Réseau > Câbles > RJ45 > 2m). Voir `Categorie.chemin()`. +- **Listes potentiellement longues.** Les pages Matériels/Catégories/ + Destinataires sont conçues pour rester utilisables avec beaucoup + d'éléments : recherche texte côté serveur (`?q=`), lignes repliées par + défaut (`
`), jamais de tableau avec un `` par colonne + affiché sur toutes les lignes à la fois (ça déborde horizontalement). ## Stack @@ -58,7 +67,8 @@ app/ main.py point d'entrée FastAPI, montage des routers config.py lecture des variables d'environnement (SMTP...) database.py connexion SQLite + dependency get_session() - models.py modèles SQLModel : Categorie, SousCategorie, Materiel, DestinataireAlerte + models.py modèles SQLModel : Categorie (arborescence auto-référencée), Materiel, DestinataireAlerte + categories_arbre.py parcours de l'arborescence (racines, aplatissement pour un indenté). +""" + +from sqlmodel import Session, select + +from app.models import Categorie + + +def categories_racines(session: Session) -> list[Categorie]: + """Catégories de premier niveau (sans parent).""" + return list(session.exec(select(Categorie).where(Categorie.parent_id.is_(None))).all()) + + +def aplatir(categories: list[Categorie], profondeur: int = 0) -> list[tuple[int, Categorie]]: + """Aplatit l'arborescence en une liste (profondeur, categorie), dans + l'ordre d'affichage (chaque nœud juste avant ses enfants). Sert à + construire un par + colonne sur chaque ligne, y compris avec une longue liste. + ------------------------------------------------------------------ */ + +.liste-materiels { + list-style: none; + margin: 0 0 2rem; + padding: 0; + border-top: 1px solid var(--acier-clair); +} + +.liste-materiels > li { + border-bottom: 1px solid var(--acier-clair); +} + +.ligne-materiel > summary { + display: grid; + grid-template-columns: auto minmax(0, 2fr) minmax(0, 1.3fr) minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.5rem; +} + +.ligne-materiel > summary:hover { + background: var(--papier-creux); +} + +.materiel-nom, +.materiel-categorie, +.materiel-code { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.materiel-nom { + font-weight: 600; +} + +.materiel-categorie, +.materiel-code { + font-size: 0.8rem; + color: var(--acier); +} + +.materiel-code { + font-family: var(--police-donnee); +} + +.materiel-quantite { + font-family: var(--police-donnee); + font-weight: 700; + text-align: right; + min-width: 2.5ch; +} + +.materiel-quantite.quantite-basse { + color: var(--brique); +} + +.formulaire-edition { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + padding: 0.25rem 0.5rem 1.25rem 1.9rem; +} + +.formulaire-edition label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-size: 0.8rem; + color: var(--acier); +} + +.formulaire-actions { + display: flex; + gap: 0.5rem; + align-items: end; } .formulaire-inline { @@ -65,31 +325,254 @@ tr.stock-bas { } .bouton-danger { - background: #fee2e2; - color: #b91c1c; - border: 1px solid #fca5a5; + background: color-mix(in srgb, var(--brique) 12%, white); + color: var(--brique); + border: 1px solid color-mix(in srgb, var(--brique) 40%, white); } .message-erreur { - color: #b91c1c; -} - -.quantite-actuelle { - font-size: 1.25rem; -} - -#champ-scan { - font-size: 1.5rem; - padding: 0.75rem; - width: 100%; - max-width: 400px; + color: var(--brique); } input, select, button { + font-family: inherit; font-size: 1rem; - padding: 0.4rem; + padding: 0.5rem 0.6rem; + border: 1px solid var(--acier-clair); + border-radius: 4px; } button { cursor: pointer; + border-color: var(--encre); + background: white; +} + +button:hover { + background: var(--encre); + color: var(--papier); +} + +/* --------------------------------------------------------------------- + Écran de scan : le seul endroit où on prend un vrai risque visuel. + Le reste de l'app reste sobre pour ne pas diluer cette signature. + ------------------------------------------------------------------ */ + +.scanner { + max-width: 480px; + margin: 0 auto; +} + +.scanner-entete { + text-align: center; + margin-bottom: 2rem; +} + +.scanner-eyebrow { + font-family: var(--police-donnee); + font-size: 0.75rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--acier); + margin: 0 0 0.25rem; +} + +.scanner-entete h1 { + font-size: 1.75rem; + font-weight: 800; + margin: 0 0 1rem; +} + +.scanner-formulaire { + position: relative; + margin-bottom: 1.5rem; +} + +.scanner-label { + display: block; + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--acier); + margin-bottom: 0.4rem; +} + +.scanner-champ { + width: 100%; + font-family: var(--police-donnee); + font-size: 1.5rem; + letter-spacing: 0.05em; + padding: 0.9rem 1rem; + background: var(--papier-creux); + border: none; + border-radius: 4px 4px 0 0; +} + +.scanner-champ::placeholder { + color: var(--acier); + opacity: 0.7; +} + +/* + * La "ligne laser" : un rappel direct du geste physique du scan (le + * faisceau rouge d'une douchette code-barres). Immobile au repos, elle ne + * s'anime que quand le champ a le focus, via le sélecteur adjacent + * input:focus + .laser-ligne (pas besoin de JavaScript). + */ +.laser-ligne { + height: 3px; + background: var(--acier-clair); + border-radius: 0 0 4px 4px; + overflow: hidden; +} + +.scanner-champ:focus + .laser-ligne { + background: var(--ambre); + animation: balayage-laser 1.6s ease-in-out infinite; +} + +@keyframes balayage-laser { + 0%, 100% { opacity: 0.5; } + 50% { opacity: 1; } +} + +.etiquette-erreur { + color: var(--brique); + font-weight: 600; + text-align: center; + margin-bottom: 1.5rem; +} + +/* + * L'étiquette de résultat : pensée comme une vraie étiquette d'inventaire + * qu'on viendrait de scanner, avec les rayures de code-barres en tête et + * une courte animation d'apparition ("tampon" qui vient de s'imprimer). + */ +.etiquette-resultat { + background: white; + border: 1px solid var(--acier-clair); + border-radius: 4px; + overflow: hidden; + animation: apparition-etiquette 0.25s ease-out; +} + +@keyframes apparition-etiquette { + from { opacity: 0; transform: scale(0.97) translateY(4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.etiquette-rayures { + height: 10px; +} + +.etiquette-resultat .etiquette-fil, +.etiquette-resultat .etiquette-nom, +.etiquette-resultat .etiquette-quantite, +.etiquette-resultat .etiquette-statut { + padding-left: 1.25rem; + padding-right: 1.25rem; +} + +.etiquette-fil { + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--acier); + margin: 1rem 0 0.15rem; +} + +.etiquette-nom { + font-size: 1.3rem; + font-weight: 800; + margin: 0 0 1rem; +} + +.etiquette-quantite { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.etiquette-nombre { + font-family: var(--police-donnee); + font-size: 3rem; + font-weight: 700; + line-height: 1; +} + +.etiquette-unite { + color: var(--acier); +} + +.etiquette-statut { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--acier); + margin: 0.6rem 0 0; +} + +.statut-puce { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.statut-ok { + background: var(--labo); +} + +.statut-bas { + background: var(--brique); +} + +.etiquette-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + background: var(--acier-clair); + margin-top: 1.25rem; +} + +.etiquette-actions form { + display: contents; +} + +.bouton-scan { + width: 100%; + border: none; + border-radius: 0; + padding: 0.9rem; + font-weight: 700; + letter-spacing: 0.03em; + background: white; +} + +.bouton-scan:hover { + background: var(--papier-creux); + color: var(--encre); +} + +.bouton-scan:active { + transform: translateY(1px); +} + +.bouton-reception { + color: var(--labo); +} + +.bouton-sortie { + color: var(--brique); +} + +/* Respecte la préférence système : pas d'animation pour qui la désactive. */ +@media (prefers-reduced-motion: reduce) { + .scanner-champ:focus + .laser-ligne { + animation: none; + } + .etiquette-resultat { + animation: none; + } } diff --git a/app/templates/categories.html b/app/templates/categories.html index fe58fef..d3e9c8a 100644 --- a/app/templates/categories.html +++ b/app/templates/categories.html @@ -1,43 +1,87 @@ {% extends "base.html" %} {% block titre %}Catégories - Gestion de stock IT{% endblock %} + +{# + Macro récursive : un nœud de l'arborescence (profondeur illimitée) et + ses enfants.
est replié par défaut pour qu'un arbre profond + ou avec beaucoup de branches reste lisible sans tout afficher d'un coup. +#} +{% macro noeud_categorie(categorie) %} +
  • +
    + + + {{ categorie.nom }} + + {%- if categorie.enfants %}{{ categorie.enfants|length }} sous-cat.{% endif -%} + {%- if categorie.enfants and categorie.materiels %} · {% endif -%} + {%- if categorie.materiels %}{{ categorie.materiels|length }} matériel(s){% endif -%} + + + +
    + + + + + + {% if not categorie.enfants and not categorie.materiels %} +
    + +
    + {% endif %} +
    + + {% if categorie.enfants %} +
      + {% for enfant in categorie.enfants %} + {{ noeud_categorie(enfant) }} + {% endfor %} +
    + {% endif %} +
    +
  • +{% endmacro %} + {% block contenu %}

    Catégories

    -
    - + + + {% if q %}Effacer{% endif %} +
    + +
    +
    -{% for categorie in categories %} -
    -
    -

    {{ categorie.nom }}

    - {% if not categorie.sous_categories %} -
    - -
    - {% endif %} -
    - -
      - {% for sous_categorie in categorie.sous_categories %} +{% if q %} + {% if resultats %} +
        + {% for categorie in resultats %}
      • - {{ sous_categorie.nom }} - {% if not sous_categorie.materiels %} -
        + {{ categorie.chemin() }} + {% if not categorie.enfants and not categorie.materiels %} +
        {% endif %}
      • {% endfor %}
      - -
      - - -
      -
    + {% else %} +

    Aucune catégorie ne correspond à « {{ q }} ».

    + {% endif %} {% else %} -

    Aucune catégorie pour l'instant.

    -{% endfor %} + {% if racines %} +
      + {% for racine in racines %} + {{ noeud_categorie(racine) }} + {% endfor %} +
    + {% else %} +

    Aucune catégorie pour l'instant.

    + {% endif %} +{% endif %} {% endblock %} diff --git a/app/templates/destinataires.html b/app/templates/destinataires.html index 8d8bc83..e64816e 100644 --- a/app/templates/destinataires.html +++ b/app/templates/destinataires.html @@ -3,16 +3,23 @@ {% block contenu %}

    Destinataires des alertes de stock bas

    -
      +
      + + {% if q %}Effacer{% endif %} +
      + +
        {% for destinataire in destinataires %}
      • - {{ destinataire.email }} + {{ destinataire.email }}
      • {% else %} -
      • Aucun destinataire configuré : aucune alerte ne sera envoyée.
      • +
      • + {% if q %}Aucun destinataire ne correspond à « {{ q }} ».{% else %}Aucun destinataire configuré : aucune alerte ne sera envoyée.{% endif %} +
      • {% endfor %}
      diff --git a/app/templates/materiels.html b/app/templates/materiels.html index b235de4..b68626c 100644 --- a/app/templates/materiels.html +++ b/app/templates/materiels.html @@ -3,51 +3,75 @@ {% block contenu %}

      Matériels

      - - - - - - - - - - - - - {% for materiel in materiels %} - - - - - - - - - {% else %} - - {% endfor %} - -
      NomSous-catégorieCode-barreQuantitéSeuil d'alerte
      {{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }} - - -
      Aucun matériel pour l'instant.
      +
      + + {% if q %}Effacer{% endif %} +
      -{# Un
      par matériel, hors du tableau : un ne peut pas - entourer plusieurs en HTML valide. Les champs du tableau ci-dessus - s'y rattachent via l'attribut form="materiel-{id}" (HTML5). #} -{% for materiel in materiels %} -
      -{% endfor %} +{# + Chaque ligne est repliée par défaut (juste le résumé, pas les champs + d'édition) : avec beaucoup de matériels, afficher 5 par ligne en + permanence rend la liste illisible et pousse le contenu hors de l'écran. + On ne dépense la largeur d'un vrai formulaire que sur la ligne qu'on + déplie. Voir aussi CLAUDE.md pour le contexte de ce choix. +#} +
        + {% for materiel in materiels %} + {% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %} +
      • +
        + + + {{ materiel.nom }} + {{ materiel.categorie.chemin() }} + {{ materiel.code_barre or "—" }} + {{ materiel.quantite }} + + +
        + + + + + +
        + + +
        +
        +
        +
      • + {% else %} +
      • + {% if q %}Aucun matériel ne correspond à « {{ q }} ».{% else %}Aucun matériel pour l'instant.{% endif %} +
      • + {% endfor %} +

      Ajouter un matériel

      -{% if sous_categories %} +{% if categories_aplaties %}
      - @@ -57,6 +81,6 @@
      {% else %} -

      Créez d'abord une catégorie et une sous-catégorie avant d'ajouter un matériel.

      +

      Créez d'abord une catégorie avant d'ajouter un matériel.

      {% endif %} {% endblock %} diff --git a/app/templates/scan.html b/app/templates/scan.html index a72b63b..636dea3 100644 --- a/app/templates/scan.html +++ b/app/templates/scan.html @@ -1,34 +1,75 @@ {% extends "base.html" %} {% block titre %}Scanner - Gestion de stock IT{% endblock %} {% block contenu %} -

      Scanner

      +
      +
      +

      Poste de scan

      +

      Identifier un matériel

      +
      -
      - -
      + {# + Un seul champ dans un
      : la douchette tape le code puis Entrée, + ce qui soumet le formulaire nativement (aucun JavaScript nécessaire + pour capter le scan). La "ligne laser" sous le champ ne s'anime que + lorsqu'il a le focus (voir .scanner-champ:focus + .laser-ligne dans + style.css) : un clin d'œil au faisceau de la douchette physique. + #} + + + + +
      -{% if erreur %} -

      {{ erreur }}

      -{% endif %} + {% if erreur %} +

      {{ erreur }}

      + {% endif %} -{% if materiel %} -
      -

      {{ materiel.nom }}

      -

      {{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}

      -

      Quantité en stock : {{ materiel.quantite }}

      + {% if materiel %} + {% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %} +
      + -
      -
      - - -
      -
      - - -
      -
      +

      {{ materiel.categorie.chemin() }}

      +

      {{ materiel.nom }}

      + +
      + {{ materiel.quantite }} + en stock +
      + + {% if materiel.seuil_alerte is not none %} +

      + + {% if sous_seuil %} + Sous le seuil d'alerte ({{ materiel.seuil_alerte }}) + {% else %} + Seuil d'alerte : {{ materiel.seuil_alerte }} + {% endif %} +

      + {% endif %} + +
      +
      + + +
      +
      + + +
      +
      +
      + {% endif %}
      -{% endif %}