- nouvelle page /retenue : sélection apprenti, date retenue, date du
problème, motif (3 cases mutex), branche (autocomplete + saisie libre
depuis NotesExamen), remarque. Génération PDF basée sur le template
AcroForm officiel, séparation des 3 widgets Date partagés en 3 champs
distincts pour ne remplir que celui de la case cochée. Téléchargement
ou envoi par email (3 destinataires).
- profession : nouveau champ ApprentiFiche.profession, dérivé du préfixe
de classe via mapping configurable dans Paramètres
("AUTOMAT" → "Automaticien CFC" par défaut). Section dédiée avec
classes orphelines détectées automatiquement.
- notices Escada : nouvelle table Notice (apprenti, titre, remarque,
date, status). Checkbox "Ajouter automatiquement une notice sur
Escada" sur /retenue qui crée une entrée pending. Bloc dédié sur
/escada listant les pending, bouton "Pousser les notices" qui lance
scripts/push_notices.py (Playwright : navigation Classes → Élèves →
Notices → Ajouter, fill date / titre / remarque, vérification post-save,
suppression DB si OK, marquage failed sinon). Nouveau task_kind "push_notices"
dans le cron pour exécution planifiée.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
684 lines
24 KiB
Python
684 lines
24 KiB
Python
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import reflex as rx
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
if str(_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_ROOT))
|
|
|
|
from src.profession import load_mapping, save_mapping, find_unmapped_classes, refresh_all_professions # noqa: E402
|
|
from src.db import get_session # noqa: E402
|
|
|
|
from ..sidebar import layout
|
|
from ..state import AuthState
|
|
|
|
_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
DATA_DIR = Path(os.getenv("DATA_DIR", str(_ROOT / "data")))
|
|
_SETTINGS_FILE = DATA_DIR / "settings.json"
|
|
|
|
_DEFAULT_SANCTION = (
|
|
"Selon le règlement de l'EM, l'apprenti a dépassé le nombre d'absences limite."
|
|
)
|
|
_DEFAULT_TEMPLATE_SUBJ = "Document EPTM — {nom_complet} ({classe})"
|
|
_DEFAULT_TEMPLATE_BODY = (
|
|
"Bonjour {prenom},\n\n"
|
|
"Veuillez trouver ci-joint votre document pour la classe {classe}.\n\n"
|
|
"Cordialement,\nL'équipe EPTM"
|
|
)
|
|
|
|
|
|
def _read_settings() -> dict:
|
|
if _SETTINGS_FILE.exists():
|
|
try:
|
|
return json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _write_settings(data: dict) -> None:
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
_SETTINGS_FILE.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
|
|
|
|
class ParamsState(AuthState):
|
|
# ── Sanction ──────────────────────────────────────────────────────────────
|
|
texte_sanction: str = ""
|
|
chef_section: str = ""
|
|
save_ok_sanction: bool = False
|
|
|
|
# ── SMTP ──────────────────────────────────────────────────────────────────
|
|
smtp_host: str = ""
|
|
smtp_port: str = "587"
|
|
smtp_login: str = ""
|
|
smtp_password: str = ""
|
|
smtp_sender: str = ""
|
|
save_ok_smtp: bool = False
|
|
|
|
# ── Escada ────────────────────────────────────────────────────────────────
|
|
escada_username: str = ""
|
|
escada_password: str = ""
|
|
totp_secret: str = ""
|
|
save_ok_escada: bool = False
|
|
|
|
# ── Template email ────────────────────────────────────────────────────────
|
|
email_subject: str = ""
|
|
email_body: str = ""
|
|
save_ok_template: bool = False
|
|
|
|
# ── App ───────────────────────────────────────────────────────────────────
|
|
app_base_url: str = ""
|
|
save_ok_app: bool = False
|
|
|
|
# ── Profession mapping ────────────────────────────────────────────────────
|
|
prof_mapping: list[dict] = []
|
|
prof_unmapped: list[str] = []
|
|
new_prefix: str = ""
|
|
new_profession: str = ""
|
|
save_ok_prof: bool = False
|
|
refresh_msg: str = ""
|
|
|
|
# ── Setters ───────────────────────────────────────────────────────────────
|
|
def set_texte_sanction(self, v: str): self.texte_sanction = v
|
|
def set_chef_section(self, v: str): self.chef_section = v
|
|
def set_smtp_host(self, v: str): self.smtp_host = v
|
|
def set_smtp_port(self, v: str): self.smtp_port = v
|
|
def set_smtp_login(self, v: str): self.smtp_login = v
|
|
def set_smtp_password(self, v: str): self.smtp_password = v
|
|
def set_smtp_sender(self, v: str): self.smtp_sender = v
|
|
def set_escada_username(self, v: str): self.escada_username = v
|
|
def set_escada_password(self, v: str): self.escada_password = v
|
|
def set_totp_secret(self, v: str): self.totp_secret = v
|
|
def set_email_subject(self, v: str): self.email_subject = v
|
|
def set_email_body(self, v: str): self.email_body = v
|
|
def set_app_base_url(self, v: str): self.app_base_url = v
|
|
def set_new_prefix(self, v: str): self.new_prefix = v
|
|
def set_new_profession(self, v: str): self.new_profession = v
|
|
|
|
def load_data(self):
|
|
if not self.authenticated:
|
|
return rx.redirect("/login")
|
|
s = _read_settings()
|
|
self.texte_sanction = s.get("texte_sanction", _DEFAULT_SANCTION)
|
|
self.chef_section = s.get("chef_section", "Patrick Rausis")
|
|
self.smtp_host = s.get("smtp_host", "smtp-relay.brevo.com")
|
|
self.smtp_port = str(s.get("smtp_port", 587))
|
|
self.smtp_login = s.get("smtp_login", s.get("smtp_email", ""))
|
|
self.smtp_password = s.get("smtp_password", "")
|
|
self.smtp_sender = s.get("smtp_sender", "EPTM Automation <noreply@eptm-automation.ch>")
|
|
self.escada_username = s.get("escada_username", "")
|
|
self.escada_password = s.get("escada_password", "")
|
|
self.totp_secret = s.get("totp_secret", "")
|
|
self.email_subject = s.get("email_subject", _DEFAULT_TEMPLATE_SUBJ)
|
|
self.email_body = s.get("email_body", _DEFAULT_TEMPLATE_BODY)
|
|
self.app_base_url = s.get("app_base_url", "https://dev.dashboard.eptm-automation.ch")
|
|
self.save_ok_sanction = False
|
|
self.save_ok_smtp = False
|
|
self.save_ok_escada = False
|
|
self.save_ok_template = False
|
|
self.save_ok_app = False
|
|
self._reload_prof_mapping()
|
|
|
|
def _reload_prof_mapping(self):
|
|
self.prof_mapping = load_mapping()
|
|
sess = get_session()
|
|
try:
|
|
self.prof_unmapped = find_unmapped_classes(sess)
|
|
finally:
|
|
sess.close()
|
|
self.save_ok_prof = False
|
|
self.refresh_msg = ""
|
|
|
|
def save_sanctions(self):
|
|
s = _read_settings()
|
|
s["texte_sanction"] = self.texte_sanction.strip()
|
|
s["chef_section"] = self.chef_section.strip()
|
|
_write_settings(s)
|
|
self.save_ok_sanction = True
|
|
self.save_ok_smtp = False
|
|
self.save_ok_escada = False
|
|
self.save_ok_template = False
|
|
|
|
def save_smtp(self):
|
|
s = _read_settings()
|
|
s["smtp_host"] = self.smtp_host.strip()
|
|
try:
|
|
s["smtp_port"] = int(self.smtp_port)
|
|
except Exception:
|
|
s["smtp_port"] = 587
|
|
s["smtp_login"] = self.smtp_login.strip()
|
|
s["smtp_password"] = self.smtp_password.strip()
|
|
s["smtp_sender"] = self.smtp_sender.strip()
|
|
s.pop("smtp_email", None)
|
|
_write_settings(s)
|
|
self.save_ok_smtp = True
|
|
self.save_ok_sanction = False
|
|
self.save_ok_escada = False
|
|
self.save_ok_template = False
|
|
|
|
def save_escada(self):
|
|
s = _read_settings()
|
|
s["escada_username"] = self.escada_username.strip()
|
|
s["escada_password"] = self.escada_password.strip()
|
|
s["totp_secret"] = self.totp_secret.strip()
|
|
_write_settings(s)
|
|
self.save_ok_escada = True
|
|
self.save_ok_sanction = False
|
|
self.save_ok_smtp = False
|
|
self.save_ok_template = False
|
|
|
|
def save_template(self):
|
|
s = _read_settings()
|
|
s["email_subject"] = self.email_subject
|
|
s["email_body"] = self.email_body
|
|
_write_settings(s)
|
|
self.save_ok_template = True
|
|
self.save_ok_sanction = False
|
|
self.save_ok_smtp = False
|
|
self.save_ok_escada = False
|
|
self.save_ok_app = False
|
|
|
|
def save_app(self):
|
|
s = _read_settings()
|
|
s["app_base_url"] = self.app_base_url.strip().rstrip("/")
|
|
_write_settings(s)
|
|
self.save_ok_app = True
|
|
self.save_ok_sanction = False
|
|
self.save_ok_smtp = False
|
|
self.save_ok_escada = False
|
|
self.save_ok_template = False
|
|
|
|
# ── Profession mapping ───────────────────────────────────────────────────
|
|
def add_mapping(self):
|
|
prefix = self.new_prefix.strip()
|
|
prof = self.new_profession.strip()
|
|
if not prefix or not prof:
|
|
return
|
|
cur = list(self.prof_mapping)
|
|
# Si le préfixe existe déjà, on met juste à jour la profession
|
|
for m in cur:
|
|
if m.get("prefix") == prefix:
|
|
m["profession"] = prof
|
|
break
|
|
else:
|
|
cur.append({"prefix": prefix, "profession": prof})
|
|
save_mapping(cur)
|
|
self.new_prefix = ""
|
|
self.new_profession = ""
|
|
self._reload_prof_mapping()
|
|
self.save_ok_prof = True
|
|
|
|
def remove_mapping(self, prefix: str):
|
|
cur = [m for m in self.prof_mapping if m.get("prefix") != prefix]
|
|
save_mapping(cur)
|
|
self._reload_prof_mapping()
|
|
self.save_ok_prof = True
|
|
|
|
def quick_add_prefix(self, prefix: str):
|
|
"""Pré-remplit le formulaire avec une classe orpheline."""
|
|
self.new_prefix = prefix
|
|
self.new_profession = ""
|
|
|
|
def apply_mapping_to_db(self):
|
|
"""Recalcule la profession pour tous les apprentis avec le mapping actuel."""
|
|
sess = get_session()
|
|
try:
|
|
n = refresh_all_professions(sess)
|
|
finally:
|
|
sess.close()
|
|
self.refresh_msg = f"{n} fiche(s) mise(s) à jour."
|
|
|
|
|
|
# ── UI helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _label(text: str) -> rx.Component:
|
|
return rx.text(text, size="2", weight="medium", color="var(--gray-11)")
|
|
|
|
|
|
def _section(title: str, *children) -> rx.Component:
|
|
return rx.box(
|
|
rx.vstack(
|
|
rx.text(title, size="4", weight="bold"),
|
|
rx.divider(),
|
|
*children,
|
|
spacing="3",
|
|
width="100%",
|
|
),
|
|
padding="1.25rem",
|
|
background_color="white",
|
|
border_radius="8px",
|
|
border="1px solid #e0e0e0",
|
|
width="100%",
|
|
)
|
|
|
|
|
|
def _save_ok_callout(show: bool) -> rx.Component:
|
|
return rx.cond(
|
|
show,
|
|
rx.callout.root(
|
|
rx.callout.icon(rx.icon("check", size=16)),
|
|
rx.callout.text("Paramètres enregistrés."),
|
|
color_scheme="green",
|
|
variant="soft",
|
|
size="1",
|
|
),
|
|
rx.fragment(),
|
|
)
|
|
|
|
|
|
def _input_row(*items) -> rx.Component:
|
|
return rx.hstack(*items, spacing="4", width="100%", flex_wrap="wrap")
|
|
|
|
|
|
def _field(label: str, input_component: rx.Component) -> rx.Component:
|
|
return rx.vstack(
|
|
_label(label),
|
|
input_component,
|
|
spacing="1",
|
|
flex="1",
|
|
min_width="200px",
|
|
width="100%",
|
|
)
|
|
|
|
|
|
# ── Sections ──────────────────────────────────────────────────────────────────
|
|
|
|
def _section_sanction() -> rx.Component:
|
|
return _section(
|
|
"Avis de sanction",
|
|
_field(
|
|
"Texte de description par défaut (champ TexteDescription)",
|
|
rx.text_area(
|
|
value=ParamsState.texte_sanction,
|
|
on_change=ParamsState.set_texte_sanction,
|
|
rows="4",
|
|
width="100%",
|
|
resize="vertical",
|
|
),
|
|
),
|
|
_field(
|
|
"Chef de section (CS)",
|
|
rx.input(
|
|
value=ParamsState.chef_section,
|
|
on_change=ParamsState.set_chef_section,
|
|
width="100%",
|
|
),
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=16),
|
|
"Enregistrer sanctions",
|
|
on_click=ParamsState.save_sanctions,
|
|
color_scheme="blue",
|
|
variant="solid",
|
|
size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_sanction),
|
|
spacing="3",
|
|
align="center",
|
|
flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
def _section_smtp() -> rx.Component:
|
|
return _section(
|
|
"Configuration email",
|
|
_input_row(
|
|
_field(
|
|
"Serveur SMTP",
|
|
rx.input(
|
|
value=ParamsState.smtp_host,
|
|
on_change=ParamsState.set_smtp_host,
|
|
placeholder="smtp-relay.brevo.com",
|
|
width="100%",
|
|
),
|
|
),
|
|
_field(
|
|
"Port",
|
|
rx.input(
|
|
value=ParamsState.smtp_port,
|
|
on_change=ParamsState.set_smtp_port,
|
|
type="number",
|
|
placeholder="587",
|
|
width="100%",
|
|
),
|
|
),
|
|
),
|
|
_input_row(
|
|
_field(
|
|
"Login SMTP (authentification)",
|
|
rx.input(
|
|
value=ParamsState.smtp_login,
|
|
on_change=ParamsState.set_smtp_login,
|
|
placeholder="login@domaine.com",
|
|
width="100%",
|
|
),
|
|
),
|
|
_field(
|
|
"Mot de passe SMTP",
|
|
rx.input(
|
|
value=ParamsState.smtp_password,
|
|
on_change=ParamsState.set_smtp_password,
|
|
type="password",
|
|
placeholder="••••••••",
|
|
width="100%",
|
|
),
|
|
),
|
|
),
|
|
_field(
|
|
"Expéditeur",
|
|
rx.input(
|
|
value=ParamsState.smtp_sender,
|
|
on_change=ParamsState.set_smtp_sender,
|
|
placeholder="EPTM Automation <noreply@eptm-automation.ch>",
|
|
width="100%",
|
|
),
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=16),
|
|
"Enregistrer configuration SMTP",
|
|
on_click=ParamsState.save_smtp,
|
|
color_scheme="blue",
|
|
variant="solid",
|
|
size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_smtp),
|
|
spacing="3",
|
|
align="center",
|
|
flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
def _section_escada() -> rx.Component:
|
|
return _section(
|
|
"Connexion Escada (synchronisation automatique)",
|
|
rx.text(
|
|
"Si renseignés, identifiant, mot de passe et code 2FA sont saisis automatiquement lors de la synchronisation.",
|
|
size="2",
|
|
color="var(--gray-10)",
|
|
),
|
|
_input_row(
|
|
_field(
|
|
"Identifiant Escada",
|
|
rx.input(
|
|
value=ParamsState.escada_username,
|
|
on_change=ParamsState.set_escada_username,
|
|
placeholder="prenom.nom",
|
|
width="100%",
|
|
),
|
|
),
|
|
_field(
|
|
"Mot de passe Escada",
|
|
rx.input(
|
|
value=ParamsState.escada_password,
|
|
on_change=ParamsState.set_escada_password,
|
|
type="password",
|
|
placeholder="••••••••",
|
|
width="100%",
|
|
),
|
|
),
|
|
),
|
|
_field(
|
|
"Clé secrète 2FA (TOTP) — Format Base32",
|
|
rx.input(
|
|
value=ParamsState.totp_secret,
|
|
on_change=ParamsState.set_totp_secret,
|
|
type="password",
|
|
placeholder="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
|
|
width="100%",
|
|
),
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=16),
|
|
"Enregistrer connexion Escada",
|
|
on_click=ParamsState.save_escada,
|
|
color_scheme="blue",
|
|
variant="solid",
|
|
size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_escada),
|
|
spacing="3",
|
|
align="center",
|
|
flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
def _section_template() -> rx.Component:
|
|
return _section(
|
|
"Template email",
|
|
rx.box(
|
|
rx.text(
|
|
"Variables disponibles : ",
|
|
rx.code("{prenom}"), ", ",
|
|
rx.code("{nom}"), ", ",
|
|
rx.code("{nom_complet}"), ", ",
|
|
rx.code("{classe}"), ", ",
|
|
rx.code("{nb_absences}"), ", ",
|
|
rx.code("{nb_excusees}"), ", ",
|
|
rx.code("{nb_non_excusees}"), ", ",
|
|
rx.code("{nb_a_traiter}"), ", ",
|
|
rx.code("{semestre}"), ", ",
|
|
rx.code("{date_du_jour}"),
|
|
size="2",
|
|
color="var(--gray-10)",
|
|
),
|
|
padding="0.5rem 0.75rem",
|
|
background_color="var(--gray-2)",
|
|
border_radius="6px",
|
|
border="1px solid var(--gray-4)",
|
|
),
|
|
_field(
|
|
"Objet",
|
|
rx.input(
|
|
value=ParamsState.email_subject,
|
|
on_change=ParamsState.set_email_subject,
|
|
width="100%",
|
|
),
|
|
),
|
|
_field(
|
|
"Corps du message",
|
|
rx.text_area(
|
|
value=ParamsState.email_body,
|
|
on_change=ParamsState.set_email_body,
|
|
rows="8",
|
|
width="100%",
|
|
resize="vertical",
|
|
font_family="monospace",
|
|
font_size="0.85rem",
|
|
),
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=16),
|
|
"Enregistrer template",
|
|
on_click=ParamsState.save_template,
|
|
color_scheme="blue",
|
|
variant="solid",
|
|
size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_template),
|
|
spacing="3",
|
|
align="center",
|
|
flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
def _mapping_row(m: rx.Var) -> rx.Component:
|
|
return rx.flex(
|
|
rx.box(
|
|
rx.text("Préfixe", size="1", color="var(--gray-10)"),
|
|
rx.text(m["prefix"], size="2", weight="medium"),
|
|
flex="1", min_width="120px",
|
|
),
|
|
rx.box(
|
|
rx.text("Profession", size="1", color="var(--gray-10)"),
|
|
rx.text(m["profession"], size="2"),
|
|
flex="2", min_width="200px",
|
|
),
|
|
rx.button(
|
|
rx.icon("trash-2", size=14),
|
|
on_click=ParamsState.remove_mapping(m["prefix"]),
|
|
color_scheme="red", variant="ghost", size="1",
|
|
),
|
|
gap="0.75rem", align="center", flex_wrap="wrap",
|
|
padding="0.4rem 0.6rem",
|
|
border="1px solid var(--gray-5)",
|
|
border_radius="6px",
|
|
background_color="white",
|
|
width="100%",
|
|
)
|
|
|
|
|
|
def _unmapped_chip(classe: rx.Var) -> rx.Component:
|
|
return rx.button(
|
|
rx.icon("plus", size=12),
|
|
classe,
|
|
on_click=ParamsState.quick_add_prefix(classe),
|
|
color_scheme="amber", variant="soft", size="1",
|
|
)
|
|
|
|
|
|
def _section_profession() -> rx.Component:
|
|
return _section(
|
|
"Correspondances classe → profession",
|
|
rx.text(
|
|
"Lors de l'import des données apprentis, la profession est dérivée "
|
|
"du préfixe de la classe (ex. classe « AUTOMAT 1 » → profession "
|
|
"« Automaticien CFC »). Utilisée notamment dans les avis de retenue.",
|
|
size="1", color="var(--gray-11)",
|
|
),
|
|
# Tableau des correspondances
|
|
rx.cond(
|
|
ParamsState.prof_mapping.length() > 0,
|
|
rx.vstack(
|
|
rx.foreach(ParamsState.prof_mapping, _mapping_row),
|
|
spacing="2", width="100%",
|
|
),
|
|
rx.text("Aucune correspondance configurée.", size="2", color="var(--gray-10)"),
|
|
),
|
|
# Classes orphelines
|
|
rx.cond(
|
|
ParamsState.prof_unmapped.length() > 0,
|
|
rx.box(
|
|
rx.text(
|
|
"Classes sans correspondance (clique pour ajouter) :",
|
|
size="2", weight="medium", color="#92400e", margin_bottom="0.4rem",
|
|
),
|
|
rx.flex(
|
|
rx.foreach(ParamsState.prof_unmapped, _unmapped_chip),
|
|
gap="0.35rem", flex_wrap="wrap",
|
|
),
|
|
padding="0.75rem",
|
|
background_color="#fef3c7",
|
|
border="1px solid #fcd34d",
|
|
border_radius="6px",
|
|
width="100%",
|
|
),
|
|
rx.fragment(),
|
|
),
|
|
# Ajout d'une nouvelle correspondance
|
|
rx.divider(),
|
|
rx.text("Ajouter / modifier une correspondance", size="2", weight="medium"),
|
|
rx.flex(
|
|
_field(
|
|
"Préfixe de classe",
|
|
rx.input(
|
|
value=ParamsState.new_prefix,
|
|
on_change=ParamsState.set_new_prefix,
|
|
placeholder="ex. AUTOMAT",
|
|
width="100%",
|
|
),
|
|
),
|
|
_field(
|
|
"Profession",
|
|
rx.input(
|
|
value=ParamsState.new_profession,
|
|
on_change=ParamsState.set_new_profession,
|
|
placeholder="ex. Automaticien CFC",
|
|
width="100%",
|
|
),
|
|
),
|
|
gap="0.75rem", flex_wrap="wrap", width="100%",
|
|
),
|
|
rx.flex(
|
|
rx.button(
|
|
rx.icon("plus", size=16),
|
|
"Ajouter / mettre à jour",
|
|
on_click=ParamsState.add_mapping,
|
|
color_scheme="blue", size="2",
|
|
),
|
|
rx.button(
|
|
rx.icon("refresh-cw", size=14),
|
|
"Appliquer aux fiches existantes",
|
|
on_click=ParamsState.apply_mapping_to_db,
|
|
color_scheme="gray", variant="soft", size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_prof),
|
|
rx.cond(
|
|
ParamsState.refresh_msg != "",
|
|
rx.text(ParamsState.refresh_msg, size="1", color="#15803d"),
|
|
rx.fragment(),
|
|
),
|
|
gap="0.5rem", align="center", flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
def _section_app() -> rx.Component:
|
|
return _section(
|
|
"Application",
|
|
rx.text(
|
|
"URL de base de l'application — utilisée pour générer les liens "
|
|
"envoyés par email (création de compte, réinitialisation de mot de passe).",
|
|
size="1", color="var(--gray-11)",
|
|
),
|
|
_field(
|
|
"URL de base (sans /)",
|
|
rx.input(
|
|
value=ParamsState.app_base_url,
|
|
on_change=ParamsState.set_app_base_url,
|
|
placeholder="https://dashboard.eptm-automation.ch",
|
|
width="100%",
|
|
),
|
|
),
|
|
rx.hstack(
|
|
rx.button(
|
|
rx.icon("save", size=16),
|
|
"Enregistrer",
|
|
on_click=ParamsState.save_app,
|
|
color_scheme="blue", variant="solid", size="2",
|
|
),
|
|
_save_ok_callout(ParamsState.save_ok_app),
|
|
spacing="3", align="center", flex_wrap="wrap",
|
|
),
|
|
)
|
|
|
|
|
|
# ── Page ──────────────────────────────────────────────────────────────────────
|
|
|
|
def params_page() -> rx.Component:
|
|
return layout(
|
|
rx.vstack(
|
|
rx.heading("Paramètres", size="7"),
|
|
_section_app(),
|
|
_section_profession(),
|
|
_section_sanction(),
|
|
_section_smtp(),
|
|
_section_escada(),
|
|
_section_template(),
|
|
spacing="5",
|
|
width="100%",
|
|
max_width="860px",
|
|
)
|
|
)
|