big update .8
This commit is contained in:
parent
a7d68971dd
commit
398e2d2d00
9 changed files with 1254 additions and 205 deletions
122
app.py
122
app.py
|
|
@ -51,9 +51,15 @@ def home():
|
||||||
if 'selected_cas' not in st.session_state:
|
if 'selected_cas' not in st.session_state:
|
||||||
st.session_state.selected_cas = None
|
st.session_state.selected_cas = None
|
||||||
|
|
||||||
# choose between cas or inci
|
with st.container(border=True):
|
||||||
type = st.radio("Cerca per:", ("CAS", "INCI"), index=0, key="search_mode")
|
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:", "")
|
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:
|
if input:
|
||||||
st.caption(f"Ricerca per {input}: trovati i seguenti ingredienti.")
|
st.caption(f"Ricerca per {input}: trovati i seguenti ingredienti.")
|
||||||
if type == "CAS":
|
if type == "CAS":
|
||||||
|
|
@ -62,13 +68,9 @@ def home():
|
||||||
results = search_cas_inci(input, type='inci')
|
results = search_cas_inci(input, type='inci')
|
||||||
|
|
||||||
if results:
|
if results:
|
||||||
# Crea le stringhe per la selectbox: "CAS - INCI"
|
|
||||||
display_options = [f"{cas} - {inci}" for cas, inci in results]
|
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")
|
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 != "":
|
if selected_display and selected_display != "":
|
||||||
cas_pattern = r'\b\d{2,7}-\d{2}-\d\b'
|
cas_pattern = r'\b\d{2,7}-\d{2}-\d\b'
|
||||||
found_cas = re.findall(cas_pattern, selected_display)
|
found_cas = re.findall(cas_pattern, selected_display)
|
||||||
|
|
@ -77,11 +79,9 @@ def home():
|
||||||
if not unique_cas:
|
if not unique_cas:
|
||||||
st.warning("Nessun pattern CAS valido trovato nella stringa.")
|
st.warning("Nessun pattern CAS valido trovato nella stringa.")
|
||||||
selected_cas = None
|
selected_cas = None
|
||||||
|
|
||||||
elif len(unique_cas) == 1:
|
elif len(unique_cas) == 1:
|
||||||
selected_cas = unique_cas[0]
|
selected_cas = unique_cas[0]
|
||||||
st.info(f"CAS rilevato: {selected_cas}")
|
#st.info(f"CAS rilevato: {selected_cas}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
selected_cas = st.selectbox(
|
selected_cas = st.selectbox(
|
||||||
label="Sono stati rilevati più CAS. Selezionane uno:",
|
label="Sono stati rilevati più CAS. Selezionane uno:",
|
||||||
|
|
@ -89,9 +89,8 @@ def home():
|
||||||
)
|
)
|
||||||
if selected_cas:
|
if selected_cas:
|
||||||
st.session_state.selected_cas = selected_cas
|
st.session_state.selected_cas = selected_cas
|
||||||
st.success(f"CAS selezionato: {selected_cas}")
|
#st.success(f"CAS selezionato: {selected_cas}")
|
||||||
else:
|
else:
|
||||||
# Nessun risultato trovato: permetti di usare l'input manuale
|
|
||||||
st.warning("Nessun risultato trovato nel database.")
|
st.warning("Nessun risultato trovato nel database.")
|
||||||
if st.button("Usa questo CAS") and type == "CAS":
|
if st.button("Usa questo CAS") and type == "CAS":
|
||||||
st.session_state.selected_cas = input.strip()
|
st.session_state.selected_cas = input.strip()
|
||||||
|
|
@ -99,15 +98,93 @@ def home():
|
||||||
else:
|
else:
|
||||||
st.info("INCI non trovato, cerca per CAS o modifica l'input.")
|
st.info("INCI non trovato, cerca per CAS o modifica l'input.")
|
||||||
|
|
||||||
# Mostra il CAS attualmente selezionato
|
|
||||||
if st.session_state.selected_cas:
|
if st.session_state.selected_cas:
|
||||||
st.info(f"CAS corrente: {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
|
# Changelog section
|
||||||
st.divider()
|
|
||||||
with st.expander("📝 Registro degli aggiornamenti"):
|
with st.expander("📝 Registro degli aggiornamenti"):
|
||||||
# Placeholder for future versions
|
# Placeholder for future versions
|
||||||
st.markdown("""
|
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
|
||||||
*v0.3.0 - 2026-02-08
|
*v0.3.0 - 2026-02-08
|
||||||
- Aggiunta pagina per la gestione dei preset dei parametri di esposizione
|
- Aggiunta pagina per la gestione dei preset dei parametri di esposizione
|
||||||
|
|
@ -145,20 +222,19 @@ def home():
|
||||||
|
|
||||||
|
|
||||||
# Navigation
|
# 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="🧪")
|
echa_page = st.Page("pages/echa.py", title="ECHA Database", icon="🧪")
|
||||||
cosing_page = st.Page("pages/cosing.py", title="CosIng", icon="💄")
|
#cosing_page = st.Page("pages/cosing.py", title="CosIng", icon="💄")
|
||||||
dap_page = st.Page("pages/dap.py", title="DAP", icon="🧬")
|
#dap_page = st.Page("pages/dap.py", title="DAP", icon="🧬")
|
||||||
exposition_page = st.Page("pages/exposition_page.py", title="Esposizione", icon="☀️")
|
exposition_page = st.Page("pages/exposition_page.py", title="Esposizione", icon="☀️")
|
||||||
ingredients_page = st.Page("pages/ingredients_page.py", title="Ingredienti", icon="📋")
|
ingredients_page = st.Page("pages/ingredients_page.py", title="Ingrediente", icon="📋")
|
||||||
#pubchem_page = st.Page("pages/pubchem.py", title="PubChem", icon="🧬")
|
order_page = st.Page("pages/order_page.py", title="Nuovo PIF", icon="🛒")
|
||||||
#cir_page = st.Page("pages/cir.py", title="CIR", icon="📊")
|
list_orders = st.Page("pages/list_orders.py", title="Ordini PIF", icon="📦")
|
||||||
|
|
||||||
|
|
||||||
pg = st.navigation({
|
pg = st.navigation({
|
||||||
"Ricerca": [home_page],
|
"Ricerca": [home_page, ingredients_page],
|
||||||
"Calcolatore MoS": [exposition_page, ingredients_page],
|
"PIF": [list_orders, order_page, exposition_page],
|
||||||
"Ricerche Online": [echa_page, cosing_page, dap_page]
|
"Online": [echa_page],
|
||||||
})
|
})
|
||||||
|
|
||||||
pg.run()
|
pg.run()
|
||||||
19
functions.py
19
functions.py
|
|
@ -50,3 +50,22 @@ def generate_pdf_download(cas, origin, link):
|
||||||
return response.content
|
return response.content
|
||||||
else:
|
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}"
|
||||||
|
|
@ -17,7 +17,6 @@ def display_ingredient(data: dict, level: int = 0):
|
||||||
|
|
||||||
# Get names
|
# Get names
|
||||||
name = data.get("commonName") or data.get("inciName") or "Unknown"
|
name = data.get("commonName") or data.get("inciName") or "Unknown"
|
||||||
chemical_name = data.get("chemicalName", "")
|
|
||||||
item_type = data.get("itemType", "ingredient")
|
item_type = data.get("itemType", "ingredient")
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
|
|
@ -33,9 +32,6 @@ def display_ingredient(data: dict, level: int = 0):
|
||||||
st.caption("🔬 Substance")
|
st.caption("🔬 Substance")
|
||||||
else:
|
else:
|
||||||
st.caption("🧴 Ingredient")
|
st.caption("🧴 Ingredient")
|
||||||
with col_header2:
|
|
||||||
if chemical_name and chemical_name.upper() != name.upper():
|
|
||||||
st.caption(f"*{chemical_name}*")
|
|
||||||
|
|
||||||
# Identifiers container
|
# Identifiers container
|
||||||
cas_numbers = data.get("casNo", [])
|
cas_numbers = data.get("casNo", [])
|
||||||
|
|
@ -57,13 +53,6 @@ def display_ingredient(data: dict, level: int = 0):
|
||||||
st.metric("EC", ", ".join(ec_numbers))
|
st.metric("EC", ", ".join(ec_numbers))
|
||||||
else:
|
else:
|
||||||
st.metric("EC", "—")
|
st.metric("EC", "—")
|
||||||
|
|
||||||
with cols[2]:
|
|
||||||
if ref_no:
|
|
||||||
st.metric("Ref. No.", ref_no)
|
|
||||||
else:
|
|
||||||
st.metric("Ref. No.", "—")
|
|
||||||
|
|
||||||
# Functions
|
# Functions
|
||||||
functions = data.get("functionName", [])
|
functions = data.get("functionName", [])
|
||||||
if functions:
|
if functions:
|
||||||
|
|
@ -11,6 +11,8 @@ st.set_page_config(
|
||||||
initial_sidebar_state="expanded"
|
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:
|
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.warning("Nessun CAS Number selezionato. Torna alla pagina principale per effettuare una ricerca.")
|
||||||
st.stop()
|
st.stop()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import streamlit as st
|
||||||
import requests
|
import requests
|
||||||
import pandas as pd
|
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"]
|
EXPOSURE_ROUTES = ["Dermal", "Oral", "Inhalation", "Ocular"]
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ with tab_crea:
|
||||||
|
|
||||||
with col2:
|
with col2:
|
||||||
sup_esposta = st.number_input("Superficie esposta (cm2)", min_value=1, max_value=17500, value=565)
|
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")
|
qta_giornaliera = st.number_input("Quantita giornaliera (g/die)", min_value=0.01, value=1.54, step=0.01, format="%.2f")
|
||||||
|
|
||||||
st.markdown("---")
|
st.markdown("---")
|
||||||
|
|
@ -84,11 +84,16 @@ with tab_crea:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Errore: {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 ---
|
# --- TAB LISTA ---
|
||||||
with tab_lista:
|
with tab_lista:
|
||||||
st.subheader("Preset Esistenti")
|
st.subheader("Preset Esistenti")
|
||||||
|
|
||||||
if st.button("Aggiorna lista"):
|
if st.button("Aggiorna lista"):
|
||||||
|
st.session_state.confirm_delete_preset = None
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -105,13 +110,41 @@ with tab_lista:
|
||||||
"ritenzione", "esposizione_calcolata", "esposizione_relativa",
|
"ritenzione", "esposizione_calcolata", "esposizione_relativa",
|
||||||
]
|
]
|
||||||
existing = [c for c in display_cols if c in df.columns]
|
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)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Elimina Preset")
|
||||||
|
|
||||||
with st.expander("Dettaglio completo"):
|
|
||||||
for preset in data["data"]:
|
for preset in data["data"]:
|
||||||
st.markdown(f"**{preset['preset_name']}**")
|
name = preset.get("preset_name", "")
|
||||||
st.json(preset)
|
col_name, col_btn = st.columns([4, 1])
|
||||||
st.markdown("---")
|
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:
|
else:
|
||||||
st.warning("Nessun preset trovato.")
|
st.warning("Nessun preset trovato.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,68 +2,29 @@ import streamlit as st
|
||||||
import requests
|
import requests
|
||||||
import pandas as pd
|
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.set_page_config(page_title="Ricerca Ingredienti", layout="wide")
|
||||||
st.title("Ricerca Ingredienti per CAS")
|
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:
|
if "ingredient_data" not in st.session_state:
|
||||||
st.session_state.ingredient_data = None
|
st.session_state.ingredient_data = None
|
||||||
if "ingredient_cas" not in st.session_state:
|
if "ingredient_cas" not in st.session_state:
|
||||||
st.session_state.ingredient_cas = ""
|
st.session_state.ingredient_cas = ""
|
||||||
|
|
||||||
# --- TAB INVENTARIO ---
|
cas_input = st.session_state.selected_cas
|
||||||
with tab_inventario:
|
force_refresh = st.session_state.force_refresh if "force_refresh" in st.session_state else False
|
||||||
st.subheader("Ingredienti gia acquisiti")
|
|
||||||
|
|
||||||
if st.button("Aggiorna inventario"):
|
if cas_input:
|
||||||
st.rerun()
|
|
||||||
|
|
||||||
try:
|
|
||||||
resp = requests.get(f"{API_BASE}/ingredients/list", timeout=10)
|
|
||||||
result = resp.json()
|
|
||||||
|
|
||||||
if result.get("success") and result.get("data"):
|
|
||||||
st.info(f"Trovati {result['total']} ingredienti nel database")
|
|
||||||
|
|
||||||
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.")
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
# --- TAB RICERCA ---
|
|
||||||
with tab_ricerca:
|
|
||||||
st.subheader("Cerca un ingrediente")
|
|
||||||
|
|
||||||
cas_input = st.text_input("CAS Number", placeholder="es. 64-17-5")
|
|
||||||
|
|
||||||
if st.button("Cerca", type="primary", disabled=not cas_input):
|
|
||||||
with st.spinner(f"Ricerca in corso per {cas_input}..."):
|
with st.spinner(f"Ricerca in corso per {cas_input}..."):
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{API_BASE}/ingredients/search",
|
f"{API_BASE}/ingredients/search",
|
||||||
json={"cas": cas_input},
|
json={"cas": cas_input, "force": force_refresh},
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
|
|
@ -81,13 +42,12 @@ with tab_ricerca:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
st.error(f"Errore: {e}")
|
st.error(f"Errore: {e}")
|
||||||
|
|
||||||
# --- TAB RISULTATO ---
|
|
||||||
with tab_risultato:
|
|
||||||
data = st.session_state.ingredient_data
|
|
||||||
|
|
||||||
if data is None:
|
data = st.session_state.ingredient_data
|
||||||
|
|
||||||
|
if data is None:
|
||||||
st.info("Effettua una ricerca per visualizzare i risultati.")
|
st.info("Effettua una ricerca per visualizzare i risultati.")
|
||||||
else:
|
else:
|
||||||
cas = data.get("cas", "")
|
cas = data.get("cas", "")
|
||||||
st.subheader(f"Ingrediente: {cas}")
|
st.subheader(f"Ingrediente: {cas}")
|
||||||
|
|
||||||
|
|
@ -133,12 +93,30 @@ with tab_risultato:
|
||||||
with col_c1:
|
with col_c1:
|
||||||
names = cosing.get("common_names", [])
|
names = cosing.get("common_names", [])
|
||||||
st.markdown(f"**Nomi comuni:** {', '.join(names) if names else 'N/A'}")
|
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", [])
|
funcs = cosing.get("functionName", [])
|
||||||
if funcs:
|
if funcs:
|
||||||
st.markdown("**Funzioni:**")
|
st.markdown("**Funzioni:**")
|
||||||
for f in funcs:
|
for f in funcs:
|
||||||
st.markdown(f"- {f}")
|
st.markdown(f"- {f}")
|
||||||
|
|
||||||
|
ref_no = cosing.get("reference", "")
|
||||||
|
if ref_no:
|
||||||
|
pdf_bytes = cosing_download(ref_no)
|
||||||
|
|
||||||
|
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.error(pdf_bytes)
|
||||||
|
|
||||||
|
|
||||||
with col_c2:
|
with col_c2:
|
||||||
annex = cosing.get("annex", [])
|
annex = cosing.get("annex", [])
|
||||||
if annex:
|
if annex:
|
||||||
|
|
@ -158,6 +136,12 @@ with tab_risultato:
|
||||||
if restriction:
|
if restriction:
|
||||||
st.warning(f"Restrizione cosmetica: {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)
|
||||||
|
|
||||||
if i < len(cosing_list) - 1:
|
if i < len(cosing_list) - 1:
|
||||||
st.divider()
|
st.divider()
|
||||||
else:
|
else:
|
||||||
|
|
@ -177,6 +161,12 @@ with tab_risultato:
|
||||||
f"({best['route']}) — Fattore: {tox.get('factor', 'N/A')}"
|
f"({best['route']}) — Fattore: {tox.get('factor', 'N/A')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
download_pdf(
|
||||||
|
casNo=cas_input,
|
||||||
|
origin=best.get("source"),
|
||||||
|
link=best.get("ref")
|
||||||
|
)
|
||||||
|
|
||||||
# Indicators table
|
# Indicators table
|
||||||
indicators = tox.get("indicators", [])
|
indicators = tox.get("indicators", [])
|
||||||
if indicators:
|
if indicators:
|
||||||
|
|
@ -185,7 +175,7 @@ with tab_risultato:
|
||||||
existing = [c for c in col_order if c in df.columns]
|
existing = [c for c in col_order if c in df.columns]
|
||||||
df = df[existing]
|
df = df[existing]
|
||||||
df.columns = ["Indicatore", "Valore", "Unita", "Via", "Tipo"][:len(existing)]
|
df.columns = ["Indicatore", "Valore", "Unita", "Via", "Tipo"][:len(existing)]
|
||||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
st.dataframe(df, width="stretch", hide_index=True)
|
||||||
else:
|
else:
|
||||||
st.warning("Dati tossicologici non disponibili")
|
st.warning("Dati tossicologici non disponibili")
|
||||||
|
|
||||||
|
|
|
||||||
439
pages/list_orders.py
Normal file
439
pages/list_orders.py
Normal 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: "Sì", 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
501
pages/order_page.py
Normal 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()
|
||||||
Loading…
Reference in a new issue