Spaces:
Sleeping
Sleeping
Download app.py from devorah-ai-2016/CaseManagement: direct link, hf CLI and curl.
- Browser
- Download file 35.7 kB
-
https://huggingface.co/spaces/devorah-ai-2016/CaseManagement/resolve/main/app.py
- Command line
-
hf download hf://spaces/devorah-ai-2016/CaseManagement/app.py
-
curl -L -o app.py https://huggingface.co/spaces/devorah-ai-2016/CaseManagement/resolve/main/app.py
35.7 kB
| from datetime import date, datetime, timedelta | |
| from io import BytesIO | |
| import os | |
| import random | |
| import sqlite3 | |
| from openpyxl import Workbook | |
| from openpyxl.drawing.image import Image as XLImage | |
| from openpyxl.styles import Alignment, Border, Font, PatternFill, Side | |
| from openpyxl.utils import get_column_letter | |
| import pandas as pd | |
| from PIL import Image as PILImage | |
| import streamlit as st | |
| # ========================================================= | |
| # 통합연동 사례관리 생성기 (클라우드/허깅페이스 최적화 버전) | |
| # ========================================================= | |
| st.set_page_config(page_title="통합 사례관리 생성기", page_icon="📋", layout="wide") | |
| CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| DB_DIR = os.path.join(CURRENT_DIR, "data") | |
| os.makedirs(DB_DIR, exist_ok=True) | |
| DB_PATH = os.path.join(DB_DIR, "clients.db") | |
| # ----------------------------- | |
| # UI 스타일 | |
| # ----------------------------- | |
| st.markdown( | |
| """ | |
| <style> | |
| .block-container { padding-top: 1.8rem; } | |
| .hero { | |
| background: linear-gradient(135deg, #6D28D9, #9333EA); | |
| padding: 26px; | |
| border-radius: 18px; | |
| text-align: center; | |
| margin-bottom: 20px; | |
| color: white; | |
| } | |
| .hero h1 { margin: 0; font-size: 32px; font-weight: 800; } | |
| .hero p { margin-top: 8px; font-size: 16px; opacity: 0.95; } | |
| .okbox { | |
| background: #e8f5e9; | |
| border-left: 6px solid #2e7d32; | |
| padding: 12px; | |
| border-radius: 8px; | |
| margin-bottom: 12px; | |
| font-size: 14px; | |
| } | |
| .warnbox { | |
| background: #fff3e0; | |
| border-left: 6px solid #ef6c00; | |
| padding: 12px; | |
| border-radius: 8px; | |
| margin-bottom: 12px; | |
| font-size: 14px; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown( | |
| """ | |
| <div class="hero"> | |
| <h1>📋 통합 사례관리 회의록 자동생성기</h1> | |
| <p>수급자 상태·상담일지·욕구사정 연동 1차 계획회의록 및 2차 결과회의록 완벽 자동화</p> | |
| </div> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ----------------------------- | |
| # DB 초기화 및 관리 | |
| # ----------------------------- | |
| def get_conn(): | |
| return sqlite3.connect(DB_PATH, check_same_thread=False) | |
| 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 '이용중', | |
| recognition_number TEXT, | |
| recognition_start_date TEXT, | |
| recognition_end_date TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS counseling_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| client_id INTEGER, | |
| counseling_date TEXT, | |
| counseling_type TEXT, | |
| counseling_reason TEXT, | |
| client_status_change TEXT, | |
| guardian_opinion TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS status_change_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| client_id INTEGER, | |
| record_date TEXT, | |
| change_category TEXT, | |
| body_status TEXT, | |
| cognitive_status TEXT, | |
| staff_observation TEXT, | |
| guardian_opinion TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS needs_assessment_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| client_id INTEGER, | |
| assessment_date TEXT, | |
| total_opinion TEXT, | |
| result_plan TEXT, | |
| fall_risk TEXT, | |
| bedsore_risk TEXT, | |
| dementia_status TEXT, | |
| diaper_use TEXT, | |
| service_needs TEXT | |
| ) | |
| """) | |
| cur.execute(""" | |
| CREATE TABLE IF NOT EXISTS case_management_records ( | |
| id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| client_id INTEGER, | |
| client_masked_name TEXT, | |
| institution_type TEXT, | |
| case_type TEXT, | |
| first_meeting_date TEXT, | |
| second_meeting_date TEXT, | |
| selection_reason TEXT, | |
| first_plan TEXT, | |
| implementation_process TEXT, | |
| second_result TEXT, | |
| evaluation_score INTEGER, | |
| missing_items TEXT, | |
| created_at TEXT | |
| ) | |
| """) | |
| conn.commit() | |
| conn.close() | |
| init_db() | |
| def load_clients(): | |
| conn = get_conn() | |
| try: | |
| df = pd.read_sql_query( | |
| "SELECT * FROM clients ORDER BY id DESC LIMIT 300", conn | |
| ) | |
| except Exception: | |
| df = pd.DataFrame() | |
| conn.close() | |
| return df | |
| def save_case_record(data): | |
| conn = get_conn() | |
| cur = conn.cursor() | |
| cur.execute( | |
| """ | |
| INSERT INTO case_management_records ( | |
| client_id, client_masked_name, institution_type, case_type, | |
| first_meeting_date, second_meeting_date, selection_reason, | |
| first_plan, implementation_process, second_result, | |
| evaluation_score, missing_items, created_at | |
| ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| """, | |
| ( | |
| data["client_id"], | |
| data["client_masked_name"], | |
| data["institution_type"], | |
| data["case_type"], | |
| data["first_meeting_date"], | |
| data["second_meeting_date"], | |
| data["selection_reason"], | |
| data["first_plan"], | |
| data["implementation_process"], | |
| data["second_result"], | |
| data["evaluation_score"], | |
| data["missing_items"], | |
| datetime.now().strftime("%Y-%m-%d %H:%M:%S"), | |
| ), | |
| ) | |
| conn.commit() | |
| conn.close() | |
| def safe(row, key, default=""): | |
| try: | |
| v = row.get(key, default) | |
| return default if pd.isna(v) else str(v) | |
| except Exception: | |
| return default | |
| def mask_name(name): | |
| name = str(name or "").strip() | |
| if len(name) >= 3: | |
| return name[0] + "○" + name[2:] | |
| if len(name) == 2: | |
| return name[0] + "○" | |
| return name or "수급자" | |
| def days_until(date_text): | |
| v = str(date_text or "").strip() | |
| if not v or v.lower() == "nan": | |
| return None | |
| try: | |
| target = pd.to_datetime(v).date() | |
| return (target - date.today()).days | |
| except Exception: | |
| return None | |
| def recognition_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}일 전 (유효)" | |
| # ----------------------------- | |
| # 사례관리 데이터베이스 | |
| # ----------------------------- | |
| CASE_TYPES = { | |
| "낙상 고위험 관리": { | |
| "reason": ( | |
| "이동 중 불안정, 화장실 이동 위험, 보행보조 필요 등 낙상 위험요인이" | |
| " 확인되어 사례관리가 필요함." | |
| ), | |
| "goal": "낙상 사고를 예방하고 안전한 이동과 생활환경을 유지한다.", | |
| "keywords": [ | |
| "낙상예방", | |
| "이동보조", | |
| "환경정비", | |
| "화장실 동행", | |
| "보행상태 관찰", | |
| ], | |
| }, | |
| "식사량 감소·영양관리": { | |
| "reason": ( | |
| "식사량 감소, 수분섭취 부족, 체중저하 우려가 확인되어 영양상태" | |
| " 관리를 위한 사례관리가 필요함." | |
| ), | |
| "goal": ( | |
| "식사량과 수분섭취를 안정적으로 유지하고 영양 저하 위험을 예방한다." | |
| ), | |
| "keywords": [ | |
| "식사보조", | |
| "수분섭취", | |
| "선호음식", | |
| "연하상태", | |
| "섭취량 기록", | |
| ], | |
| }, | |
| "인지저하·정서불안": { | |
| "reason": ( | |
| "반복질문, 의심표현, 불안감, 정서적 위축 등 인지·정서 변화가" | |
| " 확인되어 지속 관찰과 개별 대응이 필요함." | |
| ), | |
| "goal": ( | |
| "불안감을 완화하고 안정적인 일상생활과 서비스 이용이 이루어지도록" | |
| " 지원한다." | |
| ), | |
| "keywords": [ | |
| "정서지원", | |
| "인지활동", | |
| "반복설명", | |
| "안정적 환경", | |
| "보호자 상담", | |
| ], | |
| }, | |
| "욕창·피부상태 관리": { | |
| "reason": ( | |
| "기저귀 착용, 활동량 저하, 피부 발적 또는 욕창 위험이 확인되어" | |
| " 피부상태 관찰과 예방관리가 필요함." | |
| ), | |
| "goal": "피부 청결과 안위를 유지하고 욕창 발생을 예방한다.", | |
| "keywords": [ | |
| "피부관찰", | |
| "체위변경", | |
| "기저귀케어", | |
| "보습", | |
| "간호관찰", | |
| ], | |
| }, | |
| "프로그램 참여 저하": { | |
| "reason": ( | |
| "프로그램 참여 감소, 무기력, 대화 회피 등 사회적·정서적 위축이" | |
| " 관찰되어 사례관리가 필요함." | |
| ), | |
| "goal": ( | |
| "선호 활동 중심으로 참여를 유도하고 사회적 관계와 활력을 높인다." | |
| ), | |
| "keywords": [ | |
| "선호활동", | |
| "참여격려", | |
| "정서지원", | |
| "프로그램 조정", | |
| "참여도 기록", | |
| ], | |
| }, | |
| "보호자 요청·서비스 조정": { | |
| "reason": ( | |
| "보호자 요청 또는 서비스 조정 필요성이 확인되어 급여제공 방향" | |
| " 재검토와 사례관리가 필요함." | |
| ), | |
| "goal": ( | |
| "보호자 의견과 수급자 상태를 반영하여 적절한 서비스 제공 방향을" | |
| " 조정한다." | |
| ), | |
| "keywords": [ | |
| "보호자 상담", | |
| "서비스 조정", | |
| "급여계획 반영", | |
| "종사자 공유", | |
| "후속상담", | |
| ], | |
| }, | |
| } | |
| CASE_DETAILS = { | |
| "낙상 고위험 관리": { | |
| "subjective": ( | |
| "수급자 및 보호자는 최근 보행 시 다리에 힘이 풀리거나 어지럼증이" | |
| " 자주 발생하여 이동 시 불안감이 매우 크다고 호소함." | |
| ), | |
| "objective": ( | |
| "보행보조기를 사용하더라도 걸음걸이가 불안정하며 기립 시 어지럼증이" | |
| " 관찰됨. 바닥 전선 및 문턱 등 낙상 위험요소가 확인됨." | |
| ), | |
| "assessment": ( | |
| "하지 근력 저하와 균형 감각 감퇴로 인한 낙상 위험도가 높은" | |
| " 상태임. 이동 동선 정비 및 동행 부축 지원이 시급함." | |
| ), | |
| "plan": [ | |
| "실내외 보행 시 1:1 밀착 부축 지원", | |
| "미끄럼 방지 매트 및 안전 손잡이 점검", | |
| "야간 화장실 이동 시 안전 동행 및 조명 확보", | |
| "보행보조기 바퀴 및 제동장치 정기 점검", | |
| ], | |
| "resource": ( | |
| "보건소 낙상예방 운동 프로그램 안내 및 복지용구(안전손잡이 등)" | |
| " 추가 연계 검토" | |
| ), | |
| }, | |
| "식사량 감소·영양관리": { | |
| "subjective": ( | |
| "수급자는 입맛이 전혀 없다고 식사를 거부하거나 평소 섭취량의 절반" | |
| " 이하만 섭취한다고 호소함." | |
| ), | |
| "objective": ( | |
| "최근 식사 섭취량이 기존 대비 50% 미만으로 감소하였으며 연하 곤란과" | |
| " 구강 건조가 관찰됨." | |
| ), | |
| "assessment": ( | |
| "지속적인 섭취 저하 시 영양결핍 및 면역력 저하가 우려되므로" | |
| " 선호식단 반영과 부드러운 유동식 지원이 필요함." | |
| ), | |
| "plan": [ | |
| "수급자 선호 기호식 파악 및 식단 반영", | |
| "식사 중 사레들림 방지를 위한 연하보조 지원", | |
| "식간 충분한 수분 섭취 독려 및 기록", | |
| "주기적인 체중 및 영양상태 모니터링", | |
| ], | |
| "resource": ( | |
| "지역 보건소 영양플러스 상담 및 필요 시 영양 수액 진료 연계" | |
| ), | |
| }, | |
| "인지저하·정서불안": { | |
| "subjective": ( | |
| "수급자는 본인 물건을 남이 가져갔다고 의심하며 반복 질문과 불안감을" | |
| " 강하게 표현함." | |
| ), | |
| "objective": ( | |
| "시간 및 장소에 대한 지남력 저하가 관찰되며 외부 자극에 방어적 태도" | |
| " 및 배회 행동을 보임." | |
| ), | |
| "assessment": ( | |
| "치매 인지저하에 따른 BPSD(행동심리증상)로 판단되며 정면 반박을" | |
| " 지양하고 정서적 지지와 공감이 필요함." | |
| ), | |
| "plan": [ | |
| "날짜와 장소 반복 안내 및 현실감각 훈련", | |
| "의심·망상 표현 시 수용적 태도 및 화제 전환", | |
| "익숙한 소지품 중심의 환경 유지", | |
| "회상 대화 및 1:1 맞춤형 인지활동 제공", | |
| ], | |
| "resource": "치매안심센터 가족교실 및 인지자극 쉼터 프로그램 연계", | |
| }, | |
| "욕창·피부상태 관리": { | |
| "subjective": ( | |
| "수급자는 둔부 및 꼬리뼈 부위가 가렵고 뻐근하다고 호소하며 기저귀" | |
| " 착용 시 답답함을 표현함." | |
| ), | |
| "objective": ( | |
| "와상 상태로 활동량이 매우 적으며 천골 부위에 경미한 피부 발적이" | |
| " 확인됨." | |
| ), | |
| "assessment": ( | |
| "혈액순환 저하 및 습윤 환경으로 인한 욕창 발생 위험이 높아 2시간" | |
| " 단위 체위변경과 청결 관리가 필수적임." | |
| ), | |
| "plan": [ | |
| "주기적인 체위변경(2시간 간격) 철저 시행", | |
| "기저귀 오염 즉시 교체 및 피부 건조 유지", | |
| "체압 분산용 에어매트리스 점검", | |
| "피부 보습제 도포 및 매일 발적 여부 관찰", | |
| ], | |
| "resource": "욕창 드레싱 및 전문 치료를 위한 방문간호 서비스 연계", | |
| }, | |
| "프로그램 참여 저하": { | |
| "subjective": ( | |
| "수급자는 사람들과 어울리기 싫고 프로그램 참여가 귀찮다며 생활실에" | |
| " 혼자 머물기를 원함." | |
| ), | |
| "objective": ( | |
| "최근 단체 프로그램 참여율이 급감하였고 타 수급자와의 대화를" | |
| " 회피하며 무기력한 태도를 보임." | |
| ), | |
| "assessment": ( | |
| "정서적 위축 및 우울감에 기인한 것으로 보이며 강요보다는 선호하는" | |
| " 소규모 개별 활동부터 접근이 필요함." | |
| ), | |
| "plan": [ | |
| "선호 취미 및 과거 활동 파악 후 1:1 맞춤 접근", | |
| "프로그램 참여 시 칭찬과 성취감 격려", | |
| "또래 어르신과의 자연스러운 대화 환경 주선", | |
| "가벼운 산책 및 개별 말벗 서비스 제공", | |
| ], | |
| "resource": "지역 복지관 전문 정서지원 및 심리상담 멘토링 연계", | |
| }, | |
| "보호자 요청·서비스 조정": { | |
| "subjective": ( | |
| "보호자는 수급자의 상태 변화로 가정 내 돌봄 부담이 가중되어 이용" | |
| " 시간 확대를 간곡히 요청함." | |
| ), | |
| "objective": ( | |
| "수급자의 일상생활 자립도 저하가 관찰되며 보호자의 만성 피로와" | |
| " 스트레스가 누적된 상태임." | |
| ), | |
| "assessment": ( | |
| "가족의 돌봄 부담 경감을 위해 급여제공 시간 조정 및 외부 복지" | |
| " 서비스 연계가 시급한 상황임." | |
| ), | |
| "plan": [ | |
| "수급자 급여제공계획서 변경 및 이용시간 확대 검토", | |
| "보호자 정기 상담을 통한 심리적 지지", | |
| "가정 내 안전 돌봄 요령 및 복지용구 안내", | |
| "종사자 간 상태변화 정보 신속 공유", | |
| ], | |
| "resource": "지자체 긴급돌봄 및 치매가족 휴식지원 프로그램 안내", | |
| }, | |
| } | |
| def recommend_case_type(counseling_text, needs_text, condition_text): | |
| text = f"{counseling_text} {needs_text} {condition_text}".lower() | |
| scores = {k: 0 for k in CASE_TYPES} | |
| kw_map = { | |
| "낙상 고위험 관리": [ | |
| "낙상", | |
| "어지러", | |
| "넘어짐", | |
| "보행", | |
| "휠체어", | |
| "이동", | |
| "부축", | |
| ], | |
| "식사량 감소·영양관리": [ | |
| "식사", | |
| "식사량", | |
| "영양", | |
| "체중", | |
| "섭취", | |
| "연하", | |
| "사레", | |
| ], | |
| "인지저하·정서불안": [ | |
| "인지", | |
| "치매", | |
| "의심", | |
| "망상", | |
| "반복", | |
| "불안", | |
| "배회", | |
| "기억", | |
| ], | |
| "욕창·피부상태 관리": [ | |
| "욕창", | |
| "피부", | |
| "발적", | |
| "기저귀", | |
| "체위", | |
| "와상", | |
| "천골", | |
| ], | |
| "프로그램 참여 저하": [ | |
| "프로그램", | |
| "참여", | |
| "무기력", | |
| "우울", | |
| "회피", | |
| "거부", | |
| ], | |
| "보호자 요청·서비스 조정": [ | |
| "보호자", | |
| "시간", | |
| "조정", | |
| "부담", | |
| "가족", | |
| "요청", | |
| "갈등", | |
| ], | |
| } | |
| for c_type, words in kw_map.items(): | |
| for w in words: | |
| if w in text: | |
| scores[c_type] += 1 | |
| best = max(scores, key=scores.get) | |
| return best if scores[best] > 0 else "낙상 고위험 관리" | |
| def evaluate_case( | |
| selection, | |
| first_content, | |
| plan, | |
| role_division, | |
| implementation, | |
| second_result, | |
| ): | |
| required = { | |
| "선정사유": bool(selection.strip()), | |
| "1차 계획 및 목표": bool(plan.strip()) and "목표" in plan, | |
| "직종별 역할분담": all( | |
| w in role_division for w in ["시설장", "사회복지사", "요양보호사"] | |
| ), | |
| "시행과정": bool(implementation.strip()), | |
| "2차 결과평가": bool(second_result.strip()), | |
| } | |
| score = int(sum(required.values()) / len(required) * 100) | |
| missing = [k for k, v in required.items() if not v] | |
| if score >= 90: | |
| res = ( | |
| "평가 적합도가 매우 높습니다. 1차 계획, 역할분담, 시행과정, 2차 결과가" | |
| " 유기적으로 연결되었습니다." | |
| ) | |
| else: | |
| res = ( | |
| "보완이 필요합니다. 필수 항목(선정사유, 역할분담, 시행과정," | |
| " 결과평가)을 보완해 주세요." | |
| ) | |
| return score, missing, res | |
| # ----------------------------- | |
| # 엑셀 생성 엔진 (인메모리 전용) | |
| # ----------------------------- | |
| def style_range( | |
| ws, | |
| min_row, | |
| max_row, | |
| min_col=1, | |
| max_col=9, | |
| fill=None, | |
| font=None, | |
| alignment=None, | |
| ): | |
| thin = Side(style="thin", color="000000") | |
| border = Border(left=thin, right=thin, top=thin, bottom=thin) | |
| for row in ws.iter_rows( | |
| min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col | |
| ): | |
| for cell in row: | |
| cell.border = border | |
| if fill: | |
| cell.fill = fill | |
| if font: | |
| cell.font = font | |
| if alignment: | |
| cell.alignment = alignment | |
| def write_header_and_meta(ws, title, data, is_second=False): | |
| ws.page_setup.paperSize = ws.PAPERSIZE_A4 | |
| ws.page_setup.orientation = "portrait" | |
| ws.page_setup.fitToWidth = 1 | |
| ws.page_setup.fitToHeight = 0 | |
| ws.sheet_properties.pageSetUpPr.fitToPage = True | |
| ws.sheet_view.showGridLines = False | |
| for col in range(1, 10): | |
| ws.column_dimensions[get_column_letter(col)].width = 11.5 | |
| title_fill = PatternFill("solid", fgColor="EDE9FE") | |
| header_fill = PatternFill("solid", fgColor="F3F4F6") | |
| ws.merge_cells("A1:G2") | |
| ws["A1"] = title | |
| style_range( | |
| ws, | |
| 1, | |
| 2, | |
| 1, | |
| 7, | |
| fill=title_fill, | |
| font=Font(name="맑은 고딕", size=20, bold=True), | |
| alignment=Alignment(horizontal="center", vertical="center"), | |
| ) | |
| ws["H1"] = "담당자" | |
| ws["I1"] = "시설장" | |
| style_range( | |
| ws, | |
| 1, | |
| 1, | |
| 8, | |
| 9, | |
| fill=header_fill, | |
| font=Font(name="맑은 고딕", size=10, bold=True), | |
| alignment=Alignment(horizontal="center", vertical="center"), | |
| ) | |
| style_range(ws, 2, 2, 8, 9) | |
| ws.row_dimensions[1].height = 24 | |
| ws.row_dimensions[2].height = 36 | |
| m_date = ( | |
| data["second_meeting_date"] if is_second else data["first_meeting_date"] | |
| ) | |
| sub_date_lbl = "1차회의일" if is_second else "2차예정일" | |
| sub_date_val = ( | |
| data["first_meeting_date"] if is_second else data["second_meeting_date"] | |
| ) | |
| meta_rows = [ | |
| ("기관명", data["institution_name"], "기관유형", data["institution_type"], "회의일자", m_date), | |
| ("수급자", data["client_masked_name"], "장기요양등급", data["grade"], "사례유형", data["case_type"]), | |
| ("인정번호", data.get("recognition_number", "-"), "인정기간", f"{data.get('recognition_start_date','')} ~ {data.get('recognition_end_date','')}", "기한알림", data.get("recognition_alert", "-")), | |
| ("회의장소", data["meeting_place"], "작성자", data["writer"], sub_date_lbl, sub_date_val), | |
| ] | |
| r = 3 | |
| for row_data in meta_rows: | |
| ws.cell(r, 1, row_data[0]) | |
| ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3) | |
| ws.cell(r, 2, row_data[1]) | |
| ws.cell(r, 4, row_data[2]) | |
| ws.merge_cells(start_row=r, start_column=5, end_row=r, end_column=6) | |
| ws.cell(r, 5, row_data[3]) | |
| ws.cell(r, 7, row_data[4]) | |
| ws.merge_cells(start_row=r, start_column=8, end_row=r, end_column=9) | |
| ws.cell(r, 8, row_data[5]) | |
| for c in [1, 4, 7]: | |
| ws.cell(r, c).fill = header_fill | |
| ws.cell(r, c).font = Font(name="맑은 고딕", size=9.5, bold=True) | |
| ws.cell(r, c).alignment = Alignment( | |
| horizontal="center", vertical="center" | |
| ) | |
| for c in [2, 5, 8]: | |
| ws.cell(r, c).font = Font(name="맑은 고딕", size=9.5) | |
| ws.cell(r, c).alignment = Alignment( | |
| horizontal="center", vertical="center" | |
| ) | |
| style_range(ws, r, r, 1, 9) | |
| ws.row_dimensions[r].height = 24 | |
| r += 1 | |
| return r | |
| def write_sheet_sections(ws, r, sections, photos=None): | |
| header_fill = PatternFill("solid", fgColor="F3F4F6") | |
| for sec_title, content, min_h in sections: | |
| ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9) | |
| ws.cell(r, 1, sec_title) | |
| style_range( | |
| ws, | |
| r, | |
| r, | |
| 1, | |
| 9, | |
| fill=header_fill, | |
| font=Font(name="맑은 고딕", size=11, bold=True), | |
| alignment=Alignment(horizontal="center", vertical="center"), | |
| ) | |
| ws.row_dimensions[r].height = 24 | |
| r += 1 | |
| ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9) | |
| ws.cell(r, 1, content) | |
| style_range( | |
| ws, | |
| r, | |
| r, | |
| 1, | |
| 9, | |
| font=Font(name="맑은 고딕", size=9.5), | |
| alignment=Alignment( | |
| horizontal="left", vertical="top", wrap_text=True | |
| ), | |
| ) | |
| ws.row_dimensions[r].height = min_h | |
| r += 1 | |
| if photos: | |
| ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9) | |
| ws.cell(r, 1, "사례관리 사진 첨부") | |
| style_range( | |
| ws, | |
| r, | |
| r, | |
| 1, | |
| 9, | |
| fill=header_fill, | |
| font=Font(name="맑은 고딕", size=11, bold=True), | |
| alignment=Alignment(horizontal="center", vertical="center"), | |
| ) | |
| ws.row_dimensions[r].height = 24 | |
| r += 1 | |
| anchors = ["A", "D", "G"] | |
| for idx, img_file in enumerate(photos[:3]): | |
| try: | |
| pil_img = PILImage.open(img_file) | |
| img_buf = BytesIO() | |
| pil_img.save(img_buf, format="PNG") | |
| img_buf.seek(0) | |
| xl_img = XLImage(img_buf) | |
| xl_img.width = 200 | |
| xl_img.height = 140 | |
| ws.add_image(xl_img, f"{anchors[idx]}{r}") | |
| except Exception: | |
| pass | |
| style_range(ws, r, r + 6, 1, 9) | |
| for rr in range(r, r + 7): | |
| ws.row_dimensions[rr].height = 20 | |
| r += 7 | |
| # 하단 알림 | |
| ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9) | |
| ws.cell( | |
| r, | |
| 1, | |
| "※ 본 문서는 기관 운영방침 및 공단 평가기준에 따라 보완하여 사용할 수" | |
| " 있습니다.", | |
| ) | |
| style_range( | |
| ws, | |
| r, | |
| r, | |
| 1, | |
| 9, | |
| font=Font(name="맑은 고딕", size=8.5, color="777777", italic=True), | |
| alignment=Alignment(horizontal="center", vertical="center"), | |
| ) | |
| ws.row_dimensions[r].height = 24 | |
| ws.print_area = f"A1:I{r}" | |
| def generate_excel_bytes(data, photos): | |
| wb = Workbook() | |
| # Sheet 1: 1차 계획회의록 | |
| ws1 = wb.active | |
| ws1.title = "1차계획회의록" | |
| r1 = write_header_and_meta( | |
| ws1, "사례관리 1차 계획회의록", data, is_second=False | |
| ) | |
| s1_sections = [ | |
| ("사례관리 대상자 선정사유", data["selection_reason"], 60), | |
| ("상담일지 및 욕구사정 연동 요약", data["needs_summary"], 70), | |
| ("1차 회의 상세 기록", data["first_content"], 130), | |
| ( | |
| "1차 사례관리 목표 및 계획", | |
| f"[사례관리 목표]\n{data['goal']}\n\n[구체적 지원계획]\n{data['first_plan']}", | |
| 110, | |
| ), | |
| ("직종별 역할분담", data["role_division"], 90), | |
| ("참석자 명단", data["attendees_text"], 50), | |
| ] | |
| write_sheet_sections(ws1, r1, s1_sections, photos) | |
| # Sheet 2: 2차 결과회의록 | |
| ws2 = wb.create_sheet("2차결과회의록") | |
| r2 = write_header_and_meta( | |
| ws2, "사례관리 2차 결과회의록", data, is_second=True | |
| ) | |
| s2_sections = [ | |
| ("사례관리 대상자 선정사유", data["selection_reason"], 60), | |
| ( | |
| "1차 목표 및 계획 재확인", | |
| f"[목표] {data['goal']}\n[주요계획]\n{data['first_plan']}", | |
| 90, | |
| ), | |
| ("직종별 시행과정 및 개입내용", data["implementation_process"], 120), | |
| ("2차 회의결과 및 목표달성도 평가", data["second_result"], 120), | |
| ("참석자 명단", data["attendees_text"], 50), | |
| ] | |
| write_sheet_sections(ws2, r2, s2_sections) | |
| out = BytesIO() | |
| wb.save(out) | |
| out.seek(0) | |
| return out | |
| # ----------------------------- | |
| # Streamlit 화면 입력 구성 | |
| # ----------------------------- | |
| clients_df = load_clients() | |
| st.sidebar.markdown("### ⚙️ 메뉴 설정") | |
| menu_mode = st.sidebar.radio("작업 모드 선택", ["사례관리 회의록 작성", "저장기록 열람"]) | |
| if menu_mode == "저장기록 열람": | |
| st.subheader("📚 최근 저장된 사례관리 기록") | |
| conn = get_conn() | |
| try: | |
| recs = pd.read_sql_query( | |
| "SELECT * FROM case_management_records ORDER BY id DESC LIMIT 20", conn | |
| ) | |
| if recs.empty: | |
| st.info("현재 저장된 사례관리 기록이 없습니다.") | |
| else: | |
| st.dataframe(recs, use_container_width=True) | |
| except Exception as e: | |
| st.error(f"기록 조회 오류: {e}") | |
| conn.close() | |
| else: | |
| # 기본 정보 입력 | |
| c1, c2, c3 = st.columns([1.5, 1.2, 1.3]) | |
| inst_name = c1.text_input("기관명", "행복재가노인복지센터") | |
| inst_type = c2.selectbox( | |
| "기관유형", | |
| ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"], | |
| index=0, | |
| ) | |
| client_name = c3.text_input("수급자 성명", "홍길동") | |
| c4, c5, c6 = st.columns([1.5, 1.2, 1.3]) | |
| care_grade = c4.selectbox( | |
| "장기요양등급", | |
| ["1등급", "2등급", "3등급", "4등급", "5등급", "인지지원등급"], | |
| index=2, | |
| ) | |
| rec_num = c5.text_input("장기요양 인정번호", "L1234567890") | |
| rec_end = c6.date_input( | |
| "인정기간 만료일", value=date.today() + timedelta(days=240) | |
| ) | |
| c7, c8 = st.columns(2) | |
| main_cond = c7.text_area( | |
| "주요 질환 및 신체/인지 상태", | |
| "초기 치매 증상, 무릎 관절염으로 인한 보행 불안정, 야간 화장실 이동 시 낙상 위험 높음.", | |
| height=70, | |
| ) | |
| needs_input = c8.text_area( | |
| "상담 및 욕구사정 주요 내용", | |
| "보호자는 야간 낙상 사고를 가장 우려함. 수급자는 기립 시 어지럼증을 호소하며 안전한 이동 보조를 희망함.", | |
| height=70, | |
| ) | |
| rec_type = recommend_case_type(needs_input, needs_input, main_cond) | |
| col_m1, col_m2, col_m3 = st.columns([1.5, 1.2, 1.3]) | |
| case_types_list = list(CASE_TYPES.keys()) | |
| case_type = col_m1.selectbox( | |
| "사례관리 유형", | |
| case_types_list, | |
| index=( | |
| case_types_list.index(rec_type) | |
| if rec_type in case_types_list | |
| else 0 | |
| ), | |
| ) | |
| first_date = col_m2.date_input("1차 계획 회의일자", value=date.today()) | |
| second_date = col_m3.date_input( | |
| "2차 결과 회의일자 (30일 후 권장)", | |
| value=date.today() + timedelta(days=30), | |
| ) | |
| col_m4, col_m5 = st.columns(2) | |
| place = col_m4.text_input("회의장소", "기관 회의실") | |
| writer_name = col_m5.text_input("작성자", "사회복지사") | |
| # 상세 내용 자동완성 로직 | |
| details = CASE_DETAILS.get(case_type, CASE_DETAILS["낙상 고위험 관리"]) | |
| default_sel = ( | |
| f"{mask_name(client_name)} 수급자는 {care_grade} 대상자로," | |
| f" {CASE_TYPES[case_type]['reason']} 일상생활 안전과 맞춤형 급여 제공을" | |
| f" 위해 {case_type} 사례관리 대상으로 선정함." | |
| ) | |
| default_content = f"""[SOAP 구조화 기록 분석] | |
| - [S 주관적 호소] {details['subjective']} | |
| - [O 객관적 관찰] {details['objective']} | |
| - [A 전문적 판단] {details['assessment']} | |
| - [P 외부자원연계] {details['resource']} | |
| [회의 종합 의견] | |
| 수급자의 안전 확보 및 상태 악화 방지를 위해 직종별 역할을 분담하여 집중 지원을 개시하기로 결정함.""" | |
| default_plan = "\n".join([f"- {p}" for p in details["plan"]]) | |
| default_roles = """시설장: 사례관리 계획 검토, 급여제공계획 변경 승인 및 외부자원 연계 총괄 | |
| 사회복지사: 1·2차 회의 주관, 보호자 정기 상담, 맞춤형 개별이용계획 수립 및 모니터링 | |
| 요양보호사: 일상 돌봄 시 개별 지원지침 이행, 수급자 특이사항 관찰 및 급여제공기록지 작성 | |
| 간호(조무)사: 혈압·혈당 등 활력징후 측정, 복약 상태 및 전반적인 건강 모니터링""".strip() | |
| default_impl = f"""[직종별 실행 내용] | |
| - 요양보호사: 매 급여제공 시 {details['plan'][0]}을(를) 중점 시행하고 일상 기록지에 상세 기록함. | |
| - 간호(조무)사: 주 2회 활력징후 점검 및 피부·영양 상태를 지속 관찰함. | |
| - 사회복지사: 보호자와 주 1회 상담을 통해 수급자 상태 변화를 공유하고 피드백을 수렴함. | |
| - 30일간 집중 모니터링을 차질 없이 수행함.""" | |
| default_second = f"""[목표달성도 평가] | |
| - 평가 결과: 부분 달성 및 안전 상태 지속 유지 | |
| - 주요 성과: 사례관리 개입 기간 동안 낙상이나 상태 악화 없이 안정적인 일상생활을 유지함. | |
| - 향후 계획: 수급자의 자립 잔존기능 유지를 위해 수립된 개별 지원방법을 정규 급여제공계획서에 반영하여 지속 지원하기로 함.""" | |
| st.markdown("### 📝 회의록 상세 내용 검토 및 수정") | |
| col_t1, col_t2 = st.columns(2) | |
| sel_reason = col_t1.text_area( | |
| "1. 사례선정 사유", value=default_sel, height=95 | |
| ) | |
| goal_text = col_t2.text_area( | |
| "2. 사례관리 목표", value=details.get("goal", default_sel), height=95 | |
| ) | |
| col_t3, col_t4 = st.columns(2) | |
| first_cont = col_t3.text_area( | |
| "3. 1차 회의 내용 (SOAP)", value=default_content, height=135 | |
| ) | |
| plan_text = col_t4.text_area( | |
| "4. 구체적 지원계획", value=default_plan, height=135 | |
| ) | |
| col_t5, col_t6 = st.columns(2) | |
| roles_text = col_t5.text_area( | |
| "5. 직종별 역할분담", value=default_roles, height=115 | |
| ) | |
| impl_text = col_t6.text_area( | |
| "6. 시행과정 (30일간 실행기록)", value=default_impl, height=115 | |
| ) | |
| second_text = st.text_area( | |
| "7. 2차 회의결과 및 평가", value=default_second, height=100 | |
| ) | |
| attendees = st.text_input( | |
| "참석자 명단", | |
| "시설장 홍길동, 사회복지사 김복지, 요양보호사 이순신, 간호조무사 강감찬", | |
| ) | |
| photo_files = st.file_uploader( | |
| "📷 훈련/사례관리 사진 첨부 (최대 3장 / PNG, JPG)", | |
| type=["png", "jpg", "jpeg"], | |
| accept_multiple_files=True, | |
| ) | |
| # 데이터 통합 | |
| data_payload = { | |
| "client_id": 0, | |
| "client_masked_name": mask_name(client_name), | |
| "institution_name": inst_name, | |
| "institution_type": inst_type, | |
| "grade": care_grade, | |
| "recognition_number": rec_num, | |
| "recognition_start_date": str(date.today() - timedelta(days=365)), | |
| "recognition_end_date": str(rec_end), | |
| "recognition_alert": recognition_alert_text(str(rec_end)), | |
| "case_type": case_type, | |
| "first_meeting_date": str(first_date), | |
| "second_meeting_date": str(second_date), | |
| "meeting_place": place, | |
| "writer": writer_name, | |
| "selection_reason": sel_reason, | |
| "first_content": first_cont, | |
| "goal": goal_text, | |
| "first_plan": plan_text, | |
| "role_division": roles_text, | |
| "implementation_process": impl_text, | |
| "second_result": second_text, | |
| "needs_summary": f"{needs_input}\n[주요상태] {main_cond}", | |
| "attendees_text": attendees, | |
| } | |
| score, missing, compliance = evaluate_case( | |
| sel_reason, first_cont, plan_text, roles_text, impl_text, second_text | |
| ) | |
| data_payload["evaluation_score"] = score | |
| data_payload["missing_items"] = ", ".join(missing) if missing else "없음" | |
| st.markdown("---") | |
| ev1, ev2, ev3 = st.columns(3) | |
| ev1.metric("공단 평가 적합도", f"{score}점") | |
| ev2.metric("보완 필요 항목", data_payload["missing_items"]) | |
| ev3.metric("첨부 사진 수", f"{len(photo_files) if photo_files else 0}장") | |
| if score >= 90: | |
| st.markdown(f'<div class="okbox">{compliance}</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown( | |
| f'<div class="warnbox">{compliance}</div>', unsafe_allow_html=True | |
| ) | |
| col_b1, col_b2 = st.columns([1, 1]) | |
| with col_b1: | |
| if st.button("💾 사례관리 기록 DB 저장", use_container_width=True): | |
| save_case_record(data_payload) | |
| st.success("데이터베이스에 안전하게 저장되었습니다.") | |
| excel_file = generate_excel_bytes(data_payload, photo_files or []) | |
| file_name_out = ( | |
| f"사례관리회의록_{mask_name(client_name)}_{first_date}.xlsx" | |
| ) | |
| with col_b2: | |
| st.download_button( | |
| label="📥 사례관리 회의록 엑셀 다운로드 (.xlsx)", | |
| data=excel_file, | |
| file_name=file_name_out, | |
| mime=( | |
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | |
| ), | |
| use_container_width=True, | |
| type="primary", | |
| ) |