diff --git a/app.py b/app.py index 3e05826..f64b643 100644 --- a/app.py +++ b/app.py @@ -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() \ No newline at end of file diff --git a/functions.py b/functions.py index 7c4c3c9..b7a8669 100644 --- a/functions.py +++ b/functions.py @@ -49,4 +49,23 @@ def generate_pdf_download(cas, origin, link): response.raise_for_status() return response.content else: - return data['error'] \ No newline at end of file + 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}" \ No newline at end of file diff --git a/pages/cosing.py b/old/cosing.py similarity index 92% rename from pages/cosing.py rename to old/cosing.py index b070d64..bd4227a 100644 --- a/pages/cosing.py +++ b/old/cosing.py @@ -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: diff --git a/pages/dap.py b/old/dap.py similarity index 100% rename from pages/dap.py rename to old/dap.py diff --git a/pages/echa.py b/pages/echa.py index e9aa4b6..d6ed34f 100644 --- a/pages/echa.py +++ b/pages/echa.py @@ -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() diff --git a/pages/exposition_page.py b/pages/exposition_page.py index 30d9db6..ffc047d 100644 --- a/pages/exposition_page.py +++ b/pages/exposition_page.py @@ -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}") \ No newline at end of file diff --git a/pages/ingredients_page.py b/pages/ingredients_page.py index cb8c6a4..9329980 100644 --- a/pages/ingredients_page.py +++ b/pages/ingredients_page.py @@ -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) diff --git a/pages/list_orders.py b/pages/list_orders.py new file mode 100644 index 0000000..5509735 --- /dev/null +++ b/pages/list_orders.py @@ -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() diff --git a/pages/order_page.py b/pages/order_page.py new file mode 100644 index 0000000..7bc6cf1 --- /dev/null +++ b/pages/order_page.py @@ -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()