Spaces:
Sleeping
Sleeping
Download app.py from devorah-ai-2016/ClientCenter: direct link, hf CLI and curl.
- Browser
- Download file 57.3 kB
-
https://huggingface.co/spaces/devorah-ai-2016/ClientCenter/resolve/main/app.py
- Command line
-
hf download hf://spaces/devorah-ai-2016/ClientCenter/app.py
-
curl -L -o app.py https://huggingface.co/spaces/devorah-ai-2016/ClientCenter/resolve/main/app.py
57.3 kB
| 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""" | |
| <div style="background: #fff3e0; border-left: 7px solid #fb8c00; padding: 14px; border-radius: 12px; margin-bottom: 14px; color: #222;"> | |
| 🎉 <strong>오늘 생신!</strong> 오늘은 {today_list_str}의 생신입니다. 축하드립니다! 🎂 | |
| </div> | |
| """, 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"<li style='margin-bottom: 4px;'><strong>{name} 어르신</strong> ({this_month}월 {day}일 - {src_info})</li>") | |
| st.markdown(f""" | |
| <div style="background: #e8f5e9; border-left: 7px solid #43a047; padding: 14px; border-radius: 12px; margin-bottom: 14px; color: #222;"> | |
| 🎁 <strong>{this_month}월 생신 예정 어르신 목록:</strong> | |
| <ul style="margin: 8px 0 0 20px; padding: 0;"> | |
| {"".join(items_html)} | |
| </ul> | |
| </div> | |
| """, 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(""" | |
| <style> | |
| .block-container { padding-top: 2rem; } | |
| .title-box { background: linear-gradient(135deg, #2E7D32, #66BB6A); padding: 28px; border-radius: 18px; color: white; text-align: center; margin-bottom: 25px; } | |
| .title-box h1 { margin: 0; font-size: 34px; } | |
| .title-box p { font-size: 17px; margin-top: 8px; } | |
| .notice { background: #fff8e1; padding: 15px; border-left: 6px solid #f9a825; border-radius: 10px; margin-bottom: 20px; line-height: 1.65; } | |
| .okbox { background: #e8f5e9; border-left: 7px solid #43a047; padding: 14px; border-radius: 12px; margin-bottom: 14px; } | |
| .warnbox { background: #fff3e0; border-left: 7px solid #fb8c00; padding: 14px; border-radius: 12px; margin-bottom: 14px; } | |
| .dangerbox { background: #ffebee; border-left: 7px solid #e53935; padding: 14px; border-radius: 12px; margin-bottom: 14px; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| st.markdown(""" | |
| <div class="title-box"> | |
| <h1>👥 Client Center</h1> | |
| <p>수급자 등록 / 엑셀 업로드 / 검색 / 수정 / 급여제공계획 재작성 필요 여부 중앙관리</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| display_birthday_alerts() | |
| st.markdown(""" | |
| <div class="notice"> | |
| <strong>날짜 입력 안내:</strong> 날짜는 반드시 <strong>2026-03-20</strong> 형식으로 입력해 주세요.<br> | |
| 인정기간은 한 칸에 쓰지 말고 <strong>인정기간 시작일</strong>과 <strong>인정기간 종료일</strong>을 각각 입력해 주세요.<br> | |
| 인정번호는 숫자만 입력해도 저장 시 <strong>L</strong>이 자동으로 붙습니다. 예: 1234567890 → L1234567890 | |
| </div> | |
| """, 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(""" | |
| <div class="notice"> | |
| <strong>📌 엑셀/CSV 업로드 안내:</strong><br> | |
| 1. 기본은 <strong>BeeCare 전용 엑셀 양식(.xlsx)</strong>을 사용해 주세요. 엑셀파일을 다운받아 수급자정보를 입력후 업로드해주세요.<br> | |
| 2. 기관에서 사용하던 엑셀 파일도 업로드할 수 있습니다.<br> | |
| 3. 업로드가 안 될 경우: 엑셀에서 <strong>[파일] → [다른 이름으로 저장] → [CSV UTF-8] 또는 [쉼표로 구분된 파일]</strong>로 저장한 뒤 업로드해 주세요.<br> | |
| 4. 그래도 어려우면 <strong>[수급자 직접 등록]</strong>에서 한 분씩 직접 입력하시면 됩니다.<br><br> | |
| <strong>💡 추가 안내사항:</strong><br> | |
| - <strong>실질생일날짜</strong>가 있으면 → 그 날짜로 생신잔치 알림이 생성됩니다.<br> | |
| - <strong>실질생일날짜</strong>가 없으면 → 생년월일의 월/일로 자동 계산됩니다.<br> | |
| - 실질생일날짜는 <strong>09-20</strong> 형식으로 입력해도 자동으로 올해 날짜로 변환되어 적용됩니다.<br> | |
| - 기관명과 기관유형은 맨 끝 열에 입력합니다. | |
| </div> | |
| """, 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'<div class="dangerbox"><strong>급여제공계획 재작성 필요:</strong> {safe(c.get("plan_change_reason"))}</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown(f'<div class="okbox"><strong>현재 상태:</strong> {safe(c.get("plan_rewrite_status"), "해당없음")}</div>', 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("저장된 변경이력이 없습니다.") | |