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("μ μ₯λ λ³κ²½μ΄λ ₯μ΄ μμ΅λλ€.")