import os
import sqlite3
import random
import tempfile
from io import BytesIO
from datetime import date, datetime
import pandas as pd
import streamlit as st
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
from openpyxl.utils import get_column_letter
from openpyxl.drawing.image import Image as XLImage
st.set_page_config(page_title="재난훈련일지", page_icon="🚨", layout="wide")
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
DB_CANDIDATES = [
os.path.join(CURRENT_DIR, "data", "clients.db"),
os.path.join(CURRENT_DIR, "..", "Client Center", "data", "clients.db"),
os.path.join(CURRENT_DIR, "..", "ClientCenter", "data", "clients.db"),
os.path.join(CURRENT_DIR, "..", "Client_Center", "data", "clients.db"),
]
DB_PATH = next((p for p in DB_CANDIDATES if os.path.exists(p)), None)
if DB_PATH is None:
os.makedirs(os.path.join(CURRENT_DIR, "data"), exist_ok=True)
DB_PATH = os.path.join(CURRENT_DIR, "data", "clients.db")
st.markdown("""
""", unsafe_allow_html=True)
st.markdown("""
🚨 재난훈련일지 생성기
화재·지진·감염병·응급상황 훈련을 시나리오, 역할분담, 결과평가, 개선조치까지 기록합니다.
""", unsafe_allow_html=True)
st.markdown("""
필수 구조: 훈련일시, 장소, 훈련종류, 참석자, 시나리오, 역할분담, 훈련과정, 결과평가, 개선조치, 사진첨부를 포함합니다.
""", unsafe_allow_html=True)
st.markdown("""
※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다.
""", unsafe_allow_html=True)
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 disaster_training_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
institution_name TEXT,
institution_type TEXT,
training_title TEXT,
training_type TEXT,
training_date TEXT,
training_time TEXT,
training_place TEXT,
supervisor TEXT,
participants TEXT,
scenario TEXT,
role_division TEXT,
training_process TEXT,
evaluation_result TEXT,
improvement_plan TEXT,
emergency_contacts TEXT,
evaluation_score INTEGER,
missing_items TEXT,
compliance_result TEXT,
photo_count INTEGER,
created_at TEXT
)
""")
conn.commit(); conn.close()
def save_record(data):
conn = get_conn(); cur = conn.cursor()
cur.execute("""
INSERT INTO disaster_training_records (
institution_name, institution_type, training_title, training_type,
training_date, training_time, training_place, supervisor,
participants, scenario, role_division, training_process,
evaluation_result, improvement_plan, emergency_contacts,
evaluation_score, missing_items, compliance_result, photo_count, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
data["institution_name"], data["institution_type"], data["training_title"], data["training_type"],
data["training_date"], data["training_time"], data["training_place"], data["supervisor"],
data["participants"], data["scenario"], data["role_division"], data["training_process"],
data["evaluation_result"], data["improvement_plan"], data["emergency_contacts"],
data["evaluation_score"], data["missing_items"], data["compliance_result"], data.get("photo_count", 0),
datetime.now().strftime("%Y-%m-%d %H:%M:%S")
))
conn.commit(); conn.close()
def load_records():
conn = get_conn()
try:
df = pd.read_sql_query("SELECT * FROM disaster_training_records ORDER BY id DESC", conn)
except Exception:
df = pd.DataFrame()
conn.close(); return df
init_db()
TRAINING_TYPES = ["화재 대피훈련", "지진 대피훈련", "응급상황 대응훈련", "감염병 대응훈련", "정전/단수 대응훈련", "실종·배회 대응훈련", "야간 재난상황 대응훈련", "기타"]
INSTITUTION_POINTS = {
"방문요양": "재가 방문 중 응급상황 발견 시 119 신고, 보호자 연락, 기관 보고, 안전확보 절차를 중심으로 훈련합니다.",
"방문목욕": "목욕 중 낙상, 호흡곤란, 피부손상 등 상황 발생 시 즉시 중단, 안전확보, 보호자 연락, 기관 보고를 중심으로 훈련합니다.",
"방문간호": "건강상태 급변, 감염 의심, 응급처치, 의료기관 연계 절차를 중심으로 훈련합니다.",
"주간보호": "센터 내 화재·지진·송영 중 사고·감염병 발생 시 대피유도와 대피취약자 지원을 중심으로 훈련합니다.",
"요양원": "입소자 대피, 야간 순찰, 층별 역할분담, 산소·침상·휠체어 이용자의 이동지원을 중심으로 훈련합니다.",
"공동생활가정": "소규모 생활공간에서 화재·야간응급·공동생활 갈등 상황 발생 시 신속한 보고와 대피를 중심으로 훈련합니다.",
}
TRAINING_RULE_POINTS = {
"화재 대피훈련": ["초기 발견 및 전파", "119 신고", "소화기 위치 확인", "대피취약자 우선 이동", "대피 완료 후 인원점검"],
"지진 대피훈련": ["낙하물 회피", "책상·벽면 보호", "출입문 개방", "안전한 외부 장소 이동", "부상자 확인"],
"응급상황 대응훈련": ["의식·호흡 확인", "119 신고", "심폐소생술 또는 응급처치", "보호자 연락", "응급기록 작성"],
"감염병 대응훈련": ["증상자 분리", "마스크 및 보호구 착용", "손위생", "공간 소독", "보건소 및 보호자 보고"],
"정전/단수 대응훈련": ["비상조명 확인", "의료장비 사용 수급자 확인", "급수·급식 대체방안", "안전 이동", "복구상황 공유"],
"실종·배회 대응훈련": ["마지막 목격장소 확인", "실내외 수색", "보호자 연락", "112 신고 검토", "재발방지 동선 점검"],
"야간 재난상황 대응훈련": ["야간 순찰", "최소인력 역할분담", "대피취약자 우선 지원", "비상연락망 가동", "인수인계 기록"],
"기타": ["상황 전파", "안전 확보", "역할분담", "관계기관 연락", "결과평가 및 개선조치"],
}
def build_rule_application(training_type, institution_type):
points = TRAINING_RULE_POINTS.get(training_type, TRAINING_RULE_POINTS["기타"])
institution_point = INSTITUTION_POINTS.get(institution_type, "기관 상황에 맞는 재난상황 대응절차를 중심으로 훈련합니다.")
lines = [
"[재난훈련규칙 적용사항]",
f"- 훈련유형별 중점: {', '.join(points)}",
f"- 기관유형별 중점: {institution_point}",
"- 역할분담: 총괄, 신고·연락, 대피유도, 대피취약자 지원, 응급처치, 기록 담당을 구분함",
"- 결과평가: 잘된점, 미흡점, 개선조치, 보충교육 필요 여부를 기록함",
"- 평가대응: 사진첨부, 비상연락망, 참석자 명단, 훈련 진행과정이 서로 일치하도록 점검함",
]
return "\n".join(lines)
def save_uploaded_photos(files):
paths = []
for file in (files or [])[:3]:
suffix = os.path.splitext(file.name)[1].lower() or ".png"
temp_path = os.path.join(tempfile.gettempdir(), f"disaster_photo_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{suffix}")
with open(temp_path, "wb") as f:
f.write(file.getbuffer())
paths.append(temp_path)
return paths
def default_scenario(training_type, institution_type):
base = INSTITUTION_POINTS.get(institution_type, "기관 상황에 맞는 재난상황 대응절차를 중심으로 훈련합니다.")
if "화재" in training_type:
situation = "기관 내 화재 경보가 발생하여 초기 신고, 수급자 안전확인, 대피유도, 비상연락망 가동이 필요한 상황을 가정함."
elif "지진" in training_type:
situation = "지진 발생으로 낙하물과 이동 위험이 확인되어 책상·벽면 보호, 대피로 확인, 안전한 장소 이동이 필요한 상황을 가정함."
elif "응급" in training_type:
situation = "수급자 호흡곤란 또는 의식저하가 발생하여 119 신고, 보호자 연락, 응급처치, 기록이 필요한 상황을 가정함."
elif "감염" in training_type:
situation = "발열·호흡기 증상자가 발생하여 격리, 마스크 착용, 보호자 안내, 소독 및 보고가 필요한 상황을 가정함."
elif "야간" in training_type:
situation = "야간 시간대 화재 또는 응급상황이 발생하여 최소 인력으로 순회확인, 대피취약자 지원, 비상연락망 가동이 필요한 상황을 가정함."
else:
situation = "기관 운영 중 예기치 못한 재난·안전사고가 발생한 상황을 가정함."
return f"{situation}\n{base}"
def build_role_division(institution_type, custom_roles):
if custom_roles.strip():
return custom_roles.strip()
return """
시설장: 훈련 총괄, 비상연락망 가동, 관계기관 신고 및 훈련 결과 확인을 담당한다.
사회복지사: 수급자 명단 확인, 보호자 연락, 대피완료 여부 확인, 훈련기록 작성을 담당한다.
요양보호사: 수급자 이동보조, 대피취약자 지원, 낙상예방과 안전확인을 담당한다.
간호(조무)사: 응급상태 확인, 활력징후 확인, 응급처치 및 의료기관 연계 필요성을 확인한다.
운전원/기타직원: 차량·출입구·대피로 확보, 소화기 위치 확인, 외부 이동 안전을 지원한다.
""".strip()
def build_training_text(data):
return f"""
[훈련 개요]
- 훈련명: {data['training_title']}
- 훈련종류: {data['training_type']}
- 훈련일시: {data['training_date']} {data['training_time']}
- 훈련장소: {data['training_place']}
- 총괄책임자: {data['supervisor']}
[훈련 시나리오]
{data['scenario']}
[역할분담]
{data['role_division']}
[훈련 진행과정]
{data['training_process']}
[비상연락망]
{data['emergency_contacts']}
{data.get('rule_application', build_rule_application(data['training_type'], data['institution_type']))}
""".strip()
def evaluate_record(data):
required = {
"훈련명": bool(data["training_title"].strip()),
"훈련일시": bool(data["training_date"]) and bool(data["training_time"].strip()),
"훈련장소": bool(data["training_place"].strip()),
"참석자": bool(data["participants"].strip()),
"시나리오": bool(data["scenario"].strip()),
"역할분담": bool(data["role_division"].strip()),
"훈련과정": bool(data["training_process"].strip()),
"결과평가": bool(data["evaluation_result"].strip()),
"개선조치": bool(data["improvement_plan"].strip()),
"비상연락망": bool(data["emergency_contacts"].strip()),
"재난훈련규칙": bool(data.get("rule_application", "").strip()),
}
score = int(sum(required.values()) / len(required) * 100)
missing = [k for k, v in required.items() if not v]
if score >= 90:
msg = "평가 적합도가 높습니다. 훈련 시나리오, 역할분담, 진행과정, 결과평가, 개선조치가 잘 반영되었습니다."
elif score >= 75:
msg = "대체로 적합하나 일부 보완이 필요합니다. 부족 항목을 보완하면 평가 대응력이 높아집니다."
else:
msg = "보완이 필요합니다. 훈련일지에는 시나리오, 참석자, 역할분담, 결과평가, 개선조치가 구체적으로 들어가야 합니다."
return score, missing, msg
def _border():
s = Side(style="thin", color="000000")
return Border(left=s, right=s, top=s, bottom=s)
def style_range(ws, min_row, max_row, min_col=1, max_col=9, fill=None, font=None, alignment=None):
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 is not None:
cell.fill = fill
if font is not None:
cell.font = font
if alignment is not None:
cell.alignment = alignment
def setup_print(ws, max_row):
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.page_margins.left = 0.25
ws.page_margins.right = 0.25
ws.page_margins.top = 0.35
ws.page_margins.bottom = 0.35
ws.sheet_view.showGridLines = False
ws.print_area = f"A1:I{max_row}"
for col in range(1, 10):
ws.column_dimensions[get_column_letter(col)].width = 12
def write_title(ws, title, title_fill, header_fill):
ws.merge_cells(start_row=1, start_column=1, end_row=2, end_column=7)
ws.cell(1, 1).value = title
ws.cell(1, 1).font = Font(name="Malgun Gothic", size=24, bold=True)
ws.cell(1, 1).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
style_range(ws, 1, 2, 1, 7, fill=title_fill, font=Font(name="Malgun Gothic", size=24, bold=True), alignment=Alignment(horizontal="center", vertical="center", wrap_text=True))
ws.cell(1, 8).value = "담당자"; ws.cell(1, 9).value = "시설장"
ws.cell(2, 8).value = ""; ws.cell(2, 9).value = ""
style_range(ws, 1, 1, 8, 9, fill=header_fill, font=Font(name="Malgun Gothic", size=11, bold=True), alignment=Alignment(horizontal="center", vertical="center"))
style_range(ws, 2, 2, 8, 9, font=Font(name="Malgun Gothic", size=11), alignment=Alignment(horizontal="center", vertical="center"))
ws.row_dimensions[1].height = 28; ws.row_dimensions[2].height = 42
def write_info(ws, r, a, b, c, d, e, f, header_fill):
ws.cell(r, 1).value = a; ws.merge_cells(start_row=r, start_column=2, end_row=r, end_column=3); ws.cell(r, 2).value = b
ws.cell(r, 4).value = c; ws.merge_cells(start_row=r, start_column=5, end_row=r, end_column=6); ws.cell(r, 5).value = d
ws.cell(r, 7).value = e; ws.merge_cells(start_row=r, start_column=8, end_row=r, end_column=9); ws.cell(r, 8).value = f
for col in [1, 4, 7]:
ws.cell(r, col).fill = header_fill; ws.cell(r, col).font = Font(name="Malgun Gothic", size=10, bold=True); ws.cell(r, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
for col in [2, 5, 8]:
ws.cell(r, col).font = Font(name="Malgun Gothic", size=10); ws.cell(r, col).alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
style_range(ws, r, r, 1, 9); ws.row_dimensions[r].height = 28
def height_for_text(text, chars=58, max_height=420):
lines = 0
for line in str(text or "").split("\n"):
lines += max(1, len(line) // chars + 1)
return min(max(70, lines * 18 + 18), max_height)
def write_section(ws, r, title, content, header_fill, min_height=90):
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
ws.cell(r, 1).value = title
style_range(ws, r, r, 1, 9, fill=header_fill, font=Font(name="Malgun Gothic", size=13, bold=True), alignment=Alignment(horizontal="center", vertical="center", wrap_text=True))
ws.row_dimensions[r].height = 28
r += 1
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
ws.cell(r, 1).value = str(content or "")
style_range(ws, r, r, 1, 9, font=Font(name="Malgun Gothic", size=10), alignment=Alignment(horizontal="left", vertical="top", wrap_text=True))
ws.row_dimensions[r].height = max(min_height, height_for_text(content))
return r + 1
def write_internal_sheet(wb, data, header_fill):
ws = wb.create_sheet("내부점검_출력제외")
ws.sheet_state = "hidden"
rows = [("평가 적합도", f"{data['evaluation_score']}점"), ("부족 항목", data["missing_items"]), ("점검 의견", data["compliance_result"])]
for i, (a, b) in enumerate(rows, 1):
ws.cell(i, 1).value = a; ws.cell(i, 2).value = b
ws.cell(i, 1).font = Font(name="Malgun Gothic", size=10, bold=True); ws.cell(i, 1).fill = header_fill
ws.cell(i, 2).font = Font(name="Malgun Gothic", size=10); ws.cell(i, 2).alignment = Alignment(wrap_text=True, vertical="top")
ws.column_dimensions["A"].width = 18; ws.column_dimensions["B"].width = 90
def generate_excel(data):
wb = Workbook()
title_fill = PatternFill("solid", fgColor="FEE2E2")
header_fill = PatternFill("solid", fgColor="F3F4F6")
ws = wb.active
ws.title = "재난훈련일지"
write_title(ws, "재난훈련일지", title_fill, header_fill)
r = 3
write_info(ws, r, "기관명", data["institution_name"], "기관유형", data["institution_type"], "훈련일자", data["training_date"], header_fill); r += 1
write_info(ws, r, "훈련명", data["training_title"], "훈련종류", data["training_type"], "훈련시간", data["training_time"], header_fill); r += 1
write_info(ws, r, "장소", data["training_place"], "총괄", data["supervisor"], "사진", f"{data.get('photo_count', 0)}장", header_fill); r += 1
r = write_section(ws, r, "참석자 명단", data["participants"], header_fill, 70)
r = write_section(ws, r, "훈련 기록내용", build_training_text(data), header_fill, 260)
r = write_section(ws, r, "훈련 결과평가", data["evaluation_result"], header_fill, 100)
r = write_section(ws, r, "개선조치 및 후속계획", data["improvement_plan"], header_fill, 100)
r = write_section(ws, r, "재난훈련규칙 적용사항", data.get("rule_application", ""), header_fill, 90)
if data.get("photo_paths"):
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
ws.cell(r, 1).value = "훈련사진"
style_range(ws, r, r, 1, 9, fill=header_fill, font=Font(name="Malgun Gothic", size=13, bold=True), alignment=Alignment(horizontal="center", vertical="center"))
r += 1
anchors = ["A", "D", "G"]
img_row = r
for idx, photo_path in enumerate(data.get("photo_paths", [])[:3]):
try:
img = XLImage(photo_path); img.width = 210; img.height = 150; ws.add_image(img, f"{anchors[idx]}{img_row}")
except Exception:
pass
style_range(ws, img_row, img_row + 7, 1, 9)
for rr in range(img_row, img_row + 8): ws.row_dimensions[rr].height = 22
r = img_row + 8
ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=9)
ws.cell(r, 1).value = "※ 생성된 문서는 그대로 사용하셔도 되며, 기관의 운영방침, 수급자 상태, 보호자 의견 및 평가기준에 따라 추가 보완사항은 얼마든지 수정 가능합니다. 기관의 상황에 맞게 활용하시기 바랍니다."
style_range(ws, r, r, 1, 9,
font=Font(name="Malgun Gothic", size=8.5, color="595959", italic=True),
alignment=Alignment(horizontal="left", vertical="center", wrap_text=True))
ws.row_dimensions[r].height = 36
r += 1
setup_print(ws, r - 1)
write_internal_sheet(wb, data, header_fill)
output = BytesIO(); wb.save(output); output.seek(0); return output
menu = st.sidebar.radio("메뉴", ["재난훈련일지 생성", "저장기록 보기"])
st.sidebar.success("DB 연결"); st.sidebar.caption(DB_PATH)
if menu == "저장기록 보기":
st.subheader("📚 저장된 재난훈련 기록")
records = load_records()
if records.empty: st.info("저장된 재난훈련 기록이 없습니다.")
else: st.dataframe(records, use_container_width=True)
else:
st.subheader("🚨 재난훈련일지 생성")
c1, c2, c3 = st.columns(3)
institution_name = c1.text_input("기관명", "장기요양기관")
institution_type = c2.selectbox("기관유형", ["방문요양", "방문목욕", "방문간호", "주간보호", "요양원", "공동생활가정"])
training_title = c3.text_input("훈련명", "재난상황 대응훈련")
c4, c5, c6 = st.columns(3)
training_type = c4.selectbox("훈련종류", TRAINING_TYPES)
training_date = c5.date_input("훈련일자", value=date.today())
training_time = c6.text_input("훈련시간", "14:00~14:40")
c7, c8 = st.columns(2)
training_place = c7.text_input("훈련장소", "기관 내외부 및 대피장소")
supervisor = c8.text_input("총괄책임자", "시설장")
participants = st.text_area("참석자 명단", placeholder="예: 시설장 홍길동, 사회복지사 김복지, 요양보호사 이요양 외 8명", height=80)
scenario = st.text_area("훈련 시나리오", value=default_scenario(training_type, institution_type), height=120)
role_division = st.text_area("역할분담", value=build_role_division(institution_type, ""), height=130)
default_process = """1. 훈련 시작 전 훈련 목적과 안전수칙을 안내하였다.
2. 재난상황 발생을 가정하고 최초 발견자 신고 및 기관 내 보고를 실시하였다.
3. 수급자 상태와 대피취약자를 확인하고 역할분담에 따라 안전한 장소로 이동을 지원하였다.
4. 119 신고, 보호자 연락, 비상연락망 가동 절차를 확인하였다.
5. 대피 완료 후 인원 확인, 특이사항 점검, 훈련 결과평가를 실시하였다."""
training_process = st.text_area("훈련 진행과정", value=default_process, height=150)
emergency_contacts = st.text_area("비상연락망", value="119, 보호자 연락처, 시설장, 사회복지사, 관할 지자체, 협력의료기관 연락망을 확인함.", height=80)
rule_application = st.text_area("재난훈련규칙 적용사항", value=build_rule_application(training_type, institution_type), height=120)
evaluation_result = st.text_area("훈련 결과평가", value="참석자들이 재난상황 발생 시 신고, 대피유도, 인원확인, 보호자 연락 절차를 숙지하였다. 대피취약자 지원과 비상연락망 가동 절차를 재확인하였다.", height=110)
improvement_plan = st.text_area("개선조치 및 후속계획", value="대피로 장애물을 정기 점검하고, 신규직원 및 미참석자에게 보충교육을 실시한다. 다음 훈련 시 역할별 수행시간과 대피취약자 지원 절차를 추가 점검한다.", height=110)
photos = st.file_uploader("훈련사진 첨부 (최대 3장)", type=["png", "jpg", "jpeg"], accept_multiple_files=True)
if st.button("🚨 재난훈련일지 생성", use_container_width=True):
photo_paths = save_uploaded_photos(photos)
data = {
"institution_name": institution_name,
"institution_type": institution_type,
"training_title": training_title,
"training_type": training_type,
"training_date": str(training_date),
"training_time": training_time,
"training_place": training_place,
"supervisor": supervisor,
"participants": participants,
"scenario": scenario,
"role_division": role_division,
"training_process": training_process,
"evaluation_result": evaluation_result,
"improvement_plan": improvement_plan,
"emergency_contacts": emergency_contacts,
"rule_application": rule_application,
"photo_paths": photo_paths,
"photo_count": len(photo_paths),
}
score, missing, compliance = evaluate_record(data)
data["evaluation_score"] = score
data["missing_items"] = ", ".join(missing) if missing else "부족 항목 없음"
data["compliance_result"] = compliance
st.session_state["disaster_data"] = data
if "disaster_data" in st.session_state:
data = st.session_state["disaster_data"]
st.markdown("---"); st.subheader("생성 결과 수정")
data["scenario"] = st.text_area("시나리오 수정", value=data["scenario"], height=120)
data["role_division"] = st.text_area("역할분담 수정", value=data["role_division"], height=130)
data["training_process"] = st.text_area("훈련 진행과정 수정", value=data["training_process"], height=150)
data["evaluation_result"] = st.text_area("결과평가 수정", value=data["evaluation_result"], height=110)
data["improvement_plan"] = st.text_area("개선조치 수정", value=data["improvement_plan"], height=110)
data["rule_application"] = st.text_area("재난훈련규칙 적용사항 수정", value=data.get("rule_application", ""), height=120)
score, missing, compliance = evaluate_record(data)
data["evaluation_score"] = score
data["missing_items"] = ", ".join(missing) if missing else "부족 항목 없음"
data["compliance_result"] = compliance
e1, e2, e3 = st.columns(3)
e1.metric("평가 적합도", f"{score}점"); e2.metric("부족 항목", data["missing_items"]); e3.metric("사진", f"{data.get('photo_count', 0)}장")
if score >= 90: st.markdown(f'{compliance}
', unsafe_allow_html=True)
else: st.markdown(f'{compliance}
', unsafe_allow_html=True)
if st.button("💾 재난훈련 기록 DB 저장", use_container_width=True):
save_record(data); st.success("재난훈련 기록이 저장되었습니다.")
filename = f"재난훈련일지_{data['institution_type']}_{data['training_date']}.xlsx"
st.download_button("📥 재난훈련일지 엑셀 다운로드", data=generate_excel(data), file_name=filename, mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", use_container_width=True)
st.caption("재난훈련일지 생성기")