Refonte des listings et arborescence de catégories à profondeur illimitée
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 (<details>) 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).
This commit is contained in:
parent
8bb5c9406a
commit
7ce6540924
25
CLAUDE.md
25
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 `<input>` seul dans un `<form>`
|
||||
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 (`<details>`), jamais de tableau avec un `<input>` 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 <select> indenté)
|
||||
email_alerts.py vérifie le seuil d'alerte et envoie/simule l'email
|
||||
templates_engine.py instance Jinja2Templates partagée par tous les routers
|
||||
routers/ une route FastAPI par ressource (categories, materiels, scan, destinataires)
|
||||
|
|
@ -73,10 +83,13 @@ Point d'attention Starlette : `Jinja2Templates.TemplateResponse()` prend
|
|||
"page.html", {...})`), **pas** `{"request": request, ...}` dans le contexte —
|
||||
c'est l'API de la version de Starlette installée ici.
|
||||
|
||||
Un `<form>` ne peut pas entourer plusieurs `<td>` en HTML valide : pour les
|
||||
lignes de tableau éditables (`app/templates/materiels.html`), les champs
|
||||
utilisent l'attribut `form="materiel-{id}"` pour se rattacher à un `<form>`
|
||||
déclaré séparément après le tableau.
|
||||
Les listings (`materiels.html`, `categories.html`) utilisent `<details>`/
|
||||
`<summary>` pour replier les lignes/branches par défaut plutôt qu'un
|
||||
tableau tout-en-input : c'est ce qui évite le débordement horizontal et
|
||||
garde la page utilisable avec beaucoup d'éléments. Le formulaire d'édition
|
||||
complet d'un matériel ne s'affiche que dans la ligne dépliée (voir
|
||||
`.formulaire-edition` dans `style.css`), pas sur toutes les lignes en
|
||||
permanence.
|
||||
|
||||
## Emails d'alerte
|
||||
|
||||
|
|
|
|||
25
app/categories_arbre.py
Normal file
25
app/categories_arbre.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
Utilitaires de parcours de l'arborescence de catégories (profondeur
|
||||
illimitée). Partagés entre le routeur categories (affichage de l'arbre) et
|
||||
le routeur materiels (construction du <select> 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 <select> indenté sans avoir besoin de JavaScript."""
|
||||
resultat: list[tuple[int, Categorie]] = []
|
||||
for categorie in categories:
|
||||
resultat.append((profondeur, categorie))
|
||||
resultat.extend(aplatir(categorie.enfants, profondeur + 1))
|
||||
return resultat
|
||||
|
|
@ -7,7 +7,11 @@ détaillé par unité). On suit juste, pour chaque *type* de matériel, une
|
|||
quantité en stock. Ex: "PC Dell XPS 13 modèle 9310" -> quantite=5,
|
||||
"Câble RJ45 2m" -> quantite=42.
|
||||
|
||||
Hiérarchie : Categorie -> SousCategorie -> Materiel.
|
||||
Hiérarchie : Categorie (arborescence à profondeur illimitée, auto-
|
||||
référencée via parent_id) -> Materiel. Une catégorie peut avoir des
|
||||
sous-catégories, qui peuvent elles-mêmes en avoir, etc. (ex: "Réseau" >
|
||||
"Câbles" > "RJ45" > "2m"). Un matériel se rattache à n'importe quel nœud
|
||||
de l'arborescence, pas forcément une feuille.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
|
@ -16,23 +20,35 @@ from sqlmodel import Field, Relationship, SQLModel
|
|||
|
||||
|
||||
class Categorie(SQLModel, table=True):
|
||||
"""Catégorie de haut niveau (ex: 'Ordinateurs', 'Réseau', 'Périphériques')."""
|
||||
"""Nœud d'une arborescence de catégories à profondeur illimitée.
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
nom: str = Field(index=True, unique=True)
|
||||
|
||||
sous_categories: list["SousCategorie"] = Relationship(back_populates="categorie")
|
||||
|
||||
|
||||
class SousCategorie(SQLModel, table=True):
|
||||
"""Sous-catégorie rattachée à une catégorie (ex: 'Portables', 'Câbles')."""
|
||||
parent_id est vide pour une catégorie racine. La relation "parent" est
|
||||
auto-référencée (une Categorie pointe vers une autre Categorie) : c'est
|
||||
ce qui permet une profondeur arbitraire, contrairement à un modèle
|
||||
Categorie/SousCategorie à deux niveaux fixes.
|
||||
"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
nom: str
|
||||
categorie_id: int = Field(foreign_key="categorie.id")
|
||||
parent_id: Optional[int] = Field(default=None, foreign_key="categorie.id")
|
||||
|
||||
categorie: Optional[Categorie] = Relationship(back_populates="sous_categories")
|
||||
materiels: list["Materiel"] = Relationship(back_populates="sous_categorie")
|
||||
parent: Optional["Categorie"] = Relationship(
|
||||
back_populates="enfants",
|
||||
sa_relationship_kwargs={"remote_side": "Categorie.id"},
|
||||
)
|
||||
enfants: list["Categorie"] = Relationship(back_populates="parent")
|
||||
materiels: list["Materiel"] = Relationship(back_populates="categorie")
|
||||
|
||||
def chemin(self) -> str:
|
||||
"""Chemin complet depuis la racine, ex: 'Réseau / Câbles / RJ45'.
|
||||
|
||||
Utilisé partout où on affiche une catégorie hors de son arbre
|
||||
(liste de matériels, résultat de scan, recherche) pour garder le
|
||||
contexte sans avoir à afficher l'arborescence entière.
|
||||
"""
|
||||
if self.parent is None:
|
||||
return self.nom
|
||||
return f"{self.parent.chemin()} / {self.nom}"
|
||||
|
||||
|
||||
class Materiel(SQLModel, table=True):
|
||||
|
|
@ -50,9 +66,9 @@ class Materiel(SQLModel, table=True):
|
|||
# En dessous de ce seuil, une alerte email est envoyée (voir email_alerts.py).
|
||||
# None = pas d'alerte configurée pour ce matériel.
|
||||
seuil_alerte: Optional[int] = Field(default=None)
|
||||
sous_categorie_id: int = Field(foreign_key="souscategorie.id")
|
||||
categorie_id: int = Field(foreign_key="categorie.id")
|
||||
|
||||
sous_categorie: Optional[SousCategorie] = Relationship(back_populates="materiels")
|
||||
categorie: Optional[Categorie] = Relationship(back_populates="materiels")
|
||||
|
||||
|
||||
class DestinataireAlerte(SQLModel, table=True):
|
||||
|
|
|
|||
|
|
@ -1,65 +1,65 @@
|
|||
"""Routes pour la gestion des catégories et sous-catégories de matériel."""
|
||||
"""
|
||||
Routes pour la gestion de l'arborescence de catégories : profondeur
|
||||
illimitée, une catégorie peut avoir des sous-catégories qui peuvent
|
||||
elles-mêmes en avoir, etc. (Categorie.parent_id auto-référencé).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Form
|
||||
from fastapi.requests import Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.categories_arbre import categories_racines
|
||||
from app.database import get_session
|
||||
from app.models import Categorie, SousCategorie
|
||||
from app.models import Categorie
|
||||
from app.templates_engine import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def lister_categories(request: Request, session: Session = Depends(get_session)):
|
||||
"""Affiche toutes les catégories avec leurs sous-catégories imbriquées."""
|
||||
categories = session.exec(select(Categorie)).all()
|
||||
def lister_categories(request: Request, q: str = "", session: Session = Depends(get_session)):
|
||||
"""Sans recherche : affiche l'arborescence complète, repliable.
|
||||
|
||||
Avec ?q=..., bascule sur une liste plate des catégories dont le nom
|
||||
correspond, chacune affichée avec son chemin complet (ex: "Réseau /
|
||||
Câbles") — plus rapide pour retrouver une catégorie précise que de
|
||||
déplier un arbre profond ou une longue liste de racines.
|
||||
"""
|
||||
if q:
|
||||
toutes = session.exec(select(Categorie)).all()
|
||||
q_normalise = q.lower()
|
||||
resultats = [c for c in toutes if q_normalise in c.nom.lower()]
|
||||
return templates.TemplateResponse(
|
||||
request, "categories.html", {"categories": categories}
|
||||
request, "categories.html", {"q": q, "resultats": resultats, "racines": []}
|
||||
)
|
||||
|
||||
racines = categories_racines(session)
|
||||
return templates.TemplateResponse(
|
||||
request, "categories.html", {"q": "", "resultats": [], "racines": racines}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/categories")
|
||||
def creer_categorie(nom: str = Form(...), session: Session = Depends(get_session)):
|
||||
"""Crée une nouvelle catégorie de haut niveau (ex: 'Ordinateurs')."""
|
||||
session.add(Categorie(nom=nom))
|
||||
def creer_categorie(
|
||||
nom: str = Form(...), parent_id: str = Form(""), session: Session = Depends(get_session)
|
||||
):
|
||||
"""Crée une catégorie : racine si parent_id est vide, sous-catégorie
|
||||
(à n'importe quelle profondeur) sinon. Un seul endpoint pour les deux
|
||||
cas, le formulaire d'ajout de chaque nœud de l'arbre poste juste son
|
||||
propre id comme parent_id."""
|
||||
session.add(Categorie(nom=nom, parent_id=int(parent_id) if parent_id else None))
|
||||
session.commit()
|
||||
return RedirectResponse(url="/categories", status_code=303)
|
||||
|
||||
|
||||
@router.post("/categories/{categorie_id}/supprimer")
|
||||
def supprimer_categorie(categorie_id: int, session: Session = Depends(get_session)):
|
||||
"""Supprime une catégorie.
|
||||
|
||||
Refuse silencieusement si elle contient encore des sous-catégories,
|
||||
pour éviter de supprimer par erreur des matériels devenus orphelins.
|
||||
"""
|
||||
"""Supprime une catégorie. Refuse silencieusement si elle a encore des
|
||||
sous-catégories ou des matériels rattachés, pour éviter de perdre des
|
||||
données par erreur en un clic."""
|
||||
categorie = session.get(Categorie, categorie_id)
|
||||
if categorie and not categorie.sous_categories:
|
||||
if categorie and not categorie.enfants and not categorie.materiels:
|
||||
session.delete(categorie)
|
||||
session.commit()
|
||||
return RedirectResponse(url="/categories", status_code=303)
|
||||
|
||||
|
||||
@router.post("/categories/{categorie_id}/sous-categories")
|
||||
def creer_sous_categorie(
|
||||
categorie_id: int, nom: str = Form(...), session: Session = Depends(get_session)
|
||||
):
|
||||
"""Crée une sous-catégorie rattachée à une catégorie existante."""
|
||||
session.add(SousCategorie(nom=nom, categorie_id=categorie_id))
|
||||
session.commit()
|
||||
return RedirectResponse(url="/categories", status_code=303)
|
||||
|
||||
|
||||
@router.post("/sous-categories/{sous_categorie_id}/supprimer")
|
||||
def supprimer_sous_categorie(
|
||||
sous_categorie_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
"""Supprime une sous-catégorie si elle ne contient plus aucun matériel."""
|
||||
sous_categorie = session.get(SousCategorie, sous_categorie_id)
|
||||
if sous_categorie and not sous_categorie.materiels:
|
||||
session.delete(sous_categorie)
|
||||
session.commit()
|
||||
return RedirectResponse(url="/categories", status_code=303)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,14 @@ router = APIRouter()
|
|||
|
||||
|
||||
@router.get("/destinataires")
|
||||
def lister_destinataires(request: Request, session: Session = Depends(get_session)):
|
||||
"""Affiche la liste des emails configurés pour recevoir les alertes."""
|
||||
destinataires = session.exec(select(DestinataireAlerte)).all()
|
||||
def lister_destinataires(request: Request, q: str = "", session: Session = Depends(get_session)):
|
||||
"""Affiche la liste des emails configurés pour recevoir les alertes,
|
||||
filtrée par ?q=... si fourni (même comportement que /materiels, pour
|
||||
rester cohérent si la liste grandit)."""
|
||||
tous = session.exec(select(DestinataireAlerte)).all()
|
||||
destinataires = [d for d in tous if q.lower() in d.email.lower()] if q else tous
|
||||
return templates.TemplateResponse(
|
||||
request, "destinataires.html", {"destinataires": destinataires}
|
||||
request, "destinataires.html", {"destinataires": destinataires, "q": q}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,9 +33,7 @@ def ajouter_destinataire(email: str = Form(...), session: Session = Depends(get_
|
|||
|
||||
|
||||
@router.post("/destinataires/{destinataire_id}/supprimer")
|
||||
def supprimer_destinataire(
|
||||
destinataire_id: int, session: Session = Depends(get_session)
|
||||
):
|
||||
def supprimer_destinataire(destinataire_id: int, session: Session = Depends(get_session)):
|
||||
"""Retire un email de la liste des destinataires."""
|
||||
destinataire = session.get(DestinataireAlerte, destinataire_id)
|
||||
if destinataire:
|
||||
|
|
|
|||
|
|
@ -10,30 +10,44 @@ from fastapi.requests import Request
|
|||
from fastapi.responses import RedirectResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.categories_arbre import aplatir, categories_racines
|
||||
from app.database import get_session
|
||||
from app.email_alerts import verifier_et_alerter
|
||||
from app.models import Materiel, SousCategorie
|
||||
from app.models import Materiel
|
||||
from app.templates_engine import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/materiels")
|
||||
def lister_materiels(request: Request, session: Session = Depends(get_session)):
|
||||
"""Liste tous les matériels, avec les sous-catégories pour le formulaire de création."""
|
||||
materiels = session.exec(select(Materiel)).all()
|
||||
sous_categories = session.exec(select(SousCategorie)).all()
|
||||
def lister_materiels(request: Request, q: str = "", session: Session = Depends(get_session)):
|
||||
"""Liste les matériels, filtrés par nom ou code-barre si ?q=... est
|
||||
fourni (recherche insensible à la casse). Indispensable dès que la
|
||||
liste devient longue : pas de pagination, juste un filtre texte."""
|
||||
tous = session.exec(select(Materiel)).all()
|
||||
if q:
|
||||
q_normalise = q.lower()
|
||||
materiels = [
|
||||
m
|
||||
for m in tous
|
||||
if q_normalise in m.nom.lower()
|
||||
or (m.code_barre and q_normalise in m.code_barre.lower())
|
||||
]
|
||||
else:
|
||||
materiels = tous
|
||||
|
||||
categories_aplaties = aplatir(categories_racines(session))
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"materiels.html",
|
||||
{"materiels": materiels, "sous_categories": sous_categories},
|
||||
{"materiels": materiels, "categories_aplaties": categories_aplaties, "q": q},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/materiels")
|
||||
def creer_materiel(
|
||||
nom: str = Form(...),
|
||||
sous_categorie_id: int = Form(...),
|
||||
categorie_id: int = Form(...),
|
||||
code_barre: str = Form(""),
|
||||
quantite: int = Form(0),
|
||||
seuil_alerte: str = Form(""),
|
||||
|
|
@ -42,7 +56,7 @@ def creer_materiel(
|
|||
"""Crée un nouveau type de matériel dans le stock."""
|
||||
materiel = Materiel(
|
||||
nom=nom,
|
||||
sous_categorie_id=sous_categorie_id,
|
||||
categorie_id=categorie_id,
|
||||
code_barre=code_barre or None,
|
||||
quantite=quantite,
|
||||
seuil_alerte=int(seuil_alerte) if seuil_alerte else None,
|
||||
|
|
@ -58,15 +72,17 @@ def creer_materiel(
|
|||
def modifier_materiel(
|
||||
materiel_id: int,
|
||||
nom: str = Form(...),
|
||||
categorie_id: int = Form(...),
|
||||
code_barre: str = Form(""),
|
||||
quantite: int = Form(0),
|
||||
seuil_alerte: str = Form(""),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Met à jour un matériel existant (nom, code-barre, quantité, seuil d'alerte)."""
|
||||
"""Met à jour un matériel existant."""
|
||||
materiel = session.get(Materiel, materiel_id)
|
||||
if materiel:
|
||||
materiel.nom = nom
|
||||
materiel.categorie_id = categorie_id
|
||||
materiel.code_barre = code_barre or None
|
||||
materiel.quantite = quantite
|
||||
materiel.seuil_alerte = int(seuil_alerte) if seuil_alerte else None
|
||||
|
|
|
|||
|
|
@ -1,54 +1,314 @@
|
|||
/* Styles minimaux, volontairement simples. */
|
||||
/*
|
||||
* Système de tokens : le langage visuel de l'app s'inspire des étiquettes
|
||||
* d'inventaire et du geste du scanner code-barres (la ligne "laser" sous
|
||||
* le champ de scan, les rayures de code-barres en motif de rupture).
|
||||
*
|
||||
* Pas de police externe (Google Fonts, etc.) : le poste peut tourner sur
|
||||
* le réseau interne de la clinique, potentiellement sans accès internet.
|
||||
* Le "caractère" typographique vient du poids, du tracking et de
|
||||
* l'échelle plutôt que d'une police téléchargée.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--papier: #f5f3ec; /* fond, blanc "étiquette" chaud */
|
||||
--papier-creux: #ebe8de; /* zones en creux (input, table stripe) */
|
||||
--encre: #14181c; /* texte principal, quasi noir */
|
||||
--acier: #5b6470; /* texte secondaire, bordures */
|
||||
--acier-clair: #d8dbdf; /* bordures discrètes */
|
||||
--brique: #b8402a; /* alerte stock bas */
|
||||
--labo: #2e7d6b; /* stock ok / validation */
|
||||
--ambre: #c67a17; /* accent focus / scan actif */
|
||||
|
||||
--police-corps: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||
--police-donnee: ui-monospace, "SF Mono", "Cascadia Code", "Consolas",
|
||||
"Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
font-family: var(--police-corps);
|
||||
max-width: 900px;
|
||||
margin: 2rem auto;
|
||||
margin: 0 auto 4rem;
|
||||
padding: 0 1rem;
|
||||
color: #1a1a1a;
|
||||
color: var(--encre);
|
||||
background: var(--papier);
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-family: var(--police-corps);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Focus clavier visible partout, indépendamment du style propre à chaque
|
||||
contrôle (accessibilité : ne jamais s'appuyer sur la seule couleur). */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 2px solid var(--ambre);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Motif de rupture "code-barres", réutilisé en petite touche (nav, en-tête
|
||||
du scanner, étiquette de résultat) : des barres de largeurs irrégulières
|
||||
en dégradé répété, sans image ni police à charger. */
|
||||
.rayures-code-barre {
|
||||
height: 6px;
|
||||
background-image: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--encre) 0px, var(--encre) 2px,
|
||||
transparent 2px, transparent 5px,
|
||||
var(--encre) 5px, var(--encre) 6px,
|
||||
transparent 6px, transparent 12px,
|
||||
var(--encre) 12px, var(--encre) 15px,
|
||||
transparent 15px, transparent 19px
|
||||
);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 0.75rem;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
margin: 0 -1rem 2.5rem;
|
||||
padding: 1rem 1rem 0.85rem;
|
||||
background: var(--encre);
|
||||
}
|
||||
|
||||
nav a {
|
||||
text-decoration: none;
|
||||
color: #2563eb;
|
||||
color: var(--papier);
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 2rem;
|
||||
nav a:hover {
|
||||
color: var(--ambre);
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
/*
|
||||
* Disclosure native (<details>/<summary>) : utilisée partout pour replier
|
||||
* par défaut ce qui n'est pas immédiatement utile (branches de
|
||||
* l'arborescence, formulaire d'édition d'un matériel). Zéro JavaScript,
|
||||
* et ça évite d'afficher d'un coup des centaines de champs de formulaire
|
||||
* quand une liste devient longue.
|
||||
*/
|
||||
details > summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
tr.stock-bas {
|
||||
background-color: #fef2f2;
|
||||
details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carte {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
.noeud-chevron {
|
||||
flex: none;
|
||||
width: 0.55em;
|
||||
height: 0.55em;
|
||||
border-right: 2px solid var(--acier);
|
||||
border-bottom: 2px solid var(--acier);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.carte-entete {
|
||||
details[open] > summary .noeud-chevron {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.barre-recherche {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.barre-recherche input[type="search"] {
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
background: var(--papier-creux);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.lien-effacer {
|
||||
font-size: 0.85rem;
|
||||
color: var(--acier);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.liste-vide {
|
||||
color: var(--acier);
|
||||
font-style: italic;
|
||||
padding: 1rem 0.25rem;
|
||||
}
|
||||
|
||||
/* Liste plate (résultats de recherche de catégories, destinataires). */
|
||||
.liste-resultats {
|
||||
list-style: none;
|
||||
margin: 0 0 1.5rem;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--acier-clair);
|
||||
}
|
||||
|
||||
.liste-resultats li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 0.25rem;
|
||||
border-bottom: 1px solid var(--acier-clair);
|
||||
}
|
||||
|
||||
.chemin-categorie {
|
||||
font-family: var(--police-donnee);
|
||||
font-size: 0.9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Arborescence de catégories : profondeur illimitée, repliée par défaut.
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.arbre-categories,
|
||||
.arbre-categories ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.arbre-categories ul {
|
||||
margin-left: 0.9rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 1px solid var(--acier-clair);
|
||||
}
|
||||
|
||||
.noeud-categorie {
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.noeud-categorie > details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.noeud-categorie > details > summary:hover {
|
||||
background: var(--papier-creux);
|
||||
}
|
||||
|
||||
.noeud-nom {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.noeud-compte {
|
||||
font-family: var(--police-donnee);
|
||||
font-size: 0.75rem;
|
||||
color: var(--acier);
|
||||
}
|
||||
|
||||
.noeud-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.5rem 0.6rem 1.9rem;
|
||||
}
|
||||
|
||||
.formulaire-racine {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Liste des matériels : lignes compactes en lecture seule, le formulaire
|
||||
d'édition complet ne s'affiche qu'une fois la ligne dépliée. C'est ce
|
||||
qui évite le débordement horizontal d'un tableau avec un <input> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,87 @@
|
|||
{% extends "base.html" %}
|
||||
{% block titre %}Catégories - Gestion de stock IT{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Catégories</h1>
|
||||
|
||||
<form method="post" action="/categories" class="formulaire-inline">
|
||||
<input type="text" name="nom" placeholder="Nouvelle catégorie (ex: Ordinateurs)" required>
|
||||
{#
|
||||
Macro récursive : un nœud de l'arborescence (profondeur illimitée) et
|
||||
ses enfants. <details> 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) %}
|
||||
<li class="noeud-categorie">
|
||||
<details>
|
||||
<summary>
|
||||
<span class="noeud-chevron" aria-hidden="true"></span>
|
||||
<span class="noeud-nom">{{ categorie.nom }}</span>
|
||||
<span class="noeud-compte">
|
||||
{%- 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 -%}
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="noeud-actions">
|
||||
<form method="post" action="/categories" class="formulaire-inline">
|
||||
<input type="hidden" name="parent_id" value="{{ categorie.id }}">
|
||||
<input type="text" name="nom" placeholder="Ajouter une sous-catégorie" required>
|
||||
<button type="submit">Ajouter</button>
|
||||
</form>
|
||||
|
||||
{% for categorie in categories %}
|
||||
<section class="carte">
|
||||
<div class="carte-entete">
|
||||
<h2>{{ categorie.nom }}</h2>
|
||||
{% if not categorie.sous_categories %}
|
||||
<form method="post" action="/categories/{{ categorie.id }}/supprimer">
|
||||
</form>
|
||||
{% if not categorie.enfants and not categorie.materiels %}
|
||||
<form method="post" action="/categories/{{ categorie.id }}/supprimer" class="formulaire-inline">
|
||||
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if categorie.enfants %}
|
||||
<ul>
|
||||
{% for sous_categorie in categorie.sous_categories %}
|
||||
{% for enfant in categorie.enfants %}
|
||||
{{ noeud_categorie(enfant) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</details>
|
||||
</li>
|
||||
{% endmacro %}
|
||||
|
||||
{% block contenu %}
|
||||
<h1>Catégories</h1>
|
||||
|
||||
<form method="get" action="/categories" class="barre-recherche">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher une catégorie…">
|
||||
{% if q %}<a href="/categories" class="lien-effacer">Effacer</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<form method="post" action="/categories" class="formulaire-inline formulaire-racine">
|
||||
<input type="text" name="nom" placeholder="Nouvelle catégorie racine" required>
|
||||
<button type="submit">Ajouter</button>
|
||||
</form>
|
||||
|
||||
{% if q %}
|
||||
{% if resultats %}
|
||||
<ul class="liste-resultats">
|
||||
{% for categorie in resultats %}
|
||||
<li>
|
||||
{{ sous_categorie.nom }}
|
||||
{% if not sous_categorie.materiels %}
|
||||
<form method="post" action="/sous-categories/{{ sous_categorie.id }}/supprimer" class="formulaire-inline">
|
||||
<span class="chemin-categorie">{{ categorie.chemin() }}</span>
|
||||
{% if not categorie.enfants and not categorie.materiels %}
|
||||
<form method="post" action="/categories/{{ categorie.id }}/supprimer" class="formulaire-inline">
|
||||
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<form method="post" action="/categories/{{ categorie.id }}/sous-categories" class="formulaire-inline">
|
||||
<input type="text" name="nom" placeholder="Nouvelle sous-catégorie" required>
|
||||
<button type="submit">Ajouter</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<p class="liste-vide">Aucune catégorie ne correspond à « {{ q }} ».</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Aucune catégorie pour l'instant.</p>
|
||||
{% endfor %}
|
||||
{% if racines %}
|
||||
<ul class="arbre-categories">
|
||||
{% for racine in racines %}
|
||||
{{ noeud_categorie(racine) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="liste-vide">Aucune catégorie pour l'instant.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -3,16 +3,23 @@
|
|||
{% block contenu %}
|
||||
<h1>Destinataires des alertes de stock bas</h1>
|
||||
|
||||
<ul>
|
||||
<form method="get" action="/destinataires" class="barre-recherche">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher un email…">
|
||||
{% if q %}<a href="/destinataires" class="lien-effacer">Effacer</a>{% endif %}
|
||||
</form>
|
||||
|
||||
<ul class="liste-resultats">
|
||||
{% for destinataire in destinataires %}
|
||||
<li>
|
||||
{{ destinataire.email }}
|
||||
<span>{{ destinataire.email }}</span>
|
||||
<form method="post" action="/destinataires/{{ destinataire.id }}/supprimer" class="formulaire-inline">
|
||||
<button type="submit" class="bouton-danger">Supprimer</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li>Aucun destinataire configuré : aucune alerte ne sera envoyée.</li>
|
||||
<li class="liste-vide">
|
||||
{% if q %}Aucun destinataire ne correspond à « {{ q }} ».{% else %}Aucun destinataire configuré : aucune alerte ne sera envoyée.{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,51 +3,75 @@
|
|||
{% block contenu %}
|
||||
<h1>Matériels</h1>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Sous-catégorie</th>
|
||||
<th>Code-barre</th>
|
||||
<th>Quantité</th>
|
||||
<th>Seuil d'alerte</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for materiel in materiels %}
|
||||
<tr class="{{ 'stock-bas' if materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte else '' }}">
|
||||
<td><input type="text" name="nom" value="{{ materiel.nom }}" form="materiel-{{ materiel.id }}" required></td>
|
||||
<td>{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}</td>
|
||||
<td><input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}" form="materiel-{{ materiel.id }}"></td>
|
||||
<td><input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" form="materiel-{{ materiel.id }}" required></td>
|
||||
<td><input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0" form="materiel-{{ materiel.id }}"></td>
|
||||
<td>
|
||||
<button type="submit" form="materiel-{{ materiel.id }}">Enregistrer</button>
|
||||
<button type="submit" form="materiel-{{ materiel.id }}" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6">Aucun matériel pour l'instant.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<form method="get" action="/materiels" class="barre-recherche">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Rechercher par nom ou code-barre…">
|
||||
{% if q %}<a href="/materiels" class="lien-effacer">Effacer</a>{% endif %}
|
||||
</form>
|
||||
|
||||
{# Un <form> par matériel, hors du tableau : un <form> ne peut pas
|
||||
entourer plusieurs <td> en HTML valide. Les champs du tableau ci-dessus
|
||||
s'y rattachent via l'attribut form="materiel-{id}" (HTML5). #}
|
||||
{% for materiel in materiels %}
|
||||
<form id="materiel-{{ materiel.id }}" method="post" action="/materiels/{{ materiel.id }}/modifier" hidden></form>
|
||||
{% 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 <input> 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.
|
||||
#}
|
||||
<ul class="liste-materiels">
|
||||
{% for materiel in materiels %}
|
||||
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
|
||||
<li>
|
||||
<details class="ligne-materiel">
|
||||
<summary>
|
||||
<span class="noeud-chevron" aria-hidden="true"></span>
|
||||
<span class="materiel-nom">{{ materiel.nom }}</span>
|
||||
<span class="materiel-categorie">{{ materiel.categorie.chemin() }}</span>
|
||||
<span class="materiel-code">{{ materiel.code_barre or "—" }}</span>
|
||||
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">{{ materiel.quantite }}</span>
|
||||
</summary>
|
||||
|
||||
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
|
||||
<label>Nom
|
||||
<input type="text" name="nom" value="{{ materiel.nom }}" required>
|
||||
</label>
|
||||
<label>Catégorie
|
||||
<select name="categorie_id" required>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}" {{ "selected" if categorie.id == materiel.categorie_id else "" }}>
|
||||
{{ "— " * profondeur }}{{ categorie.nom }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Code-barre
|
||||
<input type="text" name="code_barre" value="{{ materiel.code_barre or '' }}">
|
||||
</label>
|
||||
<label>Quantité
|
||||
<input type="number" name="quantite" value="{{ materiel.quantite }}" min="0" required>
|
||||
</label>
|
||||
<label>Seuil d'alerte
|
||||
<input type="number" name="seuil_alerte" value="{{ materiel.seuil_alerte if materiel.seuil_alerte is not none else '' }}" min="0">
|
||||
</label>
|
||||
<div class="formulaire-actions">
|
||||
<button type="submit">Enregistrer</button>
|
||||
<button type="submit" formaction="/materiels/{{ materiel.id }}/supprimer" class="bouton-danger">Supprimer</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="liste-vide">
|
||||
{% if q %}Aucun matériel ne correspond à « {{ q }} ».{% else %}Aucun matériel pour l'instant.{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Ajouter un matériel</h2>
|
||||
{% if sous_categories %}
|
||||
{% if categories_aplaties %}
|
||||
<form method="post" action="/materiels" class="formulaire-grille">
|
||||
<label>Nom <input type="text" name="nom" required></label>
|
||||
<label>Sous-catégorie
|
||||
<select name="sous_categorie_id" required>
|
||||
{% for sous_categorie in sous_categories %}
|
||||
<option value="{{ sous_categorie.id }}">{{ sous_categorie.categorie.nom }} / {{ sous_categorie.nom }}</option>
|
||||
<label>Catégorie
|
||||
<select name="categorie_id" required>
|
||||
{% for profondeur, categorie in categories_aplaties %}
|
||||
<option value="{{ categorie.id }}">{{ "— " * profondeur }}{{ categorie.nom }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
|
@ -57,6 +81,6 @@
|
|||
<button type="submit">Créer</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Créez d'abord une <a href="/categories">catégorie et une sous-catégorie</a> avant d'ajouter un matériel.</p>
|
||||
<p>Créez d'abord une <a href="/categories">catégorie</a> avant d'ajouter un matériel.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,75 @@
|
|||
{% extends "base.html" %}
|
||||
{% block titre %}Scanner - Gestion de stock IT{% endblock %}
|
||||
{% block contenu %}
|
||||
<h1>Scanner</h1>
|
||||
<section class="scanner">
|
||||
<header class="scanner-entete">
|
||||
<p class="scanner-eyebrow">Poste de scan</p>
|
||||
<h1>Identifier un matériel</h1>
|
||||
</header>
|
||||
|
||||
<form method="post" action="/scan" class="formulaire-inline">
|
||||
<input type="text" name="code_barre" id="champ-scan" placeholder="Scannez un code-barre..." autocomplete="off" autofocus required>
|
||||
</form>
|
||||
|
||||
{% if erreur %}
|
||||
<p class="message-erreur">{{ erreur }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if materiel %}
|
||||
<section class="carte">
|
||||
<h2>{{ materiel.nom }}</h2>
|
||||
<p>{{ materiel.sous_categorie.categorie.nom }} / {{ materiel.sous_categorie.nom }}</p>
|
||||
<p class="quantite-actuelle">Quantité en stock : <strong>{{ materiel.quantite }}</strong></p>
|
||||
|
||||
<div class="formulaire-inline">
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||
<input type="hidden" name="delta" value="1">
|
||||
<button type="submit">+ 1 (réception)</button>
|
||||
{#
|
||||
Un seul champ dans un <form> : 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.
|
||||
#}
|
||||
<form method="post" action="/scan" class="scanner-formulaire">
|
||||
<label for="champ-scan" class="scanner-label">Code-barre</label>
|
||||
<input
|
||||
type="text"
|
||||
name="code_barre"
|
||||
id="champ-scan"
|
||||
class="scanner-champ"
|
||||
placeholder="En attente de scan…"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required
|
||||
>
|
||||
<div class="laser-ligne" aria-hidden="true"></div>
|
||||
</form>
|
||||
|
||||
{% if erreur %}
|
||||
<p class="etiquette-erreur">{{ erreur }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if materiel %}
|
||||
{% set sous_seuil = materiel.seuil_alerte is not none and materiel.quantite < materiel.seuil_alerte %}
|
||||
<article class="etiquette-resultat">
|
||||
<div class="rayures-code-barre etiquette-rayures" aria-hidden="true"></div>
|
||||
|
||||
<p class="etiquette-fil">{{ materiel.categorie.chemin() }}</p>
|
||||
<h2 class="etiquette-nom">{{ materiel.nom }}</h2>
|
||||
|
||||
<div class="etiquette-quantite">
|
||||
<span class="etiquette-nombre">{{ materiel.quantite }}</span>
|
||||
<span class="etiquette-unite">en stock</span>
|
||||
</div>
|
||||
|
||||
{% if materiel.seuil_alerte is not none %}
|
||||
<p class="etiquette-statut">
|
||||
<span class="statut-puce {{ 'statut-bas' if sous_seuil else 'statut-ok' }}"></span>
|
||||
{% if sous_seuil %}
|
||||
Sous le seuil d'alerte ({{ materiel.seuil_alerte }})
|
||||
{% else %}
|
||||
Seuil d'alerte : {{ materiel.seuil_alerte }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="etiquette-actions">
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||
<input type="hidden" name="delta" value="-1">
|
||||
<button type="submit">- 1 (sortie)</button>
|
||||
<button type="submit" class="bouton-scan bouton-sortie">− Sortie</button>
|
||||
</form>
|
||||
<form method="post" action="/scan/{{ materiel.id }}/ajuster">
|
||||
<input type="hidden" name="delta" value="1">
|
||||
<button type="submit" class="bouton-scan bouton-reception">+ Réception</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Remet le focus sur le champ de scan à chaque affichage de la page,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""
|
||||
Peuple la base locale (data/stock.db) avec des catégories/sous-catégories/
|
||||
matériels de démonstration, incluant des codes-barres, pour pouvoir tester
|
||||
l'écran de scan et le déclenchement des alertes email sans tout recréer à
|
||||
la main à chaque fois.
|
||||
Peuple la base locale (data/stock.db) avec une arborescence de catégories
|
||||
et des matériels de démonstration, incluant des codes-barres, pour pouvoir
|
||||
tester l'écran de scan et le déclenchement des alertes email sans tout
|
||||
recréer à la main à chaque fois.
|
||||
|
||||
Ne fait rien si la base contient déjà des matériels (pour ne pas dupliquer
|
||||
les données à chaque exécution).
|
||||
|
|
@ -21,31 +21,31 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|||
from sqlmodel import Session, select # noqa: E402
|
||||
|
||||
from app.database import engine, init_db # noqa: E402
|
||||
from app.models import Categorie, DestinataireAlerte, Materiel, SousCategorie # noqa: E402
|
||||
from app.models import Categorie, DestinataireAlerte, Materiel # noqa: E402
|
||||
|
||||
# (categorie, sous_categorie, [(nom, code_barre, quantite, seuil_alerte), ...])
|
||||
# Chaque branche est une liste de noms de catégories du plus général au
|
||||
# plus spécifique (ex: "Réseau" > "Câbles" > "RJ45"), suivie des matériels
|
||||
# rattachés au dernier niveau. La branche Réseau va jusqu'à 3 niveaux pour
|
||||
# montrer que la profondeur n'est pas limitée à catégorie/sous-catégorie.
|
||||
# Les quantités sont volontairement sous le seuil pour certains matériels,
|
||||
# afin de pouvoir vérifier immédiatement que les alertes se déclenchent bien.
|
||||
DONNEES_DEMO = [
|
||||
(
|
||||
"Ordinateurs",
|
||||
"Portables",
|
||||
["Ordinateurs", "Portables"],
|
||||
[
|
||||
("PC Dell XPS 13 (modèle 9310)", "1111111111111", 3, 5),
|
||||
("PC Lenovo ThinkPad T14", "1111111111112", 8, 3),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Réseau",
|
||||
"Câbles",
|
||||
["Réseau", "Câbles", "RJ45"],
|
||||
[
|
||||
("Câble RJ45 2m", "2222222222221", 42, 10),
|
||||
("Câble RJ45 5m", "2222222222222", 15, 10),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Périphériques",
|
||||
"Souris/claviers",
|
||||
["Périphériques", "Souris/claviers"],
|
||||
[
|
||||
("Souris USB filaire", "3333333333331", 2, 5),
|
||||
("Clavier USB AZERTY", "3333333333332", 6, 3),
|
||||
|
|
@ -56,6 +56,24 @@ DONNEES_DEMO = [
|
|||
DESTINATAIRE_DEMO = "it-dept@clinique.local"
|
||||
|
||||
|
||||
def _obtenir_ou_creer_branche(session: Session, noms: list[str]) -> Categorie:
|
||||
"""Crée (ou réutilise) une chaîne de catégories parent -> enfant à
|
||||
partir d'une liste de noms, et renvoie le dernier niveau."""
|
||||
parent_id = None
|
||||
categorie = None
|
||||
for nom in noms:
|
||||
categorie = session.exec(
|
||||
select(Categorie).where(Categorie.nom == nom, Categorie.parent_id == parent_id)
|
||||
).first()
|
||||
if categorie is None:
|
||||
categorie = Categorie(nom=nom, parent_id=parent_id)
|
||||
session.add(categorie)
|
||||
session.commit()
|
||||
session.refresh(categorie)
|
||||
parent_id = categorie.id
|
||||
return categorie
|
||||
|
||||
|
||||
def peupler() -> None:
|
||||
init_db()
|
||||
with Session(engine) as session:
|
||||
|
|
@ -63,19 +81,8 @@ def peupler() -> None:
|
|||
print("La base contient déjà des matériels, rien à faire.")
|
||||
return
|
||||
|
||||
for nom_categorie, nom_sous_categorie, materiels in DONNEES_DEMO:
|
||||
categorie = Categorie(nom=nom_categorie)
|
||||
session.add(categorie)
|
||||
session.commit()
|
||||
session.refresh(categorie)
|
||||
|
||||
sous_categorie = SousCategorie(
|
||||
nom=nom_sous_categorie, categorie_id=categorie.id
|
||||
)
|
||||
session.add(sous_categorie)
|
||||
session.commit()
|
||||
session.refresh(sous_categorie)
|
||||
|
||||
for chemin_categories, materiels in DONNEES_DEMO:
|
||||
categorie = _obtenir_ou_creer_branche(session, chemin_categories)
|
||||
for nom, code_barre, quantite, seuil_alerte in materiels:
|
||||
session.add(
|
||||
Materiel(
|
||||
|
|
@ -83,13 +90,13 @@ def peupler() -> None:
|
|||
code_barre=code_barre,
|
||||
quantite=quantite,
|
||||
seuil_alerte=seuil_alerte,
|
||||
sous_categorie_id=sous_categorie.id,
|
||||
categorie_id=categorie.id,
|
||||
)
|
||||
)
|
||||
|
||||
session.add(DestinataireAlerte(email=DESTINATAIRE_DEMO))
|
||||
session.commit()
|
||||
print("Données de démo créées : 3 catégories, 6 matériels, 1 destinataire.")
|
||||
print("Données de démo créées : arborescence de catégories, 6 matériels, 1 destinataire.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
91
tests/test_categories.py
Normal file
91
tests/test_categories.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""
|
||||
Tests de l'arborescence de catégories à profondeur illimitée : création
|
||||
sur plusieurs niveaux, chemin complet, et règles de suppression.
|
||||
"""
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models import Categorie, Materiel
|
||||
|
||||
|
||||
def test_creation_categorie_racine(client, session):
|
||||
reponse = client.post("/categories", data={"nom": "Réseau"}, follow_redirects=False)
|
||||
|
||||
assert reponse.status_code == 303
|
||||
categorie = session.exec(select(Categorie)).first()
|
||||
assert categorie.nom == "Réseau"
|
||||
assert categorie.parent_id is None
|
||||
|
||||
|
||||
def test_arborescence_profondeur_illimitee(client, session):
|
||||
"""Réseau > Câbles > RJ45 : 3 niveaux, pas juste catégorie/sous-catégorie."""
|
||||
reseau = Categorie(nom="Réseau")
|
||||
session.add(reseau)
|
||||
session.commit()
|
||||
session.refresh(reseau)
|
||||
|
||||
cables = Categorie(nom="Câbles", parent_id=reseau.id)
|
||||
session.add(cables)
|
||||
session.commit()
|
||||
session.refresh(cables)
|
||||
|
||||
reponse = client.post(
|
||||
"/categories", data={"nom": "RJ45", "parent_id": cables.id}, follow_redirects=False
|
||||
)
|
||||
|
||||
assert reponse.status_code == 303
|
||||
rj45 = session.exec(select(Categorie).where(Categorie.nom == "RJ45")).first()
|
||||
assert rj45 is not None
|
||||
assert rj45.parent_id == cables.id
|
||||
assert rj45.chemin() == "Réseau / Câbles / RJ45"
|
||||
|
||||
|
||||
def test_suppression_refusee_si_enfants(client, session):
|
||||
parent = Categorie(nom="Réseau")
|
||||
session.add(parent)
|
||||
session.commit()
|
||||
session.refresh(parent)
|
||||
session.add(Categorie(nom="Câbles", parent_id=parent.id))
|
||||
session.commit()
|
||||
|
||||
client.post(f"/categories/{parent.id}/supprimer", data={})
|
||||
|
||||
assert session.get(Categorie, parent.id) is not None
|
||||
|
||||
|
||||
def test_suppression_refusee_si_materiels(client, session):
|
||||
categorie = Categorie(nom="Réseau")
|
||||
session.add(categorie)
|
||||
session.commit()
|
||||
session.refresh(categorie)
|
||||
session.add(Materiel(nom="Câble RJ45 2m", categorie_id=categorie.id, quantite=1))
|
||||
session.commit()
|
||||
|
||||
client.post(f"/categories/{categorie.id}/supprimer", data={})
|
||||
|
||||
assert session.get(Categorie, categorie.id) is not None
|
||||
|
||||
|
||||
def test_suppression_acceptee_si_vide(client, session):
|
||||
categorie = Categorie(nom="Temporaire")
|
||||
session.add(categorie)
|
||||
session.commit()
|
||||
session.refresh(categorie)
|
||||
|
||||
client.post(f"/categories/{categorie.id}/supprimer", data={})
|
||||
|
||||
assert session.get(Categorie, categorie.id) is None
|
||||
|
||||
|
||||
def test_recherche_retourne_le_chemin_complet(client, session):
|
||||
reseau = Categorie(nom="Réseau")
|
||||
session.add(reseau)
|
||||
session.commit()
|
||||
session.refresh(reseau)
|
||||
session.add(Categorie(nom="Câbles", parent_id=reseau.id))
|
||||
session.commit()
|
||||
|
||||
reponse = client.get("/categories", params={"q": "Câbles"})
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert "Réseau / Câbles" in reponse.text
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Tests du flux principal : créer une catégorie/sous-catégorie, créer un
|
||||
Tests du flux principal : créer une arborescence de catégories, créer un
|
||||
matériel, le retrouver par scan, ajuster son stock, et vérifier que
|
||||
l'alerte de stock bas se déclenche (elle est simplement affichée dans la
|
||||
console tant qu'aucun SMTP n'est configuré, voir app/email_alerts.py).
|
||||
|
|
@ -7,20 +7,23 @@ console tant qu'aucun SMTP n'est configuré, voir app/email_alerts.py).
|
|||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models import Categorie, DestinataireAlerte, Materiel, SousCategorie
|
||||
from app.models import Categorie, DestinataireAlerte, Materiel
|
||||
|
||||
|
||||
def _creer_sous_categorie(session: Session) -> SousCategorie:
|
||||
categorie = Categorie(nom="Ordinateurs")
|
||||
session.add(categorie)
|
||||
def _creer_sous_categorie(session: Session) -> Categorie:
|
||||
"""Crée une petite arborescence à 2 niveaux (Ordinateurs > Portables)
|
||||
pour les tests qui n'ont pas besoin de tester la profondeur illimitée
|
||||
elle-même — voir test_categories.py pour ça."""
|
||||
racine = Categorie(nom="Ordinateurs")
|
||||
session.add(racine)
|
||||
session.commit()
|
||||
session.refresh(categorie)
|
||||
session.refresh(racine)
|
||||
|
||||
sous_categorie = SousCategorie(nom="Portables", categorie_id=categorie.id)
|
||||
session.add(sous_categorie)
|
||||
enfant = Categorie(nom="Portables", parent_id=racine.id)
|
||||
session.add(enfant)
|
||||
session.commit()
|
||||
session.refresh(sous_categorie)
|
||||
return sous_categorie
|
||||
session.refresh(enfant)
|
||||
return enfant
|
||||
|
||||
|
||||
def test_creation_materiel(client, session):
|
||||
|
|
@ -30,7 +33,7 @@ def test_creation_materiel(client, session):
|
|||
"/materiels",
|
||||
data={
|
||||
"nom": "PC Dell XPS 13",
|
||||
"sous_categorie_id": sous_categorie.id,
|
||||
"categorie_id": sous_categorie.id,
|
||||
"code_barre": "1234567890123",
|
||||
"quantite": "5",
|
||||
"seuil_alerte": "2",
|
||||
|
|
@ -49,7 +52,7 @@ def test_scan_trouve_le_materiel_par_code_barre(client, session):
|
|||
session.add(
|
||||
Materiel(
|
||||
nom="Câble RJ45 2m",
|
||||
sous_categorie_id=sous_categorie.id,
|
||||
categorie_id=sous_categorie.id,
|
||||
code_barre="9998887776665",
|
||||
quantite=10,
|
||||
)
|
||||
|
|
@ -75,7 +78,7 @@ def test_alerte_declenchee_sous_le_seuil(client, session, capsys):
|
|||
sous_categorie = _creer_sous_categorie(session)
|
||||
materiel = Materiel(
|
||||
nom="Souris USB",
|
||||
sous_categorie_id=sous_categorie.id,
|
||||
categorie_id=sous_categorie.id,
|
||||
quantite=3,
|
||||
seuil_alerte=5,
|
||||
)
|
||||
|
|
@ -97,7 +100,7 @@ def test_pas_alerte_si_stock_suffisant(client, session, capsys):
|
|||
sous_categorie = _creer_sous_categorie(session)
|
||||
materiel = Materiel(
|
||||
nom="Clavier USB",
|
||||
sous_categorie_id=sous_categorie.id,
|
||||
categorie_id=sous_categorie.id,
|
||||
quantite=10,
|
||||
seuil_alerte=5,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue