feat: 환자 문진표 기능 추가 (미완성)

- 설문 테이블 스키마 추가 (survey_templates, survey_progress, survey_responses)
- 설문 템플릿 데이터 추가 스크립트
- 모바일 문진표 HTML 템플릿

※ 아직 개발 중인 기능으로 추가 구현 필요
This commit is contained in:
시골약사 2026-02-15 17:46:58 +00:00
parent eac5bb72dd
commit 0af715b2c2
3 changed files with 1547 additions and 0 deletions

View File

@ -0,0 +1,70 @@
-- 환자 사전 문진표 시스템 테이블
-- 1. 문진 메인 테이블
CREATE TABLE IF NOT EXISTS patient_surveys (
survey_id INTEGER PRIMARY KEY AUTOINCREMENT,
patient_id INTEGER,
survey_token TEXT UNIQUE NOT NULL, -- QR/링크용 고유 토큰
survey_date DATE DEFAULT (date('now')),
status TEXT DEFAULT 'PENDING' CHECK(status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'REVIEWED')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
reviewed_at DATETIME,
reviewed_by TEXT,
notes TEXT,
FOREIGN KEY (patient_id) REFERENCES patients(patient_id)
);
CREATE INDEX IF NOT EXISTS idx_survey_token ON patient_surveys(survey_token);
CREATE INDEX IF NOT EXISTS idx_survey_patient ON patient_surveys(patient_id);
-- 2. 문진 응답 테이블
CREATE TABLE IF NOT EXISTS survey_responses (
response_id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL,
category TEXT NOT NULL, -- 카테고리 (TEMPERATURE, BODY_TEMP, DIGESTION, etc)
question_code TEXT NOT NULL, -- 질문 코드
question_text TEXT, -- 질문 내용
answer_value TEXT, -- 응답 값 (JSON 또는 TEXT)
answer_type TEXT, -- SINGLE, MULTIPLE, TEXT, RANGE
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (survey_id) REFERENCES patient_surveys(survey_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_response_survey ON survey_responses(survey_id);
CREATE INDEX IF NOT EXISTS idx_response_category ON survey_responses(category);
-- 3. 문진 템플릿 테이블 (질문 정의)
CREATE TABLE IF NOT EXISTS survey_templates (
template_id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
category_name TEXT NOT NULL, -- 카테고리 한글명
question_code TEXT NOT NULL UNIQUE,
question_text TEXT NOT NULL,
question_subtext TEXT, -- 부가 설명
input_type TEXT NOT NULL CHECK(input_type IN ('RADIO', 'CHECKBOX', 'RANGE', 'TEXT', 'TEXTAREA', 'NUMBER')),
options TEXT, -- JSON 배열 형태의 선택지
is_required INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_template_category ON survey_templates(category);
CREATE INDEX IF NOT EXISTS idx_template_active ON survey_templates(is_active);
-- 4. 문진 진행 상태 추적 테이블
CREATE TABLE IF NOT EXISTS survey_progress (
progress_id INTEGER PRIMARY KEY AUTOINCREMENT,
survey_id INTEGER NOT NULL,
category TEXT NOT NULL,
total_questions INTEGER DEFAULT 0,
answered_questions INTEGER DEFAULT 0,
is_completed INTEGER DEFAULT 0,
last_updated DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (survey_id) REFERENCES patient_surveys(survey_id) ON DELETE CASCADE,
UNIQUE(survey_id, category)
);
CREATE INDEX IF NOT EXISTS idx_progress_survey ON survey_progress(survey_id);

View File

@ -0,0 +1,596 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
문진표 템플릿 데이터 입력 스크립트
"""
import sqlite3
import json
# 문진 항목 데이터
survey_data = [
# 1. 체온 감각 (TEMPERATURE)
{
'category': 'TEMPERATURE',
'category_name': '체온 감각',
'questions': [
{
'code': 'COLD_SENSITIVITY',
'text': '추위를 타시나요?',
'type': 'RADIO',
'options': ['심하게 탄다', '타는 편', '약간 탄다', '안탄다', '겨울이 싫다'],
'required': True,
'order': 1
},
{
'code': 'HEAT_SENSITIVITY',
'text': '더위를 타시나요?',
'type': 'RADIO',
'options': ['심하게 탄다', '타는 편', '약간 탄다', '안탄다', '선풍기/에어컨 바람이 싫다'],
'required': True,
'order': 2
},
{
'code': 'SWEAT_AMOUNT',
'text': '땀을 흘리는 정도는?',
'type': 'RADIO',
'options': ['건조하다/없다', '약간', '보통', '많다', '아주 많다'],
'required': True,
'order': 3
},
{
'code': 'SWEAT_LOCATION',
'text': '땀이 나는 부위는? (복수선택)',
'type': 'CHECKBOX',
'options': ['얼굴', '몸전체', '손발', '하체', '머리', '이마', '겨드랑이'],
'required': False,
'order': 4
},
{
'code': 'SWEAT_TIMING',
'text': '주로 언제 땀이 나나요? (복수선택)',
'type': 'CHECKBOX',
'options': ['잘 때', '식사할 때', '긴장할 때', '여름에', '일할 때', '수시로'],
'required': False,
'order': 5
}
]
},
# 2. 신체 부위별 온도 (BODY_TEMP)
{
'category': 'BODY_TEMP',
'category_name': '신체 부위별 온도',
'questions': [
{
'code': 'HAND_TEMP',
'text': '손의 온도는?',
'type': 'RADIO',
'options': ['매우 차다', '약간 차다', '보통', '따뜻하다', '뜨겁다', '화끈거림', '저림', ''],
'required': True,
'order': 1
},
{
'code': 'FOOT_TEMP',
'text': '발의 온도는?',
'type': 'RADIO',
'options': ['매우 차다', '약간 차다', '보통', '따뜻하다', '뜨겁다', '화끈거림', '저림', ''],
'required': True,
'order': 2
},
{
'code': 'UPPER_ABDOMEN_TEMP',
'text': '윗배의 온도는?',
'type': 'RADIO',
'options': ['매우 차다', '약간 차다', '보통', '따뜻하다', '뜨겁다', '시림'],
'required': True,
'order': 3
},
{
'code': 'LOWER_ABDOMEN_TEMP',
'text': '아랫배의 온도는?',
'type': 'RADIO',
'options': ['매우 차다', '약간 차다', '보통', '따뜻하다', '뜨겁다', '복부비만'],
'required': True,
'order': 4
},
{
'code': 'WHOLE_BODY_TEMP',
'text': '몸 전체의 느낌은?',
'type': 'CHECKBOX',
'options': ['매우 차다', '약간 차다', '보통', '따뜻하다', '뜨겁다', '무겁다', '아프다', '부종'],
'required': True,
'order': 5
}
]
},
# 3. 식성 및 소화 (DIGESTION)
{
'category': 'DIGESTION',
'category_name': '식성 및 소화',
'questions': [
{
'code': 'FOOD_TEMP_PREF',
'text': '음식 온도 선호는?',
'type': 'RADIO',
'options': ['찬 것', '시원한 것', '보통', '따뜻한 것', '뜨거운 것', '모두'],
'required': True,
'order': 1
},
{
'code': 'TASTE_PREF',
'text': '맛 선호는?',
'type': 'RADIO',
'options': ['신 것', '단 것', '매운 것', '짠 것', '쓴 것', '담백한 것', '모두'],
'required': True,
'order': 2
},
{
'code': 'FOOD_HABITS',
'text': '식습관 (복수선택)',
'subtext': '주 1회 이상 섭취하는 것을 선택하세요',
'type': 'CHECKBOX',
'options': ['된장', '채식', '육류', '해물', '커피', '', '담배'],
'required': False,
'order': 3
},
{
'code': 'WATER_INTAKE',
'text': '물을 마시는 정도는?',
'type': 'RADIO',
'options': ['많이 마심', '자주', '보통', '거의 안마심'],
'required': True,
'order': 4
},
{
'code': 'APPETITE',
'text': '식욕은 어떠신가요?',
'type': 'RADIO',
'options': ['없다', '별로', '보통', '좋다', '왕성', '아침 생략'],
'required': True,
'order': 5
},
{
'code': 'MEAL_AMOUNT',
'text': '식사량은?',
'type': 'RADIO',
'options': ['적다', '보통', '많다', '1공기 이하', '1공기 이상', '일정치 않다', '저녁에 많이'],
'required': True,
'order': 6
},
{
'code': 'DIGESTION_POWER',
'text': '소화력은?',
'type': 'RADIO',
'options': ['잘 된다', '보통', '약하다', '잘 안됨', '잘 체한다'],
'required': True,
'order': 7
},
{
'code': 'INDIGESTION_SYMPTOMS',
'text': '소화불량 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['막힌듯함', '답답함', '걸린듯함', '더부룩함', '그득함', '속쓰림',
'헛배부름', '가스참', '느글거림', '트림', '구토', '헛구역', '방귀',
'꾸룩소리남', '명치아픔', '복통', '딸꾹질', '하품'],
'required': False,
'order': 8
}
]
},
# 4. 대소변 (EXCRETION)
{
'category': 'EXCRETION',
'category_name': '대소변',
'questions': [
{
'code': 'STOOL_FREQUENCY',
'text': '대변 빈도는?',
'subtext': '일 _회 / 매일 / 아침 / 불규칙',
'type': 'TEXT',
'required': True,
'order': 1
},
{
'code': 'STOOL_CONDITION',
'text': '대변 상태는? (복수선택)',
'type': 'CHECKBOX',
'options': ['변비', '된편', '굵다', '토끼똥', '설사', '물변', '보통', '가늘다', '퍼진다', '냄새심함'],
'required': True,
'order': 2
},
{
'code': 'STOOL_DIFFICULTY',
'text': '배변 시 불편함은? (복수선택)',
'type': 'CHECKBOX',
'options': ['잘 나옴', '잘 안나옴', '시원치 않다', '오래봄', '힘들게 나옴',
'조금 나옴', '남아있는 듯함', '조금씩 자주', '지림', '못 참음'],
'required': False,
'order': 3
},
{
'code': 'URINE_FREQUENCY_DAY',
'text': '낮 소변 빈도는?',
'subtext': '낮에 몇 시간마다 1회?',
'type': 'NUMBER',
'required': True,
'order': 4
},
{
'code': 'URINE_FREQUENCY_NIGHT',
'text': '밤 소변 빈도는?',
'subtext': '자다가 몇 회?',
'type': 'NUMBER',
'required': True,
'order': 5
},
{
'code': 'URINE_FREQUENCY_LEVEL',
'text': '전체적인 소변 빈도는?',
'type': 'RADIO',
'options': ['거의 안봄', '가끔', '보통', '자주', '매우 자주', '밤에 오줌 싼다'],
'required': True,
'order': 6
},
{
'code': 'URINE_COLOR',
'text': '소변 색은?',
'type': 'RADIO',
'options': ['붉다', '노랗다', '보통', '탁하다', '맑다', '커피색'],
'required': True,
'order': 7
},
{
'code': 'URINE_ABNORMAL',
'text': '소변 이상 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['거품이 남', '기름이 뜸', '단내남', '뿌옇다', '정액이 나옴'],
'required': False,
'order': 8
}
]
},
# 5. 수면 (SLEEP)
{
'category': 'SLEEP',
'category_name': '수면',
'questions': [
{
'code': 'SLEEP_HOURS',
'text': '하루 수면 시간은?',
'subtext': '예: 7시간',
'type': 'NUMBER',
'required': True,
'order': 1
},
{
'code': 'SLEEP_TIME',
'text': '수면 시간대는?',
'subtext': '예: 23시 ~ 06시',
'type': 'TEXT',
'required': False,
'order': 2
},
{
'code': 'SLEEP_SATISFACTION',
'text': '수면이 충분한가요?',
'type': 'RADIO',
'options': ['잠 부족', '잠 충분'],
'required': True,
'order': 3
},
{
'code': 'SLEEP_QUALITY',
'text': '수면 상태는? (복수선택)',
'type': 'CHECKBOX',
'options': ['잘잠', '잘못잠', '거의 못잠', '가끔 못잠', '뒤척임', '곧 잔다',
'잠들기 어렵다', '깊이 잠', '얕은 잠', '잠귀 밝음', '잘 깸', '깨면 안옴'],
'required': True,
'order': 4
},
{
'code': 'DREAM_FREQUENCY',
'text': '꿈을 꾸는 빈도는?',
'type': 'RADIO',
'options': ['밤새 꿈', '자주 꾼다', '가끔 꿈', '거의 없다', '안꾼다', '잠꼬대'],
'required': True,
'order': 5
},
{
'code': 'DREAM_TYPE',
'text': '꾸는 꿈의 종류는? (복수선택)',
'type': 'CHECKBOX',
'options': ['무서운 꿈', '죽은 사람 꿈', '쫓기는 꿈', '개꿈', '기억 안남', '기억 남'],
'required': False,
'order': 6
}
]
},
# 6. 순환 및 정신 (CIRCULATION)
{
'category': 'CIRCULATION',
'category_name': '순환 및 정신 증상',
'questions': [
{
'code': 'HEART_SYMPTOMS',
'text': '심장 관련 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['가슴뜀', '가슴답답', '가슴뻐근', '한숨쉼', '호흡곤란', '숨참', '뒷목뻐근'],
'required': False,
'order': 1
},
{
'code': 'HEAT_FLASH',
'text': '열이 달아오르는 증상이 있나요?',
'subtext': '있다면 빈도를 알려주세요 (예: 일 3회, 주 2회)',
'type': 'TEXT',
'required': False,
'order': 2
},
{
'code': 'MENTAL_SYMPTOMS',
'text': '정신적 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['잘 놀람', '불안', '초조', '우울', '비관', '신경질', '짜증',
'매사 귀찮다', '손떨림', '졸도', '가슴 막힌 듯', '조이는 듯',
'기억력 격감', '건망증', '현기증', '눈 피로감'],
'required': False,
'order': 3
},
{
'code': 'FATIGUE_SYMPTOMS',
'text': '피로 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['피로', '기운이 없다', '아침에 잘 못일어난다', '의욕이 없다', '무겁다'],
'required': False,
'order': 4
}
]
},
# 7. 여성 건강 (WOMEN_HEALTH)
{
'category': 'WOMEN_HEALTH',
'category_name': '여성 건강',
'questions': [
{
'code': 'MARITAL_STATUS',
'text': '결혼 상태는?',
'type': 'RADIO',
'options': ['미혼', '기혼'],
'required': False,
'order': 1
},
{
'code': 'MARRIAGE_YEARS',
'text': '결혼한 지 몇 년?',
'type': 'NUMBER',
'required': False,
'order': 2
},
{
'code': 'BIRTH_COUNT',
'text': '출산 횟수',
'type': 'NUMBER',
'required': False,
'order': 3
},
{
'code': 'INFERTILITY_YEARS',
'text': '불임 기간 (년)',
'type': 'NUMBER',
'required': False,
'order': 4
},
{
'code': 'MISCARRIAGE',
'text': '유산 경험',
'subtext': '자연유산 _회, 인공유산 _회',
'type': 'TEXT',
'required': False,
'order': 5
},
{
'code': 'MENSTRUAL_CYCLE',
'text': '생리 주기는?',
'subtext': '_일 간격',
'type': 'NUMBER',
'required': False,
'order': 6
},
{
'code': 'MENSTRUAL_REGULARITY',
'text': '생리 규칙성은?',
'type': 'RADIO',
'options': ['정상', '부정확', '건넘', '중단', '폐경', '계속 나옴'],
'required': False,
'order': 7
},
{
'code': 'MENSTRUAL_DURATION',
'text': '생리 기간은?',
'subtext': '_일간 (_일 많고 _일 적다)',
'type': 'TEXT',
'required': False,
'order': 8
},
{
'code': 'MENSTRUAL_AMOUNT',
'text': '생리량은?',
'type': 'RADIO',
'options': ['너무 많다', '많다', '보통', '약간 적다', '아주 적다', '줄어듬', '늦어짐', '빨라짐'],
'required': False,
'order': 9
},
{
'code': 'MENSTRUAL_COLOR',
'text': '생리 색은? (복수선택)',
'type': 'CHECKBOX',
'options': ['검붉다', '검다', '일부 덩어리', '찌꺼기', '묽다'],
'required': False,
'order': 10
},
{
'code': 'MENSTRUAL_PAIN',
'text': '생리통은?',
'type': 'RADIO',
'options': ['없다', '약간', '심하다', '극심'],
'required': False,
'order': 11
},
{
'code': 'MENSTRUAL_PAIN_TIMING',
'text': '생리통 시기는?',
'subtext': '생리 _일 부터 _일간',
'type': 'TEXT',
'required': False,
'order': 12
},
{
'code': 'MENSTRUAL_PAIN_LOCATION',
'text': '생리통 부위 (복수선택)',
'type': 'CHECKBOX',
'options': ['아랫배', '허리', '허벅지', '가슴', '머리', '전신', '몸살', '과민'],
'required': False,
'order': 13
},
{
'code': 'VAGINAL_DISCHARGE',
'text': '냉대하는?',
'type': 'RADIO',
'options': ['없다', '약간', '많다', '심하다'],
'required': False,
'order': 14
},
{
'code': 'DISCHARGE_SYMPTOMS',
'text': '냉대하 증상 (복수선택)',
'type': 'CHECKBOX',
'options': ['투명', '누렇다', '희다', '묽다', '냄새', '악취', '가렵다'],
'required': False,
'order': 15
}
]
},
# 8. 피부 (SKIN)
{
'category': 'SKIN',
'category_name': '피부',
'questions': [
{
'code': 'SKIN_COLOR',
'text': '피부 색은?',
'type': 'RADIO',
'options': ['보통', '약간 황색', '약간 검다', '희다', '창백', '누렇다', '약간 붉다'],
'required': True,
'order': 1
},
{
'code': 'SKIN_TYPE',
'text': '피부 타입은?',
'type': 'RADIO',
'options': ['보통', '섬세', '얇다', '약간 두텁다', '두텁다', '지성', '중성', '건성'],
'required': True,
'order': 2
}
]
},
# 9. 성품/체질 (PERSONALITY)
{
'category': 'PERSONALITY',
'category_name': '성품 및 체질',
'questions': [
{
'code': 'PERSONALITY_TYPE1',
'text': '성격 유형 1 - 태양인 성향 (복수선택)',
'type': 'CHECKBOX',
'options': ['저돌적이다', '기세가 강하다', '남의 말을 잘 듣지 않는다',
'거침이 없다', '독불장군이다', '안하무인이다', '뚜렷하다',
'말과 행동 빠름', '음식을 빨리 먹음', '눈매 예리/날카롭다',
'부지런함', '적극적', '활동적', '소변 자주 본다', '일을 안미룬다',
'나다니기를 좋아함', '분명하다', '나서기 잘함', '질투가 심하다'],
'required': False,
'order': 1
},
{
'code': 'PERSONALITY_TYPE2',
'text': '성격 유형 2 - 태음인 성향 (복수선택)',
'type': 'CHECKBOX',
'options': ['눕기 좋아함', '엉덩이 무겁다', '느긋함', '땀이 많다', '과묵하다',
'사람 좋다', '무던하다', '부드럽다', '가정적이다', '씻기를 싫어함',
'원만', '정중', '은근', '꾸준함', '우유부단', '된장/쓴 것 좋아함'],
'required': False,
'order': 2
},
{
'code': 'PERSONALITY_TYPE3',
'text': '성격 유형 3 - 소양인/소음인 성향 (복수선택)',
'type': 'CHECKBOX',
'options': ['약해 보인다', '겁이 많음', '세심하다', '소심', '차분함', '자상함',
'연약', '영민', '잘 미룬다', '깐깐', '치밀', '궁리는 많으나 실행은 적음'],
'required': False,
'order': 3
}
]
}
]
def insert_survey_templates():
conn = sqlite3.connect('/root/kdrug/database/kdrug.db')
cursor = conn.cursor()
# 기존 템플릿 삭제
cursor.execute("DELETE FROM survey_templates")
inserted_count = 0
for category_data in survey_data:
category = category_data['category']
category_name = category_data['category_name']
for question in category_data['questions']:
cursor.execute("""
INSERT INTO survey_templates
(category, category_name, question_code, question_text, question_subtext,
input_type, options, is_required, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
category,
category_name,
question['code'],
question['text'],
question.get('subtext'),
question['type'],
json.dumps(question.get('options', []), ensure_ascii=False),
1 if question.get('required', False) else 0,
question['order']
))
inserted_count += 1
conn.commit()
# 결과 확인
cursor.execute("""
SELECT category, category_name, COUNT(*) as cnt
FROM survey_templates
GROUP BY category
ORDER BY MIN(template_id)
""")
results = cursor.fetchall()
print(f"✅ 문진 템플릿 {inserted_count}개 항목 입력 완료\n")
print("카테고리별 질문 수:")
for row in results:
print(f" {row[0]:20s} ({row[1]:15s}): {row[2]:2d}")
conn.close()
return inserted_count
if __name__ == '__main__':
insert_survey_templates()

881
templates/survey.html Normal file
View File

@ -0,0 +1,881 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>건강 상담 사전 문진표</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
<style>
:root {
--primary-color: #4CAF50;
--secondary-color: #2196F3;
--accent-color: #FF9800;
--text-dark: #333;
--bg-light: #f5f5f5;
}
* {
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 0;
margin: 0;
}
.survey-container {
max-width: 600px;
margin: 0 auto;
padding: 0;
min-height: 100vh;
background: white;
box-shadow: 0 0 30px rgba(0,0,0,0.1);
}
.survey-header {
background: linear-gradient(135deg, var(--primary-color) 0%, #45a049 100%);
color: white;
padding: 20px;
text-align: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
}
.survey-header h1 {
font-size: 1.5rem;
margin: 0;
font-weight: 600;
}
.survey-header p {
margin: 5px 0 0 0;
font-size: 0.9rem;
opacity: 0.95;
}
.progress-bar-container {
background: white;
padding: 15px 20px;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
position: sticky;
top: 90px;
z-index: 99;
}
.progress {
height: 8px;
border-radius: 10px;
background: #e0e0e0;
}
.progress-bar {
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
border-radius: 10px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85rem;
color: #666;
margin-top: 5px;
text-align: center;
}
.category-section {
padding: 20px;
}
.category-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid var(--primary-color);
}
.category-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, var(--primary-color), #45a049);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
}
.category-title {
flex: 1;
font-size: 1.3rem;
font-weight: 600;
color: var(--text-dark);
}
.question-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: all 0.3s ease;
}
.question-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.question-text {
font-size: 1.05rem;
font-weight: 500;
color: var(--text-dark);
margin-bottom: 5px;
line-height: 1.5;
}
.question-required {
color: #f44336;
margin-left: 4px;
}
.question-subtext {
font-size: 0.85rem;
color: #757575;
margin-bottom: 15px;
}
/* Radio/Checkbox 스타일 */
.option-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-label {
display: flex;
align-items: center;
padding: 12px 15px;
background: #f9f9f9;
border: 2px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.option-label:active {
transform: scale(0.98);
}
.option-label input {
margin-right: 12px;
width: 20px;
height: 20px;
cursor: pointer;
}
.option-label:hover {
background: #f0f7ff;
border-color: var(--secondary-color);
}
.option-label input:checked + span {
font-weight: 600;
color: var(--primary-color);
}
.option-label:has(input:checked) {
background: #e8f5e9;
border-color: var(--primary-color);
}
/* Text/Number 입력 */
.form-control {
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 12px 15px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
/* 버튼 */
.btn-group-sticky {
position: sticky;
bottom: 0;
background: white;
padding: 15px 20px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
display: flex;
gap: 10px;
z-index: 98;
}
.btn {
flex: 1;
padding: 14px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary-color), #45a049);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-save {
background: linear-gradient(135deg, var(--accent-color), #f57c00);
color: white;
}
.btn-complete {
background: linear-gradient(135deg, var(--secondary-color), #1976d2);
color: white;
}
/* 로딩 스피너 */
.loading {
text-align: center;
padding: 40px;
}
.spinner-border {
width: 3rem;
height: 3rem;
border-width: 0.3em;
}
/* 완료 화면 */
.completion-screen {
text-align: center;
padding: 60px 20px;
}
.completion-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, var(--primary-color), #45a049);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
animation: scaleIn 0.5s ease;
}
.completion-icon i {
font-size: 3rem;
color: white;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.completion-title {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-dark);
margin-bottom: 10px;
}
.completion-message {
font-size: 1rem;
color: #757575;
line-height: 1.6;
}
/* 카테고리 네비게이션 */
.category-nav {
display: flex;
overflow-x: auto;
gap: 10px;
padding: 15px 20px;
background: white;
border-bottom: 1px solid #e0e0e0;
position: sticky;
top: 90px;
z-index: 97;
-webkit-overflow-scrolling: touch;
}
.category-nav::-webkit-scrollbar {
display: none;
}
.category-nav-item {
flex-shrink: 0;
padding: 8px 16px;
border-radius: 20px;
background: #f5f5f5;
color: #666;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.category-nav-item.active {
background: var(--primary-color);
color: white;
}
.category-nav-item.completed {
background: #e8f5e9;
color: var(--primary-color);
}
/* 반응형 */
@media (max-width: 576px) {
.survey-header h1 {
font-size: 1.3rem;
}
.question-card {
padding: 15px;
}
.option-label {
padding: 10px 12px;
}
}
</style>
</head>
<body>
<div class="survey-container">
<!-- 헤더 -->
<div class="survey-header">
<h1><i class="bi bi-clipboard2-pulse"></i> 건강 상담 사전 문진표</h1>
<p id="patientInfo">환자명: <span id="patientName">-</span></p>
</div>
<!-- 진행률 -->
<div class="progress-bar-container">
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%" id="progressBar"></div>
</div>
<div class="progress-text">
<span id="progressText">0 / 0</span> 완료
</div>
</div>
<!-- 카테고리 네비게이션 -->
<div class="category-nav" id="categoryNav" style="display: none;">
<!-- 동적 생성 -->
</div>
<!-- 메인 컨텐츠 -->
<div id="surveyContent">
<!-- 로딩 -->
<div class="loading" id="loadingScreen">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">문진표를 불러오는 중...</p>
</div>
<!-- 문진 폼 -->
<div id="surveyForm" style="display: none;">
<!-- 동적으로 생성됨 -->
</div>
<!-- 완료 화면 -->
<div id="completionScreen" style="display: none;">
<div class="completion-screen">
<div class="completion-icon">
<i class="bi bi-check-circle-fill"></i>
</div>
<h2 class="completion-title">제출 완료!</h2>
<p class="completion-message">
문진표가 성공적으로 제출되었습니다.<br>
담당 한의사가 확인 후 연락드리겠습니다.<br>
감사합니다.
</p>
</div>
</div>
</div>
<!-- 하단 버튼 -->
<div class="btn-group-sticky" id="buttonGroup" style="display: none;">
<button class="btn btn-save" id="saveBtn">
<i class="bi bi-bookmark"></i> 임시저장
</button>
<button class="btn btn-complete" id="completeBtn">
<i class="bi bi-check-circle"></i> 제출하기
</button>
</div>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const surveyToken = window.location.pathname.split('/').pop();
let surveyData = null;
let categories = [];
let templates = [];
let responses = {};
let currentCategoryIndex = 0;
$(document).ready(function() {
loadSurvey();
});
async function loadSurvey() {
try {
// 문진표 정보 로드
const surveyResponse = await $.get(`/api/surveys/${surveyToken}`);
surveyData = surveyResponse.data;
// 환자 정보 표시
if (surveyData.patient_name) {
$('#patientName').text(surveyData.patient_name);
}
// 카테고리 목록 로드
const categoriesResponse = await $.get('/api/surveys/categories');
categories = categoriesResponse.data;
// 템플릿 로드
const templatesResponse = await $.get('/api/surveys/templates');
templates = templatesResponse.data;
// 기존 응답 로드
if (surveyData.responses && surveyData.responses.length > 0) {
surveyData.responses.forEach(resp => {
try {
responses[resp.question_code] = JSON.parse(resp.answer_value);
} catch (e) {
responses[resp.question_code] = resp.answer_value;
}
});
}
// UI 렌더링
renderCategoryNav();
renderSurveyForm();
updateProgress();
$('#loadingScreen').hide();
$('#surveyForm').show();
$('#buttonGroup').show();
$('#categoryNav').show();
} catch (error) {
console.error('Error loading survey:', error);
alert('문진표를 불러오는데 실패했습니다.');
}
}
function renderCategoryNav() {
const nav = $('#categoryNav');
nav.empty();
categories.forEach((cat, index) => {
const navItem = $(`
<div class="category-nav-item" data-index="${index}">
${cat.category_name}
</div>
`);
navItem.on('click', function() {
scrollToCategory(index);
});
nav.append(navItem);
});
}
function renderSurveyForm() {
const form = $('#surveyForm');
form.empty();
categories.forEach((cat, catIndex) => {
const categoryQuestions = templates.filter(t => t.category === cat.category);
const section = $(`
<div class="category-section" id="category-${catIndex}">
<div class="category-header">
<div class="category-icon">
<i class="bi bi-${getCategoryIcon(cat.category)}"></i>
</div>
<div class="category-title">${cat.category_name}</div>
</div>
</div>
`);
categoryQuestions.forEach(question => {
const questionCard = renderQuestion(question);
section.append(questionCard);
});
form.append(section);
});
}
function renderQuestion(question) {
const card = $(`
<div class="question-card" data-code="${question.question_code}">
<div class="question-text">
${question.question_text}
${question.is_required ? '<span class="question-required">*</span>' : ''}
</div>
${question.question_subtext ? `<div class="question-subtext">${question.question_subtext}</div>` : ''}
<div class="answer-area"></div>
</div>
`);
const answerArea = card.find('.answer-area');
const savedValue = responses[question.question_code];
switch (question.input_type) {
case 'RADIO':
answerArea.append(renderRadio(question, savedValue));
break;
case 'CHECKBOX':
answerArea.append(renderCheckbox(question, savedValue));
break;
case 'TEXT':
case 'TEXTAREA':
answerArea.append(renderText(question, savedValue));
break;
case 'NUMBER':
answerArea.append(renderNumber(question, savedValue));
break;
}
return card;
}
function renderRadio(question, savedValue) {
const group = $('<div class="option-group"></div>');
question.options.forEach((option, index) => {
const isChecked = savedValue === option ? 'checked' : '';
const label = $(`
<label class="option-label">
<input type="radio"
name="${question.question_code}"
value="${option}"
${isChecked}
data-code="${question.question_code}"
data-category="${question.category}">
<span>${option}</span>
</label>
`);
label.find('input').on('change', function() {
saveResponse(question.question_code, question.category, this.value, 'SINGLE');
updateProgress();
});
group.append(label);
});
return group;
}
function renderCheckbox(question, savedValue) {
const group = $('<div class="option-group"></div>');
const savedArray = Array.isArray(savedValue) ? savedValue : [];
question.options.forEach((option, index) => {
const isChecked = savedArray.includes(option) ? 'checked' : '';
const label = $(`
<label class="option-label">
<input type="checkbox"
name="${question.question_code}[]"
value="${option}"
${isChecked}
data-code="${question.question_code}"
data-category="${question.category}">
<span>${option}</span>
</label>
`);
label.find('input').on('change', function() {
const checked = $(`input[name="${question.question_code}[]"]:checked`).map(function() {
return $(this).val();
}).get();
saveResponse(question.question_code, question.category, checked, 'MULTIPLE');
updateProgress();
});
group.append(label);
});
return group;
}
function renderText(question, savedValue) {
const isTextarea = question.input_type === 'TEXTAREA';
const input = $(`
<${isTextarea ? 'textarea' : 'input'}
type="text"
class="form-control"
placeholder="답변을 입력하세요"
data-code="${question.question_code}"
data-category="${question.category}"
${isTextarea ? 'rows="3"' : ''}
>${savedValue || ''}</${isTextarea ? 'textarea' : 'input'}>
`);
input.on('blur', function() {
const value = $(this).val().trim();
if (value) {
saveResponse(question.question_code, question.category, value, 'TEXT');
updateProgress();
}
});
return input;
}
function renderNumber(question, savedValue) {
const input = $(`
<input type="number"
class="form-control"
placeholder="숫자를 입력하세요"
value="${savedValue || ''}"
data-code="${question.question_code}"
data-category="${question.category}">
`);
input.on('blur', function() {
const value = $(this).val();
if (value) {
saveResponse(question.question_code, question.category, value, 'NUMBER');
updateProgress();
}
});
return input;
}
function saveResponse(questionCode, category, value, answerType) {
responses[questionCode] = value;
// 로컬 스토리지에도 저장 (임시저장용)
localStorage.setItem(`survey_${surveyToken}`, JSON.stringify(responses));
}
async function saveToServer() {
try {
const responseArray = [];
Object.keys(responses).forEach(code => {
const template = templates.find(t => t.question_code === code);
if (template) {
responseArray.push({
category: template.category,
question_code: code,
question_text: template.question_text,
answer_value: responses[code],
answer_type: Array.isArray(responses[code]) ? 'MULTIPLE' : 'SINGLE'
});
}
});
await $.ajax({
url: `/api/surveys/${surveyToken}/responses`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ responses: responseArray })
});
return true;
} catch (error) {
console.error('Save error:', error);
return false;
}
}
function updateProgress() {
const totalQuestions = templates.filter(t => t.is_required).length;
const answeredQuestions = templates.filter(t =>
t.is_required && responses[t.question_code] !== undefined && responses[t.question_code] !== ''
).length;
const percentage = totalQuestions > 0 ? (answeredQuestions / totalQuestions * 100) : 0;
$('#progressBar').css('width', percentage + '%');
$('#progressText').text(`${answeredQuestions} / ${totalQuestions}`);
// 카테고리별 완료 상태 업데이트
updateCategoryStatus();
}
function updateCategoryStatus() {
categories.forEach((cat, index) => {
const catQuestions = templates.filter(t => t.category === cat.category && t.is_required);
const catAnswered = catQuestions.filter(q => responses[q.question_code] !== undefined).length;
const isCompleted = catAnswered === catQuestions.length && catQuestions.length > 0;
const navItem = $(`.category-nav-item[data-index="${index}"]`);
if (isCompleted) {
navItem.addClass('completed');
}
});
}
function scrollToCategory(index) {
const target = $(`#category-${index}`);
if (target.length) {
$('html, body').animate({
scrollTop: target.offset().top - 160
}, 300);
$('.category-nav-item').removeClass('active');
$(`.category-nav-item[data-index="${index}"]`).addClass('active');
}
}
function getCategoryIcon(category) {
const icons = {
'TEMPERATURE': 'thermometer-half',
'BODY_TEMP': 'heart-pulse',
'DIGESTION': 'cup-straw',
'EXCRETION': 'water',
'SLEEP': 'moon-stars',
'CIRCULATION': 'activity',
'WOMEN_HEALTH': 'gender-female',
'SKIN': 'palette',
'PERSONALITY': 'person-circle'
};
return icons[category] || 'question-circle';
}
// 임시저장 버튼
$('#saveBtn').on('click', async function() {
const btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-hourglass-split"></i> 저장중...');
const success = await saveToServer();
if (success) {
btn.html('<i class="bi bi-check"></i> 저장됨');
setTimeout(() => {
btn.prop('disabled', false).html('<i class="bi bi-bookmark"></i> 임시저장');
}, 2000);
} else {
alert('저장에 실패했습니다.');
btn.prop('disabled', false).html('<i class="bi bi-bookmark"></i> 임시저장');
}
});
// 제출 버튼
$('#completeBtn').on('click', async function() {
// 필수 항목 체크
const requiredQuestions = templates.filter(t => t.is_required);
const unanswered = requiredQuestions.filter(q =>
responses[q.question_code] === undefined || responses[q.question_code] === ''
);
if (unanswered.length > 0) {
alert(`필수 항목 ${unanswered.length}개가 미응답 상태입니다.\n모든 필수 항목을 작성해주세요.`);
return;
}
if (!confirm('문진표를 제출하시겠습니까?')) {
return;
}
const btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-hourglass-split"></i> 제출중...');
// 서버에 저장
const saveSuccess = await saveToServer();
if (!saveSuccess) {
alert('저장에 실패했습니다.');
btn.prop('disabled', false).html('<i class="bi bi-check-circle"></i> 제출하기');
return;
}
// 완료 처리
try {
await $.ajax({
url: `/api/surveys/${surveyToken}/complete`,
method: 'POST'
});
// 완료 화면 표시
$('#surveyForm').hide();
$('#buttonGroup').hide();
$('#categoryNav').hide();
$('#completionScreen').show();
// 로컬 스토리지 삭제
localStorage.removeItem(`survey_${surveyToken}`);
} catch (error) {
alert('제출에 실패했습니다.');
btn.prop('disabled', false).html('<i class="bi bi-check-circle"></i> 제출하기');
}
});
// 스크롤 이벤트로 현재 카테고리 하이라이트
$(window).on('scroll', function() {
const scrollPos = $(window).scrollTop() + 200;
$('.category-section').each(function(index) {
const top = $(this).offset().top;
const bottom = top + $(this).outerHeight();
if (scrollPos >= top && scrollPos < bottom) {
$('.category-nav-item').removeClass('active');
$(`.category-nav-item[data-index="${index}"]`).addClass('active');
}
});
});
// 로컬 스토리지에서 임시 저장된 데이터 복원
$(document).ready(function() {
const saved = localStorage.getItem(`survey_${surveyToken}`);
if (saved) {
try {
const savedResponses = JSON.parse(saved);
if (Object.keys(savedResponses).length > Object.keys(responses).length) {
if (confirm('이전에 작성 중이던 내용이 있습니다. 불러오시겠습니까?')) {
responses = savedResponses;
}
}
} catch (e) {
console.error('Failed to restore saved data:', e);
}
}
});
</script>
</body>
</html>