big update .8

This commit is contained in:
adish-rmr 2026-02-22 19:40:07 +01:00
parent a7d68971dd
commit 398e2d2d00
9 changed files with 1254 additions and 205 deletions

124
app.py
View file

@ -51,9 +51,15 @@ def home():
if 'selected_cas' not in st.session_state:
st.session_state.selected_cas = None
# choose between cas or inci
type = st.radio("Cerca per:", ("CAS", "INCI"), index=0, key="search_mode")
input = st.text_input("Inserisci:", "")
with st.container(border=True):
col_left, col_right = st.columns([2, 1])
with col_left:
type = st.radio("Cerca per:", ("CAS", "INCI"), index=0, key="search_mode", horizontal=True)
input = st.text_input("Inserisci:", "")
with col_right:
real_time = st.checkbox("Ricerca online", value=False, key="real_time_search")
force_refresh = st.checkbox("Aggiorna Ingrediente", value=False)
if input:
st.caption(f"Ricerca per {input}: trovati i seguenti ingredienti.")
if type == "CAS":
@ -62,13 +68,9 @@ def home():
results = search_cas_inci(input, type='inci')
if results:
# Crea le stringhe per la selectbox: "CAS - INCI"
display_options = [f"{cas} - {inci}" for cas, inci in results]
# Selectbox con i risultati formattati
selected_display = st.selectbox("Risultati", options=[""] + display_options, key="cas_selectbox")
# Salva solo il CAS selezionato nel session_state (estrae la parte prima del " - ")
if selected_display and selected_display != "":
cas_pattern = r'\b\d{2,7}-\d{2}-\d\b'
found_cas = re.findall(cas_pattern, selected_display)
@ -77,11 +79,9 @@ def home():
if not unique_cas:
st.warning("Nessun pattern CAS valido trovato nella stringa.")
selected_cas = None
elif len(unique_cas) == 1:
selected_cas = unique_cas[0]
st.info(f"CAS rilevato: {selected_cas}")
#st.info(f"CAS rilevato: {selected_cas}")
else:
selected_cas = st.selectbox(
label="Sono stati rilevati più CAS. Selezionane uno:",
@ -89,9 +89,8 @@ def home():
)
if selected_cas:
st.session_state.selected_cas = selected_cas
st.success(f"CAS selezionato: {selected_cas}")
#st.success(f"CAS selezionato: {selected_cas}")
else:
# Nessun risultato trovato: permetti di usare l'input manuale
st.warning("Nessun risultato trovato nel database.")
if st.button("Usa questo CAS") and type == "CAS":
st.session_state.selected_cas = input.strip()
@ -99,15 +98,93 @@ def home():
else:
st.info("INCI non trovato, cerca per CAS o modifica l'input.")
# Mostra il CAS attualmente selezionato
if st.session_state.selected_cas:
st.info(f"CAS corrente: {st.session_state.selected_cas}")
if st.button("Vai a Ingredienti →", type="primary"):
st.switch_page(ingredients_page)
if real_time:
if st.button("Vai a ECHA →"):
st.switch_page(echa_page)
st.session_state.force_refresh = force_refresh
# Guide section
st.divider()
with st.expander("📖 Guida all'utilizzo"):
st.markdown("""
### Ricerca ingredienti
La barra di ricerca in questa pagina permette di cercare un ingrediente per **CAS** o per **INCI**.
Il risultato viene poi visualizzato nella pagina **Ingrediente**, che contiene:
- I dati per determinare il **DAP** (caratteristiche fisico-chimiche da PubChem)
- Le **restrizioni normative** da CosIng (annex, restrizioni cosmetiche, opinioni SCCS)
- I dati di **tossicologia** con il miglior indicatore disponibile (NOAEL/LOAEL ecc.)
- La possibilità di **scaricare direttamente le fonti** (PDF ECHA e CosIng)
> **Ricerca online** mostra anche la pagina **ECHA** con il dettaglio completo dei dossier.
> È un metodo di visualizzazione più vecchio; si consiglia di usare la pagina **Ingrediente**.
> **Aggiorna Ingrediente** forza il ricalcolo e l'aggiornamento dell'ingrediente nel database,
> utile per verificare eventuali cambi di restrizioni normative.
---
### Ordini PIF (Punti 6, 7, 8)
Dalla sezione **PIF** è possibile:
- **Nuovo PIF** inserire i dati del prodotto e la tabella QQ degli ingredienti per avviare
il calcolo automatico del **Margin of Safety (MoS)**. Il sistema seleziona automaticamente
gli indicatori tossicologici migliori per ciascun ingrediente e produce un **file Excel**
con SED, MoS e tabella QQ pronti per il PIF.
Per prodotti simili a uno già esistente, si può inserire il **numero d'ordine precedente**
per pre-compilare la tabella QQ.
- **Ordini PIF** visualizza tutti gli ordini effettuati con il relativo stato. Da qui si può:
- Scaricare l'**Excel** (SED, MoS, tabella QQ)
- Scaricare lo **ZIP delle fonti** (tutti i PDF ECHA e CosIng dell'ordine)
- **Cancellare** un ordine (sconsigliato salvo necessità)
Tutti gli ordini vengono salvati nel database, inclusa la lista degli ingredienti, così da
poter individuare facilmente eventuali PIF da rivedere in caso di modifiche alle restrizioni.
È possibile filtrare gli ordini per **stato**, **nome cliente** e **data**.
---
### Preset di esposizione
Prima di calcolare il MoS è necessario avere almeno un **preset di esposizione** configurato
nella pagina **Esposizione**. Il preset definisce i parametri del prodotto (superficie esposta,
quantità giornaliera, fattore di ritenzione, vie di esposizione, ecc.).
- Scegliere un nome **chiaro ed esplicativo** (es. *Crema viso leave-on*)
- Non possono esistere due preset con lo stesso nome
- I preset possono essere cancellati se non più necessari
- I preset sono riutilizzabili per più ordini dello stesso tipo di prodotto
---
> Ogni pagina dell'applicazione contiene una propria guida dettagliata sul suo funzionamento.
""")
# Changelog section
st.divider()
with st.expander("📝 Registro degli aggiornamenti"):
# Placeholder for future versions
st.markdown("""
### v0.8
*v0.8.0 - 2026-02-22
- Versione iniziale (v0.8.0) rilasciata per test interno e feedback
- Tutte le funzionalità principali implementate, ma in fase di test e ottimizzazione
- Modificato il flusso di ricerca, ora tutte le informazioni si trovano su Ingrediente
- Aggiustati numerosi bug minori e migliorata l'usabilità generale
* Si può visionare grado di ionizzazione e altri dati DAP direttamente su Ingrediente
* Ora si può scaricare direttamente il PDF del CosIng ufficiale (se esiste) dalla pagina Ingrediente
* Il migliore indicatore tossicologico viene automaticamente individuato
* Si può forzare l'aggiornamento dei dati di un ingrediente in caso di modifiche normative o nuovi dati tossicologici
- Aggiunta la funzionalità di creare ordini per PIF e scaricare i risultati in Excel
- Aggiunta la funzionalità di creare preset di esposizione personalizzati e cancellarli
- Aggiunta la funzionalità di visualizzare tutti i PIF creati e il loro stato di avanzamento (con possibilità di cancellarli)
- Aggiunta la funzionalità di scaricare un file ZIP con tutte le fonti (PDF ECHA e CosIng) per ogni ordine
### v0.3
*v0.3.0 - 2026-02-08
- Aggiunta pagina per la gestione dei preset dei parametri di esposizione
@ -145,20 +222,19 @@ def home():
# Navigation
home_page = st.Page(home, title="Home", icon="🏠", default=True)
home_page = st.Page(home, title="Cerca", icon="🏠", default=True)
echa_page = st.Page("pages/echa.py", title="ECHA Database", icon="🧪")
cosing_page = st.Page("pages/cosing.py", title="CosIng", icon="💄")
dap_page = st.Page("pages/dap.py", title="DAP", icon="🧬")
#cosing_page = st.Page("pages/cosing.py", title="CosIng", icon="💄")
#dap_page = st.Page("pages/dap.py", title="DAP", icon="🧬")
exposition_page = st.Page("pages/exposition_page.py", title="Esposizione", icon="☀️")
ingredients_page = st.Page("pages/ingredients_page.py", title="Ingredienti", icon="📋")
#pubchem_page = st.Page("pages/pubchem.py", title="PubChem", icon="🧬")
#cir_page = st.Page("pages/cir.py", title="CIR", icon="📊")
ingredients_page = st.Page("pages/ingredients_page.py", title="Ingrediente", icon="📋")
order_page = st.Page("pages/order_page.py", title="Nuovo PIF", icon="🛒")
list_orders = st.Page("pages/list_orders.py", title="Ordini PIF", icon="📦")
pg = st.navigation({
"Ricerca": [home_page],
"Calcolatore MoS": [exposition_page, ingredients_page],
"Ricerche Online": [echa_page, cosing_page, dap_page]
"Ricerca": [home_page, ingredients_page],
"PIF": [list_orders, order_page, exposition_page],
"Online": [echa_page],
})
pg.run()

View file

@ -49,4 +49,23 @@ def generate_pdf_download(cas, origin, link):
response.raise_for_status()
return response.content
else:
return data['error']
return data['error']
def cosing_download(ref_no: str):
url = f'https://api.tech.ec.europa.eu/cosing20/1.0/api/cosmetics/{ref_no}/export-pdf'
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'it-IT,it;q=0.9',
'Cache-Control': 'No-Cache',
'Origin': 'https://ec.europa.eu',
'Referer': 'https://ec.europa.eu/',
'Sec-Fetch-Dest': 'empty',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'same-site',
}
response = requests.get(url, headers=headers)
if response.status_code == 200:
return response.content
else:
return f"Error: {response.status_code} - {response.text}"

View file

@ -17,7 +17,6 @@ def display_ingredient(data: dict, level: int = 0):
# Get names
name = data.get("commonName") or data.get("inciName") or "Unknown"
chemical_name = data.get("chemicalName", "")
item_type = data.get("itemType", "ingredient")
# Header
@ -33,9 +32,6 @@ def display_ingredient(data: dict, level: int = 0):
st.caption("🔬 Substance")
else:
st.caption("🧴 Ingredient")
with col_header2:
if chemical_name and chemical_name.upper() != name.upper():
st.caption(f"*{chemical_name}*")
# Identifiers container
cas_numbers = data.get("casNo", [])
@ -57,13 +53,6 @@ def display_ingredient(data: dict, level: int = 0):
st.metric("EC", ", ".join(ec_numbers))
else:
st.metric("EC", "")
with cols[2]:
if ref_no:
st.metric("Ref. No.", ref_no)
else:
st.metric("Ref. No.", "")
# Functions
functions = data.get("functionName", [])
if functions:

View file

@ -11,6 +11,8 @@ st.set_page_config(
initial_sidebar_state="expanded"
)
st.info("Questa pagina mostra i dati ECHA in modo più dettagliato. È consigliato però utilizzare la pagina **Ingrediente** per avere informazioni più complete sull'ingrediente.")
if st.session_state.get('selected_cas', None) is None:
st.warning("Nessun CAS Number selezionato. Torna alla pagina principale per effettuare una ricerca.")
st.stop()

View file

@ -2,7 +2,7 @@ import streamlit as st
import requests
import pandas as pd
API_BASE = "https://api.cosmoguard.it/api/v1/"
API_BASE = "http://localhost:8000/api/v1"
EXPOSURE_ROUTES = ["Dermal", "Oral", "Inhalation", "Ocular"]
@ -32,7 +32,7 @@ with tab_crea:
with col2:
sup_esposta = st.number_input("Superficie esposta (cm2)", min_value=1, max_value=17500, value=565)
freq_applicazione = st.number_input("Frequenza applicazione (al giorno)", min_value=1, value=1)
freq_applicazione = st.number_input("Frequenza applicazione (al giorno)", min_value=0.01, value=1.0, step=0.01, format="%.2f")
qta_giornaliera = st.number_input("Quantita giornaliera (g/die)", min_value=0.01, value=1.54, step=0.01, format="%.2f")
st.markdown("---")
@ -84,11 +84,16 @@ with tab_crea:
except Exception as e:
st.error(f"Errore: {e}")
# --- Session state ---
if "confirm_delete_preset" not in st.session_state:
st.session_state.confirm_delete_preset = None
# --- TAB LISTA ---
with tab_lista:
st.subheader("Preset Esistenti")
if st.button("Aggiorna lista"):
st.session_state.confirm_delete_preset = None
st.rerun()
try:
@ -105,17 +110,45 @@ with tab_lista:
"ritenzione", "esposizione_calcolata", "esposizione_relativa",
]
existing = [c for c in display_cols if c in df.columns]
st.dataframe(df[existing], use_container_width=True)
st.dataframe(df[existing], use_container_width=True, hide_index=True)
with st.expander("Dettaglio completo"):
for preset in data["data"]:
st.markdown(f"**{preset['preset_name']}**")
st.json(preset)
st.markdown("---")
st.divider()
st.subheader("Elimina Preset")
for preset in data["data"]:
name = preset.get("preset_name", "")
col_name, col_btn = st.columns([4, 1])
col_name.write(f"**{name}**")
if st.session_state.confirm_delete_preset == name:
with col_btn:
sub1, sub2 = st.columns(2)
with sub1:
if st.button("Si", key=f"confirm_{name}", type="primary", use_container_width=True):
try:
del_resp = requests.delete(f"{API_BASE}/esposition/delete/{name}", timeout=10)
del_data = del_resp.json()
if del_data.get("success"):
st.success(f"Preset '{name}' eliminato")
st.session_state.confirm_delete_preset = None
st.rerun()
else:
st.error(del_data.get("error", "Errore eliminazione"))
except Exception as e:
st.error(f"Errore: {e}")
with sub2:
if st.button("No", key=f"cancel_{name}", use_container_width=True):
st.session_state.confirm_delete_preset = None
st.rerun()
else:
with col_btn:
if st.button("Elimina", key=f"del_{name}", use_container_width=True):
st.session_state.confirm_delete_preset = name
st.rerun()
else:
st.warning("Nessun preset trovato.")
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo.")
except Exception as e:
st.error(f"Errore nel caricamento: {e}")
st.error(f"Errore nel caricamento: {e}")

View file

@ -2,195 +2,185 @@ import streamlit as st
import requests
import pandas as pd
API_BASE = "https://api.cosmoguard.it/api/v1/"
from functions import cosing_download
from functions_ui import download_pdf
#API_BASE = "https://api.cosmoguard.it/api/v1"
API_BASE = "http://localhost:8000/api/v1"
st.set_page_config(page_title="Ricerca Ingredienti", layout="wide")
st.title("Ricerca Ingredienti per CAS")
tab_ricerca, tab_risultato, tab_inventario = st.tabs(["Ricerca", "Risultato", "Inventario"])
# --- State ---
if "ingredient_data" not in st.session_state:
st.session_state.ingredient_data = None
if "ingredient_cas" not in st.session_state:
st.session_state.ingredient_cas = ""
# --- TAB INVENTARIO ---
with tab_inventario:
st.subheader("Ingredienti gia acquisiti")
cas_input = st.session_state.selected_cas
force_refresh = st.session_state.force_refresh if "force_refresh" in st.session_state else False
if st.button("Aggiorna inventario"):
st.rerun()
if cas_input:
with st.spinner(f"Ricerca in corso per {cas_input}..."):
try:
resp = requests.post(
f"{API_BASE}/ingredients/search",
json={"cas": cas_input, "force": force_refresh},
timeout=120,
)
result = resp.json()
try:
resp = requests.get(f"{API_BASE}/ingredients/list", timeout=10)
result = resp.json()
if result.get("success") and result.get("data"):
st.session_state.ingredient_data = result["data"]
st.session_state.ingredient_cas = cas_input
st.success(f"Ingrediente {cas_input} trovato")
else:
st.session_state.ingredient_data = None
st.error(result.get("error", f"Nessun dato per CAS {cas_input}"))
if result.get("success") and result.get("data"):
st.info(f"Trovati {result['total']} ingredienti nel database")
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo.")
except Exception as e:
st.error(f"Errore: {e}")
df = pd.DataFrame(result["data"])
df["dap"] = df["dap"].map({True: "OK", False: "-"})
df["cosing"] = df["cosing"].map({True: "OK", False: "-"})
df["tox"] = df["tox"].map({True: "OK", False: "-"})
df = df.rename(columns={
"cas": "CAS",
"dap": "DAP",
"cosing": "COSING",
"tox": "TOX",
"created_at": "Data Acquisizione",
})
display_cols = ["CAS", "DAP", "COSING", "TOX", "Data Acquisizione"]
existing = [c for c in display_cols if c in df.columns]
st.dataframe(df[existing], use_container_width=True, hide_index=True)
else:
st.warning("Nessun ingrediente trovato nel database.")
data = st.session_state.ingredient_data
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo.")
except Exception as e:
st.error(f"Errore nel caricamento: {e}")
if data is None:
st.info("Effettua una ricerca per visualizzare i risultati.")
else:
cas = data.get("cas", "")
st.subheader(f"Ingrediente: {cas}")
# --- TAB RICERCA ---
with tab_ricerca:
st.subheader("Cerca un ingrediente")
# --- Header con INCI e data ---
col_h1, col_h2 = st.columns(2)
with col_h1:
inci = data.get("inci") or []
st.markdown(f"**INCI:** {', '.join(inci) if inci else 'N/A'}")
with col_h2:
st.markdown(f"**Data creazione:** {data.get('creation_date', 'N/A')}")
cas_input = st.text_input("CAS Number", placeholder="es. 64-17-5")
st.markdown("---")
if st.button("Cerca", type="primary", disabled=not cas_input):
with st.spinner(f"Ricerca in corso per {cas_input}..."):
try:
resp = requests.post(
f"{API_BASE}/ingredients/search",
json={"cas": cas_input},
timeout=120,
)
result = resp.json()
if result.get("success") and result.get("data"):
st.session_state.ingredient_data = result["data"]
st.session_state.ingredient_cas = cas_input
st.success(f"Ingrediente {cas_input} trovato")
else:
st.session_state.ingredient_data = None
st.error(result.get("error", f"Nessun dato per CAS {cas_input}"))
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo.")
except Exception as e:
st.error(f"Errore: {e}")
# --- TAB RISULTATO ---
with tab_risultato:
data = st.session_state.ingredient_data
if data is None:
st.info("Effettua una ricerca per visualizzare i risultati.")
# --- DAP Info ---
st.subheader("DAP Info")
dap = data.get("dap_info")
if dap:
col_d1, col_d2, col_d3 = st.columns(3)
with col_d1:
st.metric("Peso Molecolare (Da)", dap.get("molecular_weight", "N/A"))
st.metric("Log Pow", dap.get("log_pow", "N/A"))
with col_d2:
st.metric("TPSA", dap.get("tpsa", "N/A"))
st.metric("Punto Fusione (C)", dap.get("melting_point") or "N/A")
with col_d3:
dap_val = dap.get("dap_value", 0.5)
st.metric("DAP Value", f"{dap_val * 100:.0f}%")
st.metric("Alta Ionizzazione", dap.get("high_ionization") or "N/A")
else:
cas = data.get("cas", "")
st.subheader(f"Ingrediente: {cas}")
st.warning("Dati DAP non disponibili")
# --- Header con INCI e data ---
col_h1, col_h2 = st.columns(2)
with col_h1:
inci = data.get("inci") or []
st.markdown(f"**INCI:** {', '.join(inci) if inci else 'N/A'}")
with col_h2:
st.markdown(f"**Data creazione:** {data.get('creation_date', 'N/A')}")
st.markdown("---")
st.markdown("---")
# --- COSING Info ---
st.subheader("COSING Info")
cosing_list = data.get("cosing_info")
if cosing_list:
for i, cosing in enumerate(cosing_list):
if len(cosing_list) > 1:
st.markdown(f"**Voce {i + 1}**")
# --- DAP Info ---
st.subheader("DAP Info")
dap = data.get("dap_info")
if dap:
col_d1, col_d2, col_d3 = st.columns(3)
with col_d1:
st.metric("Peso Molecolare (Da)", dap.get("molecular_weight", "N/A"))
st.metric("Log Pow", dap.get("log_pow", "N/A"))
with col_d2:
st.metric("TPSA", dap.get("tpsa", "N/A"))
st.metric("Punto Fusione (C)", dap.get("melting_point") or "N/A")
with col_d3:
dap_val = dap.get("dap_value", 0.5)
st.metric("DAP Value", f"{dap_val * 100:.0f}%")
st.metric("Alta Ionizzazione", dap.get("high_ionization") or "N/A")
else:
st.warning("Dati DAP non disponibili")
col_c1, col_c2 = st.columns(2)
with col_c1:
names = cosing.get("common_names", [])
st.markdown(f"**Nomi comuni:** {', '.join(names) if names else 'N/A'}")
st.markdown(f"**INCI:** {', '.join(cosing.get('inci', [])) or 'N/A'}")
st.markdown(f"**CAS:** {', '.join(cosing.get('cas', [])) or 'N/A'}")
funcs = cosing.get("functionName", [])
if funcs:
st.markdown("**Funzioni:**")
for f in funcs:
st.markdown(f"- {f}")
ref_no = cosing.get("reference", "")
if ref_no:
pdf_bytes = cosing_download(ref_no)
st.markdown("---")
# --- COSING Info ---
st.subheader("COSING Info")
cosing_list = data.get("cosing_info")
if cosing_list:
for i, cosing in enumerate(cosing_list):
if len(cosing_list) > 1:
st.markdown(f"**Voce {i + 1}**")
col_c1, col_c2 = st.columns(2)
with col_c1:
names = cosing.get("common_names", [])
st.markdown(f"**Nomi comuni:** {', '.join(names) if names else 'N/A'}")
funcs = cosing.get("functionName", [])
if funcs:
st.markdown("**Funzioni:**")
for f in funcs:
st.markdown(f"- {f}")
with col_c2:
annex = cosing.get("annex", [])
if annex:
st.markdown("**Annex/Restrizioni:**")
for a in annex:
st.markdown(f"- {a}")
if isinstance(pdf_bytes, bytes):
st.download_button(
label="Download CosIng PDF",
data=pdf_bytes,
file_name=f"{cas_input}_cosing.pdf",
mime="application/pdf"
)
else:
st.markdown("**Annex:** Nessuna restrizione")
st.error(pdf_bytes)
other = cosing.get("otherRestrictions", [])
if other:
st.markdown("**Altre restrizioni:**")
for o in other:
st.markdown(f"- {o}")
with col_c2:
annex = cosing.get("annex", [])
if annex:
st.markdown("**Annex/Restrizioni:**")
for a in annex:
st.markdown(f"- {a}")
else:
st.markdown("**Annex:** Nessuna restrizione")
restriction = cosing.get("cosmeticRestriction", "")
if restriction:
st.warning(f"Restrizione cosmetica: {restriction}")
other = cosing.get("otherRestrictions", [])
if other:
st.markdown("**Altre restrizioni:**")
for o in other:
st.markdown(f"- {o}")
if i < len(cosing_list) - 1:
st.divider()
else:
st.warning("Dati COSING non disponibili")
restriction = cosing.get("cosmeticRestriction", "")
if restriction:
st.warning(f"Restrizione cosmetica: {restriction}")
link_opinions = cosing.get("sccsOpinionUrls", [])
if link_opinions:
st.markdown("**SCCS Opinions:**")
for url in link_opinions:
st.link_button("View SCCS Opinion", url)
st.markdown("---")
if i < len(cosing_list) - 1:
st.divider()
else:
st.warning("Dati COSING non disponibili")
# --- Toxicity ---
st.subheader("Tossicologia")
tox = data.get("toxicity")
if tox:
# Best case highlight
best = tox.get("best_case")
if best:
st.success(
f"Miglior indicatore: **{best['indicator']}** = {best['value']} {best['unit']} "
f"({best['route']}) — Fattore: {tox.get('factor', 'N/A')}"
)
st.markdown("---")
# Indicators table
indicators = tox.get("indicators", [])
if indicators:
df = pd.DataFrame(indicators)
col_order = ["indicator", "value", "unit", "route", "toxicity_type"]
existing = [c for c in col_order if c in df.columns]
df = df[existing]
df.columns = ["Indicatore", "Valore", "Unita", "Via", "Tipo"][:len(existing)]
st.dataframe(df, use_container_width=True, hide_index=True)
else:
st.warning("Dati tossicologici non disponibili")
# --- Toxicity ---
st.subheader("Tossicologia")
tox = data.get("toxicity")
if tox:
# Best case highlight
best = tox.get("best_case")
if best:
st.success(
f"Miglior indicatore: **{best['indicator']}** = {best['value']} {best['unit']} "
f"({best['route']}) — Fattore: {tox.get('factor', 'N/A')}"
)
download_pdf(
casNo=cas_input,
origin=best.get("source"),
link=best.get("ref")
)
st.markdown("---")
# Indicators table
indicators = tox.get("indicators", [])
if indicators:
df = pd.DataFrame(indicators)
col_order = ["indicator", "value", "unit", "route", "toxicity_type"]
existing = [c for c in col_order if c in df.columns]
df = df[existing]
df.columns = ["Indicatore", "Valore", "Unita", "Via", "Tipo"][:len(existing)]
st.dataframe(df, width="stretch", hide_index=True)
else:
st.warning("Dati tossicologici non disponibili")
# --- JSON completo ---
with st.expander("JSON completo"):
st.json(data)
st.markdown("---")
# --- JSON completo ---
with st.expander("JSON completo"):
st.json(data)

439
pages/list_orders.py Normal file
View file

@ -0,0 +1,439 @@
import streamlit as st
import requests
import pandas as pd
from datetime import datetime, date
API_BASE = "http://localhost:8000/api/v1"
STATUS_MAP = {
1: ("Ricevuto", "🔵"),
2: ("Validato", "🟡"),
3: ("Compilazione", "🟠"),
5: ("Arricchito", "🟢"),
6: ("Calcolo", "🔵"),
8: ("Completato", ""),
9: ("Errore", "🔴"),
}
def status_label(stato_ordine):
"""Ritorna label con emoji per lo stato."""
name, emoji = STATUS_MAP.get(stato_ordine, ("Sconosciuto", ""))
return f"{emoji} {name}"
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def fetch_orders():
"""Recupera la lista ordini dall'API."""
try:
resp = requests.get(f"{API_BASE}/orders/list", timeout=15)
data = resp.json()
if data.get("success"):
return data.get("data", [])
return []
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo su localhost:8000.")
return []
except Exception as e:
st.error(f"Errore nel caricamento degli ordini: {e}")
return []
def fetch_order_detail(id_ordine):
"""Recupera il dettaglio completo di un ordine."""
try:
resp = requests.get(f"{API_BASE}/orders/detail/{id_ordine}", timeout=15)
data = resp.json()
if data.get("success"):
return data.get("order")
st.error(data.get("detail", "Errore nel recupero dettaglio ordine"))
return None
except requests.ConnectionError:
st.error("Impossibile connettersi all'API.")
return None
except Exception as e:
st.error(f"Errore: {e}")
return None
def api_retry_order(id_ordine):
"""Chiama POST /orders/retry/{id_ordine}."""
try:
resp = requests.post(f"{API_BASE}/orders/retry/{id_ordine}", timeout=15)
return resp.json()
except Exception as e:
return {"success": False, "error": str(e)}
def api_delete_order(id_ordine):
"""Chiama DELETE /orders/{id_ordine}."""
try:
resp = requests.delete(f"{API_BASE}/orders/{id_ordine}", timeout=15)
return resp.json()
except Exception as e:
return {"success": False, "error": str(e)}
def download_excel(id_ordine):
"""Scarica il file Excel per un ordine."""
try:
resp = requests.get(f"{API_BASE}/orders/export/{id_ordine}", timeout=120)
if resp.status_code == 200:
return resp.content
st.error(f"Errore download Excel: {resp.json().get('detail', resp.status_code)}")
return None
except Exception as e:
st.error(f"Errore download: {e}")
return None
def download_sources(id_ordine):
"""Scarica lo ZIP delle fonti PDF per un ordine."""
try:
resp = requests.get(f"{API_BASE}/orders/export-sources/{id_ordine}", timeout=300)
if resp.status_code == 200:
return resp.content
st.error(f"Errore download fonti: {resp.json().get('detail', resp.status_code)}")
return None
except Exception as e:
st.error(f"Errore download: {e}")
return None
# ---------------------------------------------------------------------------
# Session state init
# ---------------------------------------------------------------------------
if "selected_order_id" not in st.session_state:
st.session_state.selected_order_id = None
if "confirm_delete" not in st.session_state:
st.session_state.confirm_delete = False
if "orders_cache" not in st.session_state:
st.session_state.orders_cache = None
# ---------------------------------------------------------------------------
# Page config
# ---------------------------------------------------------------------------
st.set_page_config(page_title="Gestione Ordini", page_icon="📋", layout="wide")
st.title("📋 Gestione Ordini")
with st.expander(" Come usare questa pagina"):
st.markdown("""
Questa pagina mostra tutti gli **ordini PIF** effettuati e il loro stato di avanzamento.
#### Cosa puoi fare
- **Visualizzare** la lista di tutti gli ordini con prodotto, cliente, data e stato
- **Aprire un ordine** cliccando sul suo numero (`#ID`) per vedere il dettaglio completo, inclusa la **lista QQ** degli ingredienti
- **Scaricare l'Excel** contenente il SED, il calcolo del MoS e la tabella QQ
- **Scaricare le Fonti PDF** uno ZIP con tutti i PDF di ECHA e CosIng relativi all'ordine
- **Eliminare** un ordine dal dettaglio (richiede conferma)
#### Stati degli ordini
| Stato | Significato |
|---|---|
| 🔵 Ricevuto | Ordine appena creato, in attesa di elaborazione |
| 🟡 Validato | Dati validati, pronto per la compilazione |
| 🟠 Compilazione | Elaborazione in corso |
| 🟢 Arricchito | Dati arricchiti, pronto per il calcolo |
| 🔵 Calcolo | Calcolo MoS in corso |
| Completato | Ordine completato, Excel e PDF disponibili |
| 🔴 Errore | Errore durante l'elaborazione — usa **Retry** per riprovare |
#### Filtri
Usa i filtri in cima alla lista per restringere la ricerca per **data**, **cliente** o **stato**.
""")
# ---------------------------------------------------------------------------
# Detail View
# ---------------------------------------------------------------------------
def show_order_detail(id_ordine):
"""Mostra il dettaglio di un ordine selezionato."""
# Bottone per tornare alla lista
if st.button("← Torna alla lista", key="back_btn"):
st.session_state.selected_order_id = None
st.session_state.confirm_delete = False
st.rerun()
st.divider()
with st.spinner("Caricamento dettaglio ordine..."):
order = fetch_order_detail(id_ordine)
if not order:
st.error(f"Impossibile caricare l'ordine {id_ordine}")
return
stato = order.get("stato_ordine", 0)
nome_stato = order.get("nome_stato", "Sconosciuto")
_, emoji = STATUS_MAP.get(stato, ("Sconosciuto", ""))
has_project = order.get("uuid_progetto") is not None
# Header
st.header(f"{order.get('product_name', 'N/D')} — #{id_ordine}")
st.markdown(f"**Stato:** {emoji} {nome_stato}")
# Metriche
col1, col2, col3, col4 = st.columns(4)
col1.metric("Cliente", order.get("nome_cliente", "N/D"))
col2.metric("Compilatore", order.get("nome_compilatore", "N/D") or "N/D")
col3.metric("Preset", order.get("preset_esposizione", "N/D") or "N/D")
col4.metric("Data", order.get("data_ordine", "N/D")[:10] if order.get("data_ordine") else "N/D")
# Note / Log
note = order.get("note")
if note:
with st.expander("📝 Note / Log", expanded=(stato == 9)):
if stato == 9:
st.error(note)
else:
st.info(note)
# Ingredienti
st.subheader("Ingredienti")
ingredients = order.get("ingredients", [])
if ingredients:
df = pd.DataFrame(ingredients)
display_cols = []
col_rename = {}
for col_name in ["inci", "cas", "percentage", "is_colorante", "skip_tox"]:
if col_name in df.columns:
display_cols.append(col_name)
col_rename[col_name] = {
"inci": "INCI",
"cas": "CAS",
"percentage": "Percentuale (%)",
"is_colorante": "Colorante",
"skip_tox": "Skip Tox",
}.get(col_name, col_name)
df_display = df[display_cols].rename(columns=col_rename)
# Mappa booleani a Si/No
for col_name in ["Colorante", "Skip Tox"]:
if col_name in df_display.columns:
df_display[col_name] = df_display[col_name].map({True: "", False: "-"})
st.dataframe(df_display, use_container_width=True, hide_index=True)
else:
st.info("Nessun ingrediente disponibile")
st.divider()
# Action buttons
st.subheader("Azioni")
btn_cols = st.columns(5)
# 1. Aggiorna stato
with btn_cols[0]:
if st.button("🔄 Aggiorna", key="refresh_btn", use_container_width=True):
st.rerun()
# 2. Retry (solo se ERRORE)
with btn_cols[1]:
if stato == 9:
if st.button("🔁 Retry", key="retry_btn", type="primary", use_container_width=True):
with st.spinner("Retry in corso..."):
result = api_retry_order(id_ordine)
if result.get("success"):
st.success(result.get("message", "Retry avviato"))
st.rerun()
else:
st.error(result.get("error") or result.get("detail", "Errore retry"))
else:
st.button("🔁 Retry", key="retry_btn_disabled", disabled=True, use_container_width=True)
# 3. Scarica Excel
with btn_cols[2]:
if has_project:
excel_data = None
if st.button("📊 Scarica Excel", key="excel_prep_btn", use_container_width=True):
with st.spinner("Generazione Excel..."):
excel_data = download_excel(id_ordine)
if excel_data:
st.download_button(
label="💾 Salva Excel",
data=excel_data,
file_name=f"progetto_{id_ordine}.xlsx",
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
key="excel_download"
)
else:
st.button("📊 Scarica Excel", key="excel_disabled", disabled=True,
use_container_width=True, help="Progetto non ancora disponibile")
# 4. Scarica Fonti PDF
with btn_cols[3]:
if has_project:
if st.button("📄 Scarica Fonti PDF", key="pdf_prep_btn", use_container_width=True):
with st.spinner("Generazione PDF fonti (può richiedere tempo)..."):
zip_data = download_sources(id_ordine)
if zip_data:
st.download_button(
label="💾 Salva ZIP",
data=zip_data,
file_name=f"fonti_ordine_{id_ordine}.zip",
mime="application/zip",
key="pdf_download"
)
else:
st.button("📄 Scarica Fonti PDF", key="pdf_disabled", disabled=True,
use_container_width=True, help="Progetto non ancora disponibile")
# 5. Elimina ordine
with btn_cols[4]:
if not st.session_state.confirm_delete:
if st.button("🗑️ Elimina", key="delete_btn", use_container_width=True):
st.session_state.confirm_delete = True
st.rerun()
else:
st.warning("Confermi l'eliminazione?")
sub_cols = st.columns(2)
with sub_cols[0]:
if st.button("✅ Conferma", key="confirm_yes", type="primary", use_container_width=True):
with st.spinner("Eliminazione in corso..."):
result = api_delete_order(id_ordine)
if result.get("success"):
st.success("Ordine eliminato")
st.session_state.selected_order_id = None
st.session_state.confirm_delete = False
st.session_state.orders_cache = None
st.rerun()
else:
st.error(result.get("detail") or result.get("error", "Errore eliminazione"))
with sub_cols[1]:
if st.button("❌ Annulla", key="confirm_no", use_container_width=True):
st.session_state.confirm_delete = False
st.rerun()
# ---------------------------------------------------------------------------
# List View
# ---------------------------------------------------------------------------
def show_orders_list():
"""Mostra la lista degli ordini con filtri."""
# Filtri
st.subheader("Filtri")
filter_cols = st.columns([2, 2, 2, 1])
with filter_cols[0]:
date_range = st.date_input(
"Intervallo date",
value=[],
key="date_filter",
help="Filtra per data ordine"
)
with filter_cols[1]:
client_search = st.text_input("Cerca cliente", key="client_filter",
placeholder="Nome cliente...")
with filter_cols[2]:
status_options = [f"{v[1]} {v[0]}" for v in STATUS_MAP.values()]
status_keys = list(STATUS_MAP.keys())
selected_statuses = st.multiselect("Stato", status_options, key="status_filter")
with filter_cols[3]:
st.write("") # spacer
st.write("")
if st.button("🔄 Aggiorna", key="refresh_list", use_container_width=True):
st.session_state.orders_cache = None
st.rerun()
st.divider()
# Carica ordini
if st.session_state.orders_cache is None:
with st.spinner("Caricamento ordini..."):
st.session_state.orders_cache = fetch_orders()
orders = st.session_state.orders_cache
if not orders:
st.info("Nessun ordine trovato")
return
# Applica filtri
filtered = orders
# Filtro data
if date_range and len(date_range) == 2:
start_date, end_date = date_range
filtered = [
o for o in filtered
if o.get("data_ordine") and
start_date <= datetime.fromisoformat(o["data_ordine"]).date() <= end_date
]
# Filtro cliente
if client_search:
search_lower = client_search.lower()
filtered = [
o for o in filtered
if o.get("nome_cliente") and search_lower in o["nome_cliente"].lower()
]
# Filtro stato
if selected_statuses:
selected_ids = []
for s in selected_statuses:
for k, v in STATUS_MAP.items():
if f"{v[1]} {v[0]}" == s:
selected_ids.append(k)
filtered = [o for o in filtered if o.get("stato_ordine") in selected_ids]
if not filtered:
st.warning("Nessun ordine corrisponde ai filtri selezionati")
return
st.caption(f"{len(filtered)} ordini trovati")
# Mostra lista
for order in filtered:
id_ord = order.get("id_ordine")
product = order.get("product_name", "N/D") or "N/D"
client = order.get("nome_cliente", "N/D") or "N/D"
stato = order.get("stato_ordine", 0)
data = order.get("data_ordine", "")
note = order.get("note", "")
data_display = data[:10] if data else "N/D"
stato_display = status_label(stato)
note_preview = (note[:60] + "...") if note and len(note) > 60 else (note or "")
col_btn, col_info = st.columns([1, 5])
with col_btn:
if st.button(f"#{id_ord}", key=f"order_{id_ord}", use_container_width=True):
st.session_state.selected_order_id = id_ord
st.session_state.confirm_delete = False
st.rerun()
with col_info:
info_cols = st.columns([3, 2, 1.5, 1.5, 3])
info_cols[0].write(f"**{product}**")
info_cols[1].write(client)
info_cols[2].write(data_display)
info_cols[3].write(stato_display)
if note_preview:
info_cols[4].caption(note_preview)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
if st.session_state.selected_order_id is not None:
show_order_detail(st.session_state.selected_order_id)
else:
show_orders_list()

501
pages/order_page.py Normal file
View file

@ -0,0 +1,501 @@
import streamlit as st
import requests
import pandas as pd
import re
API_BASE = "http://localhost:8000/api/v1"
# CAS validation: 2-7 digits, dash, 2 digits, dash, 1 check digit
CAS_PATTERN = re.compile(r"^\d{2,7}-\d{2}-\d$")
# Percentuale target e tolleranza
PERCENTAGE_TARGET = 100.0
PERCENTAGE_TOLERANCE = 0.01
# INCI che indicano acqua (case-insensitive)
WATER_INCI = {"aqua", "water", "eau", "aqua/water", "water/aqua", "aqua/eau"}
def is_water_inci(inci_value: str) -> bool:
"""Controlla se l'INCI corrisponde ad acqua o varianti."""
if not inci_value or not isinstance(inci_value, str):
return False
return inci_value.strip().lower() in WATER_INCI
# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
def fetch_presets() -> list[str]:
"""Recupera i nomi dei preset di esposizione dall'API."""
try:
resp = requests.get(f"{API_BASE}/esposition/presets", timeout=10)
data = resp.json()
if data.get("success") and data.get("data"):
return [p["preset_name"] for p in data["data"]]
return []
except requests.ConnectionError:
st.error("Impossibile connettersi all'API per caricare i preset. Verifica che il server sia attivo.")
return []
except Exception as e:
st.error(f"Errore nel caricamento dei preset: {e}")
return []
def fetch_clients() -> list[dict]:
"""Recupera la lista clienti dall'API. Ritorna lista di {id_cliente, nome_cliente}."""
try:
resp = requests.get(f"{API_BASE}/ingredients/clients", timeout=10)
data = resp.json()
if data.get("success") and data.get("data"):
return data["data"]
return []
except requests.ConnectionError:
return []
except Exception:
return []
def create_client(nome_cliente: str) -> bool:
"""Crea un nuovo cliente via API. Ritorna True se riuscito."""
try:
resp = requests.post(
f"{API_BASE}/ingredients/clients",
json={"nome_cliente": nome_cliente},
timeout=10,
)
data = resp.json()
return data.get("success", False)
except Exception:
return False
# ---------------------------------------------------------------------------
# Validation
# ---------------------------------------------------------------------------
def validate_order(
client_name: str,
product_name: str,
selected_preset: str | None,
df: pd.DataFrame,
) -> list[str]:
"""Valida tutti i campi dell'ordine. Ritorna lista di errori (vuota = valido)."""
errors = []
if not client_name or not client_name.strip():
errors.append("Il campo 'Nome del cliente' e obbligatorio.")
if not product_name or not product_name.strip():
errors.append("Il campo 'Nome del prodotto' e obbligatorio.")
if not selected_preset:
errors.append("Selezionare un preset di esposizione.")
# Normalizza colonne stringa (data_editor puo generare NaN)
str_df = df.copy()
str_df["inci"] = str_df["inci"].fillna("")
str_df["cas"] = str_df["cas"].fillna("")
# Righe attive: CAS compilato oppure percentuale > 0
active = str_df[(str_df["cas"].str.strip() != "") | (str_df["percentage"] > 0)]
if active.empty:
errors.append("Inserire almeno un ingrediente.")
return errors
# CAS format (solo righe non-colorante e non-acqua con CAS compilato)
for idx, row in active.iterrows():
cas_val = row["cas"].strip()
inci_val = row["inci"].strip()
is_col = bool(row["is_colorante"])
is_aqua = is_water_inci(inci_val)
# Se acqua, CAS puo essere vuoto
if is_aqua and cas_val == "":
continue
# Se colorante, skip validazione formato
if is_col:
continue
# CAS vuoto su riga non-acqua e non-colorante
if cas_val == "":
errors.append(f"Riga {idx + 1}: inserire un CAS number oppure selezionare 'Colorante'.")
continue
# Formato CAS
if not CAS_PATTERN.match(cas_val):
hint = f" ({inci_val})" if inci_val else ""
errors.append(
f"Formato CAS non valido alla riga {idx + 1}{hint}: '{cas_val}'. "
"Formato atteso: XX-XX-X (es. 56-81-5)."
)
# Somma percentuali
total_pct = active["percentage"].sum()
if abs(total_pct - PERCENTAGE_TARGET) > PERCENTAGE_TOLERANCE:
errors.append(
f"La somma delle percentuali e {total_pct:.6f}%, "
f"ma deve essere 100% (tolleranza +/- {PERCENTAGE_TOLERANCE}%)."
)
return errors
# ---------------------------------------------------------------------------
# Build payload
# ---------------------------------------------------------------------------
def build_order_payload(
client_name: str,
product_name: str,
preset_name: str,
df: pd.DataFrame,
) -> dict:
"""Costruisce il JSON dell'ordine a partire dai dati del form."""
str_df = df.copy()
str_df["inci"] = str_df["inci"].fillna("")
str_df["cas"] = str_df["cas"].fillna("")
active = str_df[(str_df["cas"].str.strip() != "") | (str_df["percentage"] > 0)]
ingredients = []
for _, row in active.iterrows():
inci_val = row["inci"].strip()
skip = bool(row["skip_tox"]) or is_water_inci(inci_val)
ingredients.append({
"inci": inci_val if inci_val else None,
"cas": row["cas"].strip(),
"percentage": round(float(row["percentage"]), 6),
"is_colorante": bool(row["is_colorante"]),
"skip_tox": skip,
})
return {
"client_name": client_name.strip(),
"product_name": product_name.strip(),
"preset_esposizione": preset_name,
"ingredients": ingredients,
}
# ---------------------------------------------------------------------------
# Display summary (reusable)
# ---------------------------------------------------------------------------
def display_orderData(order_data: dict):
"""Mostra un riepilogo leggibile dell'ordine (non JSON)."""
st.markdown("### Riepilogo Ordine")
col1, col2, col3 = st.columns(3)
with col1:
st.metric("Cliente", order_data["client_name"])
with col2:
st.metric("Prodotto", order_data["product_name"])
with col3:
st.metric("Preset Esposizione", order_data["preset_esposizione"])
ingredients = order_data.get("ingredients", [])
total_pct = sum(i["percentage"] for i in ingredients)
col4, col5, col6 = st.columns(3)
with col4:
st.metric("Numero ingredienti", len(ingredients))
with col5:
st.metric("Percentuale totale", f"{total_pct:.6f}%")
with col6:
n_col = sum(1 for i in ingredients if i["is_colorante"])
n_skip = sum(1 for i in ingredients if i["skip_tox"])
parts = []
if n_col > 0:
parts.append(f"{n_col} colorante/i")
if n_skip > 0:
parts.append(f"{n_skip} senza tox")
st.metric("Flag attivi", ", ".join(parts) if parts else "Nessuno")
st.markdown("**Ingredienti:**")
display_rows = []
for i in ingredients:
display_rows.append({
"INCI": i["inci"] if i["inci"] else "-",
"CAS": i["cas"] if i["cas"] else "-",
"Percentuale (%)": f"{i['percentage']:.6f}",
"Colorante": "Si" if i["is_colorante"] else "-",
"Salta Tox": "Si" if i["skip_tox"] else "-",
})
st.dataframe(pd.DataFrame(display_rows), use_container_width=True, hide_index=True)
# ===========================================================================
# PAGE
# ===========================================================================
st.set_page_config(page_title="Creazione Ordine", layout="wide")
st.title("Creazione Ordine")
# --- Session state init ---
if "order_presets" not in st.session_state:
st.session_state.order_presets = None
if "order_clients" not in st.session_state:
st.session_state.order_clients = None
if "order_submitted" not in st.session_state:
st.session_state.order_submitted = False
if "order_result" not in st.session_state:
st.session_state.order_result = None
if "ingredient_df" not in st.session_state:
st.session_state.ingredient_df = pd.DataFrame({
"inci": [""] * 5,
"cas": [""] * 5,
"percentage": [0.0] * 5,
"is_colorante": [False] * 5,
"skip_tox": [False] * 5,
})
if "new_client_mode" not in st.session_state:
st.session_state.new_client_mode = False
# ---------------------------------------------------------------------------
# 1. Istruzioni
# ---------------------------------------------------------------------------
with st.expander("Istruzioni per la compilazione", expanded=False):
st.markdown("""
### Come compilare l'ordine
**Campi obbligatori:**
- **Nome del cliente**: Selezionare un cliente esistente dal menu a tendina, oppure
scegliere *"+ Nuovo cliente..."* per inserirne uno nuovo.
- **Nome del prodotto**: Il nome commerciale del prodotto cosmetico.
- **Preset di esposizione**: Selezionare il preset che descrive le condizioni di utilizzo
del prodotto (es. crema viso leave-on, shampoo rinse-off, ecc.).
I preset si creano dalla pagina *Esposizione*.
**Tabella ingredienti:**
- **INCI**: Nome INCI dell'ingrediente (facoltativo, ma consigliato).
Se si inserisce *AQUA*, *Water*, *Eau* o varianti, la valutazione tossicologica viene
automaticamente saltata e il CAS puo essere lasciato vuoto.
- **CAS**: Numero CAS nel formato standard (es. `56-81-5`). Ogni riga deve contenere
**un solo** CAS number.
- **Percentuale (%)**: Percentuale in peso dell'ingrediente nella formulazione.
Sono ammessi fino a 6 decimali (es. `0.000200`).
- **Colorante / CAS speciale**: Selezionare per coloranti (CI xxxxx) o sostanze con
codici non-standard. Il formato CAS non verra validato.
- **Salta tossicologia**: Selezionare per ingredienti che non richiedono valutazione
tossicologica. Viene impostato automaticamente per AQUA/Water.
**Regole:**
- La somma delle percentuali deve essere esattamente **100%** (tolleranza: +/- 0.01%).
- E possibile aggiungere o rimuovere righe dalla tabella cliccando i pulsanti in basso.
- Compilare tutti i campi obbligatori prima di inviare l'ordine.
""")
# ---------------------------------------------------------------------------
# 2. Carica ordine esistente
# ---------------------------------------------------------------------------
st.markdown("---")
st.subheader("Carica ordine esistente")
col_lookup, col_lookup_btn = st.columns([3, 1])
with col_lookup:
order_id_input = st.number_input(
"ID Ordine",
min_value=1,
step=1,
value=None,
placeholder="es. 42",
help="Inserire l'ID numerico di un ordine esistente per precompilare il modulo.",
)
with col_lookup_btn:
st.write("")
st.write("")
lookup_clicked = st.button("Carica", disabled=order_id_input is None)
if lookup_clicked:
st.info(
f"Funzionalita in sviluppo. L'ordine con ID {order_id_input} "
"verra caricato automaticamente quando l'endpoint API sara disponibile."
)
# ---------------------------------------------------------------------------
# 3. Caricamento dati da API (preset + clienti)
# ---------------------------------------------------------------------------
if st.session_state.order_presets is None:
st.session_state.order_presets = fetch_presets()
if st.session_state.order_clients is None:
st.session_state.order_clients = fetch_clients()
preset_names = st.session_state.order_presets
client_list = st.session_state.order_clients
client_names = [c["nome_cliente"] for c in client_list]
# ---------------------------------------------------------------------------
# 4. Form fields
# ---------------------------------------------------------------------------
st.markdown("---")
st.subheader("Dati ordine")
col_left, col_right = st.columns(2)
with col_left:
# Client dropdown con opzione "Nuovo cliente"
dropdown_options = client_names + ["+ Nuovo cliente..."]
selected_client_option = st.selectbox("Nome del cliente *", options=dropdown_options)
client_name = ""
if selected_client_option == "+ Nuovo cliente...":
new_client_name = st.text_input(
"Nome nuovo cliente",
placeholder="es. Cosmetica Italia S.r.l.",
)
if new_client_name.strip():
if st.button("Aggiungi cliente"):
success = create_client(new_client_name.strip())
if success:
st.success(f"Cliente '{new_client_name.strip()}' aggiunto.")
st.session_state.order_clients = fetch_clients()
st.rerun()
else:
st.error("Errore nella creazione del cliente.")
client_name = new_client_name.strip()
else:
client_name = selected_client_option
product_name = st.text_input("Nome del prodotto *", placeholder="es. Crema idratante viso")
with col_right:
if preset_names:
selected_preset = st.selectbox("Preset di esposizione *", options=preset_names)
else:
st.warning("Nessun preset disponibile. Creare un preset nella pagina Esposizione.")
selected_preset = None
col_reload_p, col_reload_c = st.columns(2)
with col_reload_p:
if st.button("Ricarica preset", key="reload_presets"):
st.session_state.order_presets = fetch_presets()
st.rerun()
with col_reload_c:
if st.button("Ricarica clienti", key="reload_clients"):
st.session_state.order_clients = fetch_clients()
st.rerun()
# ---------------------------------------------------------------------------
# 5. Tabella ingredienti
# ---------------------------------------------------------------------------
st.markdown("---")
st.subheader("Ingredienti")
column_config = {
"inci": st.column_config.TextColumn(
"INCI",
help="Nome INCI (facoltativo). Inserire AQUA/Water per saltare automaticamente la tossicologia.",
width="medium",
),
"cas": st.column_config.TextColumn(
"CAS",
help="Numero CAS (es. 56-81-5). Puo essere vuoto per AQUA/Water.",
width="medium",
),
"percentage": st.column_config.NumberColumn(
"Percentuale (%)",
help="Percentuale in peso (fino a 6 decimali)",
min_value=0.0,
max_value=100.0,
format="%.6f",
width="small",
),
"is_colorante": st.column_config.CheckboxColumn(
"Colorante",
help="Coloranti o CAS speciali: bypassa la validazione del formato CAS",
default=False,
width="small",
),
"skip_tox": st.column_config.CheckboxColumn(
"Salta Tox",
help="Salta la valutazione tossicologica (automatico per AQUA/Water)",
default=False,
width="small",
),
}
edited_df = st.data_editor(
st.session_state.ingredient_df,
column_config=column_config,
num_rows="dynamic",
use_container_width=True,
hide_index=True,
key="ingredients_editor",
)
# Info per l'utente sulla rilevazione automatica AQUA/Water
_inci_col = edited_df["inci"].fillna("")
_aqua_count = sum(is_water_inci(v) for v in _inci_col)
if _aqua_count > 0:
st.info(f"{_aqua_count} ingrediente/i rilevato/i come AQUA/Water: tossicologia saltata automaticamente.")
# ---------------------------------------------------------------------------
# 6. Validazione
# ---------------------------------------------------------------------------
errors = validate_order(client_name, product_name, selected_preset, edited_df)
if errors:
st.markdown("---")
for err in errors:
st.error(err)
# ---------------------------------------------------------------------------
# 7. Submit
# ---------------------------------------------------------------------------
st.markdown("---")
can_submit = (len(errors) == 0) and (not st.session_state.order_submitted)
if st.button("Invia Ordine", type="primary", disabled=not can_submit):
payload = build_order_payload(client_name, product_name, selected_preset, edited_df)
st.session_state.order_result = payload
with st.spinner("Invio ordine in corso..."):
try:
resp = requests.post(
f"{API_BASE}/orders/create",
json=payload,
timeout=30,
)
result = resp.json()
if result.get("success"):
st.session_state.order_submitted = True
id_ordine = result.get("id_ordine")
st.success(f"Ordine #{id_ordine} creato. Elaborazione avviata in background.")
display_orderData(payload)
else:
st.error(result.get("error") or result.get("detail", "Errore nella creazione dell'ordine"))
except requests.ConnectionError:
st.error("Impossibile connettersi all'API. Verifica che il server sia attivo su localhost:8000.")
except Exception as e:
st.error(f"Errore: {e}")
# ---------------------------------------------------------------------------
# 8. Debug JSON
# ---------------------------------------------------------------------------
if st.session_state.order_result is not None:
with st.expander("Debug - Raw JSON"):
st.json(st.session_state.order_result)
# ---------------------------------------------------------------------------
# 9. Reset
# ---------------------------------------------------------------------------
st.markdown("---")
if st.button("Nuovo Ordine", key="reset_order"):
st.session_state.order_submitted = False
st.session_state.order_result = None
st.session_state.ingredient_df = pd.DataFrame({
"inci": [""] * 5,
"cas": [""] * 5,
"percentage": [0.0] * 5,
"is_colorante": [False] * 5,
"skip_tox": [False] * 5,
})
# Pulisci lo stato interno del widget data_editor
if "ingredients_editor" in st.session_state:
del st.session_state["ingredients_editor"]
st.rerun()