import os import re import sqlite3 from io import BytesIO from datetime import datetime, date import pandas as pd import streamlit as st from openpyxl import load_workbook from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.worksheet.datavalidation import DataValidation CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) if os.path.exists("/app/data/clients.db"): DB_PATH = "/app/data/clients.db" else: DB_DIR = os.path.join(CURRENT_DIR, "data") DB_PATH = os.path.join(DB_DIR, "clients.db") os.makedirs(DB_DIR, exist_ok=True) st.set_page_config(page_title="Client Center", page_icon="πŸ‘₯", layout="wide") # ========================================================= # BeeCare Client Center Final # - 직접 등둝 / μ—‘μ…€ μ—…λ‘œλ“œ / 검색 / μˆ˜μ • / μ‚­μ œ # - μΈμ •λ²ˆν˜Έ 숫자 μž…λ ₯ μ‹œ L μžλ™ 생성 # - 인정기간 μ‹œμž‘μΌ/μ’…λ£ŒμΌ 뢄리 μ €μž₯ # - κΈ‰μ—¬μ œκ³΅κ³„νš μž‘μ„±κΈ°ν•œ μ•Œλ¦Ό 기초 데이터 μ €μž₯ # - λ‹€λ₯Έ 생성기와 같은 data/clients.db μ‚¬μš© # ========================================================= VALID_GENDERS = ["μ—¬", "남", "기타"] VALID_GRADES = ["1λ“±κΈ‰", "2λ“±κΈ‰", "3λ“±κΈ‰", "4λ“±κΈ‰", "5λ“±κΈ‰", "인지지원등급", "λ“±κΈ‰μ™Έ", "λ―Έμž…λ ₯"] VALID_INST_TYPES = ["λ°©λ¬Έμš”μ–‘", "λ°©λ¬Έλͺ©μš•", "λ°©λ¬Έκ°„ν˜Έ", "μ£Όκ°„λ³΄ν˜Έ", "μš”μ–‘μ›", "κ³΅λ™μƒν™œκ°€μ •"] VALID_STATUS = ["μ΄μš©μ€‘", "μž…μ†Œμ€‘", "ν‡΄μ†Œ", "쀑지", "사망"] EXCEL_COLUMNS = [ "NO.", "μˆ˜κΈ‰μžμ΄λ¦„", "성별", "생년월일", "μ‹€μ§ˆμƒμΌλ‚ μ§œ", "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "μΈμ •λ²ˆν˜Έ", "μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "μž…μ†ŒμΌ", "μ΄μš©μƒνƒœ", "μ£Όμš”μ§ˆν™˜", "μΈμ§€μƒνƒœ", "μ΄λ™μƒνƒœ", "μ‹μ‚¬μƒνƒœ", "λ°°λ³€/λ°°λ‡¨μƒνƒœ", "λ³΄ν˜Έμžμ΄λ¦„", "λ³΄ν˜Έμžμ—°λ½μ²˜", "νŠΉμ΄μ‚¬ν•­", "λΉ„κ³ ", "κΈ°κ΄€μœ ν˜•", "κΈ°κ΄€λͺ…" ] REQUIRED_EXCEL_COLUMNS = ["μˆ˜κΈ‰μžμ΄λ¦„", "성별", "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "κΈ°κ΄€μœ ν˜•", "κΈ°κ΄€λͺ…"] DATE_COLUMNS = ["μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "μž…μ†ŒμΌ"] EXCEL_TO_DB = { "μˆ˜κΈ‰μžμ΄λ¦„": "name", "성별": "gender", "생년월일": "birth_date", "μ‹€μ§ˆμƒμΌλ‚ μ§œ": "real_birth_date", "μž₯κΈ°μš”μ–‘λ“±κΈ‰": "care_grade", "μΈμ •λ²ˆν˜Έ": "recognition_number", "μΈμ •κΈ°κ°„μ‹œμž‘μΌ": "recognition_start_date", "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ": "recognition_end_date", "κΈ°κ΄€μœ ν˜•": "institution_type", "κΈ°κ΄€λͺ…": "institution_name", "μž…μ†ŒμΌ": "admission_date", "μ΄μš©μƒνƒœ": "status", "μ£Όμš”μ§ˆν™˜": "main_condition", "μΈμ§€μƒνƒœ": "cognitive_status", "μ΄λ™μƒνƒœ": "mobility_status", "μ‹μ‚¬μƒνƒœ": "diet_status", "λ°°λ³€/λ°°λ‡¨μƒνƒœ": "toileting_status", "λ³΄ν˜Έμžμ΄λ¦„": "guardian_name", "λ³΄ν˜Έμžμ—°λ½μ²˜": "guardian_phone", "νŠΉμ΄μ‚¬ν•­": "notes", "λΉ„κ³ ": "remarks", } COLUMN_ALIASES = { "NO.": "NO.", "μˆ˜κΈ‰μžμ΄λ¦„": "μˆ˜κΈ‰μžμ΄λ¦„", "성별": "성별", "생년월일": "생년월일", "μ‹€μ§ˆμƒμΌλ‚ μ§œ": "μ‹€μ§ˆμƒμΌλ‚ μ§œ", "μž₯κΈ°μš”μ–‘λ“±κΈ‰": "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "μΈμ •λ²ˆν˜Έ": "μΈμ •λ²ˆν˜Έ", "μΈμ •κΈ°κ°„μ‹œμž‘μΌ": "μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ": "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "μž…μ†ŒμΌ": "μž…μ†ŒμΌ", "μ΄μš©μƒνƒœ": "μ΄μš©μƒνƒœ", "μ£Όμš”μ§ˆν™˜": "μ£Όμš”μ§ˆν™˜", "μΈμ§€μƒνƒœ": "μΈμ§€μƒνƒœ", "μ΄λ™μƒνƒœ": "μ΄λ™μƒνƒœ", "μ‹μ‚¬μƒνƒœ": "μ‹μ‚¬μƒνƒœ", "λ°°λ³€/λ°°λ‡¨μƒνƒœ": "λ°°λ³€/λ°°λ‡¨μƒνƒœ", "λ³΄ν˜Έμžμ΄λ¦„": "λ³΄ν˜Έμžμ΄λ¦„", "λ³΄ν˜Έμžμ—°λ½μ²˜": "λ³΄ν˜Έμžμ—°λ½μ²˜", "νŠΉμ΄μ‚¬ν•­": "νŠΉμ΄μ‚¬ν•­", "λΉ„κ³ ": "λΉ„κ³ ", "κΈ°κ΄€μœ ν˜•": "κΈ°κ΄€μœ ν˜•", "κΈ°κ΄€λͺ…": "κΈ°κ΄€λͺ…", # aliases "μ„±λͺ…": "μˆ˜κΈ‰μžμ΄λ¦„", "이름": "μˆ˜κΈ‰μžμ΄λ¦„", "μˆ˜κΈ‰μž 이름": "μˆ˜κΈ‰μžμ΄λ¦„", "μˆ˜κΈ‰μžλͺ…": "μˆ˜κΈ‰μžμ΄λ¦„", "μΆœμƒμ—°λ„": "생년월일", "λ“±κΈ‰": "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "μž₯κΈ°μš”μ–‘ λ“±κΈ‰": "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "인정 번호": "μΈμ •λ²ˆν˜Έ", "μΈμ •μœ νš¨κΈ°κ°„ μ‹œμž‘μΌ": "μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "인정기간 μ‹œμž‘μΌ": "μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "μΈμ •μœ νš¨κΈ°κ°„ μ’…λ£ŒμΌ": "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "인정기간 μ’…λ£ŒμΌ": "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "μ΄μš©μ‹œμž‘μΌ": "μž…μ†ŒμΌ", "이용 μ‹œμž‘μΌ": "μž…μ†ŒμΌ", "μž…μ†ŒμΌμž": "μž…μ†ŒμΌ", "μƒνƒœ": "μ΄μš©μƒνƒœ", "이용 μƒνƒœ": "μ΄μš©μƒνƒœ", "μ§ˆν™˜": "μ£Όμš”μ§ˆν™˜", "μ£Όμš” μ§ˆν™˜": "μ£Όμš”μ§ˆν™˜", "인지 μƒνƒœ": "μΈμ§€μƒνƒœ", "이동 μƒνƒœ": "μ΄λ™μƒνƒœ", "식사 μƒνƒœ": "μ‹μ‚¬μƒνƒœ", "λ°°λ³€/배뇨 μƒνƒœ": "λ°°λ³€/λ°°λ‡¨μƒνƒœ", "보호자": "λ³΄ν˜Έμžμ΄λ¦„", "보호자 이름": "λ³΄ν˜Έμžμ΄λ¦„", "보호자 μ„±λͺ…": "λ³΄ν˜Έμžμ΄λ¦„", "μ—°λ½μ²˜": "λ³΄ν˜Έμžμ—°λ½μ²˜", "λ©”λͺ¨": "λΉ„κ³ ", "κΈ°κ΄€ μœ ν˜•": "κΈ°κ΄€μœ ν˜•", "κΈ°κ΄€λͺ…μΉ­": "κΈ°κ΄€λͺ…" } def normalize_real_birth_date(value): """μ‹€μ§ˆμƒμΌλ‚ μ§œλ₯Ό MM-DD ν˜•μ‹μœΌλ‘œ λ³€ν™˜ν•©λ‹ˆλ‹€.""" v = safe(value).replace(" ", "").replace("/", "-").replace(".", "-") if not v: return "" # YYYY-MM-DD 인 경우 if re.match(r"^\d{4}-\d{2}-\d{2}$", v): return v[5:10] # MM-DD 인 경우 if re.match(r"^\d{2}-\d{2}$", v): return v # M-D 인 경우 (자릿수 보정) parts = v.split("-") if len(parts) == 2: try: m = int(parts[0]) d = int(parts[1]) if 1 <= m <= 12 and 1 <= d <= 31: return f"{m:02d}-{d:02d}" except ValueError: pass return v def get_conn(): return sqlite3.connect(DB_PATH, check_same_thread=False) def safe(value, default=""): if value is None: return default try: if pd.isna(value): return default except Exception: pass return str(value).strip() def mask_name(name): name = safe(name) if len(name) >= 3: return name[0] + "β—‹" + name[2:] if len(name) == 2: return name[0] + "β—‹" return name def normalize_recognition_number(value): """숫자만 μž…λ ₯해도 L을 λΆ™μ—¬ μ €μž₯ν•©λ‹ˆλ‹€. λΉˆμΉΈμ€ 빈칸으둜 λ‘‘λ‹ˆλ‹€.""" v = safe(value).replace(" ", "") if not v: return "" v = v.replace("l", "L") if v.startswith("L"): return "L" + re.sub(r"[^0-9]", "", v[1:]) digits = re.sub(r"[^0-9]", "", v) return f"L{digits}" if digits else v def normalize_date_text(value): """λ‚ μ§œλ₯Ό YYYY-MM-DD λ¬Έμžμ—΄λ‘œ μ •λ¦¬ν•©λ‹ˆλ‹€. λ³€ν™˜ λΆˆκ°€ 값은 원문을 μœ μ§€ν•΄ κ²€μ¦μ—μ„œ μ•ˆλ‚΄ν•©λ‹ˆλ‹€.""" v = safe(value) if not v: return "" try: dt = pd.to_datetime(v, errors="raise") return dt.strftime("%Y-%m-%d") except Exception: return v def is_valid_date_text(value): v = safe(value) if not v: return True return bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", v)) def days_until(date_text): v = normalize_date_text(date_text) if not is_valid_date_text(v) or not v: return None try: target = datetime.strptime(v, "%Y-%m-%d").date() return (target - date.today()).days except Exception: return None def plan_alert_text(end_date_text): days = days_until(end_date_text) if days is None: return "인정기간 μ’…λ£ŒμΌ λ―Έμž…λ ₯" if days < 0: return f"인정기간 만료 {abs(days)}일 κ²½κ³Ό" if days <= 30: return f"만료 {days}일 μ „: κΈ‰μ—¬κ³„νš μž¬κ²€ν†  ν•„μš”" if days <= 90: return f"만료 {days}일 μ „: κ°±μ‹ /κ³„νš 확인" return f"만료 {days}일 μ „" def get_this_year_birthday(birth_date_str, real_birth_date_str): now = datetime.now() this_year = now.year md = "" r_val = safe(real_birth_date_str) if r_val: normalized_r = normalize_real_birth_date(r_val) if re.match(r"^\d{2}-\d{2}$", normalized_r): md = normalized_r if not md: b_val = normalize_date_text(birth_date_str) if is_valid_date_text(b_val) and len(b_val) == 10: md = b_val[5:10] if not md: return None try: m, d = map(int, md.split("-")) return date(this_year, m, d) except Exception: return None def display_birthday_alerts(): df = load_clients() if df.empty: return today = date.today() this_month = today.month today_birthdays = [] month_birthdays = [] for _, row in df.iterrows(): b_date = row.get("birth_date") rb_date = row.get("real_birth_date") name_masked = mask_name(row.get("name")) bday = get_this_year_birthday(b_date, rb_date) if bday: if bday.month == today.month and bday.day == today.day: today_birthdays.append((name_masked, rb_date, b_date)) elif bday.month == this_month: month_birthdays.append((name_masked, bday.day, rb_date, b_date)) month_birthdays.sort(key=lambda x: x[1]) if today_birthdays or month_birthdays: st.markdown(f"### πŸŽ‚ {this_month}μ›” 생신 μ–΄λ₯΄μ‹  μ•Œλ¦Ό") if today_birthdays: today_list_str = ", ".join([f"**{name} μ–΄λ₯΄μ‹ **" + (f" (μ‹€μ§ˆμƒμΌ: {rb})" if rb else f" ({bd[5:10]})" if bd else "") for name, rb, bd in today_birthdays]) st.markdown(f"""
πŸŽ‰ 였늘 생신! μ˜€λŠ˜μ€ {today_list_str}의 μƒμ‹ μž…λ‹ˆλ‹€. μΆ•ν•˜λ“œλ¦½λ‹ˆλ‹€! πŸŽ‚
""", unsafe_allow_html=True) if month_birthdays: items_html = [] for name, day, rb, bd in month_birthdays: src_info = f"μ‹€μ§ˆμƒμΌ: {rb}" if rb else f"생년월일: {bd[5:10] if bd else ''}" items_html.append(f"
  • {name} μ–΄λ₯΄μ‹  ({this_month}μ›” {day}일 - {src_info})
  • ") st.markdown(f"""
    🎁 {this_month}μ›” 생신 μ˜ˆμ • μ–΄λ₯΄μ‹  λͺ©λ‘:
    """, unsafe_allow_html=True) def init_db(): conn = get_conn() cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, masked_name TEXT, gender TEXT, birth_year TEXT, care_grade TEXT, institution_type TEXT, institution_name TEXT, admission_date TEXT, main_condition TEXT, cognitive_status TEXT, mobility_status TEXT, diet_status TEXT, toileting_status TEXT, guardian_name TEXT, guardian_phone TEXT, notes TEXT, status TEXT DEFAULT 'μ΄μš©μ€‘', created_at TEXT, updated_at TEXT ) """) new_columns = { "previous_care_grade": "TEXT", "recognition_number": "TEXT", "recognition_start_date": "TEXT", "recognition_end_date": "TEXT", "plan_change_required": "TEXT DEFAULT 'μ•„λ‹ˆμ˜€'", "plan_change_type": "TEXT", "plan_change_reason": "TEXT", "plan_change_date": "TEXT", "plan_rewrite_status": "TEXT DEFAULT 'ν•΄λ‹Ήμ—†μŒ'", "service_end_reason": "TEXT", "service_end_date": "TEXT", "linked_document": "TEXT", "birth_date": "TEXT", "real_birth_date": "TEXT", "remarks": "TEXT", } cur.execute("PRAGMA table_info(clients)") existing_cols = [row[1] for row in cur.fetchall()] for col, col_type in new_columns.items(): if col not in existing_cols: cur.execute(f"ALTER TABLE clients ADD COLUMN {col} {col_type}") cur.execute(""" CREATE TABLE IF NOT EXISTS plan_change_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER, client_masked_name TEXT, institution_type TEXT, change_type TEXT, change_reason TEXT, change_date TEXT, linked_document TEXT, plan_change_required TEXT, plan_rewrite_status TEXT, memo TEXT, created_at TEXT ) """) conn.commit() conn.close() def load_clients(): conn = get_conn() df = pd.read_sql_query("SELECT * FROM clients ORDER BY id DESC", conn) conn.close() return df def add_no_column(df): df = df.copy() if "NO." not in df.columns: df.insert(0, "NO.", range(1, len(df) + 1)) else: df["NO."] = range(1, len(df) + 1) return df def load_change_logs(client_id=None): conn = get_conn() if client_id: df = pd.read_sql_query("SELECT * FROM plan_change_logs WHERE client_id=? ORDER BY id DESC", conn, params=(client_id,)) else: df = pd.read_sql_query("SELECT * FROM plan_change_logs ORDER BY id DESC", conn) conn.close() return df def calculate_plan_change(change_type, change_reason, status): termination_types = ["사망", "전원", "계약해지", "μ„œλΉ„μŠ€ μ’…λ£Œ", "μž₯κΈ°μž…μ›"] required_types = ["μƒνƒœλ³€ν™”", "λ“±κΈ‰λ³€κ²½", "보호자 μš”μ²­", "μ„œλΉ„μŠ€ λ³€κ²½", "사둀관리 κ²°κ³Ό", "μš•κ΅¬μ‚¬μ • κ²°κ³Ό", "결과평가 반영"] if change_type in termination_types or status in ["사망", "ν‡΄μ†Œ", "쀑지"]: return "μ•„λ‹ˆμ˜€", "μ’…κ²°/쀑지 처리", "κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„±λ³΄λ‹€ μ„œλΉ„μŠ€ μ’…κ²° λ˜λŠ” 쀑지 기둝 정리가 μš°μ„ μž…λ‹ˆλ‹€." if change_type in required_types: return "예", "μž¬μž‘μ„± ν•„μš”", "μƒνƒœλ³€ν™” λ˜λŠ” μ„œλΉ„μŠ€ λ³€κ²½ μ‚¬μœ κ°€ ν™•μΈλ˜μ–΄ κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„±μ΄ ν•„μš”ν•©λ‹ˆλ‹€." if any(word in change_reason for word in ["낙상", "μš•μ°½", "μž…μ›", "퇴원", "μ‹μ‚¬λŸ‰", "체쀑", "λ“±κΈ‰", "μ‹œκ°„", "μš”μΌ", "μ„œλΉ„μŠ€", "보호자", "κΈ°μ €κ·€", "인지", "보행", "ν”ΌλΆ€", "ν˜ˆμ••", "ν˜ˆλ‹Ή"]): return "예", "μž¬μž‘μ„± κ²€ν† ", "기둝 λ‚΄μš©μƒ κΈ‰μ—¬μ œκ³΅κ³„νš λ³€κ²½ κ°€λŠ₯성이 μžˆμ–΄ μž¬μž‘μ„± μ—¬λΆ€λ₯Ό κ²€ν† ν•΄μ•Ό ν•©λ‹ˆλ‹€." return "μ•„λ‹ˆμ˜€", "ν˜„μž¬κ³„νš μœ μ§€", "ν˜„μž¬ μž…λ ₯ λ‚΄μš©λ§ŒμœΌλ‘œλŠ” κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„± ν•„μš”μ„±μ΄ λ†’μ§€ μ•ŠμŠ΅λ‹ˆλ‹€." def institution_change_reasons(inst): common = ["μƒνƒœλ³€ν™”", "λ“±κΈ‰λ³€κ²½", "보호자 μš”μ²­", "μš•κ΅¬μ‚¬μ • κ²°κ³Ό", "상담일지 κ²°κ³Ό", "사둀관리 κ²°κ³Ό", "결과평가 반영", "λ³‘μ›μž…μ›", "퇴원 ν›„ 재이용", "전원", "사망", "계약해지", "μ„œλΉ„μŠ€ μ’…λ£Œ"] by_inst = { "λ°©λ¬Έμš”μ–‘": ["μ„œλΉ„μŠ€ μ‹œκ°„ λ³€κ²½", "μ„œλΉ„μŠ€ μš”μΌ λ³€κ²½", "μš”μ–‘λ³΄ν˜Έμ‚¬ λ³€κ²½", "μΈμ§€ν™œλ™ ν•„μš”", "가사지원 λ²”μœ„ μ‘°μ •", "볡약확인 ν•„μš”", "λ‚™μƒμœ„ν—˜ 증가", "동거가쑱 상황 λ³€ν™”"], "λ°©λ¬Έλͺ©μš•": ["λͺ©μš• κ±°λΆ€", "ν”ΌλΆ€μƒνƒœ λ³€ν™”", "μš•μ°½ λ°œμƒ/μœ„ν—˜ 증가", "λͺ©μš• μ „ν›„ κ±΄κ°•μƒνƒœ λ³€ν™”", "이동방법 λ³€κ²½", "λͺ©μš•μž₯μ†Œ λ³€κ²½", "보호자 ν˜‘μ‘° ν•„μš”"], "λ°©λ¬Έκ°„ν˜Έ": ["ν˜ˆμ•• λ³€ν™”", "ν˜ˆλ‹Ή λ³€ν™”", "μƒμ²˜κ΄€λ¦¬ μ‹œμž‘", "νˆ¬μ•½ λ³€κ²½", "κ°„ν˜Έμ²˜μΉ˜ λ³€κ²½", "μ˜λ£ŒκΈ°κ΄€ 연계 ν•„μš”", "건강상담 κ°•ν™”"], "μ£Όκ°„λ³΄ν˜Έ": ["μ†‘μ˜ λ³€κ²½", "ν”„λ‘œκ·Έλž¨ 참여도 λ³€ν™”", "μ‹μ‚¬ν˜•νƒœ λ³€κ²½", "λ°°λ³€μƒνƒœ λ³€ν™”", "μ •μ„œλΆˆμ•ˆ 증가", "μˆ˜κΈ‰μž κ°„ κ°ˆλ“±", "λ‚™μƒμœ„ν—˜ 증가"], "μš”μ–‘μ›": ["μš•μ°½ λ°œμƒ/μœ„ν—˜ 증가", "낙상 λ°œμƒ/μœ„ν—˜ 증가", "경관급식 μ‹œμž‘", "μ—°ν•˜κ³€λž€ λ°œμƒ", "κΈ°μ €κ·€ μΌ€μ–΄ λ³€κ²½", "μˆ˜λ©΄μƒνƒœ λ³€ν™”", "ν”„λ‘œκ·Έλž¨ μ°Έμ—¬ λ³€ν™”", "인지/행동심리 λ³€ν™”"], "κ³΅λ™μƒν™œκ°€μ •": ["μƒν™œμ μ‘ 문제", "μ •μ„œλΆˆμ•ˆ 증가", "μ‚¬νšŒκ΄€κ³„ λ³€ν™”", "행동심리증상 λ³€ν™”", "κ°€μ‘±μ†Œν†΅ λ³€ν™”", "κ°œλ³„μš•κ΅¬ λ³€ν™”", "κ³΅λ™μƒν™œ κ°ˆλ“±"], } return common + by_inst.get(inst, []) def save_change_log(client_id, client_masked_name, institution_type, change_type, change_reason, change_date, linked_document, required, rewrite_status, memo): conn = get_conn() cur = conn.cursor() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur.execute(""" INSERT INTO plan_change_logs ( client_id, client_masked_name, institution_type, change_type, change_reason, change_date, linked_document, plan_change_required, plan_rewrite_status, memo, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (client_id, client_masked_name, institution_type, change_type, change_reason, change_date, linked_document, required, rewrite_status, memo, now)) cur.execute(""" UPDATE clients SET plan_change_required=?, plan_change_type=?, plan_change_reason=?, plan_change_date=?, plan_rewrite_status=?, linked_document=?, updated_at=? WHERE id=? """, (required, change_type, change_reason, change_date, rewrite_status, linked_document, now, client_id)) conn.commit() conn.close() def normalize_excel_columns(df): df = df.copy() cleaned = [] for c in df.columns: name = str(c).replace("\n", " ").strip() # If pandas suffix is present e.g. "μ£Όμš”μ§ˆν™˜.1" if "." in name: parts = name.split(".") if parts[-1].isdigit() and parts[0] == "μ£Όμš”μ§ˆν™˜": name = parts[0] name = COLUMN_ALIASES.get(name, name) cleaned.append(name) df.columns = cleaned # Merge duplicate columns (like μ£Όμš”μ§ˆν™˜) duplicate_cols = [col for col in set(df.columns) if list(df.columns).count(col) > 1] for col in duplicate_cols: cols_to_merge = df.loc[:, df.columns == col] merged_series = cols_to_merge.apply(lambda row: ", ".join([str(val).strip() for val in row if str(val).strip()]), axis=1) df = df.loc[:, df.columns != col] df[col] = merged_series return df def make_client_excel_template(): from openpyxl import Workbook wb = Workbook() ws = wb.active ws.title = "μˆ˜κΈ‰μžλ“±λ‘μ–‘μ‹" headers = [ "NO.", "μˆ˜κΈ‰μžμ΄λ¦„", "성별", "생년월일", "μ‹€μ§ˆμƒμΌλ‚ μ§œ", "μž₯κΈ°μš”μ–‘λ“±κΈ‰", "μΈμ •λ²ˆν˜Έ", "μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "μž…μ†ŒμΌ", "μ΄μš©μƒνƒœ", "μ£Όμš”μ§ˆν™˜", "μΈμ§€μƒνƒœ", "μ΄λ™μƒνƒœ", "μ‹μ‚¬μƒνƒœ", "λ°°λ³€/λ°°λ‡¨μƒνƒœ", "λ³΄ν˜Έμžμ΄λ¦„", "λ³΄ν˜Έμžμ—°λ½μ²˜", "νŠΉμ΄μ‚¬ν•­", "λΉ„κ³ ", "κΈ°κ΄€μœ ν˜•", "κΈ°κ΄€λͺ…" ] sample_data = [ "1", "홍길동", "μ—¬", "1942-09-20", "09-20", "3λ“±κΈ‰", "1234567890", "2025-03-03", "2027-03-02", "2025-03-05", "μ΄μš©μ€‘", "치맀, κ³ ν˜ˆμ••, 당뇨, λ‚™μƒμœ„ν—˜", "단기기얡 μ €ν•˜", "μ›Œμ»€ μ‚¬μš©", "μΌλ°˜μ‹", "ν™”μž₯μ‹€ μœ λ„ ν•„μš”", "ν™λ³΄ν˜Έ", "010-0000-0000", "μ˜€μ „ ν”„λ‘œκ·Έλž¨ μ°Έμ—¬ μ„ ν˜Έ", "특이 μ†Œκ²¬ μ—†μŒ", "μ£Όκ°„λ³΄ν˜Έ", "ν–‰λ³΅μ£Όκ°„λ³΄ν˜Έμ„Όν„°" ] ws.append(headers) ws.append(sample_data) # Guide sheet guide_ws = wb.create_sheet(title="μž‘μ„±μ•ˆλ‚΄") guide_ws.append(["ν•­λͺ©", "μž…λ ₯ μ•ˆλ‚΄"]) guide_items = [ ("NO.", "μ—°λ²ˆ (숫자 μž…λ ₯)"), ("μˆ˜κΈ‰μžμ΄λ¦„", "ν•„μˆ˜. μ‹€μ œ μ΄λ¦„μœΌλ‘œ μž…λ ₯ν•˜λ©΄ ν™”λ©΄μ—λŠ” μžλ™ λ§ˆμŠ€ν‚Ήλ©λ‹ˆλ‹€."), ("성별", "μ—¬/남/기타 쀑 선택"), ("생년월일", "생년월일 μž…λ ₯ (예: 1942-09-20 λ˜λŠ” 1942)"), ("μ‹€μ§ˆμƒμΌλ‚ μ§œ", "음λ ₯ 생일 λ“± μ‹€μ œ 생일 μž…λ ₯ (예: 09-20). μ—†μœΌλ©΄ μƒλ…„μ›”μΌμ˜ μ›”/일둜 κ³„μ‚°λ©λ‹ˆλ‹€."), ("μž₯κΈ°μš”μ–‘λ“±κΈ‰", "1λ“±κΈ‰/2λ“±κΈ‰/3λ“±κΈ‰/4λ“±κΈ‰/5λ“±κΈ‰/인지지원등급/λ“±κΈ‰μ™Έ/λ―Έμž…λ ₯ 쀑 선택"), ("μΈμ •λ²ˆν˜Έ", "숫자만 μž…λ ₯해도 μ €μž₯ μ‹œ L이 μžλ™μœΌλ‘œ λΆ™μŠ΅λ‹ˆλ‹€. 예: 1234567890 β†’ L1234567890"), ("μΈμ •κΈ°κ°„μ‹œμž‘μΌ", "λ°˜λ“œμ‹œ YYYY-MM-DD ν˜•μ‹. 예: 2025-03-03"), ("μΈμ •κΈ°κ°„μ’…λ£ŒμΌ", "λ°˜λ“œμ‹œ YYYY-MM-DD ν˜•μ‹. 예: 2027-03-02"), ("μž…μ†ŒμΌ", "λ°˜λ“œμ‹œ YYYY-MM-DD ν˜•μ‹. 예: 2025-03-05"), ("μ΄μš©μƒνƒœ", "μ΄μš©μ€‘/μž…μ†Œμ€‘/ν‡΄μ†Œ/쀑지/사망 쀑 선택"), ("μ£Όμš”μ§ˆν™˜", "μ£Όμš” μ§ˆν™˜ 및 μƒνƒœ 기술"), ("μΈμ§€μƒνƒœ", "κΈ°μ–΅λ ₯, 지남λ ₯, μ˜μ‚¬μ†Œν†΅ λ“±"), ("μ΄λ™μƒνƒœ", "보행, νœ μ²΄μ–΄, λΆ€μΆ• μ—¬λΆ€ λ“±"), ("μ‹μ‚¬μƒνƒœ", "μΌλ°˜μ‹, 죽식, μ—°ν•˜κ³€λž€, 식사보쑰 λ“±"), ("λ°°λ³€/λ°°λ‡¨μƒνƒœ", "κΈ°μ €κ·€, ν™”μž₯μ‹€ μœ λ„, μ‹€κΈˆ λ“±"), ("λ³΄ν˜Έμžμ΄λ¦„", "보호자 μ„±λͺ…"), ("λ³΄ν˜Έμžμ—°λ½μ²˜", "μ „ν™”λ²ˆν˜ΈλŠ” 문자둜 μž…λ ₯ ꢌμž₯"), ("νŠΉμ΄μ‚¬ν•­", "μΆ”κ°€ νŠΉμ΄μ‚¬ν•­"), ("λΉ„κ³ ", "기타 참고사항"), ("κΈ°κ΄€μœ ν˜•", "λ°©λ¬Έμš”μ–‘/λ°©λ¬Έλͺ©μš•/λ°©λ¬Έκ°„ν˜Έ/μ£Όκ°„λ³΄ν˜Έ/μš”μ–‘μ›/κ³΅λ™μƒν™œκ°€μ • 쀑 선택"), ("κΈ°κ΄€λͺ…", "κΈ°κ΄€λͺ… μž…λ ₯"), ] for item in guide_items: guide_ws.append(item) ws.freeze_panes = "A2" ws.sheet_view.showGridLines = False header_fill = PatternFill("solid", fgColor="D9EAD3") thin = Side(style="thin", color="999999") border = Border(left=thin, right=thin, top=thin, bottom=thin) for cell in ws[1]: cell.font = Font(name="Malgun Gothic", bold=True) cell.fill = header_fill cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) cell.border = border for row in ws.iter_rows(min_row=2, max_row=200, min_col=1, max_col=len(headers)): for cell in row: cell.font = Font(name="Malgun Gothic", size=10) cell.alignment = Alignment(vertical="center", wrap_text=True) cell.border = border widths = [8, 15, 10, 15, 15, 16, 18, 18, 18, 16, 12, 28, 28, 28, 28, 28, 16, 18, 30, 30, 16, 22] for i, width in enumerate(widths, start=1): ws.column_dimensions[ws.cell(1, i).column_letter].width = width def add_dropdown_by_idx(col_idx, options): letter = ws.cell(1, col_idx).column_letter dv = DataValidation(type="list", formula1='"' + ",".join(options) + '"', allow_blank=True) ws.add_data_validation(dv) dv.add(f"{letter}2:{letter}500") add_dropdown_by_idx(headers.index("성별") + 1, VALID_GENDERS) add_dropdown_by_idx(headers.index("μž₯κΈ°μš”μ–‘λ“±κΈ‰") + 1, VALID_GRADES) add_dropdown_by_idx(headers.index("μ΄μš©μƒνƒœ") + 1, VALID_STATUS) add_dropdown_by_idx(headers.index("κΈ°κ΄€μœ ν˜•") + 1, VALID_INST_TYPES) guide_ws.sheet_view.showGridLines = False guide_ws.column_dimensions["A"].width = 22 guide_ws.column_dimensions["B"].width = 90 for row in guide_ws.iter_rows(): for cell in row: cell.font = Font(name="Malgun Gothic", size=10, bold=(cell.row == 1)) cell.alignment = Alignment(vertical="center", wrap_text=True) cell.border = border if cell.row == 1: cell.fill = header_fill out = BytesIO() wb.save(out) out.seek(0) return out def validate_client_excel(df): errors = [] df = normalize_excel_columns(df) missing = [c for c in REQUIRED_EXCEL_COLUMNS if c not in df.columns] if missing: errors.append("ν•„μˆ˜ 컬럼 λˆ„λ½: " + ", ".join(missing)) allowed_cols = set(EXCEL_COLUMNS) unknown = [c for c in df.columns if c not in allowed_cols and c != "NO."] if unknown: errors.append("양식에 μ—†λŠ” 컬럼이 μžˆμŠ΅λ‹ˆλ‹€: " + ", ".join(unknown)) if errors: return df, errors for col in EXCEL_COLUMNS: if col not in df.columns: df[col] = "" df = df[EXCEL_COLUMNS].fillna("") for col in DATE_COLUMNS: df[col] = df[col].apply(normalize_date_text) df["μΈμ •λ²ˆν˜Έ"] = df["μΈμ •λ²ˆν˜Έ"].apply(normalize_recognition_number) df["μ‹€μ§ˆμƒμΌλ‚ μ§œ"] = df["μ‹€μ§ˆμƒμΌλ‚ μ§œ"].apply(normalize_real_birth_date) for idx, row in df.iterrows(): row_no = idx + 2 if not safe(row.get("μˆ˜κΈ‰μžμ΄λ¦„")): errors.append(f"{row_no}ν–‰: μˆ˜κΈ‰μžμ΄λ¦„μ€ ν•„μˆ˜μž…λ‹ˆλ‹€.") if safe(row.get("성별")) and safe(row.get("성별")) not in VALID_GENDERS: errors.append(f"{row_no}ν–‰: 성별은 μ—¬/남/기타 쀑 ν•˜λ‚˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") if safe(row.get("μž₯κΈ°μš”μ–‘λ“±κΈ‰")) and safe(row.get("μž₯κΈ°μš”μ–‘λ“±κΈ‰")) not in VALID_GRADES: errors.append(f"{row_no}ν–‰: μž₯κΈ°μš”μ–‘λ“±κΈ‰ 값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") if safe(row.get("κΈ°κ΄€μœ ν˜•")) and safe(row.get("κΈ°κ΄€μœ ν˜•")) not in VALID_INST_TYPES: errors.append(f"{row_no}ν–‰: κΈ°κ΄€μœ ν˜• 값이 μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.") if safe(row.get("μ΄μš©μƒνƒœ")) and safe(row.get("μ΄μš©μƒνƒœ")) not in VALID_STATUS: errors.append(f"{row_no}ν–‰: μ΄μš©μƒνƒœλŠ” μ΄μš©μ€‘/μž…μ†Œμ€‘/ν‡΄μ†Œ/쀑지/사망 쀑 ν•˜λ‚˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") for col in DATE_COLUMNS: if safe(row.get(col)) and not is_valid_date_text(row.get(col)): errors.append(f"{row_no}ν–‰: {col}은 2026-03-20 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.") bd = safe(row.get("생년월일")) if bd and not (is_valid_date_text(bd) or (len(bd) == 4 and bd.isdigit())): errors.append(f"{row_no}ν–‰: 생년월일은 1942-09-20 ν˜•μ‹ λ˜λŠ” 1942 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.") return df, errors def import_clients_from_excel(df, overwrite=False): conn = get_conn() cur = conn.cursor() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") inserted = updated = skipped = 0 for _, row in df.iterrows(): data = {db_col: safe(row.get(excel_col)) for excel_col, db_col in EXCEL_TO_DB.items()} if not data.get("name"): skipped += 1 continue data["recognition_number"] = normalize_recognition_number(data.get("recognition_number")) for key in ["recognition_start_date", "recognition_end_date", "admission_date"]: data[key] = normalize_date_text(data.get(key)) data["gender"] = data.get("gender") if data.get("gender") in VALID_GENDERS else "μ—¬" data["care_grade"] = data.get("care_grade") if data.get("care_grade") in VALID_GRADES else "λ―Έμž…λ ₯" data["institution_type"] = data.get("institution_type") if data.get("institution_type") in VALID_INST_TYPES else "λ°©λ¬Έμš”μ–‘" data["status"] = data.get("status") if data.get("status") in VALID_STATUS else "μ΄μš©μ€‘" data["masked_name"] = mask_name(data.get("name")) # Extract birth_year from birth_date birth_date_val = data.get("birth_date") birth_year_val = "" if birth_date_val: birth_date_val = normalize_date_text(birth_date_val) data["birth_date"] = birth_date_val if len(birth_date_val) >= 4 and birth_date_val[:4].isdigit(): birth_year_val = birth_date_val[:4] data["birth_year"] = birth_year_val if data.get("real_birth_date"): data["real_birth_date"] = normalize_real_birth_date(data.get("real_birth_date")) cur.execute(""" SELECT id FROM clients WHERE name=? AND birth_year=? AND institution_name=? ORDER BY id DESC LIMIT 1 """, (data.get("name"), data.get("birth_year"), data.get("institution_name"))) existing = cur.fetchone() if existing and overwrite: client_id = existing[0] cur.execute(""" UPDATE clients SET masked_name=?, gender=?, birth_year=?, care_grade=?, previous_care_grade=?, recognition_number=?, recognition_start_date=?, recognition_end_date=?, institution_type=?, institution_name=?, admission_date=?, main_condition=?, cognitive_status=?, mobility_status=?, diet_status=?, toileting_status=?, guardian_name=?, guardian_phone=?, notes=?, status=?, updated_at=?, birth_date=?, real_birth_date=?, remarks=? WHERE id=? """, ( data["masked_name"], data["gender"], data["birth_year"], data["care_grade"], data["care_grade"], data["recognition_number"], data["recognition_start_date"], data["recognition_end_date"], data["institution_type"], data["institution_name"], data["admission_date"], data["main_condition"], data["cognitive_status"], data["mobility_status"], data["diet_status"], data["toileting_status"], data["guardian_name"], data["guardian_phone"], data["notes"], data["status"], now, data["birth_date"], data["real_birth_date"], data["remarks"], client_id, )) updated += 1 elif existing and not overwrite: skipped += 1 else: cur.execute(""" INSERT INTO clients ( name, masked_name, gender, birth_year, care_grade, previous_care_grade, recognition_number, recognition_start_date, recognition_end_date, institution_type, institution_name, admission_date, main_condition, cognitive_status, mobility_status, diet_status, toileting_status, guardian_name, guardian_phone, notes, status, plan_change_required, plan_rewrite_status, created_at, updated_at, birth_date, real_birth_date, remarks ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( data["name"], data["masked_name"], data["gender"], data["birth_year"], data["care_grade"], data["care_grade"], data["recognition_number"], data["recognition_start_date"], data["recognition_end_date"], data["institution_type"], data["institution_name"], data["admission_date"], data["main_condition"], data["cognitive_status"], data["mobility_status"], data["diet_status"], data["toileting_status"], data["guardian_name"], data["guardian_phone"], data["notes"], data["status"], "μ•„λ‹ˆμ˜€", "ν•΄λ‹Ήμ—†μŒ", now, now, data["birth_date"], data["real_birth_date"], data["remarks"] )) inserted += 1 conn.commit() conn.close() return inserted, updated, skipped init_db() st.markdown(""" """, unsafe_allow_html=True) st.markdown("""

    πŸ‘₯ Client Center

    μˆ˜κΈ‰μž 등둝 / μ—‘μ…€ μ—…λ‘œλ“œ / 검색 / μˆ˜μ • / κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„± ν•„μš” μ—¬λΆ€ 쀑앙관리

    """, unsafe_allow_html=True) display_birthday_alerts() st.markdown("""
    λ‚ μ§œ μž…λ ₯ μ•ˆλ‚΄: λ‚ μ§œλŠ” λ°˜λ“œμ‹œ 2026-03-20 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄ μ£Όμ„Έμš”.
    인정기간은 ν•œ 칸에 μ“°μ§€ 말고 인정기간 μ‹œμž‘μΌκ³Ό 인정기간 μ’…λ£ŒμΌμ„ 각각 μž…λ ₯ν•΄ μ£Όμ„Έμš”.
    μΈμ •λ²ˆν˜ΈλŠ” 숫자만 μž…λ ₯해도 μ €μž₯ μ‹œ L이 μžλ™μœΌλ‘œ λΆ™μŠ΅λ‹ˆλ‹€. 예: 1234567890 β†’ L1234567890
    """, unsafe_allow_html=True) menu = st.sidebar.radio("메뉴 선택", ["μˆ˜κΈ‰μž 등둝", "μ—‘μ…€ μ—…λ‘œλ“œ", "μˆ˜κΈ‰μž λͺ©λ‘", "μˆ˜κΈ‰μž 검색", "μˆ˜μ • / μ‚­μ œ", "κΈ‰μ—¬κ³„νš 변경관리", "변경이λ ₯ 보기"]) # ----------------------------- # μˆ˜κΈ‰μž 등둝 # ----------------------------- if menu == "μˆ˜κΈ‰μž 등둝": st.subheader("πŸ“ μˆ˜κΈ‰μž 직접 등둝") with st.form("client_form"): col1, col2, col3 = st.columns(3) with col1: name = st.text_input("μˆ˜κΈ‰μž 이름") gender = st.selectbox("성별", VALID_GENDERS) birth_date_raw = st.text_input("생년월일", placeholder="예: 1942-09-20 λ˜λŠ” 1942") with col2: care_grade = st.selectbox("μž₯κΈ°μš”μ–‘λ“±κΈ‰", VALID_GRADES) recognition_number_raw = st.text_input("μΈμ •λ²ˆν˜Έ", placeholder="숫자만 μž…λ ₯해도 L μžλ™ 생성") institution_type = st.selectbox("κΈ°κ΄€ μœ ν˜•", VALID_INST_TYPES) with col3: institution_name = st.text_input("κΈ°κ΄€λͺ…") admission_date = st.text_input("이용 μ‹œμž‘μΌ / μž…μ†ŒμΌ", placeholder="예: 2026-03-20") status = st.selectbox("μƒνƒœ", VALID_STATUS) col4, col5 = st.columns(2) with col4: recognition_start_date = st.text_input("인정기간 μ‹œμž‘μΌ", placeholder="예: 2025-03-03") with col5: recognition_end_date = st.text_input("인정기간 μ’…λ£ŒμΌ", placeholder="예: 2027-03-02") st.markdown("### 건강 및 μƒν™œ μƒνƒœ") c1, c2 = st.columns(2) with c1: main_condition = st.text_area("μ£Όμš” μ§ˆν™˜ / μƒνƒœ", placeholder="예: 치맀, κ³ ν˜ˆμ••, 당뇨, λ‚™μƒμœ„ν—˜ λ“±") cognitive_status = st.text_area("인지 μƒνƒœ", placeholder="예: 단기기얡 μ €ν•˜, 지남λ ₯ μ €ν•˜ λ“±") mobility_status = st.text_area("이동 μƒνƒœ", placeholder="예: μ›Œμ»€ μ‚¬μš©, λΆ€μΆ• ν•„μš”, νœ μ²΄μ–΄ μ‚¬μš© λ“±") with c2: diet_status = st.text_area("식사 μƒνƒœ", placeholder="예: μΌλ°˜μ‹, 죽식, μ—°ν•˜κ³€λž€, 식사보쑰 ν•„μš” λ“±") toileting_status = st.text_area("λ°°λ³€ / 배뇨 μƒνƒœ", placeholder="예: κΈ°μ €κ·€ 착용, ν™”μž₯μ‹€ μœ λ„ ν•„μš” λ“±") notes = st.text_area("νŠΉμ΄μ‚¬ν•­ / λ©”λͺ¨") st.markdown("### 생일 및 기타 정보") c_b1, c_b2 = st.columns(2) with c_b1: real_birth_date_raw = st.text_input("μ‹€μ§ˆμƒμΌλ‚ μ§œ (μ˜΅μ…˜)", placeholder="예: 09-20 (μƒμ‹ μž”μΉ˜ μ•Œλ¦Όμš©)") with c_b2: remarks = st.text_area("λΉ„κ³ ") st.markdown("### 보호자 정보") c3, c4 = st.columns(2) guardian_name = c3.text_input("보호자 이름") guardian_phone = c4.text_input("보호자 μ—°λ½μ²˜") submitted = st.form_submit_button("λ“±λ‘ν•˜κΈ°") if submitted: errors = [] if not name: errors.append("μˆ˜κΈ‰μž 이름은 ν•„μˆ˜μž…λ‹ˆλ‹€.") birth_date = normalize_date_text(birth_date_raw) if birth_date_raw and not (is_valid_date_text(birth_date) or (len(birth_date_raw) == 4 and birth_date_raw.isdigit())): errors.append("생년월일은 1942-09-20 ν˜•μ‹ λ˜λŠ” 1942 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.") for label, val in [("인정기간 μ‹œμž‘μΌ", recognition_start_date), ("인정기간 μ’…λ£ŒμΌ", recognition_end_date), ("이용 μ‹œμž‘μΌ", admission_date)]: val2 = normalize_date_text(val) if val2 and not is_valid_date_text(val2): errors.append(f"{label}은 2026-03-20 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄ μ£Όμ„Έμš”.") if errors: for err in errors: st.error(err) else: conn = get_conn() cur = conn.cursor() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") recognition_number = normalize_recognition_number(recognition_number_raw) # Extract birth_year birth_year = "" if birth_date and len(birth_date) >= 4 and birth_date[:4].isdigit(): birth_year = birth_date[:4] elif len(birth_date_raw) == 4 and birth_date_raw.isdigit(): birth_year = birth_date_raw real_birth_date = normalize_real_birth_date(real_birth_date_raw) cur.execute(""" INSERT INTO clients ( name, masked_name, gender, birth_year, care_grade, previous_care_grade, recognition_number, recognition_start_date, recognition_end_date, institution_type, institution_name, admission_date, main_condition, cognitive_status, mobility_status, diet_status, toileting_status, guardian_name, guardian_phone, notes, status, plan_change_required, plan_rewrite_status, created_at, updated_at, birth_date, real_birth_date, remarks ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( name, mask_name(name), gender, birth_year, care_grade, care_grade, recognition_number, normalize_date_text(recognition_start_date), normalize_date_text(recognition_end_date), institution_type, institution_name, normalize_date_text(admission_date), main_condition, cognitive_status, mobility_status, diet_status, toileting_status, guardian_name, guardian_phone, notes, status, "μ•„λ‹ˆμ˜€", "ν•΄λ‹Ήμ—†μŒ", now, now, birth_date, real_birth_date, remarks )) conn.commit() conn.close() st.success(f"{mask_name(name)} μˆ˜κΈ‰μžκ°€ λ“±λ‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€. μΈμ •λ²ˆν˜Έ: {recognition_number or 'λ―Έμž…λ ₯'}") # ----------------------------- # μ—‘μ…€ μ—…λ‘œλ“œ # ----------------------------- elif menu == "μ—‘μ…€ μ—…λ‘œλ“œ": st.subheader("πŸ“₯ μˆ˜κΈ‰μž μ—‘μ…€/CSV μ—…λ‘œλ“œ") st.markdown("""
    πŸ“Œ μ—‘μ…€/CSV μ—…λ‘œλ“œ μ•ˆλ‚΄:
    1. 기본은 BeeCare μ „μš© μ—‘μ…€ 양식(.xlsx)을 μ‚¬μš©ν•΄ μ£Όμ„Έμš”. μ—‘μ…€νŒŒμΌμ„ λ‹€μš΄λ°›μ•„ μˆ˜κΈ‰μžμ •λ³΄λ₯Ό μž…λ ₯ν›„ μ—…λ‘œλ“œν•΄μ£Όμ„Έμš”.
    2. κΈ°κ΄€μ—μ„œ μ‚¬μš©ν•˜λ˜ μ—‘μ…€ νŒŒμΌλ„ μ—…λ‘œλ“œν•  수 μžˆμŠ΅λ‹ˆλ‹€.
    3. μ—…λ‘œλ“œκ°€ μ•ˆ 될 경우: μ—‘μ…€μ—μ„œ [파일] β†’ [λ‹€λ₯Έ μ΄λ¦„μœΌλ‘œ μ €μž₯] β†’ [CSV UTF-8] λ˜λŠ” [μ‰Όν‘œλ‘œ κ΅¬λΆ„λœ 파일]둜 μ €μž₯ν•œ λ’€ μ—…λ‘œλ“œν•΄ μ£Όμ„Έμš”.
    4. κ·Έλž˜λ„ μ–΄λ €μš°λ©΄ [μˆ˜κΈ‰μž 직접 등둝]μ—μ„œ ν•œ λΆ„μ”© 직접 μž…λ ₯ν•˜μ‹œλ©΄ λ©λ‹ˆλ‹€.

    πŸ’‘ μΆ”κ°€ μ•ˆλ‚΄μ‚¬ν•­:
    - μ‹€μ§ˆμƒμΌλ‚ μ§œκ°€ 있으면 β†’ κ·Έ λ‚ μ§œλ‘œ μƒμ‹ μž”μΉ˜ μ•Œλ¦Όμ΄ μƒμ„±λ©λ‹ˆλ‹€.
    - μ‹€μ§ˆμƒμΌλ‚ μ§œκ°€ μ—†μœΌλ©΄ β†’ μƒλ…„μ›”μΌμ˜ μ›”/일둜 μžλ™ κ³„μ‚°λ©λ‹ˆλ‹€.
    - μ‹€μ§ˆμƒμΌλ‚ μ§œλŠ” 09-20 ν˜•μ‹μœΌλ‘œ μž…λ ₯해도 μžλ™μœΌλ‘œ μ˜¬ν•΄ λ‚ μ§œλ‘œ λ³€ν™˜λ˜μ–΄ μ μš©λ©λ‹ˆλ‹€.
    - κΈ°κ΄€λͺ…κ³Ό κΈ°κ΄€μœ ν˜•μ€ 맨 끝 열에 μž…λ ₯ν•©λ‹ˆλ‹€.
    """, unsafe_allow_html=True) st.download_button("πŸ“„ BeeCare μˆ˜κΈ‰μž 등둝 μ—‘μ…€ 양식 λ‹€μš΄λ‘œλ“œ", data=make_client_excel_template(), file_name="BeeCare_μˆ˜κΈ‰μžλ“±λ‘μ–‘μ‹.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True) uploaded_file = st.file_uploader("μž‘μ„±ν•œ 파일 μ—…λ‘œλ“œ (XLSX λ˜λŠ” CSV)", type=["xlsx", "csv"], help="BeeCare μ–‘μ‹μ˜ 첫 번째 μ‹œνŠΈμ΄κ±°λ‚˜ CSV ν˜•μ‹μ˜ νŒŒμΌμ„ μ˜¬λ €μ£Όμ„Έμš”.") overwrite = st.checkbox("같은 μˆ˜κΈ‰μžλͺ… + μΆœμƒμ—°λ„(μƒλ…„μ›”μΌμ˜ 연도) + κΈ°κ΄€λͺ…이 이미 있으면 κΈ°μ‘΄ 정보λ₯Ό μ—…λ°μ΄νŠΈ", value=False) if uploaded_file is not None: try: if uploaded_file.name.endswith(".csv"): try: df_raw = pd.read_csv(uploaded_file, dtype=str).dropna(how="all").fillna("") except UnicodeDecodeError: try: uploaded_file.seek(0) df_raw = pd.read_csv(uploaded_file, dtype=str, encoding="cp949").dropna(how="all").fillna("") except Exception: uploaded_file.seek(0) df_raw = pd.read_csv(uploaded_file, dtype=str, encoding="euc-kr").dropna(how="all").fillna("") else: df_raw = pd.read_excel(uploaded_file, dtype=str, sheet_name=0).dropna(how="all").fillna("") df_raw = normalize_excel_columns(df_raw) st.markdown("### μ—…λ‘œλ“œ 미리보기") st.dataframe(df_raw.head(30), use_container_width=True) df_valid, errors = validate_client_excel(df_raw) if errors: st.error("양식 λ˜λŠ” μž…λ ₯값에 λ¬Έμ œκ°€ μžˆμŠ΅λ‹ˆλ‹€.") for err in errors[:50]: st.write("- " + err) if len(errors) > 50: st.write(f"...μ™Έ {len(errors) - 50}건") else: st.success("검증 μ™„λ£Œ. μΈμ •λ²ˆν˜Έ L μžλ™ λ³€ν™˜ 및 λ‚ μ§œ ν˜•μ‹ 확인이 μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.") st.dataframe(df_valid.head(30), use_container_width=True) if st.button("βœ… 데이터 DB에 μ €μž₯ν•˜κΈ°", use_container_width=True): inserted, updated, skipped = import_clients_from_excel(df_valid, overwrite=overwrite) st.success(f"μ €μž₯ μ™„λ£Œ: μ‹ κ·œ {inserted}λͺ… / μ—…λ°μ΄νŠΈ {updated}λͺ… / κ±΄λ„ˆλœ€ {skipped}λͺ…") except Exception as e: st.error(f"νŒŒμΌμ„ μ½λŠ” 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {e}") # ----------------------------- # λͺ©λ‘ # ----------------------------- elif menu == "μˆ˜κΈ‰μž λͺ©λ‘": st.subheader("πŸ“‹ μˆ˜κΈ‰μž λͺ©λ‘") df = load_clients() if df.empty: st.info("λ“±λ‘λœ μˆ˜κΈ‰μžκ°€ μ—†μŠ΅λ‹ˆλ‹€.") else: df = add_no_column(df) df["κΈ‰μ—¬κ³„νš μ•Œλ¦Ό"] = df["recognition_end_date"].apply(plan_alert_text) if "recognition_end_date" in df.columns else "" show_cols = ["NO.", "id", "masked_name", "gender", "birth_date", "real_birth_date", "care_grade", "recognition_number", "recognition_start_date", "recognition_end_date", "κΈ‰μ—¬κ³„νš μ•Œλ¦Ό", "institution_type", "institution_name", "status", "plan_change_required", "plan_change_type", "plan_rewrite_status", "admission_date", "remarks"] existing = [c for c in show_cols if c in df.columns] st.dataframe(df[existing], use_container_width=True) buffer = BytesIO() with pd.ExcelWriter(buffer, engine="openpyxl") as writer: df[existing].to_excel(writer, index=False, sheet_name="μˆ˜κΈ‰μžλͺ©λ‘") buffer.seek(0) st.download_button("πŸ“₯ μˆ˜κΈ‰μž λͺ©λ‘ μ—‘μ…€ λ‹€μš΄λ‘œλ“œ", data=buffer, file_name=f"μˆ˜κΈ‰μžλͺ©λ‘_{datetime.now().strftime('%Y%m%d')}.xlsx", mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True) # ----------------------------- # 검색 # ----------------------------- elif menu == "μˆ˜κΈ‰μž 검색": st.subheader("πŸ” μˆ˜κΈ‰μž 검색") st.caption("μˆ˜κΈ‰μž 이름 λ˜λŠ” NO. 번호둜 검색할 수 μžˆμŠ΅λ‹ˆλ‹€.") df = add_no_column(load_clients()) keyword = st.text_input("μˆ˜κΈ‰μž μ΄λ¦„μœΌλ‘œ 검색") search_no = st.text_input("NO.둜 검색") if keyword or search_no: result = df.copy() if keyword: keyword = keyword.strip() result = result[result["name"].astype(str).str.contains(keyword, case=False, na=False) | result["masked_name"].astype(str).str.contains(keyword, case=False, na=False)] if search_no: result = result[result["NO."].astype(str) == search_no.strip()] if result.empty: st.warning("검색 κ²°κ³Όκ°€ μ—†μŠ΅λ‹ˆλ‹€.") else: st.dataframe(result, use_container_width=True) # ----------------------------- # μˆ˜μ • / μ‚­μ œ # ----------------------------- elif menu == "μˆ˜μ • / μ‚­μ œ": st.subheader("✏️ μˆ˜κΈ‰μž 정보 μˆ˜μ • / μ‚­μ œ") df = load_clients() if df.empty: st.info("μˆ˜μ •ν•  μˆ˜κΈ‰μžκ°€ μ—†μŠ΅λ‹ˆλ‹€.") else: options = df.apply(lambda x: f"{x['id']}번 - {x['masked_name']} / {x['care_grade']} / {x['institution_name']}", axis=1).tolist() selected = st.selectbox("μˆ˜μ •ν•  μˆ˜κΈ‰μž 선택", options) selected_id = int(selected.split("번")[0]) c = df[df["id"] == selected_id].iloc[0] with st.form("edit_form"): col1, col2, col3 = st.columns(3) with col1: name = st.text_input("μˆ˜κΈ‰μž 이름", value=safe(c.get("name"))) gender = st.selectbox("성별", VALID_GENDERS, index=VALID_GENDERS.index(safe(c.get("gender"))) if safe(c.get("gender")) in VALID_GENDERS else 0) birth_date_raw = st.text_input("생년월일", value=safe(c.get("birth_date")), placeholder="예: 1942-09-20 λ˜λŠ” 1942") with col2: old_grade = safe(c.get("care_grade")) care_grade = st.selectbox("μž₯κΈ°μš”μ–‘λ“±κΈ‰", VALID_GRADES, index=VALID_GRADES.index(old_grade) if old_grade in VALID_GRADES else 7) recognition_number = st.text_input("μΈμ •λ²ˆν˜Έ", value=safe(c.get("recognition_number")), help="숫자만 μž…λ ₯해도 L이 μžλ™μœΌλ‘œ λΆ™μŠ΅λ‹ˆλ‹€.") institution_type = st.selectbox("κΈ°κ΄€ μœ ν˜•", VALID_INST_TYPES, index=VALID_INST_TYPES.index(safe(c.get("institution_type"))) if safe(c.get("institution_type")) in VALID_INST_TYPES else 0) with col3: institution_name = st.text_input("κΈ°κ΄€λͺ…", value=safe(c.get("institution_name"))) admission_date = st.text_input("이용 μ‹œμž‘μΌ / μž…μ†ŒμΌ", value=safe(c.get("admission_date")), placeholder="예: 2026-03-20") status = st.selectbox("μƒνƒœ", VALID_STATUS, index=VALID_STATUS.index(safe(c.get("status"))) if safe(c.get("status")) in VALID_STATUS else 0) col4, col5 = st.columns(2) recognition_start_date = col4.text_input("인정기간 μ‹œμž‘μΌ", value=safe(c.get("recognition_start_date")), placeholder="예: 2025-03-03") recognition_end_date = col5.text_input("인정기간 μ’…λ£ŒμΌ", value=safe(c.get("recognition_end_date")), placeholder="예: 2027-03-02") main_condition = st.text_area("μ£Όμš” μ§ˆν™˜ / μƒνƒœ", value=safe(c.get("main_condition"))) cognitive_status = st.text_area("인지 μƒνƒœ", value=safe(c.get("cognitive_status"))) mobility_status = st.text_area("이동 μƒνƒœ", value=safe(c.get("mobility_status"))) diet_status = st.text_area("식사 μƒνƒœ", value=safe(c.get("diet_status"))) toileting_status = st.text_area("λ°°λ³€ / 배뇨 μƒνƒœ", value=safe(c.get("toileting_status"))) guardian_name = st.text_input("보호자 이름", value=safe(c.get("guardian_name"))) guardian_phone = st.text_input("보호자 μ—°λ½μ²˜", value=safe(c.get("guardian_phone"))) notes = st.text_area("νŠΉμ΄μ‚¬ν•­ / λ©”λͺ¨", value=safe(c.get("notes"))) st.markdown("### 생일 및 기타 정보") c_b1, c_b2 = st.columns(2) real_birth_date_raw = c_b1.text_input("μ‹€μ§ˆμƒμΌλ‚ μ§œ (μ˜΅μ…˜)", value=safe(c.get("real_birth_date")), placeholder="예: 09-20 (μƒμ‹ μž”μΉ˜ μ•Œλ¦Όμš©)") remarks = c_b2.text_area("λΉ„κ³ ", value=safe(c.get("remarks"))) save_btn = st.form_submit_button("μˆ˜μ • μ €μž₯") if save_btn: errors = [] birth_date = normalize_date_text(birth_date_raw) if birth_date_raw and not (is_valid_date_text(birth_date) or (len(birth_date_raw) == 4 and birth_date_raw.isdigit())): errors.append("생년월일은 1942-09-20 ν˜•μ‹ λ˜λŠ” 1942 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.") for label, val in [("인정기간 μ‹œμž‘μΌ", recognition_start_date), ("인정기간 μ’…λ£ŒμΌ", recognition_end_date), ("이용 μ‹œμž‘μΌ", admission_date)]: val2 = normalize_date_text(val) if val2 and not is_valid_date_text(val2): errors.append(f"{label}은 2026-03-20 ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄ μ£Όμ„Έμš”.") if errors: for err in errors: st.error(err) else: conn = get_conn() cur = conn.cursor() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") detected_required = safe(c.get("plan_change_required"), "μ•„λ‹ˆμ˜€") detected_type = safe(c.get("plan_change_type")) detected_reason = safe(c.get("plan_change_reason")) detected_date = safe(c.get("plan_change_date")) detected_status = safe(c.get("plan_rewrite_status"), "ν•΄λ‹Ήμ—†μŒ") if old_grade and care_grade != old_grade: detected_required, detected_status, auto_memo = calculate_plan_change("λ“±κΈ‰λ³€κ²½", f"{old_grade}μ—μ„œ {care_grade}둜 λ³€κ²½", status) detected_type = "λ“±κΈ‰λ³€κ²½" detected_reason = f"μž₯κΈ°μš”μ–‘λ“±κΈ‰ λ³€κ²½: {old_grade} β†’ {care_grade}" detected_date = str(date.today()) save_change_log(selected_id, safe(c.get("masked_name")), institution_type, detected_type, detected_reason, detected_date, "Client Center μ •λ³΄μˆ˜μ •", detected_required, detected_status, auto_memo) # Extract birth_year birth_year = "" if birth_date and len(birth_date) >= 4 and birth_date[:4].isdigit(): birth_year = birth_date[:4] elif len(birth_date_raw) == 4 and birth_date_raw.isdigit(): birth_year = birth_date_raw real_birth_date = normalize_real_birth_date(real_birth_date_raw) cur.execute(""" UPDATE clients SET name=?, masked_name=?, gender=?, birth_year=?, care_grade=?, previous_care_grade=?, recognition_number=?, recognition_start_date=?, recognition_end_date=?, institution_type=?, institution_name=?, admission_date=?, main_condition=?, cognitive_status=?, mobility_status=?, diet_status=?, toileting_status=?, guardian_name=?, guardian_phone=?, notes=?, status=?, plan_change_required=?, plan_change_type=?, plan_change_reason=?, plan_change_date=?, plan_rewrite_status=?, updated_at=?, birth_date=?, real_birth_date=?, remarks=? WHERE id=? """, ( name, mask_name(name), gender, birth_year, care_grade, old_grade, normalize_recognition_number(recognition_number), normalize_date_text(recognition_start_date), normalize_date_text(recognition_end_date), institution_type, institution_name, normalize_date_text(admission_date), main_condition, cognitive_status, mobility_status, diet_status, toileting_status, guardian_name, guardian_phone, notes, status, detected_required, detected_type, detected_reason, detected_date, detected_status, now, birth_date, real_birth_date, remarks, selected_id, )) conn.commit() conn.close() st.success("μˆ˜κΈ‰μž 정보가 μˆ˜μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") st.divider() if st.button("⚠️ 이 μˆ˜κΈ‰μž μ‚­μ œν•˜κΈ°"): conn = get_conn() cur = conn.cursor() cur.execute("DELETE FROM clients WHERE id = ?", (selected_id,)) conn.commit() conn.close() st.warning("μˆ˜κΈ‰μž 정보가 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.") # ----------------------------- # κΈ‰μ—¬κ³„νš 변경관리 # ----------------------------- elif menu == "κΈ‰μ—¬κ³„νš 변경관리": st.subheader("πŸ” κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„± ν•„μš” μ—¬λΆ€ 관리") df = load_clients() if df.empty: st.info("λ“±λ‘λœ μˆ˜κΈ‰μžκ°€ μ—†μŠ΅λ‹ˆλ‹€.") else: options = df.apply(lambda x: f"{x['id']}번 - {x['masked_name']} / {x['care_grade']} / {x['institution_type']} / {x['status']}", axis=1).tolist() selected = st.selectbox("μˆ˜κΈ‰μž 선택", options) selected_id = int(selected.split("번")[0]) c = df[df["id"] == selected_id].iloc[0] st.markdown("### ν˜„μž¬ μƒνƒœ") m1, m2, m3, m4, m5 = st.columns(5) m1.metric("μˆ˜κΈ‰μž", safe(c.get("masked_name"))) m2.metric("λ“±κΈ‰", safe(c.get("care_grade"))) m3.metric("κΈ°κ΄€μœ ν˜•", safe(c.get("institution_type"))) m4.metric("μž¬μž‘μ„± ν•„μš”", safe(c.get("plan_change_required"), "μ•„λ‹ˆμ˜€")) m5.metric("인정기간 μ•Œλ¦Ό", plan_alert_text(safe(c.get("recognition_end_date")))) current_required = safe(c.get("plan_change_required"), "μ•„λ‹ˆμ˜€") if current_required == "예": st.markdown(f'
    κΈ‰μ—¬μ œκ³΅κ³„νš μž¬μž‘μ„± ν•„μš”: {safe(c.get("plan_change_reason"))}
    ', unsafe_allow_html=True) else: st.markdown(f'
    ν˜„μž¬ μƒνƒœ: {safe(c.get("plan_rewrite_status"), "ν•΄λ‹Ήμ—†μŒ")}
    ', unsafe_allow_html=True) st.markdown("### λ³€κ²½μ‚¬μœ  μž…λ ₯") inst = safe(c.get("institution_type"), "λ°©λ¬Έμš”μ–‘") reason_options = institution_change_reasons(inst) col1, col2, col3 = st.columns(3) change_type = col1.selectbox("λ³€κ²½μœ ν˜•", ["μƒνƒœλ³€ν™”", "λ“±κΈ‰λ³€κ²½", "보호자 μš”μ²­", "μ„œλΉ„μŠ€ λ³€κ²½", "μš•κ΅¬μ‚¬μ • κ²°κ³Ό", "상담일지 κ²°κ³Ό", "사둀관리 κ²°κ³Ό", "결과평가 반영", "λ³‘μ›μž…μ›", "퇴원 ν›„ 재이용", "전원", "사망", "계약해지", "μ„œλΉ„μŠ€ μ’…λ£Œ", "기타"]) change_reason_preset = col2.selectbox("κΈ°κ΄€μœ ν˜•λ³„ λ³€κ²½μ‚¬μœ ", reason_options) change_date = col3.date_input("λ°œμƒμΌ / 확인일", value=date.today()) linked_document = st.multiselect("연동 κ·Όκ±° λ¬Έμ„œ", ["상담일지", "μš•κ΅¬μ‚¬μ •", "사둀관리", "κΈ‰μ—¬μ œκ³΅ 결과평가", "μƒνƒœλ³€ν™”κΈ°λ‘μ§€", "보호자 상담", "곡단 μΈμ •μ„œ", "λ³‘μ›μ§„λ£Œ/μž…ν‡΄μ› μ„œλ₯˜", "기타"], default=["상담일지"]) detail_reason = st.text_area("μ„ΈλΆ€ λ³€κ²½μ‚¬μœ ", value=f"{change_reason_preset} κ΄€λ ¨ μƒνƒœλ³€ν™” λ˜λŠ” μ„œλΉ„μŠ€ μ‘°μ • ν•„μš”μ„± 확인", height=100) required, rewrite_status, memo = calculate_plan_change(change_type, detail_reason, safe(c.get("status"))) r1, r2, r3 = st.columns(3) r1.metric("μž¬μž‘μ„± ν•„μš” μ—¬λΆ€", required) r2.metric("μ²˜λ¦¬μƒνƒœ", rewrite_status) r3.metric("κ·Όκ±°λ¬Έμ„œ", ", ".join(linked_document)) st.warning(memo) if required == "예" else st.info(memo) if st.button("πŸ’Ύ λ³€κ²½μ‚¬μœ  μ €μž₯ 및 μˆ˜κΈ‰μž μƒνƒœ 반영", use_container_width=True): save_change_log(selected_id, safe(c.get("masked_name")), inst, change_type, detail_reason, str(change_date), ", ".join(linked_document), required, rewrite_status, memo) st.success("λ³€κ²½μ‚¬μœ κ°€ μ €μž₯λ˜μ—ˆκ³  Client Center에 λ°˜μ˜λ˜μ—ˆμŠ΅λ‹ˆλ‹€.") st.markdown("### 이 μˆ˜κΈ‰μžμ˜ 변경이λ ₯") logs = load_change_logs(selected_id) st.dataframe(logs, use_container_width=True) if not logs.empty else st.info("μ €μž₯된 변경이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€.") # ----------------------------- # 변경이λ ₯ 보기 # ----------------------------- elif menu == "변경이λ ₯ 보기": st.subheader("πŸ“š 전체 κΈ‰μ—¬μ œκ³΅κ³„νš 변경이λ ₯") logs = load_change_logs() st.dataframe(logs, use_container_width=True) if not logs.empty else st.info("μ €μž₯된 변경이λ ₯이 μ—†μŠ΅λ‹ˆλ‹€.")