Refonte du système visuel : tokens sémantiques, mode sombre, cohérence

Évolution du thème existant "étiquette d'inventaire" plutôt que
remplacement : couche de tokens sémantiques + échelles espacement/
typographie, mode sombre via prefers-color-scheme, hiérarchie de boutons
(primaire/secondaire/danger), icônes SVG et état actif dans la nav, fil
d'Ariane et compteur de résultats sur /materiels, badges de stock bas
réutilisés depuis /scan, formulaire de planification regroupé en carte
sur /destinataires, et correctif de contraste en mode sombre (inputs
natifs + boutons qui restaient rendus en clair).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEC2W1xtjmpHkEYgua7gWC
This commit is contained in:
maxsoch 2026-07-19 08:47:50 +02:00
parent 5792b453e7
commit f734259f73
8 changed files with 530 additions and 169 deletions

View file

@ -43,6 +43,7 @@ qu'aucun SMTP n'est configuré (voir `app/email_alerts.py` et `.env.example`).
```bash
source .venv/bin/activate # créer avec: python3 -m venv .venv
pip install -r requirements-dev.txt
python -m playwright install chromium # une seule fois : télécharge le navigateur headless (vérif visuelle du rendu CSS)
uvicorn app.main:app --reload # lancer le serveur de dev (http://127.0.0.1:8000)
pytest # lancer les tests
ruff check . # linter

View file

@ -63,6 +63,7 @@ def lister_materiels(
or (m.code_barre and q_normalise in m.code_barre.lower())
]
categorie_selectionnee = None
if categorie_id:
categorie_selectionnee = session.get(Categorie, int(categorie_id))
ids_valides = ids_sous_arbre(categorie_selectionnee) if categorie_selectionnee else set()
@ -91,6 +92,7 @@ def lister_materiels(
"materiels": materiels,
"groupes": groupes,
"categories_aplaties": categories_aplaties,
"categorie_selectionnee": categorie_selectionnee,
"q": q,
"categorie_id": categorie_id,
"stock_bas": stock_bas,

View file

@ -7,21 +7,112 @@
* 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.
*
* Deux couches de tokens :
* - primitives (--papier, --encre, --brique...) : les couleurs brutes,
* identiques en clair/sombre dans leur intention (ex: --brique reste
* "la couleur d'alerte" dans les deux thèmes, juste recalée en teinte).
* - sémantiques (--surface, --texte, --action-primaire...) : ce que les
* composants utilisent réellement, pour ne redéfinir qu'un seul bloc
* en mode sombre (voir prefers-color-scheme plus bas) plutôt que de
* traquer chaque usage individuel dans le fichier.
*/
:root {
--papier: #f5f3ec; /* fond, blanc "étiquette" chaud */
--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 */
--brique-doux: #f3ded9; /* fond des badges d'alerte */
--labo: #2e7d6b; /* stock ok / validation */
--labo-doux: #dfeae6; /* fond des badges "ok" */
--ambre: #c67a17; /* accent focus / scan actif */
/* --encre/--papier sont une paire "texte / fond" qui s'inverse
volontairement en mode sombre (voir plus bas). La nav et le bouton
principal ont besoin de l'effet inverse : rester une barre foncée à
texte clair dans les DEUX thèmes (sinon la nav devient une barre
claire flottant sur une page sombre). D' cette paire séparée, fixe,
jamais réassignée dans le bloc dark mode. */
--contraste: #14181c;
--contraste-texte: #f5f3ec;
/* Couche sémantique — c'est elle que les composants utilisent. */
--surface: var(--papier);
--surface-creuse: var(--papier-creux);
--surface-carte: #ffffff;
--texte: var(--encre);
--texte-secondaire: var(--acier);
--bordure: var(--acier-clair);
--accent: var(--ambre);
--action-primaire: var(--contraste);
--action-primaire-texte: var(--contraste-texte);
--danger: var(--brique);
--danger-doux: var(--brique-doux);
--succes: var(--labo);
--succes-doux: var(--labo-doux);
/* Échelle d'espacement : remplace les valeurs rem ad hoc dispersées
dans le reste du fichier, pour garder un rythme vertical cohérent. */
--espace-1: 0.25rem;
--espace-2: 0.5rem;
--espace-3: 0.75rem;
--espace-4: 1rem;
--espace-5: 1.5rem;
--espace-6: 2.5rem;
/* Échelle typographique. */
--texte-xs: 0.75rem;
--texte-sm: 0.85rem;
--texte-base: 1rem;
--texte-lg: 1.3rem;
--texte-xl: 1.75rem;
--texte-2xl: 3rem;
--rayon: 4px;
--transition: 150ms ease;
--police-corps: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
--police-donnee: ui-monospace, "SF Mono", "Cascadia Code", "Consolas",
"Liberation Mono", monospace;
/* Sans cette déclaration, les contrôles natifs (case à cocher, flèche
de <select>, boutons +/- de <input type="number">) restent rendus en
clair par le navigateur même sur une page au thème sombre c'est ce
qui causait des champs blancs éclatants dans le formulaire "Ajouter
un matériel" en mode sombre système. */
color-scheme: light dark;
}
/*
* Mode sombre : suit uniquement la préférence système
* (prefers-color-scheme), pas de bouton de bascule ni de JS c'est du
* CSS pur, donc coût nul. Utile en pratique pour ce public (salle
* serveur, poste peu éclairé, veille de nuit). On garde les mêmes teintes
* (brique = toujours l'alerte, labo = toujours l'ok) juste recalées pour
* rester lisibles sur fond sombre.
*/
@media (prefers-color-scheme: dark) {
:root {
--papier: #1c1b17;
--papier-creux: #262420;
--encre: #f1efe6;
--acier: #9a9c93;
--acier-clair: #3a3934;
--brique: #e2694c;
--brique-doux: #3c231c;
--labo: #5fbfa4;
--labo-doux: #1d332c;
--ambre: #e0a340;
--surface-carte: #232119;
/* Un peu plus clair qu'en mode jour pour se détacher d'un fond déjà
sombre, mais reste délibérément foncé (voir commentaire sur
--contraste plus haut). */
--contraste: #2c2a22;
}
}
* {
@ -30,11 +121,13 @@
body {
font-family: var(--police-corps);
font-size: var(--texte-base);
line-height: 1.5;
max-width: 900px;
margin: 0 auto 4rem;
padding: 0 1rem;
color: var(--encre);
background: var(--papier);
color: var(--texte);
background: var(--surface);
}
h1, h2 {
@ -42,13 +135,40 @@ h1, h2 {
letter-spacing: -0.01em;
}
h1 {
font-size: var(--texte-xl);
}
h2 {
font-size: var(--texte-lg);
}
/* Lien d'évitement : invisible tant qu'il n'a pas le focus clavier,
permet de sauter directement au contenu sans traverser la nav à chaque
page utile sur un poste on navigue beaucoup au clavier/scan. */
.lien-evitement {
position: absolute;
left: -999px;
top: 0;
z-index: 10;
padding: var(--espace-2) var(--espace-3);
background: var(--accent);
color: var(--contraste);
font-weight: 600;
}
.lien-evitement:focus {
left: var(--espace-3);
top: var(--espace-3);
}
/* 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: 2px solid var(--accent);
outline-offset: 2px;
}
@ -70,25 +190,48 @@ select:focus-visible {
}
nav {
position: sticky;
top: 0;
z-index: 5;
display: flex;
gap: 1.5rem;
gap: var(--espace-5);
align-items: center;
margin: 0 -1rem 2.5rem;
padding: 1rem 1rem 0.85rem;
background: var(--encre);
padding: var(--espace-4) 1rem calc(var(--espace-4) - 0.15rem);
background: var(--contraste);
}
nav a {
display: inline-flex;
align-items: center;
gap: var(--espace-2);
text-decoration: none;
color: var(--papier);
color: var(--contraste-texte);
font-weight: 600;
font-size: 0.85rem;
font-size: var(--texte-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.75;
transition: opacity var(--transition), color var(--transition);
}
nav a svg {
flex: none;
width: 16px;
height: 16px;
}
nav a:hover {
color: var(--ambre);
opacity: 1;
color: var(--accent);
}
/* État actif : calculé dans base.html via request.url.path, exposé ici
par aria-current plutôt qu'une classe, pour que le style et la
sémantique accessible restent une seule et même source de vérité. */
nav a[aria-current="page"] {
opacity: 1;
color: var(--accent);
}
/*
@ -111,10 +254,10 @@ details > summary::-webkit-details-marker {
flex: none;
width: 0.55em;
height: 0.55em;
border-right: 2px solid var(--acier);
border-bottom: 2px solid var(--acier);
border-right: 2px solid var(--texte-secondaire);
border-bottom: 2px solid var(--texte-secondaire);
transform: rotate(-45deg);
transition: transform 0.15s ease;
transition: transform var(--transition);
}
details[open] > summary .noeud-chevron {
@ -126,8 +269,8 @@ details[open] > summary .noeud-chevron {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
gap: var(--espace-3);
margin-bottom: var(--espace-4);
}
.barre-recherche input[type="search"],
@ -135,12 +278,12 @@ details[open] > summary .noeud-chevron {
flex: 1;
min-width: 200px;
max-width: 360px;
background: var(--papier-creux);
background: var(--surface-creuse);
border: none;
}
.barre-filtres select {
background: var(--papier-creux);
background: var(--surface-creuse);
border: none;
}
@ -148,8 +291,8 @@ details[open] > summary .noeud-chevron {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--acier);
font-size: var(--texte-sm);
color: var(--texte-secondaire);
white-space: nowrap;
}
@ -158,28 +301,54 @@ details[open] > summary .noeud-chevron {
padding: 0;
}
/* Compteur de résultats à côté des filtres : feedback simple pour savoir
qu'une recherche/filtre "vide" est bien un résultat, pas un bug. */
.compteur-resultats {
font-size: var(--texte-sm);
color: var(--texte-secondaire);
white-space: nowrap;
}
/* Fil d'Ariane de catégorie : apparaît uniquement quand /materiels est
filtré par categorie_id, pour rappeler quel sous-arbre est affiché
(le select seul ne le montre pas une fois le formulaire soumis). */
.fil-ariane-categorie {
display: flex;
align-items: center;
gap: var(--espace-2);
flex-wrap: wrap;
font-size: var(--texte-sm);
color: var(--texte-secondaire);
margin: -0.5rem 0 var(--espace-4);
}
.fil-ariane-categorie .chemin-categorie {
font-weight: 600;
color: var(--texte);
}
/* En-tête de groupe quand la liste des matériels est regroupée par
catégorie (tri=categorie) : un simple séparateur textuel, pas une carte
à part entière, pour rester léger sur une longue liste. */
.groupe-categorie-entete {
font-family: var(--police-donnee);
font-size: 0.75rem;
font-size: var(--texte-xs);
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--acier);
color: var(--texte-secondaire);
padding: 0.9rem 0.5rem 0.3rem;
}
.lien-effacer {
font-size: 0.85rem;
color: var(--acier);
font-size: var(--texte-sm);
color: var(--texte-secondaire);
white-space: nowrap;
}
.liste-vide {
color: var(--acier);
color: var(--texte-secondaire);
font-style: italic;
padding: 1rem 0.25rem;
padding: var(--espace-4) 0.25rem;
}
/* Liste plate (résultats de recherche de catégories, destinataires). */
@ -187,16 +356,16 @@ details[open] > summary .noeud-chevron {
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
border-top: 1px solid var(--acier-clair);
border-top: 1px solid var(--bordure);
}
.liste-resultats li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
gap: var(--espace-4);
padding: 0.6rem 0.25rem;
border-bottom: 1px solid var(--acier-clair);
border-bottom: 1px solid var(--bordure);
}
.chemin-categorie {
@ -207,10 +376,32 @@ details[open] > summary .noeud-chevron {
white-space: nowrap;
}
/* Planification de l'email récapitulatif (/destinataires) : un select de
raccourcis courants et un champ libre pour une expression cron
personnalisée, tous deux visibles en même temps plutôt que de basculer
l'un/l'autre en JavaScript. */
/* Carte de réglage : regroupe visuellement plusieurs formulaires qui
modifient le même paramètre (ex: planification du résumé de stock, qui
se règle soit par raccourci, soit en cron libre) pour qu'ils se lisent
comme un seul réglage à deux modes de saisie plutôt que deux blocs
indépendants. */
.carte-reglage {
background: var(--surface-carte);
border: 1px solid var(--bordure);
border-radius: var(--rayon);
padding: var(--espace-4) var(--espace-5);
margin: var(--espace-4) 0 var(--espace-5);
display: flex;
flex-direction: column;
gap: var(--espace-3);
}
.carte-reglage .formulaire-planification,
.carte-reglage > form {
margin: 0;
}
.texte-secondaire {
margin: 0;
color: var(--texte-secondaire);
}
.formulaire-planification select {
min-width: 260px;
}
@ -220,6 +411,53 @@ details[open] > summary .noeud-chevron {
min-width: 200px;
}
/* ---------------------------------------------------------------------
Badges de statut : généralisation du "point + libellé" déjà utilisé sur
l'écran de scan (.statut-puce), réutilisé aussi dans la liste des
matériels pour que le stock bas se voie au premier coup d'œil sans
dépendre uniquement d'une couleur de texte.
------------------------------------------------------------------ */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-family: var(--police-corps);
font-size: var(--texte-xs);
font-weight: 600;
white-space: nowrap;
}
.badge-bas {
background: var(--danger-doux);
color: var(--danger);
}
.badge-ok {
background: var(--succes-doux);
color: var(--succes);
}
.statut-puce {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
flex: none;
}
.badge-bas .statut-puce,
.statut-puce.statut-bas {
background: var(--danger);
}
.badge-ok .statut-puce,
.statut-puce.statut-ok {
background: var(--succes);
}
/* ---------------------------------------------------------------------
Arborescence de catégories : profondeur illimitée, repliée par défaut.
------------------------------------------------------------------ */
@ -234,7 +472,7 @@ details[open] > summary .noeud-chevron {
.arbre-categories ul {
margin-left: 0.9rem;
padding-left: 0.85rem;
border-left: 1px solid var(--acier-clair);
border-left: 1px solid var(--bordure);
}
.noeud-categorie {
@ -246,11 +484,12 @@ details[open] > summary .noeud-chevron {
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.5rem;
border-radius: 4px;
border-radius: var(--rayon);
transition: background var(--transition);
}
.noeud-categorie > details > summary:hover {
background: var(--papier-creux);
background: var(--surface-creuse);
}
.noeud-nom {
@ -259,20 +498,20 @@ details[open] > summary .noeud-chevron {
.noeud-compte {
font-family: var(--police-donnee);
font-size: 0.75rem;
color: var(--acier);
font-size: var(--texte-xs);
color: var(--texte-secondaire);
}
.noeud-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
gap: var(--espace-3);
align-items: center;
padding: 0.4rem 0.5rem 0.6rem 1.9rem;
}
.formulaire-racine {
margin-bottom: 1.5rem;
margin-bottom: var(--espace-5);
}
/* ---------------------------------------------------------------------
@ -286,23 +525,24 @@ details[open] > summary .noeud-chevron {
list-style: none;
margin: 0 0 2rem;
padding: 0;
border-top: 1px solid var(--acier-clair);
border-top: 1px solid var(--bordure);
}
.liste-materiels > li {
border-bottom: 1px solid var(--acier-clair);
border-bottom: 1px solid var(--bordure);
}
.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;
gap: var(--espace-3);
padding: 0.6rem 0.5rem;
transition: background var(--transition);
}
.ligne-materiel > summary:hover {
background: var(--papier-creux);
background: var(--surface-creuse);
}
.materiel-nom,
@ -320,7 +560,7 @@ details[open] > summary .noeud-chevron {
.materiel-categorie,
.materiel-code {
font-size: 0.8rem;
color: var(--acier);
color: var(--texte-secondaire);
}
.materiel-code {
@ -328,6 +568,10 @@ details[open] > summary .noeud-chevron {
}
.materiel-quantite {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
font-family: var(--police-donnee);
font-weight: 700;
text-align: right;
@ -335,13 +579,48 @@ details[open] > summary .noeud-chevron {
}
.materiel-quantite.quantite-basse {
color: var(--brique);
color: var(--danger);
}
/* Sous ~640px, la grille 5 colonnes de la ligne de matériel n'a plus la
place de rester sur une seule ligne (le nom et la catégorie se
compressent trop). On repasse sur 2 lignes : nom + quantité au-dessus,
catégorie + code en dessous, en pleine largeur. */
@media (max-width: 640px) {
.ligne-materiel > summary {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"chevron nom quantite"
"chevron meta meta";
row-gap: 0.2rem;
}
.noeud-chevron {
grid-area: chevron;
}
.materiel-nom {
grid-area: nom;
}
.materiel-quantite {
grid-area: quantite;
}
.materiel-categorie {
grid-area: meta;
}
.materiel-code {
grid-area: meta;
display: none;
}
}
.formulaire-edition {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
gap: var(--espace-3);
padding: 0.25rem 0.5rem 1.25rem 1.9rem;
}
@ -350,55 +629,80 @@ details[open] > summary .noeud-chevron {
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--acier);
color: var(--texte-secondaire);
}
.formulaire-actions {
display: flex;
gap: 0.5rem;
gap: var(--espace-2);
align-items: end;
}
.formulaire-inline {
display: inline-flex;
gap: 0.5rem;
gap: var(--espace-2);
align-items: center;
}
.formulaire-grille {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
gap: var(--espace-3);
max-width: 400px;
}
.bouton-danger {
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: var(--brique);
}
input, select, button {
font-family: inherit;
font-size: 1rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--acier-clair);
border-radius: 4px;
border: 1px solid var(--bordure);
border-radius: var(--rayon);
background: var(--surface-carte);
color: var(--texte);
transition: border-color var(--transition), background var(--transition), color var(--transition);
}
button {
cursor: pointer;
border-color: var(--encre);
background: white;
background: var(--surface-carte);
color: var(--texte);
}
button:hover {
background: var(--encre);
color: var(--papier);
background: var(--surface-creuse);
}
/* Bouton principal : réservé à l'action la plus importante d'un
formulaire (créer, enregistrer). Les autres actions (filtrer,
réinitialiser, ajouter un enfant...) restent au style neutre par
défaut pour que le principal se distingue vraiment. */
.bouton-primaire {
background: var(--action-primaire);
color: var(--action-primaire-texte);
border-color: var(--action-primaire);
font-weight: 600;
}
.bouton-primaire:hover {
background: var(--accent);
border-color: var(--accent);
color: var(--contraste);
}
.bouton-danger {
background: color-mix(in srgb, var(--danger) 12%, var(--surface-carte));
color: var(--danger);
border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--surface-carte));
}
.bouton-danger:hover {
background: var(--danger);
color: var(--action-primaire-texte);
}
.message-erreur {
color: var(--danger);
}
/* ---------------------------------------------------------------------
@ -413,51 +717,51 @@ button:hover {
.scanner-entete {
text-align: center;
margin-bottom: 2rem;
margin-bottom: var(--espace-6);
}
.scanner-eyebrow {
font-family: var(--police-donnee);
font-size: 0.75rem;
font-size: var(--texte-xs);
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--acier);
color: var(--texte-secondaire);
margin: 0 0 0.25rem;
}
.scanner-entete h1 {
font-size: 1.75rem;
font-size: var(--texte-xl);
font-weight: 800;
margin: 0 0 1rem;
margin: 0 0 var(--espace-4);
}
.scanner-formulaire {
position: relative;
margin-bottom: 1.5rem;
margin-bottom: var(--espace-5);
}
.scanner-label {
display: block;
font-size: 0.75rem;
font-size: var(--texte-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--acier);
color: var(--texte-secondaire);
margin-bottom: 0.4rem;
}
.scanner-champ {
width: 100%;
font-family: var(--police-donnee);
font-size: 1.5rem;
font-size: var(--texte-lg);
letter-spacing: 0.05em;
padding: 0.9rem 1rem;
background: var(--papier-creux);
background: var(--surface-creuse);
border: none;
border-radius: 4px 4px 0 0;
border-radius: var(--rayon) var(--rayon) 0 0;
}
.scanner-champ::placeholder {
color: var(--acier);
color: var(--texte-secondaire);
opacity: 0.7;
}
@ -469,13 +773,13 @@ button:hover {
*/
.laser-ligne {
height: 3px;
background: var(--acier-clair);
border-radius: 0 0 4px 4px;
background: var(--bordure);
border-radius: 0 0 var(--rayon) var(--rayon);
overflow: hidden;
}
.scanner-champ:focus + .laser-ligne {
background: var(--ambre);
background: var(--accent);
animation: balayage-laser 1.6s ease-in-out infinite;
}
@ -488,12 +792,12 @@ button:hover {
scan trouvé, mais avec un formulaire de création à la place du détail
du stock pour créer le matériel sans quitter l'écran de scan. */
.etiquette-creation .etiquette-nom {
color: var(--brique);
color: var(--danger);
}
.etiquette-sous-titre {
padding: 0 1.25rem 1.25rem;
color: var(--acier);
color: var(--texte-secondaire);
}
.etiquette-formulaire {
@ -507,9 +811,9 @@ button:hover {
* une courte animation d'apparition ("tampon" qui vient de s'imprimer).
*/
.etiquette-resultat {
background: white;
border: 1px solid var(--acier-clair);
border-radius: 4px;
background: var(--surface-carte);
border: 1px solid var(--bordure);
border-radius: var(--rayon);
overflow: hidden;
animation: apparition-etiquette 0.25s ease-out;
}
@ -532,60 +836,45 @@ button:hover {
}
.etiquette-fil {
font-size: 0.75rem;
font-size: var(--texte-xs);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--acier);
margin: 1rem 0 0.15rem;
color: var(--texte-secondaire);
margin: var(--espace-4) 0 0.15rem;
}
.etiquette-nom {
font-size: 1.3rem;
font-size: var(--texte-lg);
font-weight: 800;
margin: 0 0 1rem;
margin: 0 0 var(--espace-4);
}
.etiquette-quantite {
display: flex;
align-items: baseline;
gap: 0.5rem;
gap: var(--espace-2);
}
.etiquette-nombre {
font-family: var(--police-donnee);
font-size: 3rem;
font-size: var(--texte-2xl);
font-weight: 700;
line-height: 1;
}
.etiquette-unite {
color: var(--acier);
color: var(--texte-secondaire);
}
.etiquette-statut {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--acier);
font-size: var(--texte-sm);
color: var(--texte-secondaire);
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);
}
/*
* Deux gestes distincts sur le stock : "ajuster" (formulaire-ajustement,
* un delta relatif pas forcément ±1) et "définir" (formulaire-definir,
@ -596,32 +885,32 @@ button:hover {
.etiquette-controles {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1.25rem;
padding: 1rem 1.25rem 1.25rem;
background: var(--papier-creux);
gap: var(--espace-3);
margin-top: var(--espace-5);
padding: var(--espace-4) 1.25rem 1.25rem;
background: var(--surface-creuse);
}
.etiquette-champ-quantite {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.75rem;
font-size: var(--texte-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--acier);
color: var(--texte-secondaire);
}
.etiquette-champ-quantite input {
font-family: var(--police-donnee);
font-size: 1.1rem;
background: white;
background: var(--surface-carte);
}
.formulaire-definir {
display: flex;
align-items: end;
gap: 0.75rem;
gap: var(--espace-3);
}
.formulaire-definir .etiquette-champ-quantite {
@ -632,8 +921,8 @@ button:hover {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--acier-clair);
margin-top: 0.5rem;
background: var(--bordure);
margin-top: var(--espace-2);
}
.bouton-scan {
@ -643,12 +932,12 @@ button:hover {
padding: 0.9rem;
font-weight: 700;
letter-spacing: 0.03em;
background: white;
background: var(--surface-carte);
}
.bouton-scan:hover {
background: var(--papier-creux);
color: var(--encre);
background: var(--surface-creuse);
color: var(--texte);
}
.bouton-scan:active {
@ -656,11 +945,11 @@ button:hover {
}
.bouton-reception {
color: var(--labo);
color: var(--succes);
}
.bouton-sortie {
color: var(--brique);
color: var(--danger);
}
/* Respecte la préférence système : pas d'animation pour qui la désactive. */
@ -671,4 +960,7 @@ button:hover {
.etiquette-resultat {
animation: none;
}
* {
transition: none !important;
}
}

View file

@ -7,13 +7,50 @@
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<a href="#contenu" class="lien-evitement">Aller au contenu</a>
{#
Icônes SVG dessinées à la main (traits simples, currentColor) plutôt
qu'une icon font externe : le poste peut tourner sans accès internet
(voir CLAUDE.md), donc rien qui dépende d'un CDN.
L'état actif compare request.url.path au préfixe de chaque section :
"request" est injecté automatiquement dans le contexte Jinja par
Starlette (TemplateResponse(request, ...)), donc aucune route n'a
besoin de le transmettre explicitement pour que ça fonctionne ici.
#}
{% set chemin = request.url.path %}
<nav>
<a href="/materiels">Matériels</a>
<a href="/categories">Catégories</a>
<a href="/scan">Scanner</a>
<a href="/destinataires">Alertes</a>
<a href="/materiels" {{ 'aria-current="page"' | safe if chemin == "/materiels" or chemin == "/" else "" }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 8 12 3 3 8v8l9 5 9-5Z"></path>
<path d="M3 8l9 5 9-5"></path>
<path d="M12 13v8"></path>
</svg>
Matériels
</a>
<a href="/categories" {{ 'aria-current="page"' | safe if chemin.startswith("/categories") else "" }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1Z"></path>
</svg>
Catégories
</a>
<a href="/scan" {{ 'aria-current="page"' | safe if chemin.startswith("/scan") else "" }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 7V4h3M21 7V4h-3M3 17v3h3M21 17v3h-3"></path>
<path d="M7 6v12M11 6v12M14 6v12M18 6v12"></path>
</svg>
Scanner
</a>
<a href="/destinataires" {{ 'aria-current="page"' | safe if chemin.startswith("/destinataires") else "" }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 4a5 5 0 0 0-5 5v3l-2 4h14l-2-4V9a5 5 0 0 0-5-5Z"></path>
<path d="M9.5 20a2.5 2.5 0 0 0 5 0"></path>
</svg>
Alertes
</a>
</nav>
<main>
<main id="contenu">
{% block contenu %}{% endblock %}
</main>
</body>

View file

@ -53,7 +53,7 @@
<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>
<button type="submit" class="bouton-primaire">Ajouter</button>
</form>
{% if q %}

View file

@ -25,46 +25,56 @@
<form method="post" action="/destinataires" class="formulaire-inline">
<input type="email" name="email" placeholder="email@clinique.local" required>
<button type="submit">Ajouter</button>
<button type="submit" class="bouton-primaire">Ajouter</button>
</form>
<h2>Résumé périodique du stock</h2>
<p class="etiquette-sous-titre">
Un email récapitulatif (stock bas en premier, puis le reste du stock) est
envoyé automatiquement selon l'horaire ci-dessous, aux destinataires listés
plus haut.
{% if prochaine_execution %}
Prochain envoi : {{ prochaine_execution.strftime("%d/%m/%Y à %H:%M") }}.
{% endif %}
</p>
{% if erreur_cron %}
<p class="message-erreur">{{ erreur_cron }}</p>
{% endif %}
{% set exemples_valeurs = exemples_cron | map(attribute=0) | list %}
<form method="post" action="/destinataires/planification" class="formulaire-inline formulaire-planification">
<select name="expression_cron">
{% for expression, description in exemples_cron %}
<option value="{{ expression }}" {{ "selected" if expression == expression_cron else "" }}>
{{ description }} ({{ expression }})
</option>
{% endfor %}
{% if expression_cron not in exemples_valeurs %}
<option value="{{ expression_cron }}" selected>Personnalisé ({{ expression_cron }})</option>
{#
Les deux formulaires ci-dessous modifient le même réglage
(expression_cron) : l'un via des raccourcis courants, l'autre en saisie
libre. Les regrouper dans une même carte les fait lire comme un seul
réglage à deux modes de saisie plutôt que deux actions indépendantes.
#}
<div class="carte-reglage">
<p class="texte-secondaire">
Un email récapitulatif (stock bas en premier, puis le reste du stock) est
envoyé automatiquement selon l'horaire ci-dessous, aux destinataires listés
plus haut.
{% if prochaine_execution %}
Prochain envoi : {{ prochaine_execution.strftime("%d/%m/%Y à %H:%M") }}.
{% endif %}
</select>
<button type="submit">Appliquer</button>
</form>
</p>
<form method="post" action="/destinataires/planification" class="formulaire-inline">
<input type="text" name="expression_cron" value="{{ expression_cron }}" class="champ-cron" placeholder="0 9 * * mon">
<button type="submit">Enregistrer une expression personnalisée</button>
</form>
<p class="etiquette-sous-titre">
Format cron : minute (0-59) heure (0-23) jour (1-31) mois (1-12) jour de la
semaine. Utilisez les noms de jours (mon, tue, wed, thu, fri, sat, sun),
pas de chiffres — leur numérotation ne suit pas le cron Unix habituel et
prête à confusion.
</p>
{% if erreur_cron %}
<p class="message-erreur">{{ erreur_cron }}</p>
{% endif %}
{% set exemples_valeurs = exemples_cron | map(attribute=0) | list %}
<form method="post" action="/destinataires/planification" class="formulaire-inline formulaire-planification">
<select name="expression_cron">
{% for expression, description in exemples_cron %}
<option value="{{ expression }}" {{ "selected" if expression == expression_cron else "" }}>
{{ description }} ({{ expression }})
</option>
{% endfor %}
{% if expression_cron not in exemples_valeurs %}
<option value="{{ expression_cron }}" selected>Personnalisé ({{ expression_cron }})</option>
{% endif %}
</select>
<button type="submit">Appliquer</button>
</form>
<form method="post" action="/destinataires/planification" class="formulaire-inline">
<input type="text" name="expression_cron" value="{{ expression_cron }}" class="champ-cron" placeholder="0 9 * * mon">
<button type="submit">Enregistrer une expression personnalisée</button>
</form>
<p class="texte-secondaire">
Format cron : minute (0-59) heure (0-23) jour (1-31) mois (1-12) jour de la
semaine. Utilisez les noms de jours (mon, tue, wed, thu, fri, sat, sun),
pas de chiffres — leur numérotation ne suit pas le cron Unix habituel et
prête à confusion.
</p>
</div>
{% endblock %}

View file

@ -21,7 +21,10 @@
<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>
<span class="materiel-quantite {{ 'quantite-basse' if sous_seuil else '' }}">
{{ materiel.quantite }}
{% if sous_seuil %}<span class="badge badge-bas"><span class="statut-puce" aria-hidden="true"></span>bas</span>{% endif %}
</span>
</summary>
<form method="post" action="/materiels/{{ materiel.id }}/modifier" class="formulaire-edition">
@ -87,8 +90,23 @@
{% if q or categorie_id or stock_bas or tri != "nom_asc" %}
<a href="/materiels" class="lien-effacer">Réinitialiser</a>
{% endif %}
<span class="compteur-resultats">{{ materiels|length }} matériel(s)</span>
</form>
{#
Le select de catégorie ci-dessus ne montre plus, une fois soumis, quel
sous-arbre est réellement affiché (juste son nom dans la liste
déroulante). Ce fil d'Ariane le rappelle explicitement, avec un lien
pour revenir à "toutes les catégories".
#}
{% if categorie_selectionnee %}
<p class="fil-ariane-categorie">
Filtré sur <span class="chemin-categorie">{{ categorie_selectionnee.chemin() }}</span>
<a href="/materiels{{ '?q=' + q if q else '' }}" class="lien-effacer">(retirer)</a>
</p>
{% endif %}
<ul class="liste-materiels">
{% if groupes %}
{% for chemin, groupe in groupes %}
@ -123,7 +141,7 @@
<label>Code-barre <input type="text" name="code_barre"></label>
<label>Quantité initiale <input type="number" name="quantite" value="0" min="0"></label>
<label>Seuil d'alerte <input type="number" name="seuil_alerte" min="0"></label>
<button type="submit">Créer</button>
<button type="submit" class="bouton-primaire">Créer</button>
</form>
{% else %}
<p>Créez d'abord une <a href="/categories">catégorie</a> avant d'ajouter un matériel.</p>

View file

@ -2,3 +2,4 @@
pytest>=8.3
httpx>=0.27
ruff>=0.7
playwright>=1.61