Spaces:
Sleeping
Sleeping
| # ============================================================================== | |
| # IMPORT LIBRARIES | |
| # ============================================================================== | |
| from tqdm import tqdm | |
| from anonymizer import load_ner_model, anonymize_text | |
| import streamlit as st | |
| import pandas as pd | |
| from streamlit_modal import Modal | |
| from ai_assistant import get_consultation_response | |
| from pathlib import Path | |
| import base64 | |
| from datetime import datetime, date | |
| from dateutil.relativedelta import relativedelta | |
| import numpy as np | |
| import re | |
| import statsmodels.api as sm | |
| from sklearn.linear_model import LinearRegression | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| try: | |
| import google.generativeai as genai | |
| except ImportError: | |
| genai = None | |
| try: | |
| from weasyprint import HTML | |
| except ImportError: | |
| HTML = None | |
| # ============================================================================== | |
| # --- 1. การตั้งค่าและตัวแปรหลัก --- | |
| # ============================================================================== | |
| DATA_DIR = Path("data") | |
| DATA_DIR.mkdir(exist_ok=True) | |
| PERSISTED_DATA_PATH = DATA_DIR / "processed_incident_data.parquet" | |
| ADMIN_PASSWORD = st.secrets.get("ADMIN_PASSWORD", "admin1234") | |
| # ============================================================================== | |
| # PAGE CONFIGURATION | |
| # ============================================================================== | |
| LOGO_URL = "https://raw.githubusercontent.com/HOIARRTool/hoiarr/refs/heads/main/logo1.png" | |
| st.set_page_config(page_title="HOIA-RR", page_icon=LOGO_URL, layout="wide") | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600;700&display=swap'); | |
| /* ✅ --- START: แก้ไขการกำหนดฟอนต์ --- */ | |
| /* กำหนดฟอนต์ 'Kanit' ให้กับส่วนหลักของแอป โดยไม่กระทบไอคอน */ | |
| html, body, [data-testid="stAppViewContainer"], [data-testid="stSidebar"] { | |
| font-family: 'Kanit', sans-serif; | |
| } | |
| /* ✅ --- END: สิ้นสุดการแก้ไข --- */ | |
| /* --- Gradient Text for Sidebar Title --- */ | |
| .gradient-text { | |
| background-image: linear-gradient(45deg, #f09433, #e6683c, #dc2743, #bc1888, #833ab4); | |
| -webkit-background-clip: text; | |
| background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| font-weight: 700; | |
| display: inline-block; | |
| } | |
| /* --- Original App Styles --- */ | |
| [data-testid="stChatInput"] textarea { min-height: 80px; height: 100px; resize: vertical; background-color: transparent; border: none; } | |
| .metric-box { | |
| border: 1px solid #ddd; | |
| padding: 10px; | |
| border-radius: 0.5rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| .metric-box .label { font-size: 0.9rem; color: #555; } | |
| .metric-box .value { font-size: 1.8rem; font-weight: bold; color: #262730; } | |
| .metric-box-1 { background-color: #e6fffa; border-color: #b2f5ea; } | |
| .metric-box-2 { background-color: #fff3e0; border-color: #ffe0b2; } | |
| .metric-box-3 { background-color: #fce4ec; border-color: #f8bbd0; } | |
| .metric-box-4 { background-color: #e3f2fd; border-color: #bbdefb; } | |
| .metric-box-5 { background-color: #f0f4c3; border-color: #e6ee9c; } | |
| .metric-box-6 { background-color: #ffecb3; border-color: #ffd54f; } | |
| .metric-box-7 { background-color: #ffcdd2; border-color: #ef9a9a; } | |
| .summary-table { width: 100%; border-collapse: collapse; table-layout: fixed; } | |
| .summary-table th, .summary-table td { border: 1px solid #ddd; padding: 8px; text-align: left; word-wrap: break-word; overflow-wrap: break-word; } | |
| .summary-table th { background-color: #f2f2f2; } | |
| .summary-table-4-col th:nth-child(1), .summary-table-4-col td:nth-child(1) { width: 20%; } | |
| .summary-table-4-col th:nth-child(2), .summary-table-4-col td:nth-child(2) { width: 20%; } | |
| .summary-table-4-col th:nth-child(3), .summary-table-4-col td:nth-child(3) { width: 10%; } | |
| .summary-table-4-col th:nth-child(4), .summary-table-4-col td:nth-child(4) { width: 50%; } | |
| .summary-table-5-col th:nth-child(1), .summary-table-5-col td:nth-child(1) { width: 15%; } | |
| .summary-table-5-col th:nth-child(2), .summary-table-5-col td:nth-child(2) { width: 15%; } | |
| .summary-table-5-col th:nth-child(3), .summary-table-5-col td:nth-child(3) { width: 20%; } | |
| .summary-table-5-col th:nth-child(4), .summary-table-5-col td:nth-child(4) { width: 10%; } | |
| .summary-table-5-col th:nth-child(5), .summary-table-5-col td:nth-child(5) { width: 40%; } | |
| .summary-table-6-col th:nth-child(1), .summary-table-6-col td:nth-child(1) { width: 12%; } | |
| .summary-table-6-col th:nth-child(2), .summary-table-6-col td:nth-child(2) { width: 12%; } | |
| .summary-table-6-col th:nth-child(3), .summary-table-6-col td:nth-child(3) { width: 20%; } | |
| .summary-table-6-col th:nth-child(4), .summary-table-6-col td:nth-child(4) { width: 16%; } | |
| .summary-table-6-col th:nth-child(5), .summary-table-6-col td:nth-child(5) { width: 8%; } | |
| .summary-table-6-col th:nth-child(6), .summary-table-6-col td:nth-child(6) { width: 32%; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <style> | |
| @media print { | |
| div[data-testid="stHorizontalBlock"] { display: grid !important; grid-template-columns: repeat(5, 1fr) !important; gap: 1.2rem !important; } | |
| .stDataFrame, .stTable { break-inside: avoid; page-break-inside: avoid; } | |
| thead, tr, th, td { break-inside: avoid !important; page-break-inside: avoid !important; } | |
| h1, h2, h3, h4, h5 { page-break-after: avoid; } | |
| } | |
| .custom-header { font-size: 20px; font-weight: bold; margin-top: 0px !important; padding-top: 0px !important; } | |
| div[data-testid="stHorizontalBlock"] > div div[data-testid="stMetric"] { border: 1px solid #ddd; padding: 0.75rem; border-radius: 0.5rem; height: 100%; display: flex; flex-direction: column; justify-content: center; } | |
| div[data-testid="stHorizontalBlock"] > div:nth-child(1) div[data-testid="stMetric"] { background-color: #e6fffa; border-color: #b2f5ea; } | |
| div[data-testid="stHorizontalBlock"] > div:nth-child(2) div[data-testid="stMetric"] { background-color: #fff3e0; border-color: #ffe0b2; } | |
| div[data-testid="stHorizontalBlock"] > div:nth-child(3) div[data-testid="stMetric"] { background-color: #fce4ec; border-color: #f8bbd0; } | |
| div[data-testid="stHorizontalBlock"] > div:nth-child(4) div[data-testid="stMetric"] { background-color: #e3f2fd; border-color: #bbdefb; } | |
| div[data-testid="stHorizontalBlock"] > div div[data-testid="stMetric"] [data-testid="stMetricLabel"] > div, | |
| div[data-testid="stHorizontalBlock"] > div div[data-testid="stMetric"] [data-testid="stMetricValue"], | |
| div[data-testid="stHorizontalBlock"] > div div[data-testid="stMetric"] [data-testid="stMetricDelta"] { color: #262730 !important; } | |
| div[data-testid="stMetric"] [data-testid="stMetricLabel"] > div { font-size: 0.8rem !important; line-height: 1.2 !important; white-space: normal !important; overflow-wrap: break-word !important; word-break: break-word; display: block !important; } | |
| div[data-testid="stMetric"] [data-testid="stMetricValue"] { font-size: 1.3rem !important; } | |
| div[data-testid="stMetric"] [data-testid="stMetricDelta"] { font-size: 0.75rem !important; } | |
| div[data-testid="stHorizontalBlock"] > div .stExpander { border: none !important; box-shadow: none !important; padding: 0 !important; margin-top: 0.5rem; } | |
| div[data-testid="stHorizontalBlock"] > div .stExpander header { padding: 0.25rem 0.5rem !important; font-size: 0.75rem !important; border-radius: 0.25rem; } | |
| div[data-testid="stHorizontalBlock"] > div .stExpander div[data-testid="stExpanderDetails"] { max-height: 200px; overflow-y: auto; } | |
| .stDataFrame table td, .stDataFrame table th { color: black !important; font-size: 0.9rem !important; } | |
| .stDataFrame table th { font-weight: bold !important; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ============================================================================== | |
| # ANALYSIS FUNCTIONS | |
| # ============================================================================== | |
| def load_data(uploaded_file): | |
| try: | |
| return pd.read_excel(uploaded_file, engine='openpyxl', keep_default_na=False) | |
| except Exception as e: | |
| st.error(f"เกิดข้อผิดพลาดในการอ่านไฟล์ Excel: {e}") | |
| return pd.DataFrame() | |
| def calculate_persistence_risk_score(_df: pd.DataFrame, total_months: int): | |
| risk_level_map_to_score = {"51": 21, "52": 22, "53": 23, "54": 24, "55": 25, "41": 16, "42": 17, "43": 18, "44": 19, | |
| "45": 20, "31": 11, "32": 12, "33": 13, "34": 14, "35": 15, "21": 6, "22": 7, "23": 8, | |
| "24": 9, "25": 10, "11": 1, "12": 2, "13": 3, "14": 4, "15": 5} | |
| if _df.empty or 'รหัส' not in _df.columns or 'Risk Level' not in _df.columns: return pd.DataFrame() | |
| analysis_df = _df[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง', 'Risk Level']].copy() | |
| analysis_df['Ordinal_Risk_Score'] = analysis_df['Risk Level'].astype(str).map(risk_level_map_to_score) | |
| analysis_df.dropna(subset=['Ordinal_Risk_Score'], inplace=True) | |
| if analysis_df.empty: return pd.DataFrame() | |
| persistence_metrics = analysis_df.groupby('รหัส').agg( | |
| Average_Ordinal_Risk_Score=('Ordinal_Risk_Score', 'mean'), | |
| Total_Occurrences=('รหัส', 'size') | |
| ).reset_index() | |
| total_months = max(1, total_months) | |
| persistence_metrics['Incident_Rate_Per_Month'] = persistence_metrics['Total_Occurrences'] / total_months | |
| max_rate = max(1, persistence_metrics['Incident_Rate_Per_Month'].max()) | |
| persistence_metrics['Frequency_Score'] = persistence_metrics['Incident_Rate_Per_Month'] / max_rate | |
| persistence_metrics['Avg_Severity_Score'] = persistence_metrics['Average_Ordinal_Risk_Score'] / 25.0 | |
| persistence_metrics['Persistence_Risk_Score'] = persistence_metrics['Frequency_Score'] + persistence_metrics[ | |
| 'Avg_Severity_Score'] | |
| incident_names = _df[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง']].drop_duplicates() | |
| final_df = pd.merge(persistence_metrics, incident_names, on='รหัส', how='left') | |
| return final_df.sort_values(by='Persistence_Risk_Score', ascending=False) | |
| def calculate_frequency_trend_poisson(_df: pd.DataFrame): | |
| if _df.empty or 'รหัส' not in _df.columns or 'Occurrence Date' not in _df.columns: return pd.DataFrame() | |
| analysis_df = _df[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง', 'Occurrence Date']].copy() | |
| analysis_df.dropna(subset=['Occurrence Date'], inplace=True) | |
| if analysis_df.empty: return pd.DataFrame() | |
| analysis_df['YearMonth'] = pd.to_datetime(analysis_df['Occurrence Date']).dt.to_period('M') | |
| full_date_range = pd.period_range(start=analysis_df['YearMonth'].min(), end=analysis_df['YearMonth'].max(), | |
| freq='M') | |
| results = [] | |
| for code in analysis_df['รหัส'].unique(): | |
| incident_subset = analysis_df[analysis_df['รหัส'] == code] | |
| if len(incident_subset) < 3 or len(incident_subset.groupby('YearMonth')) < 2: continue | |
| monthly_counts = incident_subset.groupby('YearMonth').size().reindex(full_date_range, fill_value=0) | |
| y = monthly_counts.values | |
| X = sm.add_constant(np.arange(len(monthly_counts))) | |
| try: | |
| model = sm.Poisson(y, X).fit(disp=0) | |
| results.append({ | |
| 'รหัส': code, 'Poisson_Trend_Slope': model.params[1], | |
| 'Total_Occurrences': y.sum(), 'Months_Observed': len(y) | |
| }) | |
| except Exception: | |
| continue | |
| if not results: return pd.DataFrame() | |
| final_df = pd.DataFrame(results) | |
| incident_names = _df[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง']].drop_duplicates() | |
| final_df = pd.merge(final_df, incident_names, on='รหัส', how='left') | |
| return final_df.sort_values(by='Poisson_Trend_Slope', ascending=False) | |
| def create_poisson_trend_plot(df, selected_code_for_plot, display_df=None, show_ci=True): | |
| # เตรียมช่วงเดือนเต็มของทั้งชุดข้อมูล (แกน x) | |
| full_date_range_for_plot = pd.period_range( | |
| start=pd.to_datetime(df['Occurrence Date']).dt.to_period('M').min(), | |
| end=pd.to_datetime(df['Occurrence Date']).dt.to_period('M').max(), | |
| freq='M' | |
| ) | |
| # นับจำนวนครั้งต่อเดือนของรหัสที่เลือก | |
| subset = df[df['รหัส'] == selected_code_for_plot].copy() | |
| subset['YearMonth'] = pd.to_datetime(subset['Occurrence Date']).dt.to_period('M') | |
| counts = subset.groupby('YearMonth').size().reindex(full_date_range_for_plot, fill_value=0) | |
| # เตรียมข้อมูลสำหรับโมเดล Poisson: y = counts, X = [const, time] | |
| y = counts.values.astype(float) | |
| t = np.arange(len(counts), dtype=float) | |
| X = sm.add_constant(t) | |
| # fit Poisson (log link): mu_t = exp(beta0 + beta1 * t) | |
| beta0 = beta1 = None | |
| mu_hat = None | |
| mu_lo = mu_hi = None | |
| if len(y) >= 2 and y.sum() > 0: | |
| try: | |
| model = sm.Poisson(y, X).fit(disp=0) | |
| beta0, beta1 = model.params | |
| # คาดการณ์ค่าเฉลี่ยที่คาดหวังต่อเดือนจากโมเดล | |
| eta = beta0 + beta1 * t # linear predictor | |
| mu_hat = np.exp(eta) # expected counts | |
| if show_ci: | |
| # 95% CI บนสเกลลอก -> แปลงกลับเป็นสเกลนับ | |
| cov = model.cov_params() | |
| design = np.column_stack([np.ones_like(t), t]) | |
| se_eta = np.sqrt(np.einsum('ij,jk,ik->i', design, cov, design)) | |
| eta_lo = eta - 1.96 * se_eta | |
| eta_hi = eta + 1.96 * se_eta | |
| mu_lo = np.exp(eta_lo) | |
| mu_hi = np.exp(eta_hi) | |
| except Exception as e: | |
| st.warning(f"คำนวณเส้นแนวโน้ม Poisson ไม่สำเร็จ: {e}") | |
| # วาดกราฟ | |
| fig_plot = go.Figure() | |
| # แท่งจำนวนครั้งจริงต่อเดือน | |
| fig_plot.add_trace(go.Bar( | |
| x=counts.index.strftime('%Y-%m'), | |
| y=y, | |
| name='จำนวนครั้งที่เกิดจริง', | |
| marker=dict(color='#AED6F1', cornerradius=8) | |
| )) | |
| # เส้นแนวโน้มจาก Poisson + ช่วงความเชื่อมั่น (ถ้ามี) | |
| if mu_hat is not None: | |
| fig_plot.add_trace(go.Scatter( | |
| x=counts.index.strftime('%Y-%m'), | |
| y=mu_hat, | |
| mode='lines', | |
| name='แนวโน้มคาดหมาย (Poisson)', | |
| line=dict(width=2) | |
| )) | |
| if show_ci and (mu_lo is not None) and (mu_hi is not None): | |
| # วาด band 95% CI (บนก่อนล่าง แล้ว fill='tonexty') | |
| fig_plot.add_trace(go.Scatter( | |
| x=counts.index.strftime('%Y-%m'), | |
| y=mu_hi, | |
| mode='lines', | |
| line=dict(width=0), | |
| showlegend=False, | |
| hoverinfo='skip' | |
| )) | |
| fig_plot.add_trace(go.Scatter( | |
| x=counts.index.strftime('%Y-%m'), | |
| y=mu_lo, | |
| mode='lines', | |
| fill='tonexty', | |
| name='95% CI', | |
| line=dict(width=0), | |
| fillcolor='rgba(0,0,0,0.08)' | |
| )) | |
| fig_plot.update_layout( | |
| title=f'การกระจายตัวของอุบัติการณ์: {selected_code_for_plot}', | |
| xaxis_title='เดือน-ปี', | |
| yaxis_title='จำนวนครั้งที่เกิด', | |
| barmode='overlay', | |
| legend=dict(x=0.01, y=0.99, bgcolor='rgba(255,255,255,0.7)') | |
| ) | |
| # ข้อความประกอบ: ใช้พารามิเตอร์จาก Poisson (ถ้าคำนวณได้) ไม่ต้องพึ่ง display_df | |
| if beta1 is not None: | |
| factor = float(np.exp(beta1)) | |
| annot_text = (f"<b>Poisson slope: {beta1:.4f}</b><br>" | |
| f"อัตราเปลี่ยนแปลง: x{factor:.2f} ต่อเดือน") | |
| else: | |
| annot_text = "<b>Poisson slope: N/A</b><br>อัตราเปลี่ยนแปลง: N/A" | |
| fig_plot.add_annotation( | |
| x=0.5, y=0.98, | |
| xref="paper", yref="paper", | |
| text=annot_text, | |
| showarrow=False, | |
| font=dict(size=12, color="black"), | |
| align="center", | |
| bordercolor="black", | |
| borderwidth=1, | |
| borderpad=4, | |
| bgcolor="rgba(255, 255, 224, 0.7)" | |
| ) | |
| return fig_plot | |
| def create_goal_summary_table(data_df_goal, goal_category_name_param, | |
| e_up_non_numeric_levels_param, e_up_numeric_levels_param=None, | |
| is_org_safety_table=False): | |
| goal_category_name_param = str(goal_category_name_param).strip() | |
| if 'หมวด' not in data_df_goal.columns: | |
| return pd.DataFrame() | |
| df_filtered_by_goal_cat = data_df_goal[ | |
| data_df_goal['หมวด'].astype(str).str.strip() == goal_category_name_param].copy() | |
| if df_filtered_by_goal_cat.empty: return pd.DataFrame() | |
| if 'Incident Type' not in df_filtered_by_goal_cat.columns or 'Impact' not in df_filtered_by_goal_cat.columns: return pd.DataFrame() | |
| try: | |
| pvt_table_goal = pd.crosstab(df_filtered_by_goal_cat['Incident Type'], | |
| df_filtered_by_goal_cat['Impact'].astype(str).str.strip(), margins=True, | |
| margins_name='รวมทั้งหมด') | |
| except Exception: | |
| return pd.DataFrame() | |
| if 'รวมทั้งหมด' in pvt_table_goal.index: pvt_table_goal = pvt_table_goal.drop(index='รวมทั้งหมด') | |
| if pvt_table_goal.empty: return pd.DataFrame() | |
| if 'รวมทั้งหมด' not in pvt_table_goal.columns: pvt_table_goal['รวมทั้งหมด'] = pvt_table_goal.sum(axis=1) | |
| all_impact_columns_goal = [str(col).strip() for col in pvt_table_goal.columns if col != 'รวมทั้งหมด'] | |
| e_up_non_numeric_levels_param_stripped = [str(level).strip() for level in e_up_non_numeric_levels_param] | |
| e_up_numeric_levels_param_stripped = [str(level).strip() for level in | |
| e_up_numeric_levels_param] if e_up_numeric_levels_param else [] | |
| e_up_columns_goal = [col for col in all_impact_columns_goal if | |
| col not in e_up_non_numeric_levels_param_stripped and ( | |
| not e_up_numeric_levels_param_stripped or col not in e_up_numeric_levels_param_stripped)] | |
| report_data_goal = [] | |
| for incident_type_goal, row_data_goal in pvt_table_goal.iterrows(): | |
| total_e_up_count_goal = sum(row_data_goal[col] for col in e_up_columns_goal if | |
| col in row_data_goal.index and pd.notna(row_data_goal[col])) | |
| total_all_impacts_goal = row_data_goal['รวมทั้งหมด'] if 'รวมทั้งหมด' in row_data_goal and pd.notna( | |
| row_data_goal['รวมทั้งหมด']) else 0 | |
| percent_e_up_goal = (total_e_up_count_goal / total_all_impacts_goal * 100) if total_all_impacts_goal > 0 else 0 | |
| report_data_goal.append( | |
| {'Incident Type': incident_type_goal, 'รวม E-up': total_e_up_count_goal, 'ร้อยละ E-up': percent_e_up_goal}) | |
| report_df_goal = pd.DataFrame(report_data_goal) | |
| if report_df_goal.empty: | |
| merged_report_table_goal = pvt_table_goal.reset_index() | |
| merged_report_table_goal['รวม E-up'] = 0 | |
| merged_report_table_goal['ร้อยละ E-up'] = 0.0 | |
| else: | |
| merged_report_table_goal = pd.merge(pvt_table_goal.reset_index(), report_df_goal, on='Incident Type', | |
| how='outer') | |
| if 'รวม E-up' not in merged_report_table_goal.columns: | |
| merged_report_table_goal['รวม E-up'] = 0 | |
| else: | |
| merged_report_table_goal['รวม E-up'].fillna(0, inplace=True) | |
| if 'ร้อยละ E-up' not in merged_report_table_goal.columns: | |
| merged_report_table_goal['ร้อยละ E-up'] = 0.0 | |
| else: | |
| merged_report_table_goal['ร้อยละ E-up'].fillna(0.0, inplace=True) | |
| cols_to_drop_from_display_goal = [col for col in e_up_non_numeric_levels_param_stripped if | |
| col in merged_report_table_goal.columns] | |
| if e_up_numeric_levels_param_stripped: cols_to_drop_from_display_goal.extend( | |
| [col for col in e_up_numeric_levels_param_stripped if col in merged_report_table_goal.columns]) | |
| merged_report_table_goal = merged_report_table_goal.drop(columns=cols_to_drop_from_display_goal, errors='ignore') | |
| total_col_original_name, e_up_col_name, percent_e_up_col_name = 'รวมทั้งหมด', 'รวม E-up', 'ร้อยละ E-up' | |
| if is_org_safety_table: | |
| total_col_display_name, e_up_col_display_name, percent_e_up_display_name = 'รวม 1-5', 'รวม 3-5', 'ร้อยละ 3-5' | |
| merged_report_table_goal.rename( | |
| columns={total_col_original_name: total_col_display_name, e_up_col_name: e_up_col_display_name, | |
| percent_e_up_col_name: percent_e_up_display_name}, inplace=True, errors='ignore') | |
| else: | |
| total_col_display_name, e_up_col_display_name, percent_e_up_display_name = 'รวม A-I', e_up_col_name, percent_e_up_col_name | |
| merged_report_table_goal.rename(columns={total_col_original_name: total_col_display_name}, inplace=True, | |
| errors='ignore') | |
| merged_report_table_goal['Incident Type Name'] = merged_report_table_goal['Incident Type'].map(type_name).fillna( | |
| merged_report_table_goal['Incident Type']) | |
| final_columns_goal_order = ['Incident Type Name'] + [col for col in e_up_columns_goal if | |
| col in merged_report_table_goal.columns] + [ | |
| e_up_col_display_name, total_col_display_name, percent_e_up_display_name] | |
| final_columns_present_goal = [col for col in final_columns_goal_order if col in merged_report_table_goal.columns] | |
| merged_report_table_goal = merged_report_table_goal[final_columns_present_goal] | |
| if percent_e_up_display_name in merged_report_table_goal.columns and pd.api.types.is_numeric_dtype( | |
| merged_report_table_goal[percent_e_up_display_name]): | |
| try: | |
| merged_report_table_goal[percent_e_up_display_name] = merged_report_table_goal[ | |
| percent_e_up_display_name].astype(float).map('{:.2f}%'.format) | |
| except ValueError: | |
| pass | |
| return merged_report_table_goal.set_index('Incident Type Name') | |
| def create_severity_table(input_df, row_column_name, table_title, specific_row_order=None): | |
| if not isinstance(input_df, | |
| pd.DataFrame) or input_df.empty or row_column_name not in input_df.columns or 'Impact Level' not in input_df.columns: return None | |
| temp_df = input_df.copy() | |
| temp_df['Impact Level'] = temp_df['Impact Level'].astype(str).str.strip().replace('N/A', 'ไม่ระบุ') | |
| if temp_df[row_column_name].dropna().empty: return None | |
| try: | |
| severity_crosstab = pd.crosstab(temp_df[row_column_name].astype(str).str.strip(), temp_df['Impact Level']) | |
| except Exception: | |
| return None | |
| impact_level_map_cols = {'1': 'A-B (1)', '2': 'C-D (2)', '3': 'E-F (3)', '4': 'G-H (4)', '5': 'I (5)', | |
| 'ไม่ระบุ': 'ไม่ระบุ LV'} | |
| desired_cols_ordered_keys = ['1', '2', '3', '4', '5', 'ไม่ระบุ'] | |
| for col_key in desired_cols_ordered_keys: | |
| if col_key not in severity_crosstab.columns: severity_crosstab[col_key] = 0 | |
| present_ordered_keys = [key for key in desired_cols_ordered_keys if key in severity_crosstab.columns] | |
| if not present_ordered_keys: return None | |
| severity_crosstab = severity_crosstab[present_ordered_keys].rename(columns=impact_level_map_cols) | |
| final_display_cols_renamed = [impact_level_map_cols[key] for key in present_ordered_keys if | |
| key in impact_level_map_cols] | |
| if not final_display_cols_renamed: return None | |
| severity_crosstab['รวมทุกระดับ'] = severity_crosstab[ | |
| [col for col in final_display_cols_renamed if col in severity_crosstab.columns]].sum(axis=1) | |
| if specific_row_order: | |
| severity_crosstab = severity_crosstab.reindex([str(i) for i in specific_row_order]).fillna(0).astype(int) | |
| else: | |
| severity_crosstab = severity_crosstab[severity_crosstab['รวมทุกระดับ'] > 0] | |
| if severity_crosstab.empty: return None | |
| st.markdown(f"##### {table_title}") | |
| display_column_order_from_map = [impact_level_map_cols.get(key) for key in desired_cols_ordered_keys] | |
| display_column_order_present = [col for col in display_column_order_from_map if | |
| col in severity_crosstab.columns] + ( | |
| ['รวมทุกระดับ'] if 'รวมทุกระดับ' in severity_crosstab.columns else []) | |
| st.dataframe( | |
| severity_crosstab[[col for col in display_column_order_present if col in severity_crosstab.columns]].astype( | |
| int), use_container_width=True) | |
| return severity_crosstab | |
| def create_psg9_summary_table(input_df): | |
| if not isinstance(input_df, | |
| pd.DataFrame) or 'หมวดหมู่มาตรฐานสำคัญ' not in input_df.columns or 'Impact' not in input_df.columns: return None | |
| psg9_placeholders = ["ไม่จัดอยู่ใน PSG9 Catalog", "ไม่สามารถระบุ (Merge PSG9 ล้มเหลว)", | |
| "ไม่สามารถระบุ (เช็คคอลัมน์ใน PSG9code.xlsx)", | |
| "ไม่สามารถระบุ (PSG9code.xlsx ไม่ได้โหลด/ว่างเปล่า)", | |
| "ไม่สามารถระบุ (Merge PSG9 ล้มเหลว - rename)", "ไม่สามารถระบุ (Merge PSG9 ล้มเหลว - no col)", | |
| "ไม่สามารถระบุ (PSG9code.xlsx ไม่ได้โหลด/ข้อมูลไม่ครบถ้วน)"] | |
| df_filtered = input_df[ | |
| ~input_df['หมวดหมู่มาตรฐานสำคัญ'].isin(psg9_placeholders) & input_df['หมวดหมู่มาตรฐานสำคัญ'].notna()].copy() | |
| if df_filtered.empty: return pd.DataFrame() | |
| try: | |
| summary_table = pd.crosstab(df_filtered['หมวดหมู่มาตรฐานสำคัญ'], df_filtered['Impact'], margins=True, | |
| margins_name='รวม A-I') | |
| except Exception: | |
| return pd.DataFrame() | |
| if 'รวม A-I' in summary_table.index: summary_table = summary_table.drop(index='รวม A-I') | |
| if summary_table.empty: return pd.DataFrame() | |
| all_impacts, e_up_impacts = list('ABCDEFGHI'), list('EFGHI') | |
| for impact_col in all_impacts: | |
| if impact_col not in summary_table.columns: summary_table[impact_col] = 0 | |
| if 'รวม A-I' not in summary_table.columns: summary_table['รวม A-I'] = summary_table[ | |
| [col for col in all_impacts if col in summary_table.columns]].sum(axis=1) | |
| summary_table['รวม E-up'] = summary_table[[col for col in e_up_impacts if col in summary_table.columns]].sum(axis=1) | |
| summary_table['ร้อยละ E-up'] = (summary_table['รวม E-up'] / summary_table['รวม A-I'] * 100).fillna(0) | |
| psg_order = [PSG9_label_dict[i] for i in sorted(PSG9_label_dict.keys())] | |
| summary_table = summary_table.reindex(psg_order).fillna(0) | |
| display_cols_order = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'รวม E-up', 'รวม A-I', 'ร้อยละ E-up'] | |
| final_table = summary_table[[col for col in display_cols_order if col in summary_table.columns]].copy() | |
| for col in final_table.columns: | |
| if col != 'ร้อยละ E-up': final_table[col] = final_table[col].astype(int) | |
| final_table['ร้อยละ E-up'] = final_table['ร้อยละ E-up'].map('{:.2f}%'.format) | |
| return final_table | |
| def get_text_color_for_bg(hex_color): | |
| try: | |
| hex_color = hex_color.lstrip('#') | |
| if len(hex_color) != 6: return '#000000' | |
| rgb = tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) | |
| luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255 | |
| return '#FFFFFF' if luminance < 0.5 else '#000000' | |
| except ValueError: | |
| return '#000000' | |
| def prioritize_incidents_nb_logit_v2(_df: pd.DataFrame, | |
| horizon: int = 3, | |
| min_months: int = 4, | |
| min_total: int = 5, | |
| w_expected_severe: float = 0.7, | |
| w_freq_growth: float = 0.2, | |
| w_sev_growth: float = 0.1, | |
| alpha_floor: float = 1e-8): | |
| """ | |
| เวอร์ชันแก้บั๊ก: คำนวณ Priority แบบ NB+Logit และทำสกอร์หลังรวม DataFrame (ไม่เรียก fillna บนสเกลาร์) | |
| """ | |
| req = ['รหัส', 'Occurrence Date', 'Impact Level'] | |
| if any(c not in _df.columns for c in req): | |
| return pd.DataFrame() | |
| d = _df.copy() | |
| d = d[pd.to_datetime(d['Occurrence Date'], errors='coerce').notna()] | |
| if d.empty: | |
| return pd.DataFrame() | |
| d['YearMonth'] = pd.to_datetime(d['Occurrence Date']).dt.to_period('M') | |
| full_range = pd.period_range(d['YearMonth'].min(), d['YearMonth'].max(), freq='M') | |
| rows = [] | |
| for code, sub in d.groupby('รหัส'): | |
| if len(sub) < min_total: | |
| continue | |
| # ===== ความถี่รายเดือน (NB) ===== | |
| counts = sub.groupby('YearMonth').size().reindex(full_range, fill_value=0).astype(float) | |
| y = counts.values | |
| n_months = len(counts) | |
| t = np.arange(n_months, dtype=float) | |
| X = sm.add_constant(t) | |
| nb_beta0 = np.nan; nb_beta1 = np.nan; nb_p = np.nan; nb_factor = np.nan; alpha_hat = np.nan | |
| mu_future = np.zeros(horizon, dtype=float) | |
| if n_months >= min_months and y.sum() > 0: | |
| try: | |
| pois = sm.GLM(y, X, family=sm.families.Poisson()).fit() | |
| mu = pois.fittedvalues | |
| num = float(((y - mu)**2 - y).sum()) | |
| den = float(max((mu**2).sum(), 1e-12)) | |
| alpha_hat = max(num/den, alpha_floor) | |
| nb = sm.GLM(y, X, family=sm.families.NegativeBinomial(alpha=alpha_hat)).fit() | |
| nb_beta0, nb_beta1 = float(nb.params[0]), float(nb.params[1]) | |
| nb_p = float(nb.pvalues[1]) | |
| nb_factor = float(np.exp(nb_beta1)) | |
| t_future = np.arange(n_months, n_months + horizon, dtype=float) | |
| eta_future = nb_beta0 + nb_beta1 * t_future | |
| mu_future = np.exp(eta_future) | |
| except Exception: | |
| pass | |
| # ===== สัดส่วนเหตุรุนแรง LV3-5 (Logit) ===== | |
| sub['__sev__'] = sub['Impact Level'].astype(str).isin(['3','4','5']).astype(int) | |
| sev_counts = sub.groupby('YearMonth')['__sev__'].sum().reindex(full_range, fill_value=0).astype(float) | |
| n_counts = sub.groupby('YearMonth').size().reindex(full_range, fill_value=0).astype(float) | |
| mask = n_counts > 0 | |
| lg_beta0 = np.nan; lg_beta1 = np.nan; lg_p = np.nan; sev_or = np.nan | |
| p_future = np.full(horizon, np.nan, dtype=float) | |
| if mask.sum() >= min_months and sev_counts[mask].sum() > 0 and (sev_counts[mask] < n_counts[mask]).any(): | |
| try: | |
| endog = (sev_counts[mask] / n_counts[mask]).values | |
| Xt = sm.add_constant(np.arange(n_months)[mask].astype(float)) | |
| logit = sm.GLM(endog, Xt, family=sm.families.Binomial(), freq_weights=n_counts[mask].values).fit() | |
| lg_beta0, lg_beta1 = float(logit.params[0]), float(logit.params[1]) | |
| lg_p = float(logit.pvalues[1]) | |
| sev_or = float(np.exp(lg_beta1)) | |
| t_future_all = np.arange(n_months, n_months + horizon, dtype=float) | |
| lin = lg_beta0 + lg_beta1 * t_future_all | |
| p_future = 1/(1 + np.exp(-lin)) | |
| p_future = np.clip(p_future, 1e-6, 1-1e-6) | |
| except Exception: | |
| pass | |
| else: | |
| base_p = (sev_counts.sum()/n_counts.sum()) if n_counts.sum() > 0 else 0.0 | |
| p_future = np.full(horizon, base_p, dtype=float) | |
| expected_all_nextH = float(np.nansum(mu_future)) | |
| expected_sev_nextH = float(np.nansum(mu_future * p_future)) | |
| freq_rising = (nb_beta1 > 0) and (pd.notna(nb_p) and nb_p < 0.05) | |
| sev_rising = (lg_beta1 > 0) and (pd.notna(lg_p) and lg_p < 0.05) | |
| rows.append({ | |
| 'รหัส': code, | |
| 'ชื่ออุบัติการณ์ความเสี่ยง': sub['ชื่ออุบัติการณ์ความเสี่ยง'].iloc[0] if 'ชื่ออุบัติการณ์ความเสี่ยง' in sub else '', | |
| 'Months_Observed': int(n_months), | |
| 'Total_Occurrences': int(y.sum()), | |
| 'NB_alpha_hat': alpha_hat, | |
| 'Freq_NB_Slope': nb_beta1, | |
| 'Freq_p_value': nb_p, | |
| 'Freq_Factor_per_month': nb_factor, # เก็บค่า “ดิบ” มาก่อน | |
| 'Severity_Logit_Slope': lg_beta1, | |
| 'Severity_p_value': lg_p, | |
| 'Severe_OR_per_month': sev_or, # เก็บค่า “ดิบ” มาก่อน | |
| 'Expected_All_nextH': expected_all_nextH, | |
| 'Expected_Severe_nextH': expected_sev_nextH, | |
| 'Freq_Rising': freq_rising, | |
| 'Sev_Rising': sev_rising | |
| }) | |
| if not rows: | |
| return pd.DataFrame() | |
| out = pd.DataFrame(rows) | |
| # ---------- ช่วยฟังก์ชัน: รับอาเรย์/Series เท่านั้น (ไม่รองรับสเกลาร์) ---------- | |
| def _safe_log_pos_arr(x): | |
| arr = np.asarray(x, dtype=float) | |
| arr[~np.isfinite(arr)] = 1.0 | |
| arr = np.clip(arr, 1e-12, None) | |
| return np.log(arr) | |
| def _norm01_pos_arr(x): | |
| arr = np.asarray(x, dtype=float) | |
| arr[~np.isfinite(arr)] = 0.0 | |
| arr = np.clip(arr, 0, None) | |
| rng = arr.max() - arr.min() | |
| return (arr - arr.min())/rng if rng > 0 else np.zeros_like(arr) | |
| # ---------- เตรียมคอลัมน์ก่อนทำสกอร์ ---------- | |
| out['Freq_Factor_per_month'] = pd.to_numeric(out['Freq_Factor_per_month'], errors='coerce').fillna(1.0) | |
| out['Severe_OR_per_month'] = pd.to_numeric(out['Severe_OR_per_month'], errors='coerce').fillna(1.0) | |
| out['Expected_Severe_nextH'] = pd.to_numeric(out['Expected_Severe_nextH'], errors='coerce').fillna(0.0) | |
| # ---------- คำนวณสกอร์แบบเวคเตอร์ (ไม่ยุ่งกับสเกลาร์) ---------- | |
| out['Score_expected_severe'] = _norm01_pos_arr(out['Expected_Severe_nextH'].values) | |
| out['Score_freq_growth'] = _norm01_pos_arr(_safe_log_pos_arr(out['Freq_Factor_per_month'].values)) | |
| out['Score_sev_growth'] = _norm01_pos_arr(_safe_log_pos_arr(out['Severe_OR_per_month'].values)) | |
| # bonus ถ้า 2 แนวโน้มมีนัย | |
| bonus = np.where((out['Freq_Rising']) & (out['Sev_Rising']), 0.05, 0.0) | |
| out['Priority_Score'] = ( | |
| w_expected_severe * out['Score_expected_severe'] + | |
| w_freq_growth * out['Score_freq_growth'] + | |
| w_sev_growth * out['Score_sev_growth'] + | |
| bonus | |
| ) | |
| cols = [ | |
| 'รหัส','ชื่ออุบัติการณ์ความเสี่ยง','Months_Observed','Total_Occurrences', | |
| 'Expected_All_nextH','Expected_Severe_nextH', | |
| 'Freq_Factor_per_month','Freq_p_value', | |
| 'Severe_OR_per_month','Severity_p_value', | |
| 'NB_alpha_hat','Priority_Score', | |
| 'Freq_Rising','Sev_Rising' | |
| ] | |
| out = out[cols].sort_values('Priority_Score', ascending=False).reset_index(drop=True) | |
| return out | |
| def generate_executive_summary_pdf(df_filtered, metrics_data, total_month, df_freq, min_date_str, max_date_str): | |
| """ | |
| สร้างไฟล์ PDF จากข้อมูลสรุปสำหรับผู้บริหาร (เวอร์ชันสมบูรณ์ 8 หัวข้อ) | |
| """ | |
| if HTML is None: | |
| st.error("กรุณาติดตั้ง WeasyPrint เพื่อใช้งานฟังก์ชันนี้: pip install weasyprint") | |
| return None | |
| # --- เตรียมข้อมูลสำหรับแต่ละส่วน --- | |
| # 2. Risk Matrix & Top 10 | |
| impact_level_keys = ['5', '4', '3', '2', '1'] | |
| freq_level_keys = ['1', '2', '3', '4', '5'] | |
| matrix_df = df_filtered[ | |
| df_filtered['Impact Level'].isin(impact_level_keys) & df_filtered['Frequency Level'].isin(freq_level_keys)] | |
| matrix_data_html = "ไม่มีข้อมูล" | |
| if not matrix_df.empty: | |
| matrix_data = pd.crosstab(matrix_df['Impact Level'], matrix_df['Frequency Level']) | |
| matrix_data = matrix_data.reindex(index=impact_level_keys, columns=freq_level_keys, fill_value=0) | |
| impact_labels = {'5': "5 (Extreme)", '4': "4 (Major)", '3': "3 (Moderate)", '2': "2 (Minor)", | |
| '1': "1 (Insignificant)"} | |
| freq_labels = {'1': "F1", '2': "F2", '3': "F3", '4': "F4", '5': "F5"} | |
| matrix_data_html = matrix_data.rename(index=impact_labels, columns=freq_labels).to_html( | |
| classes="styled-table", | |
| table_id="risk-matrix-table" | |
| ) | |
| frequency_legend_html = """ | |
| <div class="legend"> | |
| <h4>หมายเหตุ คำอธิบายความถี่ (Frequency)</h4> | |
| <p> | |
| <b>F1</b> = Remote (น้อยกว่า 2 ครั้ง/เดือน)<br> | |
| <b>F2</b> = Uncommon (2-3 ครั้ง/เดือน)<br> | |
| <b>F3</b> = Occasional (4-6 ครั้ง/เดือน)<br> | |
| <b>F4</b> = Probable (7-29 ครั้ง/เดือน)<br> | |
| <b>F5</b> = Frequent (มากกว่าหรือเท่ากับ 30 ครั้ง/เดือน) | |
| </p> | |
| </div> | |
| """ | |
| top10_df = df_freq.nlargest(10, 'count').copy() | |
| incident_names = df_filtered[['Incident', 'ชื่ออุบัติการณ์ความเสี่ยง']].drop_duplicates() | |
| top10_df = pd.merge(top10_df, incident_names, on='Incident', how='left') | |
| top10_html = top10_df[['Incident', 'count']].to_html( | |
| classes="styled-table", | |
| index=False, | |
| table_id="top10-table" | |
| ) | |
| # 3. Sentinel Events | |
| sentinel_html = "<p>ไม่พบ Sentinel Events ในช่วงเวลานี้</p>" | |
| if 'Sentinel code for check' in df_filtered.columns: | |
| sentinel_df = df_filtered[df_filtered['Sentinel code for check'].isin(sentinel_composite_keys)] | |
| if not sentinel_df.empty: | |
| sentinel_html = sentinel_df[['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด']].to_html( | |
| classes="styled-table", | |
| index=False, | |
| table_id="sentinel-table" | |
| ) | |
| psg9_summary_table = create_psg9_summary_table(df_filtered) | |
| psg9_html = "<p>ไม่พบข้อมูล PSG9 ในช่วงเวลานี้</p>" | |
| if psg9_summary_table is not None and not psg9_summary_table.empty: | |
| # to_html จะสร้าง index เป็นคอลัมน์แรกโดยอัตโนมัติ | |
| psg9_html = psg9_summary_table.to_html( | |
| classes="styled-table", | |
| table_id="psg9-table" | |
| ) | |
| #เตรียมข้อมูลสำหรับตาราง "สรุปอุบัติการณ์ตามเป้าหมาย" | |
| goal_definitions = { | |
| "Patient Safety/ Common Clinical Risk": "P:Patient Safety Goals หรือ Common Clinical Risk Incident", | |
| "Specific Clinical Risk": "S:Specific Clinical Risk Incident", | |
| "Personnel Safety": "P:Personnel Safety Goals", | |
| "Organization Safety": "O:Organization Safety Goals" | |
| } | |
| safety_goals_html_parts = [] | |
| for display_name, cat_name in goal_definitions.items(): | |
| is_org_safety = (display_name == "Organization Safety") | |
| summary_table = create_goal_summary_table( | |
| df_filtered, cat_name, | |
| e_up_non_numeric_levels_param=[] if is_org_safety else ['A', 'B', 'C', 'D'], | |
| e_up_numeric_levels_param=['1', '2'] if is_org_safety else None, | |
| is_org_safety_table=is_org_safety | |
| ) | |
| if summary_table is not None and not summary_table.empty: | |
| safety_goals_html_parts.append(f"<h3>{display_name}</h3>") | |
| # ใช้ class 'auto-width-table' สำหรับตารางที่มีคอลัมน์ไม่แน่นอน | |
| safety_goals_html_parts.append(summary_table.to_html(classes="styled-table auto-width-table")) | |
| safety_goals_html = "".join( | |
| safety_goals_html_parts) if safety_goals_html_parts else "<p>ไม่มีข้อมูลสำหรับสรุปตามเป้าหมาย</p>" | |
| #เตรียมข้อมูลสำหรับตาราง "อุบัติการณ์ที่ยังไม่แก้ไข" | |
| unresolved_severe_df = df_filtered[ | |
| df_filtered['Impact Level'].isin(['3', '4', '5']) & | |
| df_filtered['Resulting Actions'].astype(str).isin(['None', '', 'nan']) | |
| ] | |
| unresolved_severe_html = "<p>ไม่พบอุบัติการณ์รุนแรงที่ยังไม่ถูกแก้ไขในช่วงเวลานี้</p>" | |
| if not unresolved_severe_df.empty: | |
| # เลือกคอลัมน์และจัดรูปแบบวันที่ | |
| df_for_pdf = unresolved_severe_df[['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด_Anonymized']].copy() | |
| df_for_pdf['Occurrence Date'] = df_for_pdf['Occurrence Date'].dt.strftime('%d/%m/%Y') | |
| unresolved_severe_html = df_for_pdf.to_html( | |
| classes="styled-table", | |
| index=False, | |
| table_id="unresolved-table" | |
| ) | |
| #เตรียมข้อมูลสำหรับ "ความเสี่ยงเรื้อรัง" | |
| persistence_html = "<p>ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ความเสี่ยงเรื้อรัง</p>" | |
| persistence_df = calculate_persistence_risk_score(df_filtered, total_month) | |
| if not persistence_df.empty: | |
| top_persistence = persistence_df.head(5) | |
| p_list_items = ["<ol>"] | |
| for index, row in top_persistence.iterrows(): | |
| item_text = ( | |
| f"<li><b>{row['รหัส']}: {row['ชื่ออุบัติการณ์ความเสี่ยง']}</b><br>" | |
| f"<small><i>ดัชนีความเรื้อรัง: {row['Persistence_Risk_Score']:.2f} " | |
| f"(เกิดเฉลี่ย: {row['Incident_Rate_Per_Month']:.2f} ครั้ง/เดือน)</i></small></li>" | |
| ) | |
| p_list_items.append(item_text) | |
| p_list_items.append("</ol>") | |
| persistence_html = "".join(p_list_items) | |
| #เตรียมข้อมูลสำหรับ "Early Warning" | |
| early_warning_html = "<p>ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ Early Warning</p>" | |
| if 'prioritize_incidents_nb_logit_v2' in globals(): | |
| ew_df = prioritize_incidents_nb_logit_v2(df_filtered, horizon=3, min_months=4, min_total=5) | |
| if not ew_df.empty: | |
| top_ew = ew_df.head(5) | |
| # สร้าง HTML List จากข้อมูล Top 5 | |
| ew_list_items = ["<ol>"] # <ol> คือ Ordered List (รายการเรียงลำดับ) | |
| for index, row in top_ew.iterrows(): | |
| item_text = ( | |
| f"<li><b>{row['รหัส']}: {row['ชื่ออุบัติการณ์ความเสี่ยง']}</b><br>" | |
| f"<small><i>คะแนนความสำคัญ: {row['Priority_Score']:.3f}, " | |
| f"คาดการณ์เหตุรุนแรงใน 3 เดือน: {row['Expected_Severe_nextH']:.2f} ครั้ง</i></small></li>" | |
| ) | |
| ew_list_items.append(item_text) | |
| ew_list_items.append("</ol>") | |
| early_warning_html = "".join(ew_list_items) | |
| # --- สร้าง HTML Content --- | |
| html_string = f""" | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <style> | |
| @page {{ | |
| size: A4; | |
| margin: 2cm 1.5cm; /* เพิ่ม margin บนล่างเล็กน้อยเพื่อให้มีที่สำหรับ footer */ | |
| /* สร้าง footer ที่มุมขวาล่าง */ | |
| @bottom-center {{ | |
| content: "หน้า " counter(page) " / " counter(pages); | |
| font-family: "TH SarabunPSK", sans-serif; | |
| font-size: 9pt; | |
| color: #888; /* สีเทา */ | |
| }} | |
| }} | |
| /* --- จบส่วนที่แก้ไข --- */ | |
| body {{ font-family: "TH SarabunPSK", sans-serif; font-size: 12pt; }} | |
| h1, h2, h3 {{ font-family: "TH SarabunPSK", sans-serif; color: #001f3f; border-bottom: 2px solid #001f3f; padding-bottom: 5px;}} | |
| h2 {{ page-break-before: always; }} /* ขึ้นหน้าใหม่ทุกครั้งที่เจอ h2 */ | |
| h1 + h2 {{ page-break-before: auto; }} /* ยกเว้น h2 แรกสุด */ | |
| .styled-table {{ width: 100%; border-collapse: collapse; margin-top: 1em; table-layout: fixed; }} | |
| .styled-table th, .styled-table td {{ border: 1px solid #ddd; padding: 6px; text-align: left; word-wrap: break-word; }} | |
| .styled-table th {{ background-color: #f2f2f2; }} | |
| .metric-container {{ display: flex; justify-content: space-around; padding: 10px; background-color: #f9f9f9; border-radius: 5px; }} | |
| .metric {{ text-align: top; }} | |
| .metric-label {{ font-size: 11pt; color: #555; }} | |
| .metric-value {{ font-size: 16pt; font-weight: bold; }} | |
| #sentinel-table th:nth-child(1), #sentinel-table td:nth-child(1) {{ width: 24%; }} | |
| #sentinel-table th:nth-child(2), #sentinel-table td:nth-child(2) {{ width: 24%; }} | |
| #sentinel-table th:nth-child(3), #sentinel-table td:nth-child(3) {{ width: 10%; }} | |
| #sentinel-table th:nth-child(4), #sentinel-table td:nth-child(4) {{ width: 38%; }} | |
| #top10-table th:nth-child(1), #top10-table td:nth-child(1) {{ width: 80%; }} | |
| #top10-table th:nth-child(2), #top10-table td:nth-child(2) {{ width: 20%; }} | |
| #risk-matrix-table th:nth-child(1), #risk-matrix-table td:nth-child(1) {{ width: 40%; }} | |
| #risk-matrix-table th:nth-child(n+2), #risk-matrix-table td:nth-child(n+2) {{ width: 10%; }} | |
| #psg9-table th:nth-child(1), #psg9-table td:nth-child(1) {{ width: 28%; text-align: left; }} | |
| #psg9-table th:nth-child(n+2):nth-child(-n+10), | |
| #psg9-table td:nth-child(n+2):nth-child(-n+10) {{ width: 3.4%; text-align: center; }} | |
| #psg9-table th:nth-child(n+11):nth-child(-n+12), | |
| #psg9-table td:nth-child(n+11):nth-child(-n+12) {{ width: 6.5%; text-align: center; }} | |
| #psg9-table th:nth-child(13), #psg9-table td:nth-child(13) {{ width: 10%; text-align: center; }} | |
| #unresolved-table th:nth-child(1), #unresolved-table td:nth-child(1) {{ width: 16%; }} | |
| #unresolved-table th:nth-child(2), #unresolved-table td:nth-child(2) {{ width: 22%; }} | |
| #unresolved-table th:nth-child(3), #unresolved-table td:nth-child(3) {{ width: 10%; }} | |
| #unresolved-table th:nth-child(4), #unresolved-table td:nth-child(4) {{ width: 48%; }} | |
| .auto-width-table {{ | |
| table-layout: auto; | |
| }} | |
| ol {{ padding-left: 30px; }} | |
| li {{ margin-bottom: 10px; }} | |
| small {{ color: #555; }} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>บทสรุปสำหรับผู้บริหาร</h1> | |
| <p><b>ช่วงข้อมูลที่วิเคราะห์:</b> {min_date_str} ถึง {max_date_str} (รวม {total_month} เดือน)</p> | |
| <p><b>จำนวนอุบัติการณ์ที่พบทั้งหมด:</b> {metrics_data.get('total_processed_incidents', 0):,} รายการ</p> | |
| <h1>1. แดชบอร์ดสรุปภาพรวม</h1> | |
| <div class="metric-container"> | |
| <div class="metric"><div class="metric-label">อุบัติการณ์ทั้งหมด</div><div class="metric-value">{metrics_data.get('total_processed_incidents', 0):,}</div></div> | |
| <div class="metric"><div class="metric-label">Sentinel Events</div><div class="metric-value">{metrics_data.get('total_sentinel_incidents_for_metric1', 0):,}</div></div> | |
| <div class="metric"><div class="metric-label">PSG9</div><div class="metric-value">{metrics_data.get('total_psg9_incidents_for_metric1', 0):,}</div></div> | |
| <div class="metric"><div class="metric-label">ความรุนแรงสูง</div><div class="metric-value">{metrics_data.get('total_severe_incidents', 0):,}</div></div> | |
| <div class="metric"><div class="metric-label">รุนแรง & ยังไม่แก้ไข</div><div class="metric-value">{metrics_data.get('total_severe_unresolved_incidents_val', 'N/A')}</div></div> | |
| </div> | |
| <h1>2. Risk Matrix และ Top 10 อุบัติการณ์</h1> | |
| <h3>Risk Matrix</h3> | |
| {matrix_data_html} | |
| {frequency_legend_html} | |
| <h2>Top 10 อุบัติการณ์ (ตามความถี่)</h2> | |
| {top10_html} | |
| <h2>3. รายการ Sentinel Events</h2> | |
| {sentinel_html} | |
| <h2>4. วิเคราะห์ตามหมวดหมู่ มาตรฐานสำคัญจำเป็นต่อความปลอดภัย 9 ข้อ</h2> | |
| {psg9_html} | |
| <h2>5. สรุปอุบัติการณ์ตามเป้าหมาย (Safety Goals)</h2> | |
| {safety_goals_html} | |
| <h2>6. สรุปอุบัติการณ์ที่เป็นปัญหาเรื้อรัง (Persistence Risk - Top 5)</h2> | |
| {persistence_html} | |
| <h3>7. Early Warning: อุบัติการณ์ที่มีแนวโน้มสูงขึ้น ใน 3 เดือนข้างหน้า (Top 5)</h3> | |
| {early_warning_html} | |
| <h2>8. รายการอุบัติการณ์รุนแรง (E-I & 3-5) ที่ยังไม่ถูกแก้ไข</h2> | |
| {unresolved_severe_html} | |
| </body> | |
| </html> | |
| """ | |
| # --- แปลง HTML เป็น PDF --- | |
| return HTML(string=html_string).write_pdf() | |
| # ============================================================================== | |
| # STATIC DATA DEFINITIONS | |
| # ============================================================================== | |
| PSG9_FILE_PATH = "PSG9code.xlsx" | |
| SENTINEL_FILE_PATH = "Sentinel2024.xlsx" | |
| ALLCODE_FILE_PATH = "Code2024.xlsx" | |
| psg9_r_codes_for_counting = set() | |
| sentinel_composite_keys = set() | |
| df2 = pd.DataFrame() | |
| try: | |
| if Path(PSG9_FILE_PATH).is_file(): | |
| PSG9code_df_master = pd.read_excel(PSG9_FILE_PATH) | |
| if 'รหัส' in PSG9code_df_master.columns: | |
| psg9_r_codes_for_counting = set(PSG9code_df_master['รหัส'].astype(str).str.strip().unique()) | |
| if Path(SENTINEL_FILE_PATH).is_file(): | |
| Sentinel2024_df = pd.read_excel(SENTINEL_FILE_PATH) | |
| if 'รหัส' in Sentinel2024_df.columns and 'Impact' in Sentinel2024_df.columns: | |
| Sentinel2024_df['รหัส'] = Sentinel2024_df['รหัส'].astype(str).str.strip() | |
| Sentinel2024_df['Impact'] = Sentinel2024_df['Impact'].astype(str).str.strip() | |
| Sentinel2024_df.dropna(subset=['รหัส', 'Impact'], inplace=True) | |
| sentinel_composite_keys = set((Sentinel2024_df['รหัส'] + '-' + Sentinel2024_df['Impact']).unique()) | |
| if Path(ALLCODE_FILE_PATH).is_file(): | |
| allcode2024_df = pd.read_excel(ALLCODE_FILE_PATH) | |
| if 'รหัส' in allcode2024_df.columns and all(c in allcode2024_df.columns for c in ["ชื่ออุบัติการณ์ความเสี่ยง", "กลุ่ม", "หมวด"]): | |
| df2 = allcode2024_df[["รหัส", "ชื่ออุบัติการณ์ความเสี่ยง", "กลุ่ม", "หมวด"]].drop_duplicates().copy() | |
| df2['รหัส'] = df2['รหัส'].astype(str).str.strip() | |
| except Exception as e: | |
| st.error(f"เกิดปัญหาในการโหลดไฟล์นิยาม: {e}") | |
| risk_color_data = { | |
| 'Category Color': ["Critical", "Critical", "Critical", "Critical", "Critical", "High", "High", "Critical", "Critical", "Critical", "Medium", "Medium", "High", "Critical", "Critical", "Low", "Medium", "Medium", "High", "High", "Low", "Low", "Low", "Medium", "Medium"], | |
| 'Risk Level': ["51", "52", "53", "54", "55", "41", "42", "43", "44", "45", "31", "32", "33", "34", "35", "21", "22", "23", "24", "25", "11", "12", "13", "14", "15"]} | |
| risk_color_df = pd.DataFrame(risk_color_data) | |
| display_cols_common = ['Occurrence Date', 'รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง', 'Impact', 'Impact Level', 'รายละเอียดการเกิด_Anonymized', 'Resulting Actions'] | |
| month_label = {1: '01 มกราคม', 2: '02 กุมภาพันธ์', 3: '03 มีนาคม', 4: '04 เมษายน', 5: '05 พฤษภาคม', 6: '06 มิถุนายน', 7: '07 กรกฎาคม', 8: '08 สิงหาคม', 9: '09 กันยายน', 10: '10 ตุลาคม', 11: '11 พฤศจิกายน', 12: '12 ธันวาคม'} | |
| PSG9_label_dict = { | |
| 1: '01 ผ่าตัดผิดคน ผิดข้าง ผิดตำแหน่ง ผิดหัตถการ', 2: '02 บุคลากรติดเชื้อจากการปฏิบัติหน้าที่', | |
| 3: '03 การติดเชื้อสำคัญ (SSI, VAP,CAUTI, CLABSI)', 4: '04 การเกิด Medication Error และ Adverse Drug Event', | |
| 5: '05 การให้เลือดผิดคน ผิดหมู่ ผิดชนิด', 6: '06 การระบุตัวผู้ป่วยผิดพลาด', | |
| 7: '07 ความคลาดเคลื่อนในการวินิจฉัยโรค', 8: '08 การรายงานผลการตรวจทางห้องปฏิบัติการ/พยาธิวิทยา คลาดเคลื่อน', | |
| 9: '09 การคัดกรองที่ห้องฉุกเฉินคลาดเคลื่อน' | |
| } | |
| # ✅ *** ส่วนที่เพิ่มกลับเข้ามา *** | |
| type_name = {'CPS': 'Safe Surgery', 'CPI': 'Infection Prevention and Control', 'CPM': 'Medication & Blood Safety', | |
| 'CPP': 'Patient Care Process', 'CPL': 'Line, Tube & Catheter and Laboratory', 'CPE': 'Emergency Response', | |
| 'CSG': 'Gynecology & Obstetrics diseases and procedure', 'CSS': 'Surgical diseases and procedure', | |
| 'CSM': 'Medical diseases and procedure', 'CSP': 'Pediatric diseases and procedure', | |
| 'CSO': 'Orthopedic diseases and procedure', 'CSD': 'Dental diseases and procedure', | |
| 'GPS': 'Social Media and Communication', 'GPI': 'Infection and Exposure', | |
| 'GPM': 'Mental Health and Mediation', 'GPP': 'Process of work', 'GPL': 'Lane (Traffic) and Legal Issues', | |
| 'GPE': 'Environment and Working Conditions', 'GOS': 'Strategy, Structure, Security', | |
| 'GOI': 'Information Technology & Communication, Internal control & Inventory', | |
| 'GOM': 'Manpower, Management', 'GOP': 'Policy, Process of work & Operation', | |
| 'GOL': 'Licensed & Professional certificate', 'GOE': 'Economy'} | |
| colors2 = np.array([["#e1f5fe", "#f6c8b6", "#dd191d", "#dd191d", "#dd191d", "#dd191d", "#dd191d"], | |
| ["#e1f5fe", "#f6c8b6", "#ff8f00", "#ff8f00", "#dd191d", "#dd191d", "#dd191d"], | |
| ["#e1f5fe", "#f6c8b6", "#ffee58", "#ffee58", "#ff8f00", "#dd191d", "#dd191d"], | |
| ["#e1f5fe", "#f6c8b6", "#42db41", "#ffee58", "#ffee58", "#ff8f00", "#ff8f00"], | |
| ["#e1f5fe", "#f6c8b6", "#42db41", "#42db41", "#42db41", "#ffee58", "#ffee58"], | |
| ["#e1f5fe", "#f6c8b6", "#f6c8b6", "#f6c8b6", "#f6c8b6", "#f6c8b6", "#f6c8b6"], | |
| ["#e1f5fe", "#e1f5fe", "#e1f5fe", "#e1f5fe", "#e1f5fe", "#e1f5fe", "#e1f5fe"]]) | |
| # ============================================================================== | |
| # MAIN APP STRUCTURE | |
| # โครงสร้างหลักของโปรแกรม แบ่งการทำงานออกเป็นส่วนๆ คือหน้าสำหรับผู้ดูแล และหน้าแดชบอร์ด | |
| # ============================================================================== | |
| def display_admin_page(): | |
| st.title("🔑 Admin: Data Upload") | |
| st.warning("ส่วนนี้สำหรับผู้ดูแลระบบเท่านั้น") | |
| password = st.text_input("กรุณาใส่รหัสผ่าน:", type="password") | |
| if password == ADMIN_PASSWORD: | |
| st.success("เข้าสู่ระบบสำเร็จ!") | |
| st.header("อัปโหลดไฟล์รายงานอุบัติการณ์ (.csv หรือ .xlsx)") | |
| uploaded_file = st.file_uploader("เลือกไฟล์ของคุณที่นี่:", type=[".xlsx", ".csv"]) | |
| if uploaded_file: | |
| with st.spinner("กำลังประมวลผลไฟล์ กรุณารอสักครู่..."): | |
| df = None | |
| try: | |
| uploaded_file.seek(0) | |
| df = pd.read_csv(uploaded_file, keep_default_na=False, encoding='utf-8-sig', engine='python') | |
| except Exception as e: | |
| st.error(f"เกิดข้อผิดพลาดในการอ่านไฟล์: {e}") | |
| st.stop() | |
| if df.empty: | |
| st.warning("ไฟล์ที่อัปโหลดไม่มีข้อมูล") | |
| st.stop() | |
| st.success("อ่านไฟล์สำเร็จ! กำลังประมวลผลข้อมูล...") | |
| df.columns = [col.strip() for col in df.columns] | |
| required_source_cols = ["รหัส: เรื่องอุบัติการณ์", "วันที่เกิดอุบัติการณ์", "ความรุนแรง"] | |
| missing_source_cols = [key for key in required_source_cols if key not in df.columns] | |
| if missing_source_cols: | |
| st.error(f"ไม่พบคอลัมน์ที่จำเป็นในไฟล์: {', '.join(missing_source_cols)}") | |
| st.stop() | |
| df.rename(columns={"วันที่เกิดอุบัติการณ์": "Occurrence Date", "ความรุนแรง": "Impact"}, inplace=True) | |
| df['Incident'] = df['รหัส: เรื่องอุบัติการณ์'].astype(str).str.split(':', n=1).str[0].str.strip() | |
| df = df[df['Incident'] != ''].copy() | |
| if df.empty: | |
| st.error("ไม่พบข้อมูลที่มี 'รหัสอุบัติการณ์' ที่ถูกต้องเลยหลังการกรอง") | |
| st.stop() | |
| df['ชื่ออุบัติการณ์ความเสี่ยง'] = df['รหัส: เรื่องอุบัติการณ์'].astype(str).str.split(':', n=1).str[ | |
| 1].str.strip() | |
| df['รหัส'] = df['Incident'].astype(str).str.slice(0, 6).str.strip() | |
| if 'สถานะ' in df.columns: | |
| df['Resulting Actions'] = df['สถานะ'].apply(lambda x: 'None' if 'รอแก้ไข' in str(x) else str(x)) | |
| else: | |
| df['Resulting Actions'] = 'N/A' | |
| df.replace('', 'None', inplace=True) | |
| df = df.fillna('None') | |
| df['Impact'] = df['Impact'].astype(str).str.strip() | |
| if not df2.empty: | |
| df = pd.merge(df, df2[['รหัส', 'กลุ่ม', 'หมวด']], on='รหัส', how='left') | |
| for col in ["กลุ่ม", "หมวด"]: | |
| if col not in df.columns: | |
| df[col] = 'N/A' | |
| else: | |
| df[col].fillna('N/A', inplace=True) | |
| df['Occurrence Date'] = pd.to_datetime(df['Occurrence Date'], dayfirst=True, errors='coerce') | |
| invalid_dates = df['Occurrence Date'].isna().sum() | |
| if invalid_dates > 0: | |
| st.warning(f"ตรวจพบและข้าม {invalid_dates} แถวที่มีรูปแบบ 'วันที่' ไม่ถูกต้อง") | |
| df.dropna(subset=['Occurrence Date'], inplace=True) | |
| if df.empty: | |
| st.error("ไม่พบข้อมูลที่มี 'วันที่' ที่ถูกต้องเลยหลังการกรอง") | |
| st.stop() | |
| impact_level_map = {('A', 'B', '1'): '1', ('C', 'D', '2'): '2', ('E', 'F', '3'): '3', | |
| ('G', 'H', '4'): '4', ('I', '5'): '5'} | |
| def map_impact_level_func(val): | |
| s_val = str(val); | |
| for k, v in impact_level_map.items(): | |
| if s_val in k: return v | |
| return 'N/A' | |
| df['Impact Level'] = df['Impact'].apply(map_impact_level_func) | |
| max_p, min_p = df['Occurrence Date'].max().to_period('M'), df['Occurrence Date'].min().to_period('M') | |
| total_month_calc = max(1, (max_p.year - min_p.year) * 12 + (max_p.month - min_p.month) + 1) | |
| incident_counts_map = df['Incident'].value_counts() | |
| df['count'] = df['Incident'].map(incident_counts_map) | |
| df['Incident Rate/mth'] = (df['count'] / total_month_calc).round(1) | |
| conditions_freq = [(df['Incident Rate/mth'] < 2.0), (df['Incident Rate/mth'] < 3.9), | |
| (df['Incident Rate/mth'] < 6.9), (df['Incident Rate/mth'] < 29.9)] | |
| choices_freq = ['1', '2', '3', '4'] | |
| df['Frequency Level'] = np.select(conditions_freq, choices_freq, default='5') | |
| df['Risk Level'] = df.apply( | |
| lambda row: f"{row['Impact Level']}{row['Frequency Level']}" if pd.notna(row['Impact Level']) and | |
| row[ | |
| 'Impact Level'] != 'N/A' else 'N/A', | |
| axis=1) | |
| df = pd.merge(df, risk_color_df, on='Risk Level', how='left') | |
| df['Category Color'].fillna('Undefined', inplace=True) | |
| df['Incident Type'] = df['Incident'].astype(str).str[:3] | |
| df['Month'] = df['Occurrence Date'].dt.month | |
| df['เดือน'] = df['Month'].map(month_label) | |
| df['Year'] = df['Occurrence Date'].dt.year.astype(str) | |
| PSG9_ID_COL = 'PSG_ID' | |
| if 'PSG9code_df_master' in globals() and not PSG9code_df_master.empty and PSG9_ID_COL in PSG9code_df_master.columns: | |
| standards_to_merge = PSG9code_df_master[['รหัส', PSG9_ID_COL]].copy().drop_duplicates( | |
| subset=['รหัส']) | |
| standards_to_merge['รหัส'] = standards_to_merge['รหัส'].astype(str).str.strip() | |
| df = pd.merge(df, standards_to_merge, on='รหัส', how='left') | |
| df['หมวดหมู่มาตรฐานสำคัญ'] = df[PSG9_ID_COL].map(PSG9_label_dict).fillna( | |
| "ไม่จัดอยู่ใน PSG9 Catalog") | |
| else: | |
| df['หมวดหมู่มาตรฐานสำคัญ'] = "ไม่สามารถระบุ (PSG9code.xlsx ไม่ได้โหลด)" | |
| for col in df.select_dtypes(include=['object']).columns: | |
| df[col] = df[col].astype(str) | |
| # ========================================================== | |
| # ✨ ส่วนที่เพิ่มเข้ามา: การปกปิดข้อมูลส่วนบุคคล ✨ | |
| # ========================================================== | |
| st.info("กำลังโหลดโมเดล AI สำหรับปกปิดข้อมูลส่วนบุคคล (ครั้งแรกอาจใช้เวลาสักครู่)...") | |
| ner_model = load_ner_model() | |
| if ner_model and 'รายละเอียดการเกิด' in df.columns: | |
| st.info("กำลังประมวลผลเพื่อปกปิดข้อมูลส่วนบุคคลใน 'รายละเอียดการเกิด'...") | |
| # สร้างคอลัมน์ใหม่สำหรับเก็บข้อมูลที่ปกปิดแล้ว | |
| df['รายละเอียดการเกิด_Anonymized'] = '' | |
| # ใช้ tqdm เพื่อแสดง progress bar | |
| progress_bar = st.progress(0) | |
| total_rows = len(df) | |
| # ใช้ .apply() จะเร็วกว่าการวนลูปด้วย for | |
| # เราสร้าง lambda function เพื่อส่ง model เข้าไปใน anonymize_text | |
| df['รายละเอียดการเกิด_Anonymized'] = df['รายละเอียดการเกิด'].astype(str).apply( | |
| lambda text: anonymize_text(text, ner_model) | |
| ) | |
| progress_bar.progress(1.0) # เสร็จสิ้น | |
| st.success("ปกปิดข้อมูลส่วนบุคคลเรียบร้อยแล้ว!") | |
| else: | |
| # หากไม่มีคอลัมน์ 'รายละเอียดการเกิด' ให้สร้างคอลัมน์เปล่าไว้ | |
| df['รายละเอียดการเกิด_Anonymized'] = df.get('รายละเอียดการเกิด', '') | |
| for col in df.select_dtypes(include=['object']).columns: | |
| df[col] = df[col].astype(str) | |
| # บันทึกข้อมูลลง Parquet (ตอนนี้จะมีคอลัมน์ที่ปกปิดแล้วด้วย) | |
| df.to_parquet(PERSISTED_DATA_PATH, index=False) | |
| st.success(f"ประมวลผลสำเร็จ! ข้อมูล {len(df)} รายการถูกบันทึกแล้ว") | |
| # ... สิ้นสุดฟังก์ชัน | |
| df.to_parquet(PERSISTED_DATA_PATH, index=False) | |
| st.success(f"ประมวลผลสำเร็จ! ข้อมูล {len(df)} รายการถูกบันทึกแล้ว") | |
| def display_executive_dashboard(): | |
| # --- 1. โหลดข้อมูล --- | |
| try: | |
| df = pd.read_parquet(PERSISTED_DATA_PATH) | |
| df['Occurrence Date'] = pd.to_datetime(df['Occurrence Date']) | |
| except FileNotFoundError: | |
| st.warning("ยังไม่มีข้อมูลในระบบ กรุณาติดต่อผู้ดูแลระบบเพื่ออัปโหลดข้อมูล") | |
| st.markdown(f'<p>หากคุณเป็น Admin กรุณาไปที่ URL <a href="/?page=admin">?page=admin</a> เพื่ออัปโหลดข้อมูล</p>', | |
| unsafe_allow_html=True) | |
| return | |
| # --- 2. สร้าง Sidebar และเมนูกรองข้อมูล --- | |
| st.sidebar.markdown( | |
| f"""<div style="display: flex; align-items: center; margin-bottom: 1rem;"><img src="{LOGO_URL}" style="height: 32px; margin-right: 10px;"><h2 style="margin: 0; font-size: 1.7rem;"><span class="gradient-text">HOIA-RR Menu</span></h2></div>""", | |
| unsafe_allow_html=True) | |
| st.sidebar.markdown("---") | |
| analysis_options_list = ["RCA Helpdesk (AI Assistant)", "จัดการข้อมูล (Admin)"] | |
| if 'selected_analysis' not in st.session_state: | |
| st.session_state.selected_analysis = analysis_options_list[0] | |
| for option in analysis_options_list: | |
| if st.sidebar.button(option, key=f"btn_{option}", | |
| type="primary" if st.session_state.selected_analysis == option else "secondary", | |
| use_container_width=True): | |
| st.session_state.selected_analysis = option | |
| st.rerun() | |
| st.sidebar.markdown("---") | |
| st.sidebar.header("Filter by Date") | |
| min_date_in_data = df['Occurrence Date'].min().date() | |
| max_date_in_data = df['Occurrence Date'].max().date() | |
| today = datetime.now().date() | |
| filter_option = st.sidebar.selectbox("เลือกช่วงเวลา:", | |
| ["ทั้งหมด", "ปีนี้", "ไตรมาสนี้", "เดือนนี้", "ปีที่แล้ว", "กำหนดเอง..."]) | |
| start_date, end_date = min_date_in_data, max_date_in_data | |
| if filter_option == "ปีนี้": | |
| start_date = today.replace(month=1, day=1) | |
| end_date = today | |
| elif filter_option == "ไตรมาสนี้": | |
| current_quarter = (today.month - 1) // 3 + 1 | |
| start_date = datetime(today.year, 3 * current_quarter - 2, 1).date() | |
| end_date = today | |
| elif filter_option == "เดือนนี้": | |
| start_date = today.replace(day=1) | |
| end_date = today | |
| elif filter_option == "ปีที่แล้ว": | |
| last_year = today.year - 1 | |
| start_date = datetime(last_year, 1, 1).date() | |
| end_date = datetime(last_year, 12, 31).date() | |
| elif filter_option == "กำหนดเอง...": | |
| start_date, end_date = st.sidebar.date_input( | |
| "เลือกระหว่างวันที่:", | |
| [min_date_in_data, max_date_in_data], | |
| min_value=min_date_in_data, | |
| max_value=max_date_in_data | |
| ) | |
| df_filtered = df[(df['Occurrence Date'].dt.date >= start_date) & (df['Occurrence Date'].dt.date <= end_date)].copy() | |
| # ✅ *** เพิ่มบรรทัดนี้เพื่อสร้างคอลัมน์ชื่อเต็มสำหรับทุกการวิเคราะห์ *** | |
| df_filtered['Incident Type Name'] = df_filtered['Incident Type'].map(type_name).fillna(df_filtered['Incident Type']) | |
| if df_filtered.empty: | |
| st.sidebar.warning("ไม่พบข้อมูลในช่วงเวลาที่ท่านเลือก") | |
| st.warning("ไม่พบข้อมูลในช่วงเวลาที่ท่านเลือก กรุณาเลือกช่วงเวลาอื่น") | |
| return | |
| min_date_str = df_filtered['Occurrence Date'].min().strftime('%d/%m/%Y') | |
| max_date_str = df_filtered['Occurrence Date'].max().strftime('%d/%m/%Y') | |
| max_p = df_filtered['Occurrence Date'].max().to_period('M') | |
| min_p = df_filtered['Occurrence Date'].min().to_period('M') | |
| total_month = (max_p.year - min_p.year) * 12 + (max_p.month - min_p.month) + 1 | |
| total_month = max(1, total_month) | |
| st.sidebar.markdown(f"**ช่วงข้อมูล:** {min_date_str} ถึง {max_date_str}") | |
| st.sidebar.markdown(f"**จำนวนเดือน:** {total_month} เดือน") | |
| st.sidebar.markdown(f"**จำนวนอุบัติการณ์:** {df_filtered.shape[0]:,} รายการ") | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown("เลือกส่วนที่ต้องการแสดงผล:") | |
| analysis_options_list = [ | |
| "แดชบอร์ดสรุปภาพรวม", "Heatmap รายเดือน", "Sentinel Events & Top 10", | |
| "Risk Matrix (Interactive)", "กราฟสรุปอุบัติการณ์ (รายมิติ)", | |
| "Sankey: ภาพรวม", "Sankey: มาตรฐานสำคัญจำเป็นต่อความปลอดภัย 9 ข้อ", | |
| "สรุปอุบัติการณ์ตาม Safety Goals", "วิเคราะห์ตามหมวดหมู่และสถานะการแก้ไข", | |
| "Persistence Risk Index", "Early Warning: อุบัติการณ์ที่มีแนวโน้มสูงขึ้น", "บทสรุปสำหรับผู้บริหาร", | |
| ] | |
| if 'selected_analysis' not in st.session_state: | |
| st.session_state.selected_analysis = analysis_options_list[0] | |
| for option in analysis_options_list: | |
| if st.sidebar.button(option, key=f"btn_{option}", | |
| type="primary" if st.session_state.selected_analysis == option else "secondary", | |
| use_container_width=True): | |
| st.session_state.selected_analysis = option | |
| st.rerun() | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown(f""" | |
| **กิตติกรรมประกาศ:** ขอขอบพระคุณ | |
| - Prof. Shin Ushiro | |
| - นพ.อนุวัฒน์ ศุภชุติกุล | |
| - นพ.ก้องเกียรติ เกษเพ็ชร์ | |
| - พญ.ปิยวรรณ ลิ้มปัญญาเลิศ | |
| - ภก.ปรมินทร์ วีระอนันตวัฒน์ | |
| - ผศ.ดร.นิเวศน์ กุลวงค์ (อ.ที่ปรึกษา) | |
| เป็นอย่างสูง สำหรับการริเริ่ม เติมเต็ม สนับสนุน และสร้างแรงบันดาลใจ อันเป็นรากฐานสำคัญในการพัฒนาเครื่องมือนี้ | |
| """) | |
| # ✅ --- เพิ่มโค้ดส่วนนี้เข้าไปที่ท้ายสุดของ Sidebar --- | |
| st.sidebar.markdown("---") | |
| st.sidebar.markdown( | |
| '<p style="font-size:12px; color:gray;">*เครื่องมือนี้เป็นส่วนหนึ่งของวิทยานิพนธ์ IMPLEMENTING THE HOSPITAL OCCURRENCE/INCIDENT ANALYSIS & RISK REGISTER (HOIA-RR TOOL) IN THAI HOSPITALS: A STUDY ON EFFECTIVE ADOPTION โดย นางสาววิลาศินี เขื่อนแก้ว นักศึกษาปริญญาโท สำนักวิชาวิทยาศาสตร์สุขภาพ มหาวิทยาลัยแม่ฟ้าหลวง</p>', | |
| unsafe_allow_html=True) | |
| # ✅ --- จบส่วนที่เพิ่ม --- | |
| # --- 3. คำนวณ Metrics สำหรับ Dashboard --- | |
| metrics_data = {} | |
| metrics_data['total_processed_incidents'] = df_filtered.shape[0] | |
| metrics_data['total_psg9_incidents_for_metric1'] = \ | |
| df_filtered[df_filtered['รหัส'].isin(psg9_r_codes_for_counting)].shape[ | |
| 0] if 'psg9_r_codes_for_counting' in globals() else 0 | |
| if 'sentinel_composite_keys' in globals() and sentinel_composite_keys: | |
| df_filtered['Sentinel code for check'] = df_filtered['รหัส'].astype(str).str.strip() + '-' + df_filtered[ | |
| 'Impact'].astype(str).str.strip() | |
| metrics_data['total_sentinel_incidents_for_metric1'] = \ | |
| df_filtered[df_filtered['Sentinel code for check'].isin(sentinel_composite_keys)].shape[0] | |
| else: | |
| metrics_data['total_sentinel_incidents_for_metric1'] = 0 | |
| severe_impact_levels_list = ['3', '4', '5'] | |
| df_severe_incidents_calc = df_filtered[df_filtered['Impact Level'].isin(severe_impact_levels_list)].copy() | |
| metrics_data['total_severe_incidents'] = df_severe_incidents_calc.shape[0] | |
| if 'Resulting Actions' in df_filtered.columns: | |
| unresolved_conditions = df_severe_incidents_calc['Resulting Actions'].astype(str).isin(['None', '', 'nan']) | |
| df_severe_unresolved_calc = df_severe_incidents_calc[unresolved_conditions].copy() | |
| metrics_data['total_severe_unresolved_incidents_val'] = df_severe_unresolved_calc.shape[0] | |
| metrics_data['total_severe_unresolved_psg9_incidents_val'] = \ | |
| df_severe_unresolved_calc[df_severe_unresolved_calc['รหัส'].isin(psg9_r_codes_for_counting)].shape[ | |
| 0] if 'psg9_r_codes_for_counting' in globals() else 0 | |
| else: | |
| metrics_data['total_severe_unresolved_incidents_val'] = "N/A" | |
| metrics_data['total_severe_unresolved_psg9_incidents_val'] = "N/A" | |
| metrics_data['total_month'] = total_month | |
| df_freq = df_filtered['Incident'].value_counts().reset_index() | |
| df_freq.columns = ['Incident', 'count'] | |
| # --- 4. PAGE CONTENT ROUTING --- | |
| selected_analysis = st.session_state.selected_analysis | |
| if selected_analysis == "แดชบอร์ดสรุปภาพรวม": | |
| st.markdown("<h4 style='color: #001f3f;'>สรุปภาพรวมอุบัติการณ์:</h4>", unsafe_allow_html=True) | |
| with st.expander("แสดง/ซ่อน ตารางข้อมูลอุบัติการณ์ทั้งหมด (Full Data Table)"): | |
| st.dataframe(df_filtered, hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", format="DD/MM/YYYY") | |
| }) | |
| dashboard_expander_cols = ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด_Anonymized', 'Resulting Actions'] | |
| date_format_config = {"Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", format="DD/MM/YYYY")} | |
| total_processed_incidents = metrics_data.get("total_processed_incidents", 0) | |
| total_psg9_incidents_for_metric1 = metrics_data.get("total_psg9_incidents_for_metric1", 0) | |
| total_sentinel_incidents_for_metric1 = metrics_data.get("total_sentinel_incidents_for_metric1", 0) | |
| total_severe_incidents = metrics_data.get("total_severe_incidents", 0) | |
| total_severe_unresolved_incidents_val = metrics_data.get("total_severe_unresolved_incidents_val", "N/A") | |
| total_severe_unresolved_psg9_incidents_val = metrics_data.get("total_severe_unresolved_psg9_incidents_val", | |
| "N/A") | |
| df_severe_incidents = df_filtered[df_filtered['Impact Level'].isin(['3', '4', '5'])].copy() | |
| total_severe_psg9_incidents = \ | |
| df_severe_incidents[df_severe_incidents['รหัส'].isin(psg9_r_codes_for_counting)].shape[0] | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.metric("Total", f"{total_processed_incidents:,}") | |
| with col2: | |
| st.metric("PSG9", f"{total_psg9_incidents_for_metric1:,}") | |
| with st.expander(f"ดูรายละเอียด ({total_psg9_incidents_for_metric1} รายการ)"): | |
| psg9_df = df_filtered[df_filtered['รหัส'].isin(psg9_r_codes_for_counting)] | |
| st.dataframe(psg9_df[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| with col3: | |
| st.metric("Sentinel", f"{total_sentinel_incidents_for_metric1:,}") | |
| with st.expander(f"ดูรายละเอียด ({total_sentinel_incidents_for_metric1} รายการ)"): | |
| if 'Sentinel code for check' in df_filtered.columns: | |
| sentinel_df = df_filtered[df_filtered['Sentinel code for check'].isin(sentinel_composite_keys)] | |
| st.dataframe(sentinel_df[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| col4, col5, col6, col7 = st.columns(4) | |
| with col4: | |
| st.metric("E-I & 3-5 [all]", f"{total_severe_incidents:,}") | |
| with st.expander(f"ดูรายละเอียด ({total_severe_incidents} รายการ)"): | |
| st.dataframe(df_severe_incidents[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| with col5: | |
| st.metric("E-I & 3-5 [PSG9]", f"{total_severe_psg9_incidents:,}") | |
| with st.expander(f"ดูรายละเอียด ({total_severe_psg9_incidents} รายการ)"): | |
| severe_psg9_df = df_severe_incidents[df_severe_incidents['รหัส'].isin(psg9_r_codes_for_counting)] | |
| st.dataframe(severe_psg9_df[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| with col6: | |
| val_unresolved_all = f"{total_severe_unresolved_incidents_val:,}" if isinstance( | |
| total_severe_unresolved_incidents_val, int) else "N/A" | |
| st.metric(f"E-I & 3-5 [all] ที่ยังไม่ถูกแก้ไข", val_unresolved_all) | |
| if isinstance(total_severe_unresolved_incidents_val, int) and total_severe_unresolved_incidents_val > 0: | |
| with st.expander(f"ดูรายละเอียด ({total_severe_unresolved_incidents_val} รายการ)"): | |
| unresolved_df_all = df_filtered[ | |
| df_filtered['Impact Level'].isin(['3', '4', '5']) & df_filtered['Resulting Actions'].astype( | |
| str).isin(['None', '', 'nan'])] | |
| st.dataframe(unresolved_df_all[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| with col7: | |
| val_unresolved_psg9 = f"{total_severe_unresolved_psg9_incidents_val:,}" if isinstance( | |
| total_severe_unresolved_psg9_incidents_val, int) else "N/A" | |
| st.metric(f"E-I & 3-5 [PSG9] ที่ยังไม่ถูกแก้ไข", val_unresolved_psg9) | |
| if isinstance(total_severe_unresolved_psg9_incidents_val, | |
| int) and total_severe_unresolved_psg9_incidents_val > 0: | |
| with st.expander(f"ดูรายละเอียด ({total_severe_unresolved_psg9_incidents_val} รายการ)"): | |
| unresolved_df_all = df_filtered[ | |
| df_filtered['Impact Level'].isin(['3', '4', '5']) & df_filtered['Resulting Actions'].astype( | |
| str).isin(['None', '', 'nan'])] | |
| unresolved_df_psg9 = unresolved_df_all[unresolved_df_all['รหัส'].isin(psg9_r_codes_for_counting)] | |
| st.dataframe(unresolved_df_psg9[dashboard_expander_cols], use_container_width=True, hide_index=True, | |
| column_config=date_format_config) | |
| st.markdown("---") | |
| # เตรียมข้อมูล: จัดกลุ่มข้อมูลตามปี-เดือน แล้วนับจำนวน | |
| monthly_counts = df_filtered.copy() | |
| monthly_counts['เดือน-ปี'] = monthly_counts['Occurrence Date'].dt.strftime('%Y-%m') | |
| incident_trend = monthly_counts.groupby('เดือน-ปี').size().reset_index(name='จำนวนอุบัติการณ์') | |
| incident_trend = incident_trend.sort_values(by='เดือน-ปี') | |
| st.markdown("---") | |
| total_incidents = metrics_data.get('total_processed_incidents', 0) | |
| resolved_incidents = df_filtered[~df_filtered['Resulting Actions'].astype(str).isin(['None', '', 'nan'])].shape[ | |
| 0] | |
| status_data = pd.DataFrame({ | |
| 'สถานะ': ['อุบัติการณ์ทั้งหมด', 'ที่แก้ไขแล้ว'], | |
| 'จำนวน': [total_incidents, resolved_incidents] | |
| }) | |
| fig_status = px.bar( | |
| status_data, | |
| x='จำนวน', | |
| y='สถานะ', | |
| orientation='h', | |
| title='ภาพรวมอุบัติการณ์ทั้งหมดเทียบกับที่แก้ไขแล้ว', | |
| text='จำนวน', | |
| color='สถานะ', | |
| color_discrete_map={ | |
| 'อุบัติการณ์ทั้งหมด': '#1f77b4', # สีน้ำเงิน | |
| 'ที่แก้ไขแล้ว': '#2ca02c' # สีเขียว | |
| }, | |
| labels={'สถานะ': '', 'จำนวน': 'จำนวนอุบัติการณ์'} | |
| ) | |
| fig_status.update_layout( | |
| yaxis={'categoryorder': 'total ascending'}, | |
| showlegend=False | |
| ) | |
| st.plotly_chart(fig_status, use_container_width=True) | |
| # สร้างกราฟเส้น | |
| fig_trend = px.line( | |
| incident_trend, | |
| x='เดือน-ปี', | |
| y='จำนวนอุบัติการณ์', | |
| title='จำนวนอุบัติการณ์ทั้งหมดที่เกิดขึ้นในแต่ละเดือน', | |
| markers=True, # เพิ่มจุดบนเส้นเพื่อให้เห็นข้อมูลแต่ละเดือนชัดขึ้น | |
| labels={'เดือน-ปี': 'เดือน', 'จำนวนอุบัติการณ์': 'จำนวนครั้ง'}, | |
| line_shape = 'spline' | |
| ) | |
| fig_trend.update_traces(line=dict(width=3)) | |
| st.plotly_chart(fig_trend, use_container_width=True) | |
| elif selected_analysis == "Heatmap รายเดือน": | |
| st.markdown("<h4 style='color: #001f3f;'>Heatmap: จำนวนอุบัติการณ์รายเดือน</h4>", unsafe_allow_html=True) | |
| st.info( | |
| "💡 Heatmap นี้แสดงความถี่ของการเกิดอุบัติการณ์แต่ละรหัสในแต่ละเดือน สีที่เข้มกว่าหมายถึงจำนวนครั้งที่เกิดสูงกว่า ช่วยให้มองเห็นรูปแบบหรืออุบัติการณ์ที่เกิดบ่อยในช่วงเวลาต่างๆ ได้ง่ายขึ้น") | |
| st.markdown("<h5 style='color: #003366;'>ภาพรวมอุบัติการณ์ทั้งหมด (เลือกจำนวนที่แสดงได้)</h5>", | |
| unsafe_allow_html=True) | |
| heatmap_req_cols = ['รหัส', 'เดือน', 'ชื่ออุบัติการณ์ความเสี่ยง', 'Month', 'หมวด'] | |
| if not all(col in df_filtered.columns for col in heatmap_req_cols): | |
| st.warning(f"ไม่สามารถสร้าง Heatmap ได้ เนื่องจากขาดคอลัมน์ที่จำเป็น: {', '.join(heatmap_req_cols)}") | |
| else: | |
| df_heat = df_filtered.copy() | |
| df_heat['incident_label'] = df_heat['รหัส'] + " | " + df_heat['ชื่ออุบัติการณ์ความเสี่ยง'].fillna('') | |
| total_counts = df_heat['incident_label'].value_counts().reset_index() | |
| total_counts.columns = ['incident_label', 'total_count'] | |
| top_n = st.slider( | |
| "เลือกจำนวนอุบัติการณ์ (ตามความถี่) ที่ต้องการแสดงบน Heatmap รวม:", | |
| min_value=5, max_value=min(50, len(total_counts)), | |
| value=min(20, len(total_counts)), step=5, key="top_n_slider" | |
| ) | |
| top_incident_labels = total_counts.nlargest(top_n, 'total_count')['incident_label'] | |
| df_heat_filtered_view = df_heat[df_heat['incident_label'].isin(top_incident_labels)] | |
| try: | |
| heatmap_pivot = pd.pivot_table(df_heat_filtered_view, values='Incident', index='incident_label', | |
| columns='เดือน', aggfunc='count', fill_value=0) | |
| sorted_month_names = [v for k, v in sorted(month_label.items())] | |
| available_months = [m for m in sorted_month_names if m in heatmap_pivot.columns] | |
| if available_months: | |
| heatmap_pivot = heatmap_pivot[available_months] | |
| heatmap_pivot = heatmap_pivot.reindex(top_incident_labels).dropna() | |
| if not heatmap_pivot.empty: | |
| fig_heatmap = px.imshow(heatmap_pivot, | |
| labels=dict(x="เดือน", y="อุบัติการณ์", color="จำนวนครั้ง"), | |
| text_auto=True, aspect="auto", color_continuous_scale='Reds') | |
| fig_heatmap.update_layout(title_text=f"Heatmap ของอุบัติการณ์ Top {top_n} ที่เกิดบ่อยที่สุด", | |
| height=max(600, len(heatmap_pivot.index) * 25), xaxis_title="เดือน", | |
| yaxis_title="รหัส | ชื่ออุบัติการณ์") | |
| fig_heatmap.update_xaxes(side="top") | |
| st.plotly_chart(fig_heatmap, use_container_width=True) | |
| except Exception as e: | |
| st.error(f"เกิดข้อผิดพลาดในการสร้าง Heatmap รวม: {e}") | |
| st.markdown("---") | |
| st.markdown("<h5 style='color: #003366;'>Heatmap แยกตามเป้าหมายความปลอดภัย (Safety Goal)</h5>", | |
| unsafe_allow_html=True) | |
| goal_search_terms = { | |
| "Patient Safety/ Common Clinical Risk": "Patient Safety", "Specific Clinical Risk": "Specific Clinical", | |
| "Personnel Safety": "Personnel Safety", "Organization Safety": "Organization Safety" | |
| } | |
| for display_name, search_term in goal_search_terms.items(): | |
| df_goal_filtered = df_heat[df_heat['หมวด'].str.contains(search_term, na=False, case=False)].copy() | |
| if df_goal_filtered.empty: | |
| st.markdown(f"**{display_name}**") | |
| st.info(f"ไม่พบข้อมูลสำหรับเป้าหมายนี้") | |
| st.markdown("---") | |
| continue | |
| try: | |
| goal_pivot = pd.pivot_table(df_goal_filtered, values='Incident', index='incident_label', | |
| columns='เดือน', aggfunc='count', fill_value=0) | |
| if not goal_pivot.empty: | |
| sorted_month_names = [v for k, v in sorted(month_label.items())] | |
| available_months_goal = [m for m in sorted_month_names if m in goal_pivot.columns] | |
| if available_months_goal: | |
| goal_pivot = goal_pivot[available_months_goal] | |
| incident_counts_in_goal = df_goal_filtered['incident_label'].value_counts() | |
| sorted_incidents = incident_counts_in_goal.index.tolist() | |
| goal_pivot = goal_pivot.reindex(sorted_incidents).dropna(how='all') | |
| if goal_pivot.empty: | |
| st.markdown(f"**{display_name}**") | |
| st.info(f"ไม่มีข้อมูลอุบัติการณ์ที่บันทึกเป็นรายเดือนสำหรับเป้าหมายนี้") | |
| st.markdown("---") | |
| continue | |
| fig_goal = px.imshow(goal_pivot, labels=dict(x="เดือน", y="อุบัติการณ์", color="จำนวน"), | |
| text_auto=True, aspect="auto", color_continuous_scale='Oranges') | |
| fig_goal.update_layout(title_text=f"<b>{display_name}</b>", | |
| height=max(500, len(goal_pivot.index) * 28), xaxis_title="เดือน", | |
| yaxis_title="รหัส | ชื่ออุบัติการณ์") | |
| fig_goal.update_xaxes(side="top") | |
| st.plotly_chart(fig_goal, use_container_width=True) | |
| st.markdown("---") | |
| except Exception as e: | |
| st.error(f"เกิดข้อผิดพลาดในการสร้าง Heatmap สำหรับ '{display_name}': {e}") | |
| elif selected_analysis == "Sentinel Events & Top 10": | |
| st.markdown("<h4 style='color: #001f3f;'>รายการ Sentinel Events ที่ตรวจพบ</h4>", unsafe_allow_html=True) | |
| # ✅ แก้ไข: ใช้ df_filtered ที่ผ่านการกรองตามช่วงเวลาแล้ว | |
| if 'sentinel_composite_keys' in globals() and sentinel_composite_keys and 'Sentinel code for check' in df_filtered.columns: | |
| sentinel_events = df_filtered[df_filtered['Sentinel code for check'].isin(sentinel_composite_keys)].copy() | |
| if not sentinel_events.empty: | |
| if 'Sentinel2024_df' in globals() and not Sentinel2024_df.empty and 'ชื่ออุบัติการณ์ความเสี่ยง' in Sentinel2024_df.columns: | |
| sentinel_events = pd.merge(sentinel_events, | |
| Sentinel2024_df[['รหัส', 'Impact', 'ชื่ออุบัติการณ์ความเสี่ยง']].rename( | |
| columns={'ชื่ออุบัติการณ์ความเสี่ยง': 'Sentinel Event Name'}), | |
| on=['รหัส', 'Impact'], how='left') | |
| display_sentinel_cols = ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด', | |
| 'Resulting Actions'] | |
| if 'Sentinel Event Name' in sentinel_events.columns: | |
| display_sentinel_cols.insert(2, 'Sentinel Event Name') | |
| final_display_cols = [col for col in display_sentinel_cols if col in sentinel_events.columns] | |
| st.dataframe(sentinel_events[final_display_cols], use_container_width=True, hide_index=True, | |
| column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", format="DD/MM/YYYY")}) | |
| else: | |
| st.info("ไม่พบ Sentinel Events ในช่วงเวลาที่เลือก") | |
| else: | |
| st.warning("ไม่สามารถตรวจสอบ Sentinel Events ได้ (ไฟล์ Sentinel2024.xlsx อาจไม่มีข้อมูล)") | |
| st.markdown("---") | |
| st.subheader("Top 10 อุบัติการณ์ (ตามความถี่)") | |
| if not df_freq.empty: | |
| df_freq_top10 = df_freq.nlargest(10, 'count') | |
| incident_names = df_filtered[['Incident', 'ชื่ออุบัติการณ์ความเสี่ยง']].drop_duplicates() | |
| df_freq_top10 = pd.merge(df_freq_top10, incident_names, on='Incident', how='left') | |
| st.dataframe( | |
| df_freq_top10[['Incident', 'count']], | |
| column_config={ | |
| "Incident": "รหัส Incident", | |
| "count": "จำนวนครั้ง" | |
| }, | |
| use_container_width=True, | |
| hide_index=True | |
| ) | |
| else: | |
| st.warning("ไม่สามารถแสดง Top 10 อุบัติการณ์ได้") | |
| elif selected_analysis == "Risk Matrix (Interactive)": | |
| st.subheader("Risk Matrix (Interactive)") | |
| matrix_data_counts = np.zeros((5, 5), dtype=int) | |
| impact_level_keys = ['5', '4', '3', '2', '1'] | |
| freq_level_keys = ['1', '2', '3', '4', '5'] | |
| matrix_df = df_filtered[ | |
| df_filtered['Impact Level'].isin(impact_level_keys) & | |
| df_filtered['Frequency Level'].isin(freq_level_keys) | |
| ].copy() | |
| # 2. ทำการนับจาก DataFrame ที่สะอาดแล้วเท่านั้น | |
| if not matrix_df.empty: | |
| risk_counts_df = matrix_df.groupby(['Impact Level', 'Frequency Level']).size().reset_index(name='counts') | |
| for _, row in risk_counts_df.iterrows(): | |
| il_key, fl_key = str(row['Impact Level']), str(row['Frequency Level']) | |
| # ไม่จำเป็นต้องเช็คซ้ำ เพราะเรากรองข้อมูลมาแล้ว | |
| row_idx, col_idx = impact_level_keys.index(il_key), freq_level_keys.index(fl_key) | |
| matrix_data_counts[row_idx, col_idx] = row['counts'] | |
| impact_labels_display = { | |
| '5': "I / 5<br>Extreme / Death", '4': "G-H / 4<br>Major / Severe", | |
| '3': "E-F / 3<br>Moderate", '2': "C-D / 2<br>Minor / Low", '1': "A-B / 1<br>Insignificant / No Harm" | |
| } | |
| freq_labels_display_short = {"1": "F1", "2": "F2", "3": "F3", "4": "F4", "5": "F5"} | |
| freq_labels_display_long = { | |
| "1": "Remote<br>(<2/mth)", "2": "Uncommon<br>(2-3/mth)", "3": "Occasional<br>(4-6/mth)", | |
| "4": "Probable<br>(7-29/mth)", "5": "Frequent<br>(>=30/mth)" | |
| } | |
| impact_to_color_row = {'5': 0, '4': 1, '3': 2, '2': 3, '1': 4} | |
| freq_to_color_col = {'1': 2, '2': 3, '3': 4, '4': 5, '5': 6} | |
| cols_header = st.columns([2.2, 1, 1, 1, 1, 1]) | |
| with cols_header[0]: | |
| st.markdown( | |
| f"<div style='background-color:{colors2[6, 0]}; color:{get_text_color_for_bg(colors2[6, 0])}; padding:8px; text-align:center; font-weight:bold; border-radius:3px; margin:1px; height:60px; display:flex; align-items:center; justify-content:center;'>Impact / Frequency</div>", | |
| unsafe_allow_html=True) | |
| for i, fl_key in enumerate(freq_level_keys): | |
| with cols_header[i + 1]: | |
| header_freq_bg_color = colors2[5, freq_to_color_col.get(fl_key, 2) - 1] | |
| header_freq_text_color = get_text_color_for_bg(header_freq_bg_color) | |
| st.markdown( | |
| f"<div style='background-color:{header_freq_bg_color}; color:{header_freq_text_color}; padding:8px; text-align:center; font-weight:bold; border-radius:3px; margin:1px; height:60px; display:flex; flex-direction: column; align-items:center; justify-content:center;'><div>{freq_labels_display_short.get(fl_key, '')}</div><div style='font-size:0.7em;'>{freq_labels_display_long.get(fl_key, '')}</div></div>", | |
| unsafe_allow_html=True) | |
| for il_key in impact_level_keys: | |
| cols_data_row = st.columns([2.2, 1, 1, 1, 1, 1]) | |
| row_idx_color = impact_to_color_row[il_key] | |
| with cols_data_row[0]: | |
| header_impact_bg_color = colors2[row_idx_color, 1] | |
| header_impact_text_color = get_text_color_for_bg(header_impact_bg_color) | |
| st.markdown( | |
| f"<div style='background-color:{header_impact_bg_color}; color:{header_impact_text_color}; padding:8px; text-align:center; font-weight:bold; border-radius:3px; margin:1px; height:70px; display:flex; align-items:center; justify-content:center;'>{impact_labels_display[il_key]}</div>", | |
| unsafe_allow_html=True) | |
| for i, fl_key in enumerate(freq_level_keys): | |
| with cols_data_row[i + 1]: | |
| count = matrix_data_counts[impact_level_keys.index(il_key), freq_level_keys.index(fl_key)] | |
| cell_bg_color = colors2[row_idx_color, freq_to_color_col[fl_key]] | |
| text_color = get_text_color_for_bg(cell_bg_color) | |
| st.markdown( | |
| f"<div style='background-color:{cell_bg_color}; color:{text_color}; padding:5px; margin:1px; border-radius:3px; text-align:center; font-weight:bold; min-height:40px; display:flex; align-items:center; justify-content:center;'>{count}</div>", | |
| unsafe_allow_html=True) | |
| if count > 0: | |
| button_key = f"view_risk_{il_key}_{fl_key}" | |
| if st.button("👁️", key=button_key, help=f"ดูรายการ - {count} รายการ", use_container_width=True): | |
| st.session_state.clicked_risk_impact = il_key | |
| st.session_state.clicked_risk_freq = fl_key | |
| st.session_state.show_incident_table = True | |
| st.rerun() | |
| else: | |
| st.markdown("<div style='height:38px; margin-top:5px;'></div>", unsafe_allow_html=True) | |
| if st.session_state.get('show_incident_table', False) and st.session_state.clicked_risk_impact is not None: | |
| il_selected = st.session_state.clicked_risk_impact | |
| fl_selected = st.session_state.clicked_risk_freq | |
| df_incidents_in_cell = df_filtered[(df_filtered['Impact Level'].astype(str) == il_selected) & ( | |
| df_filtered['Frequency Level'].astype(str) == fl_selected)].copy() | |
| expander_title = f"รายการอุบัติการณ์: Impact Level {il_selected}, Frequency Level {fl_selected} - พบ {len(df_incidents_in_cell)} รายการ" | |
| with st.expander(expander_title, expanded=True): | |
| st.dataframe(df_incidents_in_cell[display_cols_common], use_container_width=True, hide_index=True) | |
| if st.button("ปิดรายการ", key="clear_risk_selection_button"): | |
| st.session_state.show_incident_table = False | |
| st.session_state.clicked_risk_impact = None | |
| st.session_state.clicked_risk_freq = None | |
| st.rerun() | |
| st.write("---") | |
| st.subheader("ตารางสรุปสีตามระดับความเสี่ยงสูงสุดของแต่ละอุบัติการณ์") | |
| st.info("สีและป้ายกำกับ (I: Impact, F: Frequency) มาจากช่องที่มีความเสี่ยงสูงสุดของอุบัติการณ์ประเภทนั้นๆ") | |
| if 'Impact Level' in df_filtered.columns and 'Frequency Level' in df_filtered.columns: | |
| incident_risk_summary = df_filtered.groupby(['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง']).agg( | |
| max_impact_level=('Impact Level', 'max'), | |
| frequency_level=('Frequency Level', 'first'), | |
| total_occurrences=('Incident Rate/mth', 'first') | |
| ).reset_index() | |
| def get_color_for_incident(row): | |
| il_key, fl_key = str(row['max_impact_level']), str(row['frequency_level']) | |
| if il_key in impact_to_color_row and fl_key in freq_to_color_col: | |
| return colors2[impact_to_color_row[il_key], freq_to_color_col[fl_key]] | |
| return '#808080' | |
| incident_risk_summary['risk_color_hex'] = incident_risk_summary.apply(get_color_for_incident, axis=1) | |
| incident_risk_summary = incident_risk_summary.sort_values(by='total_occurrences', ascending=False) | |
| st.write("---") | |
| for _, row in incident_risk_summary.iterrows(): | |
| color, text_color = row['risk_color_hex'], get_text_color_for_bg(row['risk_color_hex']) | |
| risk_label = f"I: {row['max_impact_level']} | F: {row['frequency_level']}" | |
| col1, col2 = st.columns([1, 6]) | |
| with col1: | |
| st.markdown( | |
| f'<div style="background-color: {color}; color: {text_color}; font-weight: bold; text-align: center; padding: 8px; border-radius: 5px; height: 100%; display: flex; align-items: center; justify-content: center;">{risk_label}</div>', | |
| unsafe_allow_html=True) | |
| with col2: | |
| st.markdown( | |
| f"**{row['รหัส']} | {row['ชื่ออุบัติการณ์ความเสี่ยง']}** (อัตราการเกิด: {row.get('total_occurrences', 0):.2f} ครั้ง/เดือน)") | |
| else: | |
| st.warning("ไม่พบคอลัมน์ 'Impact Level' หรือ 'Frequency Level' ที่จำเป็นสำหรับการสร้างตารางสรุปสี") | |
| elif selected_analysis == "กราฟสรุปอุบัติการณ์ (รายมิติ)": | |
| st.markdown("<h4 style='color: #001f3f;'>กราฟสรุปอุบัติการณ์ (แบ่งตามมิติต่างๆ)</h4>", unsafe_allow_html=True) | |
| pastel_color_discrete_map_dimensions = {'Critical': '#FF9999', 'High': '#FFCC99', 'Medium': '#FFFF99', | |
| 'Low': '#99FF99', 'Undefined': '#D3D3D3'} | |
| tab1_v, tab2_v, tab3_v, tab4_v = st.tabs( | |
| ["👁️By Goals (หมวด)", "👁️By Group (กลุ่ม)", "👁️By Shift (เวร)", "👁️By Place (สถานที่)"]) | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| df_charts = df_filtered.copy() | |
| df_charts['Count'] = 1 | |
| with tab1_v: | |
| # ✅ แก้ไข: ใช้ total_month ที่คำนวณใหม่ | |
| st.markdown(f"Incidents by Safety Goals ({total_month} เดือน)") | |
| if 'หมวด' in df_charts.columns: | |
| df_c1 = df_charts[~df_charts['หมวด'].isin( | |
| ['N/A', 'N/A (ข้อมูลจาก AllCode ไม่พร้อมใช้งาน)', 'N/A (ไม่พบรหัสใน AllCode)'])] | |
| if not df_c1.empty: | |
| fig_c1 = px.bar(df_c1.groupby(['หมวด', 'Category Color']).size().reset_index(name='Count'), | |
| x='หมวด', y='Count', color='Category Color', | |
| color_discrete_map=pastel_color_discrete_map_dimensions) | |
| st.plotly_chart(fig_c1, use_container_width=True) | |
| with tab2_v: | |
| st.markdown(f"Incidents by Group ({total_month} เดือน)") | |
| if 'กลุ่ม' in df_charts.columns: | |
| df_c2 = df_charts[~df_charts['กลุ่ม'].isin( | |
| ['N/A', 'N/A (ข้อมูลจาก AllCode ไม่พร้อมใช้งาน)', 'N/A (ไม่พบรหัสใน AllCode)'])] | |
| if not df_c2.empty: | |
| fig_c2 = px.bar(df_c2.groupby(['กลุ่ม', 'Category Color']).size().reset_index(name='Count'), | |
| x='กลุ่ม', y='Count', color='Category Color', | |
| color_discrete_map=pastel_color_discrete_map_dimensions) | |
| st.plotly_chart(fig_c2, use_container_width=True) | |
| with tab3_v: | |
| st.markdown(f"Incidents by Shift ({total_month} เดือน)") | |
| if 'ช่วงเวลา/เวร' in df_charts.columns: | |
| df_c3 = df_charts[df_charts['ช่วงเวลา/เวร'].notna() & ~df_charts['ช่วงเวลา/เวร'].isin(['None', 'N/A'])] | |
| if not df_c3.empty: | |
| fig_c3 = px.bar(df_c3.groupby(['ช่วงเวลา/เวร', 'Category Color']).size().reset_index(name='Count'), | |
| x='ช่วงเวลา/เวร', y='Count', color='Category Color', | |
| color_discrete_map=pastel_color_discrete_map_dimensions) | |
| st.plotly_chart(fig_c3, use_container_width=True) | |
| with tab4_v: | |
| st.markdown(f"Incidents by Place ({total_month} เดือน)") | |
| if 'ชนิดสถานที่' in df_charts.columns: | |
| df_c4 = df_charts[df_charts['ชนิดสถานที่'].notna() & ~df_charts['ชนิดสถานที่'].isin(['None', 'N/A'])] | |
| if not df_c4.empty: | |
| fig_c4 = px.bar(df_c4.groupby(['ชนิดสถานที่', 'Category Color']).size().reset_index(name='Count'), | |
| x='ชนิดสถานที่', y='Count', color='Category Color', | |
| color_discrete_map=pastel_color_discrete_map_dimensions) | |
| st.plotly_chart(fig_c4, use_container_width=True) | |
| elif selected_analysis == "Sankey: ภาพรวม": | |
| st.markdown("<h4 style='color: #001f3f;'>Sankey Diagram: ภาพรวม</h4>", unsafe_allow_html=True) | |
| st.markdown(""" | |
| <style> | |
| .plot-container .svg-container .sankey-node text { | |
| stroke-width: 0 !important; text-shadow: none !important; paint-order: stroke fill; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| req_cols = ['หมวด', 'Impact', 'Impact Level', 'Category Color'] | |
| if not all(col in df_filtered.columns for col in req_cols): | |
| st.warning(f"ไม่พบคอลัมน์ที่จำเป็น ({', '.join(req_cols)}) สำหรับการสร้าง Sankey diagram") | |
| else: | |
| # ✅ ใช้ df_filtered เป็นข้อมูลตั้งต้น | |
| sankey_df = df_filtered.copy() | |
| placeholders = ['None', '', 'N/A', 'ไม่ระบุ', | |
| 'N/A (ข้อมูลจาก AllCode ไม่พร้อมใช้งาน)', | |
| 'N/A (ไม่พบรหัสใน AllCode หรือค่าว่างใน AllCode)'] | |
| # กรองข้อมูลที่มี 'หมวด' ที่ถูกต้องออกมาเพื่อใช้งาน | |
| sankey_df = sankey_df[~sankey_df['หมวด'].astype(str).isin(placeholders)] | |
| if sankey_df.empty: | |
| st.warning("ไม่สามารถสร้าง Sankey Diagram ได้ เนื่องจากไม่มีข้อมูล 'หมวด' ที่ถูกต้องในช่วงเวลาที่เลือก") | |
| else: | |
| sankey_df['หมวด_Node'] = "หมวด: " + sankey_df['หมวด'].astype(str).str.strip() | |
| sankey_df['Impact_AI_Node'] = "Impact: " + sankey_df['Impact'].astype(str).str.strip() | |
| sankey_df.loc[ | |
| sankey_df['Impact'].astype(str).isin(placeholders), 'Impact_AI_Node'] = "Impact: ไม่ระบุ A-I" | |
| impact_level_display_map = {'1': "Level: 1 (A-B)", '2': "Level: 2 (C-D)", '3': "Level: 3 (E-F)", | |
| '4': "Level: 4 (G-H)", '5': "Level: 5 (I)", 'N/A': "Level: ไม่ระบุ"} | |
| sankey_df['Impact_Level_Node'] = sankey_df['Impact Level'].astype(str).str.strip().map( | |
| impact_level_display_map).fillna("Level: ไม่ระบุ") | |
| sankey_df['Risk_Category_Node'] = "Risk: " + sankey_df['Category Color'].astype(str).str.strip() | |
| node_cols = ['หมวด_Node', 'Impact_AI_Node', 'Impact_Level_Node', 'Risk_Category_Node'] | |
| sankey_df.dropna(subset=node_cols, inplace=True) | |
| labels_muad = sorted(list(sankey_df['หมวด_Node'].unique())) | |
| impact_ai_order = [f"Impact: {i}" for i in ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']] + [ | |
| "Impact: ไม่ระบุ A-I"] | |
| labels_impact_ai = sorted(list(sankey_df['Impact_AI_Node'].unique()), | |
| key=lambda x: impact_ai_order.index(x) if x in impact_ai_order else 999) | |
| level_order_map = {"Level: 1 (A-B)": 1, "Level: 2 (C-D)": 2, "Level: 3 (E-F)": 3, "Level: 4 (G-H)": 4, | |
| "Level: 5 (I)": 5, "Level: ไม่ระบุ": 6} | |
| labels_impact_level = sorted(list(sankey_df['Impact_Level_Node'].unique()), | |
| key=lambda x: level_order_map.get(x, 999)) | |
| risk_order = ["Risk: Critical", "Risk: High", "Risk: Medium", "Risk: Low", "Risk: Undefined"] | |
| labels_risk_cat = sorted(list(sankey_df['Risk_Category_Node'].unique()), | |
| key=lambda x: risk_order.index(x) if x in risk_order else 999) | |
| all_labels_ordered = labels_muad + labels_impact_ai + labels_impact_level + labels_risk_cat | |
| all_labels = list(pd.Series(all_labels_ordered).unique()) | |
| label_to_idx = {label: i for i, label in enumerate(all_labels)} | |
| source_indices, target_indices, values = [], [], [] | |
| links1 = sankey_df.groupby(['หมวด_Node', 'Impact_AI_Node']).size().reset_index(name='value') | |
| for _, row in links1.iterrows(): | |
| if row['หมวด_Node'] in label_to_idx and row['Impact_AI_Node'] in label_to_idx: | |
| source_indices.append(label_to_idx[row['หมวด_Node']]) | |
| target_indices.append(label_to_idx[row['Impact_AI_Node']]) | |
| values.append(row['value']) | |
| links2 = sankey_df.groupby(['Impact_AI_Node', 'Impact_Level_Node']).size().reset_index(name='value') | |
| for _, row in links2.iterrows(): | |
| if row['Impact_AI_Node'] in label_to_idx and row['Impact_Level_Node'] in label_to_idx: | |
| source_indices.append(label_to_idx[row['Impact_AI_Node']]) | |
| target_indices.append(label_to_idx[row['Impact_Level_Node']]) | |
| values.append(row['value']) | |
| links3 = sankey_df.groupby(['Impact_Level_Node', 'Risk_Category_Node']).size().reset_index(name='value') | |
| for _, row in links3.iterrows(): | |
| if row['Impact_Level_Node'] in label_to_idx and row['Risk_Category_Node'] in label_to_idx: | |
| source_indices.append(label_to_idx[row['Impact_Level_Node']]) | |
| target_indices.append(label_to_idx[row['Risk_Category_Node']]) | |
| values.append(row['value']) | |
| if source_indices: | |
| node_colors = [] | |
| palette1, palette2, palette3 = px.colors.qualitative.Pastel1, px.colors.qualitative.Pastel2, px.colors.qualitative.Set3 | |
| risk_color_map = {"Risk: Critical": "red", "Risk: High": "orange", "Risk: Medium": "#F7DC6F", | |
| "Risk: Low": "green", "Risk: Undefined": "grey"} | |
| for label in all_labels: | |
| if label.startswith("หมวด:"): | |
| node_colors.append(palette1[labels_muad.index(label) % len(palette1)]) | |
| elif label.startswith("Impact:"): | |
| node_colors.append(palette2[labels_impact_ai.index(label) % len(palette2)]) | |
| elif label.startswith("Level:"): | |
| node_colors.append(palette3[labels_impact_level.index(label) % len(palette3)]) | |
| elif label.startswith("Risk:"): | |
| node_colors.append(risk_color_map.get(label, 'grey')) | |
| else: | |
| node_colors.append('rgba(200,200,200,0.8)') | |
| link_colors_rgba = [ | |
| f'rgba({int(c.lstrip("#")[0:2], 16)},{int(c.lstrip("#")[2:4], 16)},{int(c.lstrip("#")[4:6], 16)},0.3)' if c.startswith( | |
| '#') else 'rgba(200,200,200,0.3)' for c in [node_colors[s] for s in source_indices]] | |
| fig = go.Figure(data=[go.Sankey( | |
| arrangement='snap', | |
| node=dict(pad=15, thickness=18, line=dict(color="rgba(0,0,0,0.6)", width=0.75), | |
| label=all_labels, color=node_colors, | |
| hovertemplate='%{label} มีจำนวน: %{value}<extra></extra>'), | |
| link=dict(source=source_indices, target=target_indices, value=values, color=link_colors_rgba, | |
| hovertemplate='จาก %{source.label}<br />ไปยัง %{target.label}<br />จำนวน: %{value}<extra></extra>') | |
| )]) | |
| fig.update_layout( | |
| title_text="<b>แผนภาพ Sankey:</b> หมวด -> Impact (A-I) -> Impact Level (1-5) -> Risk Category", | |
| font_size=12, height=max(700, len(all_labels) * 18), template='plotly_white', | |
| margin=dict(t=60, l=10, r=10, b=20)) | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.warning("ไม่สามารถสร้างลิงก์สำหรับ Sankey diagram ได้ (อาจไม่มีความเชื่อมโยงของข้อมูล)") | |
| elif selected_analysis == "Sankey: มาตรฐานสำคัญจำเป็นต่อความปลอดภัย 9 ข้อ": | |
| st.markdown("<h4 style='color: #001f3f;'>Sankey Diagram: มาตรฐานสำคัญจำเป็นต่อความปลอดภัย 9 ข้อ</h4>", | |
| unsafe_allow_html=True) | |
| st.markdown(""" | |
| <style> | |
| .plot-container .svg-container .sankey-node text { | |
| stroke-width: 0 !important; text-shadow: none !important; paint-order: stroke fill; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| required_cols = ['หมวดหมู่มาตรฐานสำคัญ', 'รหัส', 'Impact', 'ชื่ออุบัติการณ์ความเสี่ยง', 'Category Color'] | |
| if not all(col in df_filtered.columns for col in required_cols): | |
| st.warning(f"ไม่พบคอลัมน์ที่จำเป็น ({', '.join(required_cols)}) สำหรับการสร้าง Sankey diagram") | |
| else: | |
| # ✅ แก้ไข: ใช้ df_filtered เป็นข้อมูลตั้งต้น | |
| sankey_df_new = df_filtered.copy() | |
| placeholders_to_filter = ["ไม่จัดอยู่ใน PSG9 Catalog", "ไม่สามารถระบุ (Merge PSG9 ล้มเหลว)", | |
| "ไม่สามารถระบุ (PSG9code.xlsx ไม่ได้โหลด)"] | |
| sankey_df_new = sankey_df_new[ | |
| ~sankey_df_new['หมวดหมู่มาตรฐานสำคัญ'].astype(str).isin(placeholders_to_filter)] | |
| if sankey_df_new.empty: | |
| st.warning("ไม่พบข้อมูลที่เกี่ยวข้องกับ PSG9 ในช่วงเวลาที่เลือก") | |
| else: | |
| psg9_to_cp_gp_map = {PSG9_label_dict[num].strip(): 'CP (หมวดตาม PSG9)' for num in | |
| [1, 3, 4, 5, 6, 7, 8, 9] if num in PSG9_label_dict} | |
| psg9_to_cp_gp_map.update( | |
| {PSG9_label_dict[num].strip(): 'GP (หมวดตาม PSG9)' for num in [2] if num in PSG9_label_dict}) | |
| sankey_df_new['หมวด_CP_GP_Node'] = sankey_df_new['หมวดหมู่มาตรฐานสำคัญ'].map(psg9_to_cp_gp_map) | |
| sankey_df_new['หมวดหมู่PSG_Node'] = "PSG9: " + sankey_df_new['หมวดหมู่มาตรฐานสำคัญ'] | |
| sankey_df_new['รหัส_Node'] = "รหัส: " + sankey_df_new['รหัส'] | |
| sankey_df_new['Impact_AI_Node'] = "Impact: " + sankey_df_new['Impact'] | |
| sankey_df_new['Risk_Category_Node'] = "Risk: " + sankey_df_new['Category Color'] | |
| sankey_df_new['ชื่ออุบัติการณ์ความเสี่ยง_for_hover'] = sankey_df_new[ | |
| 'ชื่ออุบัติการณ์ความเสี่ยง'].fillna('ไม่มีคำอธิบาย') | |
| cols_for_dropna = ['หมวด_CP_GP_Node', 'หมวดหมู่PSG_Node', 'รหัส_Node', 'Impact_AI_Node', | |
| 'Risk_Category_Node'] | |
| sankey_df_new.dropna(subset=cols_for_dropna, inplace=True) | |
| labels_muad_cp_gp_simp = sorted(list(sankey_df_new['หมวด_CP_GP_Node'].unique())) | |
| labels_psg9_cat_simp = sorted(list(sankey_df_new['หมวดหมู่PSG_Node'].unique())) | |
| rh_node_to_desc_map = sankey_df_new.drop_duplicates(subset=['รหัส_Node']).set_index('รหัส_Node')[ | |
| 'ชื่ออุบัติการณ์ความเสี่ยง_for_hover'].to_dict() | |
| labels_rh_simp = sorted(list(sankey_df_new['รหัส_Node'].unique())) | |
| labels_impact_ai_simp = sorted(list(sankey_df_new['Impact_AI_Node'].unique())) | |
| risk_order = ["Risk: Critical", "Risk: High", "Risk: Medium", "Risk: Low", "Risk: Undefined"] | |
| labels_risk_category = sorted(list(sankey_df_new['Risk_Category_Node'].unique()), | |
| key=lambda x: risk_order.index(x) if x in risk_order else 99) | |
| all_labels_ordered_simp = labels_muad_cp_gp_simp + labels_psg9_cat_simp + labels_rh_simp + labels_impact_ai_simp + labels_risk_category | |
| all_labels_simp = list(pd.Series(all_labels_ordered_simp).unique()) | |
| label_to_idx_simp = {label: i for i, label in enumerate(all_labels_simp)} | |
| customdata_for_nodes_simp = [ | |
| f"<br>คำอธิบาย: {str(rh_node_to_desc_map.get(label_node, ''))}" if label_node in rh_node_to_desc_map else "" | |
| for label_node in all_labels_simp] | |
| source_indices_simp, target_indices_simp, values_simp = [], [], [] | |
| links_l1 = sankey_df_new.groupby(['หมวด_CP_GP_Node', 'หมวดหมู่PSG_Node']).size().reset_index( | |
| name='value') | |
| for _, row in links_l1.iterrows(): | |
| if row['หมวด_CP_GP_Node'] in label_to_idx_simp and row['หมวดหมู่PSG_Node'] in label_to_idx_simp: | |
| source_indices_simp.append(label_to_idx_simp[row['หมวด_CP_GP_Node']]) | |
| target_indices_simp.append(label_to_idx_simp[row['หมวดหมู่PSG_Node']]) | |
| values_simp.append(row['value']) | |
| links_l2 = sankey_df_new.groupby(['หมวดหมู่PSG_Node', 'รหัส_Node']).size().reset_index(name='value') | |
| for _, row in links_l2.iterrows(): | |
| if row['หมวดหมู่PSG_Node'] in label_to_idx_simp and row['รหัส_Node'] in label_to_idx_simp: | |
| source_indices_simp.append(label_to_idx_simp[row['หมวดหมู่PSG_Node']]) | |
| target_indices_simp.append(label_to_idx_simp[row['รหัส_Node']]) | |
| values_simp.append(row['value']) | |
| links_l3 = sankey_df_new.groupby(['รหัส_Node', 'Impact_AI_Node']).size().reset_index(name='value') | |
| for _, row in links_l3.iterrows(): | |
| if row['รหัส_Node'] in label_to_idx_simp and row['Impact_AI_Node'] in label_to_idx_simp: | |
| source_indices_simp.append(label_to_idx_simp[row['รหัส_Node']]) | |
| target_indices_simp.append(label_to_idx_simp[row['Impact_AI_Node']]) | |
| values_simp.append(row['value']) | |
| links_l4 = sankey_df_new.groupby(['Impact_AI_Node', 'Risk_Category_Node']).size().reset_index( | |
| name='value') | |
| for _, row in links_l4.iterrows(): | |
| if row['Impact_AI_Node'] in label_to_idx_simp and row['Risk_Category_Node'] in label_to_idx_simp: | |
| source_indices_simp.append(label_to_idx_simp[row['Impact_AI_Node']]) | |
| target_indices_simp.append(label_to_idx_simp[row['Risk_Category_Node']]) | |
| values_simp.append(row['value']) | |
| if source_indices_simp: | |
| node_colors_simp = [] | |
| palette_l1, palette_l2, palette_l3, palette_l4 = px.colors.qualitative.Bold, px.colors.qualitative.Pastel, px.colors.qualitative.Vivid, px.colors.qualitative.Set3 | |
| risk_cat_color_map = {"Risk: Critical": "red", "Risk: High": "orange", "Risk: Medium": "#F7DC6F", | |
| "Risk: Low": "green", "Risk: Undefined": "grey"} | |
| for label_node in all_labels_simp: | |
| if label_node in labels_muad_cp_gp_simp: | |
| node_colors_simp.append( | |
| palette_l1[labels_muad_cp_gp_simp.index(label_node) % len(palette_l1)]) | |
| elif label_node in labels_psg9_cat_simp: | |
| node_colors_simp.append( | |
| palette_l2[labels_psg9_cat_simp.index(label_node) % len(palette_l2)]) | |
| elif label_node in labels_rh_simp: | |
| node_colors_simp.append(palette_l3[labels_rh_simp.index(label_node) % len(palette_l3)]) | |
| elif label_node in labels_impact_ai_simp: | |
| node_colors_simp.append( | |
| palette_l4[labels_impact_ai_simp.index(label_node) % len(palette_l4)]) | |
| elif label_node in labels_risk_category: | |
| node_colors_simp.append(risk_cat_color_map.get(label_node, 'grey')) | |
| else: | |
| node_colors_simp.append('rgba(200,200,200,0.8)') | |
| link_colors_simp = [] | |
| default_link_color_simp = 'rgba(200,200,200,0.35)' | |
| for s_idx in source_indices_simp: | |
| try: | |
| hex_color = node_colors_simp[s_idx] | |
| h = hex_color.lstrip('#') | |
| rgb_tuple = tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) | |
| link_colors_simp.append(f'rgba({rgb_tuple[0]},{rgb_tuple[1]},{rgb_tuple[2]},0.3)') | |
| except: | |
| link_colors_simp.append(default_link_color_simp) | |
| fig_sankey_psg9_simplified = go.Figure(data=[go.Sankey( | |
| arrangement='snap', | |
| node=dict(pad=10, thickness=15, line=dict(color="rgba(0,0,0,0.4)", width=0.4), | |
| label=all_labels_simp, color=node_colors_simp, customdata=customdata_for_nodes_simp, | |
| hovertemplate='<b>%{label}</b><br>จำนวน: %{value}%{customdata}<extra></extra>'), | |
| link=dict(source=source_indices_simp, target=target_indices_simp, value=values_simp, | |
| color=link_colors_simp, | |
| hovertemplate='จาก %{source.label}<br />ไปยัง %{target.label}<br />จำนวน: %{value}<extra></extra>') | |
| )]) | |
| fig_sankey_psg9_simplified.update_layout( | |
| title_text="<b>แผนภาพ SANKEY:</b> CP/GP -> หมวดหมู่ PSG9 -> รหัส -> Impact -> Risk Category", | |
| font_size=11, height=max(800, len(all_labels_simp) * 12 + 200), | |
| template='plotly_white', margin=dict(t=70, l=10, r=10, b=20) | |
| ) | |
| st.plotly_chart(fig_sankey_psg9_simplified, use_container_width=True) | |
| else: | |
| st.warning("ไม่สามารถสร้างลิงก์สำหรับ Sankey diagram (PSG9) ได้") | |
| elif selected_analysis == "สรุปอุบัติการณ์ตาม Safety Goals": | |
| st.markdown("<h4 style='color: #001f3f;'>สรุปอุบัติการณ์ตามเป้าหมาย (Safety Goals)</h4>", | |
| unsafe_allow_html=True) | |
| goal_definitions = { | |
| "Patient Safety/ Common Clinical Risk": "P:Patient Safety Goals หรือ Common Clinical Risk Incident", | |
| "Specific Clinical Risk": "S:Specific Clinical Risk Incident", | |
| "Personnel Safety": "P:Personnel Safety Goals", | |
| "Organization Safety": "O:Organization Safety Goals" | |
| } | |
| # --- ส่วนที่ 1: แสดงตารางสรุปเหมือนเดิม --- | |
| for display_name, cat_name in goal_definitions.items(): | |
| st.markdown(f"##### {display_name}") | |
| is_org_safety = (display_name == "Organization Safety") | |
| summary_table = create_goal_summary_table( | |
| df_filtered, | |
| cat_name, | |
| e_up_non_numeric_levels_param=[] if is_org_safety else ['A', 'B', 'C', 'D'], | |
| e_up_numeric_levels_param=['1', '2'] if is_org_safety else None, | |
| is_org_safety_table=is_org_safety | |
| ) | |
| if summary_table is not None and not summary_table.empty: | |
| st.dataframe(summary_table, use_container_width=True) | |
| else: | |
| st.info(f"ไม่มีข้อมูลสำหรับ '{display_name}' ในช่วงเวลาที่เลือก") | |
| # --- ส่วนที่ 2: กราฟแท่งเปรียบเทียบ % E-up (ที่เพิ่มเข้ามา) --- | |
| st.markdown("---") | |
| st.subheader("📊 เจาะลึกสัดส่วนอุบัติการณ์รุนแรง (% E-up) ในแต่ละหัวข้อ") | |
| severe_levels = ['E', 'F', 'G', 'H', 'I', '3', '4', '5'] | |
| valid_goals = [goal for goal in df_filtered['หมวด'].unique() if | |
| goal and goal not in ['N/A', 'N/A (ข้อมูลจาก AllCode ไม่พร้อมใช้งาน)', | |
| 'N/A (ไม่พบรหัสใน AllCode)']] | |
| for goal in valid_goals: | |
| st.markdown(f"#### {goal}") | |
| goal_df = df_filtered[df_filtered['หมวด'] == goal].copy() | |
| summary = goal_df.groupby('Incident Type Name').apply( | |
| lambda x: (x['Impact'].isin(severe_levels).sum() / len(x) * 100) if len(x) > 0 else 0 | |
| ).reset_index(name='ร้อยละ E-up') | |
| summary = summary[summary['ร้อยละ E-up'] > 0].sort_values(by='ร้อยละ E-up', ascending=True) | |
| if summary.empty: | |
| st.info("ไม่พบอุบัติการณ์รุนแรง (E-up) ในหมวดหมู่นี้") | |
| continue | |
| fig = px.bar( | |
| summary, | |
| x='ร้อยละ E-up', | |
| y='Incident Type Name', | |
| orientation='h', | |
| title=f"สัดส่วนอุบัติการณ์รุนแรงในหมวด {goal}", | |
| labels={'Incident Type Name': 'หัวข้ออุบัติการณ์', 'ร้อยละ E-up': 'ร้อยละของอุบัติการณ์รุนแรง (%)'}, | |
| text_auto='.2f', | |
| color='ร้อยละ E-up', | |
| color_continuous_scale='Reds' | |
| ) | |
| fig.update_layout(yaxis_title=None, xaxis_ticksuffix="%") | |
| st.plotly_chart(fig, use_container_width=True) | |
| # --- ส่วนที่ 3: กราฟ Sunburst (ที่เพิ่มเข้ามา) --- | |
| st.markdown("---") | |
| st.subheader("☀️ ภาพรวมสัดส่วนอุบัติการณ์รุนแรงแบบ Sunburst") | |
| total_counts = df_filtered.groupby(['หมวด', 'Incident Type Name']).size().reset_index(name='จำนวนทั้งหมด') | |
| severe_df = df_filtered[df_filtered['Impact'].isin(severe_levels)] | |
| severe_counts = severe_df.groupby(['หมวด', 'Incident Type Name']).size().reset_index(name='จำนวน E-up') | |
| summary_df = pd.merge(total_counts, severe_counts, on=['หมวด', 'Incident Type Name'], how='left').fillna(0) | |
| summary_df['ร้อยละ E-up'] = (summary_df['จำนวน E-up'] / summary_df['จำนวนทั้งหมด'] * 100) | |
| summary_df = summary_df[summary_df['จำนวนทั้งหมด'] > 0] | |
| if summary_df.empty: | |
| st.info("ไม่มีข้อมูลเพียงพอสำหรับสร้างกราฟ Sunburst") | |
| else: | |
| fig_sunburst = px.sunburst( | |
| summary_df, | |
| path=['หมวด', 'Incident Type Name'], | |
| values='จำนวนทั้งหมด', | |
| color='ร้อยละ E-up', | |
| color_continuous_scale='YlOrRd', | |
| hover_data={'ร้อยละ E-up': ':.2f'}, | |
| title="ภาพรวมสัดส่วนอุบัติการณ์รุนแรง (ขนาด = จำนวนรวม, สี = % E-up)" | |
| ) | |
| fig_sunburst.update_traces(textinfo="label+percent entry") | |
| st.plotly_chart(fig_sunburst, use_container_width=True) | |
| elif selected_analysis == "วิเคราะห์ตามหมวดหมู่และสถานะการแก้ไข": | |
| st.markdown("<h4 style='color: #001f3f;'>วิเคราะห์ตามหมวดหมู่และสถานะการแก้ไข</h4>", unsafe_allow_html=True) | |
| if 'Resulting Actions' not in df_filtered.columns or 'หมวดหมู่มาตรฐานสำคัญ' not in df_filtered.columns: | |
| st.error( | |
| "ไม่สามารถแสดงข้อมูลได้ เนื่องจากไม่พบคอลัมน์ 'Resulting Actions' หรือ 'หมวดหมู่มาตรฐานสำคัญ' ในข้อมูล") | |
| else: | |
| tab_psg9, tab_groups, tab_summary, tab_waitlist = st.tabs( | |
| ["👁️ วิเคราะห์ตามหมวดหมู่ PSG9", "👁️ วิเคราะห์ตามกลุ่มหลัก (C/G)", | |
| "👁️ สรุปเปอร์เซ็นต์การแก้ไขอุบัติการณ์รุนแรง (E-I & 3-5)","👁️ อุบัติการณ์ที่รอการแก้ไข(ตามความรุนแรง)"]) | |
| # --- Tab ที่ 1: วิเคราะห์ตามหมวดหมู่ PSG9 --- | |
| with tab_psg9: | |
| st.subheader("ภาพรวมอุบัติการณ์ตามมาตรฐานสำคัญจำเป็นต่อความปลอดภัย (PSG9)") | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| psg9_summary_table = create_psg9_summary_table(df_filtered) | |
| if psg9_summary_table is not None and not psg9_summary_table.empty: | |
| st.dataframe(psg9_summary_table, use_container_width=True) | |
| else: | |
| st.info("ไม่พบข้อมูลอุบัติการณ์ที่เกี่ยวข้องกับมาตรฐานสำคัญ 9 ข้อในช่วงเวลานี้") | |
| st.markdown("---") | |
| st.subheader("สถานะการแก้ไขในแต่ละหมวดหมู่ PSG9") | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| psg9_categories = {k: v for k, v in PSG9_label_dict.items() if | |
| v in df_filtered['หมวดหมู่มาตรฐานสำคัญ'].unique()} | |
| for psg9_id, psg9_name in psg9_categories.items(): | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| psg9_df = df_filtered[df_filtered['หมวดหมู่มาตรฐานสำคัญ'] == psg9_name] | |
| total_count = len(psg9_df) | |
| resolved_df = psg9_df[~psg9_df['Resulting Actions'].astype(str).isin(['None', '', 'nan'])] | |
| resolved_count = len(resolved_df) | |
| unresolved_count = total_count - resolved_count | |
| expander_title = f"{psg9_name} (ทั้งหมด: {total_count} | แก้ไขแล้ว: {resolved_count} | รอแก้ไข: {unresolved_count})" | |
| with st.expander(expander_title): | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric("จำนวนทั้งหมด", f"{total_count:,}") | |
| c2.metric("ดำเนินการแก้ไขแล้ว", f"{resolved_count:,}") | |
| c3.metric("รอการแก้ไข", f"{unresolved_count:,}") | |
| if total_count > 0: | |
| tab_resolved, tab_unresolved = st.tabs( | |
| [f"รายการที่แก้ไขแล้ว ({resolved_count})", f"รายการที่รอการแก้ไข ({unresolved_count})"]) | |
| with tab_resolved: | |
| if resolved_count > 0: | |
| st.dataframe( | |
| resolved_df[['Occurrence Date', 'Incident', 'Impact', 'Resulting Actions']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.info("ไม่มีรายการที่แก้ไขแล้วในหมวดนี้") | |
| with tab_unresolved: | |
| if unresolved_count > 0: | |
| st.dataframe( | |
| psg9_df[psg9_df['Resulting Actions'].astype(str).isin(['None', '', 'nan'])][ | |
| ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.success("อุบัติการณ์ทั้งหมดในหมวดนี้ได้รับการแก้ไขแล้ว") | |
| # --- Tab ที่ 2: วิเคราะห์ตามกลุ่มหลัก (C/G) --- | |
| with tab_groups: | |
| st.subheader("เจาะลึกสถานะการแก้ไขตามกลุ่มหลักและหมวดย่อย") | |
| st.markdown("#### กลุ่มอุบัติการณ์ทางคลินิก (รหัสขึ้นต้นด้วย C)") | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| df_clinical = df_filtered[df_filtered['รหัส'].str.startswith('C', na=False)].copy() | |
| if df_clinical.empty: | |
| st.info("ไม่พบข้อมูลอุบัติการณ์กลุ่ม Clinical") | |
| else: | |
| clinical_categories = sorted([cat for cat in df_clinical['หมวด'].unique() if cat and pd.notna(cat)]) | |
| for category in clinical_categories: | |
| category_df = df_clinical[df_clinical['หมวด'] == category] | |
| total_count = len(category_df) | |
| resolved_df = category_df[ | |
| ~category_df['Resulting Actions'].astype(str).isin(['None', '', 'nan'])] | |
| resolved_count = len(resolved_df) | |
| unresolved_count = total_count - resolved_count | |
| expander_title = f"{category} (ทั้งหมด: {total_count} | แก้ไขแล้ว: {resolved_count} | รอแก้ไข: {unresolved_count})" | |
| with st.expander(expander_title): | |
| tab_resolved, tab_unresolved = st.tabs( | |
| [f"รายการที่แก้ไขแล้ว ({resolved_count})", f"รายการที่รอการแก้ไข ({unresolved_count})"]) | |
| with tab_resolved: | |
| if resolved_count > 0: | |
| st.dataframe( | |
| resolved_df[['Occurrence Date', 'Incident', 'Impact', 'Resulting Actions']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.info("ไม่มีรายการที่แก้ไขแล้วในหมวดนี้") | |
| with tab_unresolved: | |
| if unresolved_count > 0: | |
| st.dataframe(category_df[category_df['Resulting Actions'].astype(str).isin( | |
| ['None', '', 'nan'])][ | |
| ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.success("อุบัติการณ์ทั้งหมดในหมวดนี้ได้รับการแก้ไขแล้ว") | |
| st.markdown("---") | |
| st.markdown("#### กลุ่มอุบัติการณ์ทั่วไป (รหัสขึ้นต้นด้วย G)") | |
| # ✅ แก้ไข: ใช้ df_filtered | |
| df_general = df_filtered[df_filtered['รหัส'].str.startswith('G', na=False)].copy() | |
| if df_general.empty: | |
| st.info("ไม่พบข้อมูลอุบัติการณ์กลุ่ม General") | |
| else: | |
| general_categories = sorted([cat for cat in df_general['หมวด'].unique() if cat and pd.notna(cat)]) | |
| for category in general_categories: | |
| category_df = df_general[df_general['หมวด'] == category] | |
| total_count = len(category_df) | |
| resolved_df = category_df[ | |
| ~category_df['Resulting Actions'].astype(str).isin(['None', '', 'nan'])] | |
| resolved_count = len(resolved_df) | |
| unresolved_count = total_count - resolved_count | |
| expander_title = f"{category} (ทั้งหมด: {total_count} | แก้ไขแล้ว: {resolved_count} | รอแก้ไข: {unresolved_count})" | |
| with st.expander(expander_title): | |
| tab_resolved, tab_unresolved = st.tabs( | |
| [f"รายการที่แก้ไขแล้ว ({resolved_count})", f"รายการที่รอการแก้ไข ({unresolved_count})"]) | |
| with tab_resolved: | |
| if resolved_count > 0: | |
| st.dataframe( | |
| resolved_df[['Occurrence Date', 'Incident', 'Impact', 'Resulting Actions']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.info("ไม่มีรายการที่แก้ไขแล้วในหมวดนี้") | |
| with tab_unresolved: | |
| if unresolved_count > 0: | |
| st.dataframe(category_df[category_df['Resulting Actions'].astype(str).isin( | |
| ['None', '', 'nan'])][ | |
| ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด_Anonymized']], | |
| hide_index=True, use_container_width=True, column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| else: | |
| st.success("อุบัติการณ์ทั้งหมดในหมวดนี้ได้รับการแก้ไขแล้ว") | |
| # --- Tab ที่ 3: สรุปเปอร์เซ็นต์การแก้ไข --- | |
| with tab_summary: | |
| st.subheader("สรุปเปอร์เซ็นต์การแก้ไขอุบัติการณ์รุนแรง (E-I & 3-5)") | |
| # ✅ หมายเหตุ: ค่าเหล่านี้ถูกคำนวณจาก df_filtered ที่ด้านบนของฟังก์ชัน display_executive_dashboard() แล้ว | |
| total_severe_incidents = metrics_data.get("total_severe_incidents", 0) | |
| total_severe_unresolved_incidents_val = metrics_data.get("total_severe_unresolved_incidents_val", 0) | |
| # คำนวณเฉพาะส่วนของ PSG9 จาก df_filtered | |
| severe_df = df_filtered[df_filtered['Impact Level'].isin(['3', '4', '5'])] | |
| total_severe_psg9_incidents = severe_df[severe_df['รหัส'].isin(psg9_r_codes_for_counting)].shape[0] | |
| total_severe_unresolved_psg9_incidents_val = metrics_data.get( | |
| "total_severe_unresolved_psg9_incidents_val", 0) | |
| val_row3_total_pct = ( | |
| total_severe_unresolved_incidents_val / total_severe_incidents * 100) if total_severe_incidents > 0 else 0 | |
| val_row3_psg9_pct = ( | |
| total_severe_unresolved_psg9_incidents_val / total_severe_psg9_incidents * 100) if total_severe_psg9_incidents > 0 else 0 | |
| summary_action_data = [ | |
| {"รายละเอียด": "1. จำนวนอุบัติการณ์รุนแรง E-I & 3-5", "ทั้งหมด": f"{total_severe_incidents:,}", | |
| "เฉพาะ PSG9": f"{total_severe_psg9_incidents:,}"}, | |
| {"รายละเอียด": "2. อุบัติการณ์ E-I & 3-5 ที่ยังไม่ได้รับการแก้ไข", | |
| "ทั้งหมด": f"{total_severe_unresolved_incidents_val:,}", | |
| "เฉพาะ PSG9": f"{total_severe_unresolved_psg9_incidents_val:,}"}, | |
| {"รายละเอียด": "3. % อุบัติการณ์ E-I & 3-5 ที่ยังไม่ได้รับการแก้ไข", | |
| "ทั้งหมด": f"{val_row3_total_pct:.2f}%", "เฉพาะ PSG9": f"{val_row3_psg9_pct:.2f}%"} | |
| ] | |
| st.dataframe(pd.DataFrame(summary_action_data).set_index('รายละเอียด'), use_container_width=True) | |
| # --- Tab ที่ 4: รายการอุบัติการณ์ที่รอการแก้ไข --- | |
| with tab_waitlist: | |
| st.subheader("รายการอุบัติการณ์ที่รอการแก้ไข (ตามความรุนแรง)") | |
| unresolved_df = df_filtered[df_filtered['Resulting Actions'].astype(str).isin(['None', '', 'nan'])].copy() | |
| if unresolved_df.empty: | |
| st.success("🎉 ไม่พบรายการที่รอการแก้ไขในช่วงเวลานี้ ยอดเยี่ยมมากครับ!") | |
| else: | |
| st.metric("จำนวนรายการที่รอการแก้ไขทั้งหมด", f"{len(unresolved_df):,} รายการ") | |
| severity_order = ['Critical', 'High', 'Medium', 'Low', 'Undefined'] | |
| for severity in severity_order: | |
| severity_df = unresolved_df[unresolved_df['Category Color'] == severity] | |
| if not severity_df.empty: | |
| with st.expander(f"ระดับความรุนแรง: {severity} ({len(severity_df)} รายการ)"): | |
| display_cols = ['Occurrence Date', 'Incident', 'Impact', | |
| 'รายละเอียดการเกิด_Anonymized'] | |
| st.dataframe(severity_df[display_cols], use_container_width=True, hide_index=True, | |
| column_config={"Occurrence Date": st.column_config.DatetimeColumn("วันที่เกิด", | |
| format="DD/MM/YYYY")}) | |
| elif selected_analysis == "Persistence Risk Index": | |
| st.markdown("<h4 style='color: #001f3f;'>ดัชนีความเสี่ยงเรื้อรัง (Persistence Risk Index)</h4>", unsafe_allow_html=True) | |
| st.info( | |
| "ตารางนี้ให้คะแนนอุบัติการณ์ที่เกิดขึ้นซ้ำและมีความเสี่ยงโดยเฉลี่ยสูง ซึ่งเป็นปัญหาเรื้อรังที่ควรได้รับการทบทวนเชิงระบบ") | |
| # ✅ แก้ไข: เรียกใช้ฟังก์ชันด้วย df_filtered และ total_month ที่ผ่านการกรองตามช่วงเวลาแล้ว | |
| persistence_df = calculate_persistence_risk_score(df_filtered, total_month) | |
| if not persistence_df.empty: | |
| display_df_persistence = persistence_df.rename(columns={ | |
| 'Persistence_Risk_Score': 'ดัชนีความเรื้อรัง', | |
| 'Average_Ordinal_Risk_Score': 'คะแนนเสี่ยงเฉลี่ย', | |
| 'Incident_Rate_Per_Month': 'อัตราการเกิด (ครั้ง/เดือน)', | |
| 'Total_Occurrences': 'จำนวนครั้งทั้งหมด' | |
| }) | |
| st.dataframe( | |
| display_df_persistence[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง', 'คะแนนเสี่ยงเฉลี่ย', 'ดัชนีความเรื้อรัง', | |
| 'อัตราการเกิด (ครั้ง/เดือน)', 'จำนวนครั้งทั้งหมด']], | |
| use_container_width=True, | |
| hide_index=True, | |
| column_config={ | |
| "คะแนนเสี่ยงเฉลี่ย": st.column_config.NumberColumn(format="%.2f"), | |
| "อัตราการเกิด (ครั้ง/เดือน)": st.column_config.NumberColumn(format="%.2f"), | |
| "ดัชนีความเรื้อรัง": st.column_config.ProgressColumn( | |
| "ดัชนีความเสี่ยงเรื้อรัง", | |
| help="คำนวณจากความถี่และความรุนแรงเฉลี่ย ยิ่งสูงยิ่งเป็นปัญหาเรื้อรัง", | |
| min_value=0, | |
| max_value=2, # ค่าสูงสุดทางทฤษฎีคือ 2 (Frequency Score = 1, Severity Score = 1) | |
| format="%.2f" | |
| ) | |
| } | |
| ) | |
| st.markdown("---") | |
| st.markdown("##### กราฟวิเคราะห์ลักษณะของปัญหาเรื้อรัง") | |
| fig = px.scatter( | |
| persistence_df, | |
| x="Average_Ordinal_Risk_Score", | |
| y="Incident_Rate_Per_Month", | |
| size="Total_Occurrences", | |
| color="Persistence_Risk_Score", | |
| hover_name="ชื่ออุบัติการณ์ความเสี่ยง", | |
| color_continuous_scale=px.colors.sequential.Reds, | |
| size_max=60, | |
| labels={ | |
| "Average_Ordinal_Risk_Score": "คะแนนความเสี่ยงเฉลี่ย (ยิ่งขวายิ่งรุนแรง)", | |
| "Incident_Rate_Per_Month": "อัตราการเกิดต่อเดือน (ยิ่งสูงยิ่งบ่อย)", | |
| "Persistence_Risk_Score": "ดัชนีความเรื้อรัง", | |
| "Total_Occurrences": "จำนวนครั้งทั้งหมด" | |
| }, | |
| title="การกระจายตัวของปัญหาเรื้อรัง: ความถี่ vs ความรุนแรง" | |
| ) | |
| fig.update_layout(xaxis_title="ความรุนแรงเฉลี่ย", yaxis_title="ความถี่เฉลี่ย") | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.warning("ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ความเสี่ยงเรื้อรังในช่วงเวลานี้") | |
| elif selected_analysis == "Early Warning: อุบัติการณ์ที่มีแนวโน้มสูงขึ้น": | |
| st.markdown("<h4 style='color:#001f3f;'>Early Warning: อุบัติการณ์ที่มีแนวโน้มสูงขึ้น</h4>", unsafe_allow_html=True) | |
| # ตรวจสอบว่ามีฟังก์ชันที่จำเป็นอยู่หรือไม่ | |
| if 'prioritize_incidents_nb_logit_v2' not in globals(): | |
| st.error("ไม่พบฟังก์ชัน `prioritize_incidents_nb_logit_v2` ในโค้ด") | |
| else: | |
| # ส่วนควบคุมการวิเคราะห์ | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| horizon = st.slider("พยากรณ์ล่วงหน้า (เดือน):", 1, 12, 3, 1, key="ew_horizon") | |
| with c2: | |
| min_months = st.slider("ขั้นต่ำเดือนที่ใช้วิเคราะห์:", 3, 12, 4, 1, key="ew_min_months") | |
| with c3: | |
| min_total = st.slider("ขั้นต่ำจำนวนครั้งสะสม/รหัส:", 3, 200, 5, 1, key="ew_min_total") | |
| st.markdown("**น้ำหนักคะแนน (รวมกัน = 1 อัตโนมัติ)**") | |
| c4, c5, c6 = st.columns(3) | |
| with c4: | |
| w1 = st.slider("คาดการณ์เหตุรุนแรง (ฐาน 0.7)", 0.0, 1.0, 0.7, 0.05, key="ew_w1") | |
| with c5: | |
| w2 = st.slider("การเติบโตความถี่ (ฐาน 0.2)", 0.0, 1.0, 0.2, 0.05, key="ew_w2") | |
| with c6: | |
| w3 = st.slider("การเติบโตความรุนแรง (ฐาน 0.1)", 0.0, 1.0, 0.1, 0.05, key="ew_w3") | |
| # Normalize น้ำหนักให้รวมเท่ากับ 1 | |
| _sumw = max(w1 + w2 + w3, 1e-9) | |
| w1n, w2n, w3n = w1 / _sumw, w2 / _sumw, w3 / _sumw | |
| try: | |
| # ✅ แก้ไข: เรียกใช้ฟังก์ชันด้วย df_filtered | |
| res = prioritize_incidents_nb_logit_v2( | |
| df_filtered, | |
| horizon=horizon, | |
| min_months=min_months, | |
| min_total=min_total, | |
| w_expected_severe=w1n, | |
| w_freq_growth=w2n, | |
| w_sev_growth=w3n | |
| ) | |
| except Exception as e: | |
| st.error(f"เกิดข้อผิดพลาดระหว่างคำนวณลำดับความสำคัญ: {e}") | |
| res = pd.DataFrame() # สร้าง DataFrame ว่างเพื่อไม่ให้โค้ดส่วนล่าง error | |
| if res.empty: | |
| st.info("ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ Early Warning ในช่วงเวลานี้") | |
| else: | |
| topn = st.slider("แสดง Top-N:", 5, 50, 10, 5, key="ew_topn") | |
| only_sig = st.checkbox("แสดงเฉพาะรหัสที่มีนัยสำคัญ (ถี่↑ และ/หรือ รุนแรง↑)", value=False, key="ew_only_sig") | |
| show = res.copy() | |
| if only_sig: | |
| show = show[ | |
| (show['Freq_p_value'].notna() & (show['Freq_p_value'] < 0.05)) | | |
| (show['Severity_p_value'].notna() & (show['Severity_p_value'] < 0.05)) | |
| ] | |
| st.dataframe( | |
| show.head(topn), | |
| use_container_width=True, hide_index=True, | |
| column_config={ | |
| 'รหัส': st.column_config.Column("รหัส"), | |
| 'ชื่ออุบัติการณ์ความเสี่ยง': st.column_config.Column("ชื่ออุบัติการณ์", width="large"), | |
| 'Months_Observed': st.column_config.NumberColumn("เดือนที่วิเคราะห์", format="%d"), | |
| 'Total_Occurrences': st.column_config.NumberColumn("ครั้งสะสม", format="%d"), | |
| 'Expected_Severe_nextH': st.column_config.NumberColumn(f"คาดการณ์ 'รุนแรง' (H={horizon})", | |
| format="%.1f"), | |
| 'Freq_Factor_per_month': st.column_config.NumberColumn("เท่าความถี่/เดือน", format="%.2f"), | |
| 'Freq_p_value': st.column_config.NumberColumn("p(ถี่↑)", format="%.3f"), | |
| 'Severe_OR_per_month': st.column_config.NumberColumn("Odds รุนแรง/เดือน", format="%.2f"), | |
| 'Severity_p_value': st.column_config.NumberColumn("p(รุนแรง↑)", format="%.3f"), | |
| 'Priority_Score': st.column_config.ProgressColumn("Priority", min_value=0, | |
| max_value=show['Priority_Score'].max(), | |
| format="%.3f"), | |
| } | |
| ) | |
| with st.expander("เกณฑ์ที่ใช้จัดอันดับ (อธิบายย่อ)"): | |
| st.markdown(f""" | |
| - **คาดการณ์ 'รุนแรง' (H={horizon})**: คาดการณ์จำนวนเหตุการณ์รุนแรง (ระดับ 3–5) ที่จะเกิดขึ้นในอีก {horizon} เดือนข้างหน้า | |
| - **Priority Score**: คะแนนรวมที่ถ่วงน้ำหนักระหว่าง 'การคาดการณ์เหตุรุนแรง', 'แนวโน้มความถี่ที่เพิ่มขึ้น', และ 'แนวโน้มสัดส่วนความรุนแรงที่เพิ่มขึ้น' | |
| - **p(ถี่↑)** และ **p(รุนแรง↑)**: ค่า p-value ยิ่งน้อย (เช่น < 0.05) ยิ่งหมายความว่าแนวโน้มที่เพิ่มขึ้นนั้นมีนัยสำคัญทางสถิติ | |
| """) | |
| elif selected_analysis == "บทสรุปสำหรับผู้บริหาร": | |
| st.markdown("<h4 style='color: #001f3f;'>บทสรุปสำหรับผู้บริหาร</h4>", unsafe_allow_html=True) | |
| st.markdown(f"**เรื่อง:** รายงานสรุปอุบัติการณ์โรงพยาบาล") | |
| st.markdown(f"**ช่วงข้อมูลที่วิเคราะห์:** {min_date_str} ถึง {max_date_str} (รวม {total_month} เดือน)") | |
| st.markdown(f"**จำนวนอุบัติการณ์ที่พบทั้งหมด:** {metrics_data.get('total_processed_incidents', 0):,} รายการ") | |
| st.markdown("---") | |
| # --- 1. แดชบอร์ดสรุปภาพรวม --- | |
| st.subheader("1. แดชบอร์ดสรุปภาพรวม") | |
| col1_m, col2_m, col3_m, col4_m, col5_m = st.columns(5) | |
| with col1_m: | |
| st.metric("อุบัติการณ์ทั้งหมด", f"{metrics_data.get('total_processed_incidents', 0):,}") | |
| with col2_m: | |
| st.metric("Sentinel Events", f"{metrics_data.get('total_sentinel_incidents_for_metric1', 0):,}") | |
| with col3_m: | |
| st.metric("มาตรฐานสำคัญฯ 9 ข้อ", f"{metrics_data.get('total_psg9_incidents_for_metric1', 0):,}") | |
| with col4_m: | |
| st.metric("ความรุนแรงสูง (E-I & 3-5)", f"{metrics_data.get('total_severe_incidents', 0):,}") | |
| with col5_m: | |
| val_unresolved = metrics_data.get('total_severe_unresolved_incidents_val', 'N/A') | |
| st.metric("รุนแรงสูง & ยังไม่แก้ไข", | |
| f"{val_unresolved:,}" if isinstance(val_unresolved, int) else val_unresolved) | |
| st.markdown("---") | |
| # --- 2. Risk Matrix และ Top 10 อุบัติการณ์ --- | |
| st.subheader("2. Risk Matrix และ Top 10 อุบัติการณ์") | |
| col_matrix, col_top10 = st.columns(2) | |
| with col_matrix: | |
| st.markdown("##### Risk Matrix") | |
| impact_level_keys = ['5', '4', '3', '2', '1'] | |
| freq_level_keys = ['1', '2', '3', '4', '5'] | |
| matrix_df = df_filtered[ | |
| df_filtered['Impact Level'].isin(impact_level_keys) & df_filtered['Frequency Level'].isin( | |
| freq_level_keys)] | |
| if not matrix_df.empty: | |
| matrix_data = pd.crosstab(matrix_df['Impact Level'], matrix_df['Frequency Level']) | |
| matrix_data = matrix_data.reindex(index=impact_level_keys, columns=freq_level_keys, fill_value=0) | |
| impact_labels = {'5': "5 (Extreme)", '4': "4 (Major)", '3': "3 (Moderate)", '2': "2 (Minor)", | |
| '1': "1 (Insignificant)"} | |
| freq_labels = {'1': "F1", '2': "F2", '3': "F3", '4': "F4", '5': "F5"} | |
| st.table(matrix_data.rename(index=impact_labels, columns=freq_labels)) | |
| with col_top10: | |
| st.markdown("##### Top 10 อุบัติการณ์ (ตามความถี่)") | |
| if not df_freq.empty: | |
| df_freq_top10 = df_freq.nlargest(10, 'count').copy() | |
| display_top10 = pd.merge(df_freq_top10, | |
| df_filtered[['Incident', 'ชื่ออุบัติการณ์ความเสี่ยง']].drop_duplicates(), | |
| on='Incident', how='left') | |
| st.dataframe(display_top10[['Incident', 'count']], hide_index=True, | |
| use_container_width=True, | |
| column_config={"Incident": "รหัส", | |
| "count": "จำนวน"}) | |
| else: | |
| st.info("ไม่มีข้อมูล Top 10") | |
| st.markdown("---") | |
| # --- 3. รายการ Sentinel Events --- | |
| st.subheader("3. รายการ Sentinel Events") | |
| if 'Sentinel code for check' in df_filtered.columns: | |
| sentinel_events_df = df_filtered[df_filtered['Sentinel code for check'].isin(sentinel_composite_keys)] | |
| if not sentinel_events_df.empty: | |
| st.dataframe(sentinel_events_df[['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด_Anonymized']], | |
| hide_index=True, use_container_width=True, | |
| column_config={"Occurrence Date": "วันที่เกิด", "Incident": "รหัส", "Impact": "ระดับ", | |
| "รายละเอียดการเกิด_Anonymized": "รายละเอียด"}) | |
| else: | |
| st.info("ไม่พบ Sentinel Events ในช่วงเวลาที่เลือก") | |
| st.markdown("---") | |
| # --- 4. PSG9 Summary --- | |
| st.subheader("4. วิเคราะห์ตามหมวดหมู่ มาตรฐานสำคัญจำเป็นต่อความปลอดภัย 9 ข้อ") | |
| psg9_summary_table = create_psg9_summary_table(df_filtered) | |
| if psg9_summary_table is not None and not psg9_summary_table.empty: | |
| st.table(psg9_summary_table) | |
| else: | |
| st.info("ไม่พบข้อมูลอุบัติการณ์ที่เกี่ยวข้องกับ PSG9 ในช่วงเวลานี้") | |
| st.markdown("---") | |
| # --- 5. รายการอุบัติการณ์รุนแรงที่ยังไม่ถูกแก้ไข --- | |
| st.subheader("5. รายการอุบัติการณ์รุนแรง (E-I & 3-5) ที่ยังไม่ถูกแก้ไข") | |
| if 'Resulting Actions' in df_filtered.columns: | |
| unresolved_severe_df = df_filtered[ | |
| df_filtered['Impact Level'].isin(['3', '4', '5']) & | |
| df_filtered['Resulting Actions'].astype(str).isin(['None', '', 'nan']) | |
| ] | |
| if not unresolved_severe_df.empty: | |
| display_cols_unresolved = ['Occurrence Date', 'Incident', 'Impact', 'รายละเอียดการเกิด_Anonymized'] | |
| st.dataframe( | |
| unresolved_severe_df[display_cols_unresolved], | |
| hide_index=True, | |
| use_container_width=True, | |
| column_config={ | |
| "Occurrence Date": st.column_config.DatetimeColumn( | |
| "วันที่เกิด", | |
| format="DD/MM/YYYY", | |
| ), | |
| "Incident": "รหัส", | |
| "Impact": "ระดับ", | |
| "รายละเอียดการเกิด_Anonymized": "รายละเอียด" | |
| } | |
| ) | |
| else: | |
| st.info("ไม่พบอุบัติการณ์รุนแรงที่ยังไม่ถูกแก้ไขในช่วงเวลานี้") | |
| # --- 6. สรุปอุบัติการณ์ตามเป้าหมาย Safety Goals --- | |
| st.subheader("6. สรุปอุบัติการณ์ตามเป้าหมาย Safety Goals") | |
| goal_definitions = { | |
| "Patient Safety/ Common Clinical Risk": "P:Patient Safety Goals หรือ Common Clinical Risk Incident", | |
| "Specific Clinical Risk": "S:Specific Clinical Risk Incident", | |
| "Personnel Safety": "P:Personnel Safety Goals", "Organization Safety": "O:Organization Safety Goals"} | |
| for display_name, cat_name in goal_definitions.items(): | |
| st.markdown(f"##### {display_name}") | |
| is_org_safety = (display_name == "Organization Safety") | |
| summary_table = create_goal_summary_table(df_filtered, cat_name, | |
| e_up_non_numeric_levels_param=[] if is_org_safety else ['A', 'B', | |
| 'C', 'D'], | |
| e_up_numeric_levels_param=['1', '2'] if is_org_safety else None, | |
| is_org_safety_table=is_org_safety) | |
| if summary_table is not None and not summary_table.empty: | |
| st.table(summary_table) | |
| else: | |
| st.info(f"ไม่มีข้อมูลสำหรับ '{display_name}'") | |
| st.markdown("---") | |
| # --- 7. Early Warning (Top 5) --- | |
| st.subheader("7. Early Warning: อุบัติการณ์ที่มีแนวโน้มสูงขึ้น ใน 3 เดือนข้างหน้า (Top 5)") | |
| st.write( | |
| "แสดง Top 5 อุบัติการณ์ที่ถูกจัดลำดับความสำคัญสูงสุด โดยพิจารณาจากแนวโน้มความถี่, ความรุนแรง, และจำนวนที่คาดการณ์ว่าจะเกิดในอนาคต") | |
| if 'prioritize_incidents_nb_logit_v2' in globals(): | |
| early_warning_df = prioritize_incidents_nb_logit_v2(df_filtered, horizon=3, min_months=4, min_total=5) | |
| if not early_warning_df.empty: | |
| top_ew_incidents = early_warning_df.head(5).copy() | |
| display_ew_df = top_ew_incidents.rename( | |
| columns={'ชื่ออุบัติการณ์ความเสี่ยง': 'ชื่ออุบัติการณ์', 'Priority_Score': 'คะแนนความสำคัญ', | |
| 'Expected_Severe_nextH': 'คาดการณ์เหตุรุนแรง (3 ด.)'}) | |
| st.dataframe( | |
| display_ew_df[['รหัส', 'ชื่ออุบัติการณ์', 'คะแนนความสำคัญ', 'คาดการณ์เหตุรุนแรง (3 ด.)']], | |
| column_config={ | |
| "คะแนนความสำคัญ": st.column_config.ProgressColumn("คะแนนความสำคัญ", format="%.3f", min_value=0, | |
| max_value=float( | |
| display_ew_df['คะแนนความสำคัญ'].max())), | |
| "คาดการณ์เหตุรุนแรง (3 ด.)": st.column_config.NumberColumn(format="%.2f") | |
| }, | |
| use_container_width=True, hide_index=True | |
| ) | |
| else: | |
| st.info("ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ Early Warning") | |
| else: | |
| st.warning("ไม่พบฟังก์ชันสำหรับวิเคราะห์ Early Warning") | |
| st.markdown("---") | |
| # --- 8. สรุปอุบัติการณ์ที่เป็นปัญหาเรื้อรัง (Top 5) --- | |
| st.subheader("8. สรุปอุบัติการณ์ที่เป็นปัญหาเรื้อรัง (Persistence Risk - Top 5)") | |
| st.write("แสดง Top 5 อุบัติการณ์ที่เกิดขึ้นบ่อยและมีความรุนแรงเฉลี่ยสูง ซึ่งควรทบทวนเชิงระบบ") | |
| persistence_df_exec = calculate_persistence_risk_score(df_filtered, total_month) | |
| if not persistence_df_exec.empty: | |
| top_persistence_incidents = persistence_df_exec.head(5) | |
| display_df_persistence = top_persistence_incidents.rename( | |
| columns={'Persistence_Risk_Score': 'ดัชนีความเรื้อรัง', | |
| 'Average_Ordinal_Risk_Score': 'คะแนนเสี่ยงเฉลี่ย'}) | |
| st.dataframe( | |
| display_df_persistence[['รหัส', 'ชื่ออุบัติการณ์ความเสี่ยง', 'คะแนนเสี่ยงเฉลี่ย', 'ดัชนีความเรื้อรัง']], | |
| column_config={ | |
| "คะแนนเสี่ยงเฉลี่ย": st.column_config.NumberColumn(format="%.2f"), | |
| "ดัชนีความเรื้อรัง": st.column_config.ProgressColumn("ดัชนีความเรื้อรัง", min_value=0, max_value=2, | |
| format="%.2f") | |
| }, | |
| use_container_width=True | |
| ) | |
| else: | |
| st.info("ไม่มีข้อมูลเพียงพอสำหรับวิเคราะห์ความเสี่ยงเรื้อรัง") | |
| st.markdown("---") | |
| st.subheader("ดาวน์โหลดรายงานสรุปเป็นไฟล์ PDF") | |
| if st.button("สร้างไฟล์ PDF สำหรับดาวน์โหลด"): | |
| with st.spinner("กำลังสร้าง PDF... (อาจใช้เวลาสักครู่)"): | |
| # ✅ เรียกใช้ฟังก์ชันเวอร์ชันใหม่ โดยส่งข้อมูลที่จำเป็นทั้งหมดเข้าไป | |
| pdf_file = generate_executive_summary_pdf( | |
| df_filtered=df_filtered, | |
| metrics_data=metrics_data, | |
| total_month=total_month, | |
| df_freq=df_freq, | |
| min_date_str=min_date_str, | |
| max_date_str=max_date_str | |
| ) | |
| st.download_button( | |
| label="📥 ดาวน์โหลด PDF ที่สร้างเสร็จแล้ว", | |
| data=pdf_file, | |
| file_name=f"Executive_Summary_{date.today().strftime('%Y-%m-%d')}.pdf", | |
| mime="application/pdf" | |
| ) | |
| elif selected_analysis == "RCA Helpdesk (AI Assistant)": | |
| st.markdown("<h4 style='color: #001f3f;'>AI Assistant: ที่ปรึกษาเคสอุบัติการณ์</h4>", unsafe_allow_html=True) | |
| # --- การตั้งค่า AI (ยังคงเดิม) --- | |
| AI_IS_CONFIGURED = False | |
| if genai: | |
| try: | |
| genai.configure(api_key=st.secrets["GOOGLE_API_KEY"]) | |
| AI_IS_CONFIGURED = True | |
| except Exception as e: | |
| st.error(f"⚠️ ไม่สามารถตั้งค่า AI Assistant ได้: {e}") | |
| else: | |
| st.error("ไม่ได้ติดตั้งไลบรารี google-generativeai") | |
| if not AI_IS_CONFIGURED: | |
| st.stop() | |
| # --- หน้าปรึกษาเคสใหม่ (เวอร์ชันไม่มีแท็บ) --- | |
| st.info("อธิบายรายละเอียดของอุบัติการณ์ที่เกิดขึ้น เพื่อให้ AI ช่วยให้คำปรึกษา") | |
| incident_description = st.text_area( | |
| "กรุณาอธิบายรายละเอียดอุบัติการณ์ที่นี่:", | |
| height=250, | |
| placeholder="เช่น ผู้ป่วยหญิงอายุ 65 ปี เป็นโรคเบาหวาน ได้รับยา losartan แต่เกิดผื่นขึ้นทั่วตัว แพทย์ประเมินว่าเป็นอาการแพ้ยา..." | |
| ) | |
| if st.button("ขอคำปรึกษาจาก AI", type="primary", use_container_width=True): | |
| if not incident_description.strip(): | |
| st.warning("กรุณาป้อนรายละเอียดอุบัติการณ์ก่อนครับ") | |
| else: | |
| with st.spinner("AI กำลังวิเคราะห์และให้คำปรึกษา..."): | |
| # เรียกใช้ฟังก์ชันที่ปรึกษา | |
| consultation = get_consultation_response(incident_description) | |
| st.markdown("---") | |
| st.markdown("### ผลการปรึกษาจาก AI:") | |
| st.markdown(consultation) | |
| elif selected_analysis == "จัดการข้อมูล (Admin)": | |
| # เมื่อผู้ใช้เลือกเมนูนี้ ให้เรียกฟังก์ชันหน้า Admin ขึ้นมาแสดงผล | |
| display_admin_page() | |
| def main(): | |
| page = st.query_params.get("page", "executive") | |
| if page == "admin": | |
| display_admin_page() | |
| else: | |
| display_executive_dashboard() | |
| if __name__ == "__main__": | |
| main() |