- WebSocket 클라이언트 추가 (ws://localhost:8765) - 처방 감지 시 자동 토스트 알림 (누적 표시) - 연결 상태 표시 (자동감지 ON/OFF) - fix: med.days → med.duration 필드명 수정 (복용일수 0 버그)
2276 lines
92 KiB
HTML
2276 lines
92 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>조제관리 - 청춘라벨</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 헤더 */
|
||
.header {
|
||
background: rgba(255,255,255,0.95);
|
||
padding: 15px 30px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||
}
|
||
.header h1 {
|
||
font-size: 1.5rem;
|
||
color: #4c1d95;
|
||
}
|
||
.header h1 span { font-size: 0.9rem; color: #6b7280; margin-left: 10px; }
|
||
|
||
/* 날짜 선택 & 통계 */
|
||
.controls {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
}
|
||
.date-picker {
|
||
padding: 8px 15px;
|
||
border: 2px solid #8b5cf6;
|
||
border-radius: 8px;
|
||
font-size: 1rem;
|
||
color: #4c1d95;
|
||
cursor: pointer;
|
||
}
|
||
.stats-box {
|
||
display: flex;
|
||
gap: 15px;
|
||
}
|
||
.stat-item {
|
||
background: #f3e8ff;
|
||
padding: 8px 15px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
}
|
||
.stat-item .num { font-size: 1.3rem; font-weight: bold; color: #7c3aed; }
|
||
.stat-item .label { font-size: 0.75rem; color: #6b7280; }
|
||
|
||
/* 메인 컨테이너 */
|
||
.main-container {
|
||
display: flex;
|
||
height: calc(100vh - 80px);
|
||
padding: 20px;
|
||
gap: 20px;
|
||
}
|
||
|
||
/* 왼쪽: 환자 목록 */
|
||
.patient-list {
|
||
width: 380px;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.patient-list-header {
|
||
background: #4c1d95;
|
||
color: #fff;
|
||
padding: 15px 20px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.patient-list-header .count {
|
||
background: rgba(255,255,255,0.2);
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 0.85rem;
|
||
}
|
||
.patient-items {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
}
|
||
.patient-card {
|
||
background: #f8fafc;
|
||
border: 2px solid transparent;
|
||
border-radius: 10px;
|
||
padding: 12px 15px;
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.patient-card:hover { background: #ede9fe; border-color: #c4b5fd; }
|
||
.patient-card.active { background: #ddd6fe; border-color: #8b5cf6; }
|
||
.patient-card .top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||
.patient-card .name { font-size: 1.1rem; font-weight: 600; color: #1e1b4b; }
|
||
.patient-card .order {
|
||
background: #8b5cf6;
|
||
color: #fff;
|
||
padding: 2px 10px;
|
||
border-radius: 12px;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
}
|
||
.patient-card .info { font-size: 0.85rem; color: #64748b; }
|
||
.patient-card .hospital { font-size: 0.8rem; color: #8b5cf6; margin-top: 4px; }
|
||
|
||
/* 오른쪽: 처방 상세 */
|
||
.prescription-detail {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.detail-header {
|
||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||
color: #fff;
|
||
padding: 20px 25px;
|
||
}
|
||
.detail-header .patient-name { font-size: 1.5rem; font-weight: 700; }
|
||
.detail-header .patient-info { font-size: 0.9rem; opacity: 0.9; margin-top: 5px; }
|
||
.detail-header .rx-info {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-top: 12px;
|
||
font-size: 0.85rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.detail-header .rx-info span {
|
||
background: rgba(255,255,255,0.2);
|
||
padding: 4px 12px;
|
||
border-radius: 15px;
|
||
}
|
||
.detail-header .rx-info .disease-badge {
|
||
background: #fef3c7 !important;
|
||
color: #92400e !important;
|
||
margin-left: 5px;
|
||
}
|
||
.detail-header .rx-info .otc-badge {
|
||
background: #dbeafe !important;
|
||
color: #1e40af !important;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.detail-header .rx-info .otc-badge:hover {
|
||
background: #bfdbfe !important;
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
/* PAAI 버튼 */
|
||
.detail-header .rx-info .paai-badge {
|
||
background: linear-gradient(135deg, #10b981, #059669) !important;
|
||
color: #fff !important;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
font-weight: 600;
|
||
}
|
||
.detail-header .rx-info .paai-badge:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||
}
|
||
|
||
/* PAAI 모달 */
|
||
.paai-modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
z-index: 1100;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20px;
|
||
}
|
||
.paai-modal.show { display: flex; }
|
||
.paai-modal-content {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
width: 100%;
|
||
max-width: 700px;
|
||
max-height: 85vh;
|
||
overflow: hidden;
|
||
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.paai-modal-header {
|
||
background: linear-gradient(135deg, #10b981, #059669);
|
||
color: #fff;
|
||
padding: 20px 25px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.paai-modal-header h3 { font-size: 1.3rem; }
|
||
.paai-modal-close {
|
||
background: rgba(255,255,255,0.2);
|
||
border: none;
|
||
color: #fff;
|
||
width: 36px;
|
||
height: 36px;
|
||
border-radius: 50%;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
.paai-modal-close:hover { background: rgba(255,255,255,0.3); }
|
||
.paai-modal-body {
|
||
padding: 25px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
.paai-loading {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
}
|
||
.paai-loading .spinner {
|
||
width: 50px;
|
||
height: 50px;
|
||
border: 4px solid #e5e7eb;
|
||
border-top-color: #10b981;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 20px;
|
||
}
|
||
.paai-section {
|
||
margin-bottom: 24px;
|
||
}
|
||
.paai-section-title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: #374151;
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.paai-section-content {
|
||
background: #f9fafb;
|
||
padding: 15px;
|
||
border-radius: 10px;
|
||
font-size: 0.95rem;
|
||
line-height: 1.6;
|
||
color: #4b5563;
|
||
}
|
||
.paai-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
.paai-list li {
|
||
padding: 8px 0;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
.paai-list li:last-child { border-bottom: none; }
|
||
.paai-list li::before {
|
||
content: '•';
|
||
color: #10b981;
|
||
font-weight: bold;
|
||
}
|
||
.paai-caution {
|
||
background: #fef3c7 !important;
|
||
border-left: 4px solid #f59e0b;
|
||
}
|
||
.paai-otc-rec {
|
||
background: #dbeafe !important;
|
||
padding: 12px 15px;
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.paai-otc-rec .product { font-weight: 600; color: #1e40af; }
|
||
.paai-otc-rec .reason { font-size: 0.9rem; color: #64748b; margin-top: 4px; }
|
||
.paai-kims-severe {
|
||
background: #fee2e2 !important;
|
||
border-left: 4px solid #ef4444;
|
||
}
|
||
.paai-modal-footer {
|
||
padding: 15px 25px;
|
||
border-top: 1px solid #e5e7eb;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.paai-feedback {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.paai-feedback span { font-size: 0.9rem; color: #6b7280; }
|
||
.paai-feedback button {
|
||
padding: 8px 16px;
|
||
border: 2px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
transition: all 0.2s;
|
||
}
|
||
.paai-feedback button:hover { border-color: #10b981; }
|
||
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
|
||
.paai-timing {
|
||
font-size: 0.8rem;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
/* PAAI 토스트 알림 */
|
||
.paai-toast-container {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
pointer-events: none;
|
||
}
|
||
.paai-toast {
|
||
pointer-events: auto;
|
||
background: linear-gradient(135deg, #10b981, #059669);
|
||
color: #fff;
|
||
padding: 15px 20px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4);
|
||
cursor: pointer;
|
||
animation: slideIn 0.3s ease-out;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 280px;
|
||
transition: transform 0.2s, box-shadow 0.2s;
|
||
}
|
||
.paai-toast:hover {
|
||
transform: translateX(-5px);
|
||
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.5);
|
||
}
|
||
.paai-toast .icon {
|
||
font-size: 1.5rem;
|
||
}
|
||
.paai-toast .content {
|
||
flex: 1;
|
||
}
|
||
.paai-toast .title {
|
||
font-weight: 700;
|
||
font-size: 0.95rem;
|
||
}
|
||
.paai-toast .subtitle {
|
||
font-size: 0.8rem;
|
||
opacity: 0.9;
|
||
margin-top: 2px;
|
||
}
|
||
.paai-toast .close-btn {
|
||
background: rgba(255,255,255,0.2);
|
||
border: none;
|
||
color: #fff;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.paai-toast .close-btn:hover {
|
||
background: rgba(255,255,255,0.3);
|
||
}
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes slideOut {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
.paai-toast.removing {
|
||
animation: slideOut 0.3s ease-in forwards;
|
||
}
|
||
|
||
/* PAAI 버튼 로딩 상태 */
|
||
.paai-badge.loading {
|
||
background: linear-gradient(135deg, #6b7280, #9ca3af) !important;
|
||
pointer-events: none;
|
||
}
|
||
.paai-badge .spinner-small {
|
||
display: inline-block;
|
||
width: 14px;
|
||
height: 14px;
|
||
border: 2px solid rgba(255,255,255,0.3);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
margin-right: 6px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
/* OTC 모달 */
|
||
.otc-modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
z-index: 1000;
|
||
overflow-y: auto;
|
||
}
|
||
.otc-modal-content {
|
||
max-width: 600px;
|
||
margin: 40px auto;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||
overflow: hidden;
|
||
}
|
||
.otc-modal-header {
|
||
background: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||
color: #fff;
|
||
padding: 20px 25px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.otc-modal-header h3 { margin: 0; font-size: 1.2rem; }
|
||
.otc-modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: #fff;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
opacity: 0.8;
|
||
}
|
||
.otc-modal-close:hover { opacity: 1; }
|
||
.otc-summary {
|
||
display: flex;
|
||
gap: 20px;
|
||
padding: 15px 25px;
|
||
background: #f8fafc;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
.otc-summary-item {
|
||
text-align: center;
|
||
}
|
||
.otc-summary-item .num {
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #1e40af;
|
||
}
|
||
.otc-summary-item .label {
|
||
font-size: 0.75rem;
|
||
color: #64748b;
|
||
}
|
||
.otc-frequent {
|
||
padding: 15px 25px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
.otc-frequent h4 {
|
||
margin: 0 0 10px 0;
|
||
font-size: 0.9rem;
|
||
color: #475569;
|
||
}
|
||
.otc-frequent-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.otc-frequent-item {
|
||
background: #eff6ff;
|
||
color: #1e40af;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 0.8rem;
|
||
}
|
||
.otc-purchases {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
padding: 15px 25px;
|
||
}
|
||
.otc-purchase {
|
||
border: 1px solid #e2e8f0;
|
||
border-radius: 10px;
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.otc-purchase-header {
|
||
background: #f1f5f9;
|
||
padding: 10px 15px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.85rem;
|
||
}
|
||
.otc-purchase-header .date { color: #475569; font-weight: 600; }
|
||
.otc-purchase-header .amount { color: #1e40af; font-weight: 600; }
|
||
.otc-purchase-items {
|
||
padding: 10px 15px;
|
||
}
|
||
.otc-purchase-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 0;
|
||
font-size: 0.85rem;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
}
|
||
.otc-purchase-item:last-child { border-bottom: none; }
|
||
.otc-purchase-item .thumb {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 6px;
|
||
object-fit: cover;
|
||
background: #f1f5f9;
|
||
flex-shrink: 0;
|
||
}
|
||
.otc-purchase-item .thumb-placeholder {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 6px;
|
||
background: linear-gradient(135deg, #f1f5f9, #e2e8f0);
|
||
border: 2px dashed #cbd5e1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.otc-purchase-item .thumb-placeholder svg {
|
||
width: 20px;
|
||
height: 20px;
|
||
fill: #94a3b8;
|
||
}
|
||
.otc-purchase-item .info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.otc-purchase-item .name { color: #1e293b; font-weight: 500; }
|
||
.otc-purchase-item .category { font-size: 0.75rem; color: #64748b; }
|
||
.otc-purchase-item .qty { color: #64748b; white-space: nowrap; }
|
||
|
||
/* 약품 목록 */
|
||
.medication-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
.med-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
.med-table th {
|
||
background: #f1f5f9;
|
||
padding: 12px 15px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
border-bottom: 2px solid #e2e8f0;
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
.med-table td {
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
vertical-align: middle;
|
||
}
|
||
.med-table tr:hover { background: #f8fafc; }
|
||
.med-name { font-weight: 600; color: #1e293b; }
|
||
.med-code { font-size: 0.75rem; color: #94a3b8; }
|
||
.med-dosage {
|
||
background: #dbeafe;
|
||
color: #1e40af;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
display: inline-block;
|
||
}
|
||
.med-form {
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 0.75rem;
|
||
}
|
||
|
||
/* 빈 상태 */
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
color: #94a3b8;
|
||
}
|
||
.empty-state .icon { font-size: 4rem; margin-bottom: 15px; }
|
||
.empty-state .text { font-size: 1.1rem; }
|
||
|
||
/* 액션 버튼 */
|
||
.action-bar {
|
||
background: #f8fafc;
|
||
padding: 15px 25px;
|
||
border-top: 1px solid #e2e8f0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
.btn {
|
||
padding: 10px 25px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 0.95rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.btn-primary { background: #7c3aed; color: #fff; }
|
||
.btn-primary:hover { background: #6d28d9; }
|
||
.btn-secondary { background: #e2e8f0; color: #475569; }
|
||
.btn-secondary:hover { background: #cbd5e1; }
|
||
|
||
/* 로딩 */
|
||
.loading {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
}
|
||
.spinner {
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 4px solid #e2e8f0;
|
||
border-top-color: #7c3aed;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* 이전 처방 비교 영역 */
|
||
.history-section {
|
||
border-top: 3px solid #e2e8f0;
|
||
background: #fafafa;
|
||
}
|
||
.history-header {
|
||
background: linear-gradient(135deg, #64748b, #94a3b8);
|
||
color: #fff;
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.history-header .title { font-weight: 600; }
|
||
.history-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.history-nav button {
|
||
background: rgba(255,255,255,0.2);
|
||
border: none;
|
||
color: #fff;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
font-size: 1.1rem;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.history-nav button:hover:not(:disabled) { background: rgba(255,255,255,0.4); }
|
||
.history-nav button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
.history-nav .index { font-size: 0.9rem; }
|
||
.history-info {
|
||
padding: 10px 20px;
|
||
background: #f1f5f9;
|
||
font-size: 0.85rem;
|
||
color: #64748b;
|
||
display: flex;
|
||
gap: 15px;
|
||
}
|
||
.history-meds {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
padding: 10px 20px;
|
||
}
|
||
.history-meds table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 0.85rem;
|
||
}
|
||
.history-meds th {
|
||
background: #e2e8f0;
|
||
padding: 8px 10px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
color: #475569;
|
||
}
|
||
.history-meds td {
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
}
|
||
.history-meds .med-name { font-weight: 500; }
|
||
.history-empty {
|
||
padding: 30px;
|
||
text-align: center;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
/* 처방 비교 상태 */
|
||
.med-status {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
margin-left: 8px;
|
||
}
|
||
.status-added { background: #dcfce7; color: #166534; }
|
||
.status-removed { background: #fee2e2; color: #991b1b; }
|
||
.status-changed { background: #fef3c7; color: #92400e; }
|
||
.status-same { background: #f1f5f9; color: #64748b; }
|
||
|
||
tr.row-added { background: #f0fdf4 !important; }
|
||
tr.row-removed { background: #fef2f2 !important; opacity: 0.7; }
|
||
tr.row-changed { background: #fffbeb !important; }
|
||
|
||
.change-arrow {
|
||
color: #94a3b8;
|
||
margin: 0 4px;
|
||
}
|
||
.change-from {
|
||
text-decoration: line-through;
|
||
color: #94a3b8;
|
||
}
|
||
.change-to {
|
||
background: #fbbf24;
|
||
padding: 2px 6px;
|
||
border-radius: 4px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* 비교 모드 토글 */
|
||
.compare-toggle {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 15px;
|
||
background: #f8fafc;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
font-size: 0.85rem;
|
||
}
|
||
.compare-toggle input[type="checkbox"] {
|
||
width: 18px;
|
||
height: 18px;
|
||
cursor: pointer;
|
||
}
|
||
.compare-legend {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-left: auto;
|
||
font-size: 0.75rem;
|
||
}
|
||
.compare-legend span {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- PAAI 토스트 컨테이너 -->
|
||
<div class="paai-toast-container" id="paaiToastContainer"></div>
|
||
|
||
<!-- 헤더 -->
|
||
<header class="header">
|
||
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
||
<div class="controls">
|
||
<input type="date" id="dateSelect" class="date-picker">
|
||
<div class="stats-box">
|
||
<div class="stat-item">
|
||
<div class="num" id="statPrescriptions">-</div>
|
||
<div class="label">처방</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="num" id="statPatients">-</div>
|
||
<div class="label">환자</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="num" id="statMedications">-</div>
|
||
<div class="label">금액</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- 메인 -->
|
||
<div class="main-container">
|
||
<!-- 왼쪽: 환자 목록 -->
|
||
<div class="patient-list">
|
||
<div class="patient-list-header">
|
||
<span>📋 환자 목록</span>
|
||
<span class="count" id="patientCount">0명</span>
|
||
</div>
|
||
<div class="patient-items" id="patientItems">
|
||
<div class="loading"><div class="spinner"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 오른쪽: 처방 상세 -->
|
||
<div class="prescription-detail">
|
||
<div class="detail-header" id="detailHeader" style="display:none;">
|
||
<div class="patient-name" id="detailName">-</div>
|
||
<div class="patient-info" id="detailInfo">-</div>
|
||
<div class="rx-info" id="rxInfo"></div>
|
||
</div>
|
||
<div class="medication-list" id="medicationList">
|
||
<div class="empty-state">
|
||
<div class="icon">👈</div>
|
||
<div class="text">환자를 선택하세요</div>
|
||
</div>
|
||
</div>
|
||
<div class="compare-toggle" id="compareToggle" style="display:none;">
|
||
<input type="checkbox" id="compareMode" onchange="toggleCompareMode()">
|
||
<label for="compareMode">이전 처방과 비교</label>
|
||
<div class="compare-legend" id="compareLegend" style="display:none;">
|
||
<span><span class="med-status status-added">🆕 추가</span></span>
|
||
<span><span class="med-status status-changed">🔄 변경</span></span>
|
||
<span><span class="med-status status-removed">❌ 중단</span></span>
|
||
<span><span class="med-status status-same">✓ 동일</span></span>
|
||
</div>
|
||
</div>
|
||
<div class="action-bar" id="actionBar" style="display:none;">
|
||
<button class="btn btn-secondary" onclick="selectAll()">전체 선택</button>
|
||
<button class="btn btn-secondary" onclick="previewLabels()" style="background:#3b82f6;color:#fff;">👁️ 미리보기</button>
|
||
<button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
|
||
</div>
|
||
|
||
<!-- PAAI 분석 모달 -->
|
||
<div class="paai-modal" id="paaiModal">
|
||
<div class="paai-modal-content">
|
||
<div class="paai-modal-header">
|
||
<h3>🤖 PAAI 분석 결과</h3>
|
||
<button class="paai-modal-close" onclick="closePaaiModal()">×</button>
|
||
</div>
|
||
<div class="paai-modal-body" id="paaiBody">
|
||
<div class="paai-loading">
|
||
<div class="spinner"></div>
|
||
<div>AI 분석 중...</div>
|
||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">KIMS 상호작용 확인 + AI 분석</div>
|
||
</div>
|
||
</div>
|
||
<div class="paai-modal-footer" id="paaiFooter" style="display:none;">
|
||
<div class="paai-feedback">
|
||
<span>도움이 되셨나요?</span>
|
||
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
|
||
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</button>
|
||
</div>
|
||
<div class="paai-timing" id="paaiTiming"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OTC 구매 이력 모달 -->
|
||
<div class="otc-modal" id="otcModal">
|
||
<div class="otc-modal-content">
|
||
<div class="otc-modal-header">
|
||
<h3>💊 OTC 구매 이력</h3>
|
||
<button class="otc-modal-close" onclick="closeOtcModal()">×</button>
|
||
</div>
|
||
<div class="otc-summary" id="otcSummary"></div>
|
||
<div class="otc-frequent" id="otcFrequent"></div>
|
||
<div class="otc-purchases" id="otcPurchases"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 미리보기 모달 -->
|
||
<div id="previewModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:1000;overflow-y:auto;">
|
||
<div style="max-width:400px;margin:50px auto;background:#fff;border-radius:12px;padding:20px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;">
|
||
<h3 style="margin:0;color:#4c1d95;">🏷️ 라벨 미리보기</h3>
|
||
<button onclick="closePreview()" style="background:none;border:none;font-size:1.5rem;cursor:pointer;">×</button>
|
||
</div>
|
||
<div id="previewContent" style="display:flex;flex-direction:column;gap:15px;align-items:center;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 이전 처방 비교 -->
|
||
<div class="history-section" id="historySection" style="display:none;">
|
||
<div class="history-header">
|
||
<span class="title">📜 이전 처방</span>
|
||
<div class="history-nav">
|
||
<button onclick="prevHistory()" id="historyPrev" disabled>‹</button>
|
||
<span class="index" id="historyIndex">-</span>
|
||
<button onclick="nextHistory()" id="historyNext" disabled>›</button>
|
||
</div>
|
||
</div>
|
||
<div class="history-info" id="historyInfo"></div>
|
||
<div class="history-meds" id="historyMeds">
|
||
<div class="history-empty">이전 처방 없음</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentPrescriptionId = null;
|
||
let currentPatientCode = null;
|
||
let currentMedications = [];
|
||
let historyData = [];
|
||
let historyIndex = 0;
|
||
let compareMode = false;
|
||
let otcData = null;
|
||
let currentPrescriptionData = null; // PAAI용 처방 데이터
|
||
|
||
// HTML 이스케이프
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 초기화
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const today = new Date().toISOString().split('T')[0];
|
||
document.getElementById('dateSelect').value = today;
|
||
loadPatients(today);
|
||
loadStats(today);
|
||
});
|
||
|
||
// 날짜 변경
|
||
document.getElementById('dateSelect').addEventListener('change', (e) => {
|
||
loadPatients(e.target.value);
|
||
loadStats(e.target.value);
|
||
clearDetail();
|
||
});
|
||
|
||
// 처방전 목록 로드
|
||
async function loadPatients(date) {
|
||
const container = document.getElementById('patientItems');
|
||
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
|
||
try {
|
||
const res = await fetch(`/pmr/api/prescriptions?date=${date}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.prescriptions.length > 0) {
|
||
container.innerHTML = data.prescriptions.map(p => `
|
||
<div class="patient-card" onclick="selectPatient('${p.prescription_id}', this)">
|
||
<div class="top">
|
||
<span class="name">${p.patient_name || '이름없음'}</span>
|
||
<span class="order">${p.order_number || '-'}</span>
|
||
</div>
|
||
<div class="info">
|
||
${p.age ? p.age + '세' : ''} ${p.gender || ''}
|
||
${p.time ? '• ' + p.time : ''}
|
||
</div>
|
||
<div class="hospital">${p.hospital || ''} ${p.doctor ? '(' + p.doctor + ')' : ''}</div>
|
||
</div>
|
||
`).join('');
|
||
document.getElementById('patientCount').textContent = data.count + '명';
|
||
} else {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="icon">📭</div>
|
||
<div class="text">해당 날짜에 처방이 없습니다</div>
|
||
</div>
|
||
`;
|
||
document.getElementById('patientCount').textContent = '0명';
|
||
}
|
||
} catch (err) {
|
||
container.innerHTML = `<div class="empty-state"><div class="text">오류: ${err.message}</div></div>`;
|
||
}
|
||
}
|
||
|
||
// 통계 로드
|
||
async function loadStats(date) {
|
||
try {
|
||
const res = await fetch(`/pmr/api/stats?date=${date}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
document.getElementById('statPatients').textContent = '-';
|
||
document.getElementById('statPrescriptions').textContent = data.stats.total_prescriptions;
|
||
document.getElementById('statMedications').textContent = Math.round(data.stats.total_amount / 10000) + '만';
|
||
}
|
||
} catch (err) {
|
||
console.error('Stats error:', err);
|
||
}
|
||
}
|
||
|
||
// 환자 선택
|
||
async function selectPatient(prescriptionId, element) {
|
||
// UI 활성화
|
||
document.querySelectorAll('.patient-card').forEach(c => c.classList.remove('active'));
|
||
element.classList.add('active');
|
||
currentPrescriptionId = prescriptionId;
|
||
|
||
const medList = document.getElementById('medicationList');
|
||
medList.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
|
||
try {
|
||
const res = await fetch(`/pmr/api/prescription/${prescriptionId}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
// PAAI용 처방 데이터 저장
|
||
currentPrescriptionData = {
|
||
pre_serial: prescriptionId,
|
||
cus_code: data.patient.cus_code,
|
||
name: data.patient.name,
|
||
st1: data.disease_info?.code_1 || '',
|
||
st1_name: data.disease_info?.name_1 || '',
|
||
st2: data.disease_info?.code_2 || '',
|
||
st2_name: data.disease_info?.name_2 || '',
|
||
medications: data.medications || []
|
||
};
|
||
|
||
// 헤더 업데이트
|
||
document.getElementById('detailHeader').style.display = 'block';
|
||
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
|
||
document.getElementById('detailInfo').textContent =
|
||
`${data.patient.age || '-'}세 / ${data.patient.gender || '-'} / ${data.patient.birthdate || '-'}`;
|
||
|
||
// 질병 정보 표시 (각각 별도 뱃지)
|
||
let diseaseHtml = '';
|
||
if (data.disease_info) {
|
||
const d = data.disease_info;
|
||
if (d.name_1) {
|
||
diseaseHtml += `<span class="disease-badge">🩺 ${d.name_1}</span>`;
|
||
}
|
||
if (d.name_2) {
|
||
diseaseHtml += `<span class="disease-badge">🩺 ${d.name_2}</span>`;
|
||
}
|
||
}
|
||
|
||
document.getElementById('rxInfo').innerHTML = `
|
||
<span>🏥 ${data.prescription.hospital || '-'}</span>
|
||
<span>👨⚕️ ${data.prescription.doctor || '-'}</span>
|
||
<span>📅 ${data.prescription.date}</span>
|
||
<span>💊 ${data.medication_count}종</span>
|
||
${diseaseHtml}
|
||
`;
|
||
|
||
// 약품 테이블
|
||
if (data.medications.length > 0) {
|
||
medList.innerHTML = `
|
||
<table class="med-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px;"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||
<th>약품명</th>
|
||
<th>제형</th>
|
||
<th>용량</th>
|
||
<th>횟수</th>
|
||
<th>일수</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${data.medications.map(m => `
|
||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
|
||
<td>
|
||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||
<div class="med-code">${m.medication_code}</div>
|
||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||
</td>
|
||
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
|
||
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
|
||
<td>${m.frequency || '-'}회</td>
|
||
<td>${m.duration || '-'}일</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
} else {
|
||
medList.innerHTML = '<div class="empty-state"><div class="text">처방 약품이 없습니다</div></div>';
|
||
}
|
||
|
||
document.getElementById('actionBar').style.display = 'flex';
|
||
|
||
// 현재 약품 저장
|
||
currentMedications = data.medications;
|
||
|
||
// 이전 처방 로드
|
||
currentPatientCode = data.patient.code;
|
||
if (currentPatientCode) {
|
||
loadPatientHistory(currentPatientCode, prescriptionId);
|
||
checkOtcHistory(currentPatientCode);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
medList.innerHTML = `<div class="empty-state"><div class="text">오류: ${err.message}</div></div>`;
|
||
}
|
||
}
|
||
|
||
// 환자 이전 처방 로드
|
||
async function loadPatientHistory(cusCode, excludeSerial) {
|
||
const section = document.getElementById('historySection');
|
||
const medsDiv = document.getElementById('historyMeds');
|
||
|
||
try {
|
||
const res = await fetch(`/pmr/api/patient/${cusCode}/history?limit=10&exclude=${excludeSerial}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.history.length > 0) {
|
||
historyData = data.history;
|
||
historyIndex = 0;
|
||
section.style.display = 'block';
|
||
document.getElementById('compareToggle').style.display = 'flex';
|
||
renderHistory();
|
||
} else {
|
||
section.style.display = 'none';
|
||
document.getElementById('compareToggle').style.display = 'none';
|
||
historyData = [];
|
||
}
|
||
} catch (err) {
|
||
console.error('History error:', err);
|
||
section.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 이전 처방 렌더링
|
||
function renderHistory() {
|
||
if (historyData.length === 0) return;
|
||
|
||
const h = historyData[historyIndex];
|
||
|
||
// 인덱스 표시
|
||
document.getElementById('historyIndex').textContent =
|
||
`${historyIndex + 1} / ${historyData.length}`;
|
||
|
||
// 네비게이션 버튼
|
||
document.getElementById('historyPrev').disabled = historyIndex <= 0;
|
||
document.getElementById('historyNext').disabled = historyIndex >= historyData.length - 1;
|
||
|
||
// 정보
|
||
document.getElementById('historyInfo').innerHTML = `
|
||
<span>📅 ${h.date}</span>
|
||
<span>🏥 ${h.hospital || '-'}</span>
|
||
<span>👨⚕️ ${h.doctor || '-'}</span>
|
||
<span>💊 ${h.medication_count}종</span>
|
||
<span>💰 ${(h.copayment || 0).toLocaleString()}원</span>
|
||
`;
|
||
|
||
// 약품 테이블
|
||
if (h.medications && h.medications.length > 0) {
|
||
document.getElementById('historyMeds').innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>약품명</th>
|
||
<th>용량</th>
|
||
<th>횟수</th>
|
||
<th>일수</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${h.medications.map(m => `
|
||
<tr>
|
||
<td>
|
||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
|
||
</td>
|
||
<td>${m.dosage || '-'}</td>
|
||
<td>${m.frequency || '-'}회</td>
|
||
<td>${m.duration || '-'}일</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
} else {
|
||
document.getElementById('historyMeds').innerHTML = '<div class="history-empty">약품 정보 없음</div>';
|
||
}
|
||
}
|
||
|
||
// 이전 처방 네비게이션
|
||
function prevHistory() {
|
||
if (historyIndex > 0) {
|
||
historyIndex--;
|
||
renderHistory();
|
||
if (compareMode) applyCompareMode();
|
||
}
|
||
}
|
||
|
||
function nextHistory() {
|
||
if (historyIndex < historyData.length - 1) {
|
||
historyIndex++;
|
||
renderHistory();
|
||
if (compareMode) applyCompareMode();
|
||
}
|
||
}
|
||
|
||
// 비교 모드 토글
|
||
function toggleCompareMode() {
|
||
compareMode = document.getElementById('compareMode').checked;
|
||
document.getElementById('compareLegend').style.display = compareMode ? 'flex' : 'none';
|
||
|
||
if (compareMode) {
|
||
applyCompareMode();
|
||
} else {
|
||
// 비교 모드 해제 - 테이블 다시 렌더링
|
||
rerenderMedicationTable();
|
||
}
|
||
}
|
||
|
||
// 처방 비교 로직
|
||
function comparePrescriptions(current, previous) {
|
||
const result = [];
|
||
const prevMap = new Map(previous.map(m => [m.medication_code, m]));
|
||
const currCodes = new Set(current.map(m => m.medication_code));
|
||
|
||
// 현재 처방 약품 처리
|
||
for (const curr of current) {
|
||
const prev = prevMap.get(curr.medication_code);
|
||
if (!prev) {
|
||
// 추가된 약
|
||
result.push({ ...curr, status: 'added' });
|
||
} else {
|
||
// 비교
|
||
const changes = [];
|
||
if (parseFloat(curr.dosage) !== parseFloat(prev.dosage)) {
|
||
changes.push({ field: 'dosage', from: prev.dosage, to: curr.dosage });
|
||
}
|
||
if (parseInt(curr.frequency) !== parseInt(prev.frequency)) {
|
||
changes.push({ field: 'frequency', from: prev.frequency, to: curr.frequency });
|
||
}
|
||
if (parseInt(curr.duration) !== parseInt(prev.duration)) {
|
||
changes.push({ field: 'duration', from: prev.duration, to: curr.duration });
|
||
}
|
||
|
||
if (changes.length > 0) {
|
||
result.push({ ...curr, status: 'changed', changes });
|
||
} else {
|
||
result.push({ ...curr, status: 'same' });
|
||
}
|
||
}
|
||
}
|
||
|
||
// 중단된 약 (이전에 있었는데 현재 없음)
|
||
for (const prev of previous) {
|
||
if (!currCodes.has(prev.medication_code)) {
|
||
result.push({ ...prev, status: 'removed' });
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
// 비교 모드 적용
|
||
function applyCompareMode() {
|
||
if (historyData.length === 0 || !currentMedications.length) return;
|
||
|
||
const prevMeds = historyData[historyIndex].medications || [];
|
||
const compared = comparePrescriptions(currentMedications, prevMeds);
|
||
|
||
// 테이블 다시 렌더링
|
||
renderComparedTable(compared);
|
||
}
|
||
|
||
// 비교 결과 테이블 렌더링
|
||
function renderComparedTable(compared) {
|
||
const medList = document.getElementById('medicationList');
|
||
|
||
// 상태별 정렬: 추가 > 변경 > 동일 > 중단
|
||
const order = { added: 0, changed: 1, same: 2, removed: 3 };
|
||
compared.sort((a, b) => order[a.status] - order[b.status]);
|
||
|
||
medList.innerHTML = `
|
||
<table class="med-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px;"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||
<th>약품명</th>
|
||
<th>상태</th>
|
||
<th>용량</th>
|
||
<th>횟수</th>
|
||
<th>일수</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${compared.map(m => {
|
||
const rowClass = 'row-' + m.status;
|
||
const statusLabel = {
|
||
added: '<span class="med-status status-added">🆕 추가</span>',
|
||
removed: '<span class="med-status status-removed">❌ 중단</span>',
|
||
changed: '<span class="med-status status-changed">🔄 변경</span>',
|
||
same: '<span class="med-status status-same">✓ 동일</span>'
|
||
}[m.status];
|
||
|
||
// 변경된 필드 찾기
|
||
const getChangeValue = (field, value) => {
|
||
if (m.status !== 'changed' || !m.changes) return value || '-';
|
||
const change = m.changes.find(c => c.field === field);
|
||
if (change) {
|
||
return `<span class="change-from">${change.from || '-'}</span>` +
|
||
`<span class="change-arrow">→</span>` +
|
||
`<span class="change-to">${change.to || '-'}</span>`;
|
||
}
|
||
return value || '-';
|
||
};
|
||
|
||
const disabled = m.status === 'removed' ? 'disabled' : '';
|
||
|
||
return `
|
||
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
|
||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
|
||
<td>
|
||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||
<div class="med-code">${m.medication_code}</div>
|
||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||
</td>
|
||
<td>${statusLabel}</td>
|
||
<td>${m.status === 'changed' ? getChangeValue('dosage', m.dosage) : (m.dosage || '-')}</td>
|
||
<td>${m.status === 'changed' ? getChangeValue('frequency', m.frequency) : (m.frequency || '-')}회</td>
|
||
<td>${m.status === 'changed' ? getChangeValue('duration', m.duration) : (m.duration || '-')}일</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
// 일반 테이블로 복원
|
||
function rerenderMedicationTable() {
|
||
if (!currentMedications.length) return;
|
||
|
||
const medList = document.getElementById('medicationList');
|
||
medList.innerHTML = `
|
||
<table class="med-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="width:40px;"><input type="checkbox" id="checkAll" onchange="toggleAll(this)"></th>
|
||
<th>약품명</th>
|
||
<th>제형</th>
|
||
<th>용량</th>
|
||
<th>횟수</th>
|
||
<th>일수</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${currentMedications.map(m => `
|
||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
|
||
<td>
|
||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
||
<div class="med-code">${m.medication_code}</div>
|
||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||
</td>
|
||
<td>${m.formulation ? `<span class="med-form">${m.formulation}</span>` : '-'}</td>
|
||
<td><span class="med-dosage">${m.dosage || '-'}</span></td>
|
||
<td>${m.frequency || '-'}회</td>
|
||
<td>${m.duration || '-'}일</td>
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
// OTC 구매 이력 체크
|
||
async function checkOtcHistory(cusCode) {
|
||
try {
|
||
const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`);
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.count > 0) {
|
||
otcData = data;
|
||
// OTC 뱃지 추가 (질병 뱃지 앞에)
|
||
const rxInfo = document.getElementById('rxInfo');
|
||
const otcBadge = `<span class="otc-badge" onclick="showOtcModal()">💊 OTC ${data.count}건</span>`;
|
||
rxInfo.innerHTML = otcBadge + rxInfo.innerHTML;
|
||
} else {
|
||
otcData = null;
|
||
}
|
||
|
||
// PAAI 버튼 추가 (항상 표시)
|
||
addPaaiButton();
|
||
} catch (err) {
|
||
console.error('OTC check error:', err);
|
||
otcData = null;
|
||
// OTC 오류여도 PAAI 버튼은 추가
|
||
addPaaiButton();
|
||
}
|
||
}
|
||
|
||
// OTC 모달 표시
|
||
function showOtcModal() {
|
||
if (!otcData) return;
|
||
|
||
const modal = document.getElementById('otcModal');
|
||
const summary = document.getElementById('otcSummary');
|
||
const frequent = document.getElementById('otcFrequent');
|
||
const purchases = document.getElementById('otcPurchases');
|
||
|
||
// 요약
|
||
summary.innerHTML = `
|
||
<div class="otc-summary-item">
|
||
<div class="num">${otcData.summary.total_visits}</div>
|
||
<div class="label">방문</div>
|
||
</div>
|
||
<div class="otc-summary-item">
|
||
<div class="num">${(otcData.summary.total_amount / 10000).toFixed(1)}만</div>
|
||
<div class="label">총 구매액</div>
|
||
</div>
|
||
`;
|
||
|
||
// 자주 구매하는 품목
|
||
if (otcData.summary.frequent_items && otcData.summary.frequent_items.length > 0) {
|
||
frequent.innerHTML = `
|
||
<h4>🔥 자주 구매하는 품목</h4>
|
||
<div class="otc-frequent-list">
|
||
${otcData.summary.frequent_items.map(item =>
|
||
`<span class="otc-frequent-item">${item.name} (${item.count}회)</span>`
|
||
).join('')}
|
||
</div>
|
||
`;
|
||
frequent.style.display = 'block';
|
||
} else {
|
||
frequent.style.display = 'none';
|
||
}
|
||
|
||
// 구매 이력
|
||
purchases.innerHTML = otcData.purchases.map(p => `
|
||
<div class="otc-purchase">
|
||
<div class="otc-purchase-header">
|
||
<span class="date">📅 ${p.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3') || p.datetime}</span>
|
||
<span class="amount">${p.amount.toLocaleString()}원</span>
|
||
</div>
|
||
<div class="otc-purchase-items">
|
||
${p.items.map(item => `
|
||
<div class="otc-purchase-item">
|
||
${item.image
|
||
? `<img class="thumb" src="data:image/jpeg;base64,${item.image}" alt="">`
|
||
: `<div class="thumb-placeholder"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
|
||
}
|
||
<div class="info">
|
||
<div class="name">${item.name}</div>
|
||
${item.category ? `<div class="category">${item.category}</div>` : ''}
|
||
</div>
|
||
<span class="qty">${item.quantity}개 / ${item.price.toLocaleString()}원</span>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
modal.style.display = 'block';
|
||
}
|
||
|
||
// OTC 모달 닫기
|
||
function closeOtcModal() {
|
||
document.getElementById('otcModal').style.display = 'none';
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
let currentPaaiLogId = null;
|
||
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
|
||
const paaiPendingRequests = new Set(); // 진행 중인 요청
|
||
|
||
function addPaaiButton() {
|
||
const rxInfo = document.getElementById('rxInfo');
|
||
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
|
||
|
||
const preSerial = currentPrescriptionData?.pre_serial;
|
||
const paaiBtn = document.createElement('span');
|
||
paaiBtn.className = 'paai-badge';
|
||
paaiBtn.id = 'paaiBtn';
|
||
|
||
// 캐시에 결과가 있으면 "결과 보기" 버튼
|
||
if (preSerial && paaiResultCache[preSerial]) {
|
||
paaiBtn.innerHTML = '✅ PAAI 결과 보기';
|
||
paaiBtn.onclick = () => openPaaiResultModal(preSerial);
|
||
}
|
||
// 진행 중이면 로딩 상태
|
||
else if (preSerial && paaiPendingRequests.has(preSerial)) {
|
||
paaiBtn.classList.add('loading');
|
||
paaiBtn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||
paaiBtn.onclick = null;
|
||
}
|
||
// 기본: 분석 버튼
|
||
else {
|
||
paaiBtn.innerHTML = '🤖 PAAI 분석';
|
||
paaiBtn.onclick = triggerPaaiAnalysis;
|
||
}
|
||
|
||
rxInfo.appendChild(paaiBtn);
|
||
}
|
||
|
||
// 비동기 분석 트리거 (모달 열지 않음)
|
||
async function triggerPaaiAnalysis() {
|
||
if (!currentPrescriptionData) return;
|
||
|
||
const preSerial = currentPrescriptionData.pre_serial;
|
||
const patientName = currentPrescriptionData.name || '환자';
|
||
|
||
// 이미 진행 중이면 무시
|
||
if (paaiPendingRequests.has(preSerial)) {
|
||
showPaaiToast(patientName, '이미 분석 중입니다...', 'pending', preSerial);
|
||
return;
|
||
}
|
||
|
||
// 캐시에 있으면 바로 모달 열기
|
||
if (paaiResultCache[preSerial]) {
|
||
openPaaiResultModal(preSerial);
|
||
return;
|
||
}
|
||
|
||
// 버튼 로딩 상태
|
||
const btn = document.getElementById('paaiBtn');
|
||
if (btn) {
|
||
btn.classList.add('loading');
|
||
btn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||
}
|
||
|
||
// 진행 중 표시
|
||
paaiPendingRequests.add(preSerial);
|
||
|
||
// 요청 데이터 구성 (현재 환자 데이터 스냅샷 저장)
|
||
const requestSnapshot = {
|
||
pre_serial: preSerial,
|
||
cus_code: currentPrescriptionData.cus_code,
|
||
patient_name: patientName,
|
||
disease_info: {
|
||
code_1: currentPrescriptionData.st1 || '',
|
||
name_1: currentPrescriptionData.st1_name || '',
|
||
code_2: currentPrescriptionData.st2 || '',
|
||
name_2: currentPrescriptionData.st2_name || ''
|
||
},
|
||
current_medications: (currentPrescriptionData.medications || []).map(med => ({
|
||
code: med.medication_code,
|
||
name: med.med_name,
|
||
dosage: med.dosage,
|
||
frequency: med.frequency,
|
||
days: med.duration // 백엔드는 duration 필드 사용
|
||
})),
|
||
previous_serial: currentPrescriptionData.previous_serial || '',
|
||
previous_medications: (currentPrescriptionData.previous_medications || []).map(med => ({
|
||
code: med.medication_code,
|
||
name: med.med_name,
|
||
dosage: med.dosage,
|
||
frequency: med.frequency,
|
||
days: med.duration // 백엔드는 duration 필드 사용
|
||
})),
|
||
otc_history: otcData ? {
|
||
visit_count: otcData.summary?.total_visits || 0,
|
||
frequent_items: otcData.summary?.frequent_items || [],
|
||
purchases: otcData.purchases || []
|
||
} : {}
|
||
};
|
||
|
||
// 비동기 분석 실행 (await 없이 백그라운드)
|
||
performPaaiAnalysis(preSerial, patientName, requestSnapshot);
|
||
}
|
||
|
||
// 실제 분석 수행 (백그라운드)
|
||
async function performPaaiAnalysis(preSerial, patientName, requestData) {
|
||
try {
|
||
const response = await fetch('/pmr/api/paai/analyze', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(requestData)
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 캐시에 저장
|
||
paaiResultCache[preSerial] = {
|
||
result: result,
|
||
patientName: patientName,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
// 토스트 알림
|
||
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
||
} else {
|
||
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
||
}
|
||
|
||
} catch (err) {
|
||
console.error('PAAI error:', err);
|
||
showPaaiToast(patientName, '분석 오류: ' + err.message, 'error', preSerial);
|
||
} finally {
|
||
paaiPendingRequests.delete(preSerial);
|
||
|
||
// 현재 보고 있는 환자면 버튼 상태 업데이트
|
||
if (currentPrescriptionData?.pre_serial === preSerial) {
|
||
updatePaaiButtonState(preSerial);
|
||
}
|
||
}
|
||
}
|
||
|
||
// PAAI 버튼 상태 업데이트
|
||
function updatePaaiButtonState(preSerial) {
|
||
const btn = document.getElementById('paaiBtn');
|
||
if (!btn) return;
|
||
|
||
btn.classList.remove('loading');
|
||
|
||
// 캐시에 결과가 있으면 "결과 보기"
|
||
if (paaiResultCache[preSerial]) {
|
||
btn.innerHTML = '✅ PAAI 결과 보기';
|
||
btn.onclick = () => openPaaiResultModal(preSerial);
|
||
}
|
||
// 진행 중이면 로딩
|
||
else if (paaiPendingRequests.has(preSerial)) {
|
||
btn.classList.add('loading');
|
||
btn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||
btn.onclick = null;
|
||
}
|
||
// 기본
|
||
else {
|
||
btn.innerHTML = '🤖 PAAI 분석';
|
||
btn.onclick = triggerPaaiAnalysis;
|
||
}
|
||
}
|
||
|
||
// 토스트 알림 표시
|
||
function showPaaiToast(patientName, message, type, preSerial) {
|
||
const container = document.getElementById('paaiToastContainer');
|
||
|
||
const toast = document.createElement('div');
|
||
toast.className = 'paai-toast';
|
||
toast.dataset.preSerial = preSerial;
|
||
|
||
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
|
||
|
||
toast.innerHTML = `
|
||
<div class="icon">${icon}</div>
|
||
<div class="content">
|
||
<div class="title">${escapeHtml(patientName)}님</div>
|
||
<div class="subtitle">${escapeHtml(message)}</div>
|
||
</div>
|
||
<button class="close-btn" onclick="event.stopPropagation(); removePaaiToast(this.parentElement);">×</button>
|
||
`;
|
||
|
||
// 성공이면 클릭 시 모달 열기
|
||
if (type === 'success') {
|
||
toast.onclick = () => {
|
||
openPaaiResultModal(preSerial);
|
||
removePaaiToast(toast);
|
||
};
|
||
}
|
||
|
||
container.appendChild(toast);
|
||
|
||
// 에러/대기 토스트는 5초 후 자동 제거, 성공은 15초
|
||
const timeout = type === 'success' ? 15000 : 5000;
|
||
setTimeout(() => removePaaiToast(toast), timeout);
|
||
}
|
||
|
||
// 토스트 제거
|
||
function removePaaiToast(toast) {
|
||
if (!toast || !toast.parentElement) return;
|
||
toast.classList.add('removing');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}
|
||
|
||
// 캐시된 결과로 모달 열기
|
||
function openPaaiResultModal(preSerial) {
|
||
const cached = paaiResultCache[preSerial];
|
||
if (!cached) return;
|
||
|
||
const modal = document.getElementById('paaiModal');
|
||
const body = document.getElementById('paaiBody');
|
||
const footer = document.getElementById('paaiFooter');
|
||
|
||
// 모달 헤더 업데이트 (환자명 표시)
|
||
const header = modal.querySelector('.paai-modal-header h3');
|
||
if (header) {
|
||
header.textContent = `🤖 PAAI 분석 - ${cached.patientName}님`;
|
||
}
|
||
|
||
currentPaaiLogId = cached.result.log_id;
|
||
displayPaaiResult(cached.result);
|
||
modal.classList.add('show');
|
||
}
|
||
|
||
// 기존 방식으로 모달 열기 (현재 환자)
|
||
async function showPaaiModal() {
|
||
if (!currentPrescriptionData) return;
|
||
|
||
const preSerial = currentPrescriptionData.pre_serial;
|
||
|
||
// 캐시에 있으면 바로 표시
|
||
if (paaiResultCache[preSerial]) {
|
||
openPaaiResultModal(preSerial);
|
||
return;
|
||
}
|
||
|
||
// 없으면 분석 트리거
|
||
triggerPaaiAnalysis();
|
||
}
|
||
|
||
function displayPaaiResult(result) {
|
||
const body = document.getElementById('paaiBody');
|
||
const footer = document.getElementById('paaiFooter');
|
||
const timing = document.getElementById('paaiTiming');
|
||
|
||
const analysis = result.analysis || {};
|
||
const kims = result.kims_summary || {};
|
||
|
||
let html = '';
|
||
|
||
// KIMS 상호작용 요약
|
||
if (kims.interaction_count > 0) {
|
||
html += `
|
||
<div class="paai-section">
|
||
<div class="paai-section-title">⚠️ KIMS 상호작용 (${kims.interaction_count}건)</div>
|
||
<div class="paai-section-content ${kims.has_severe ? 'paai-kims-severe' : 'paai-caution'}">
|
||
${analysis.kims_analysis || 'KIMS 상호작용이 감지되었습니다. 상세 내용을 확인하세요.'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 처방 분석
|
||
if (analysis.prescription_insight) {
|
||
html += `
|
||
<div class="paai-section">
|
||
<div class="paai-section-title">📋 처방 분석</div>
|
||
<div class="paai-section-content">${analysis.prescription_insight}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 복용 주의사항
|
||
if (analysis.cautions && analysis.cautions.length > 0) {
|
||
html += `
|
||
<div class="paai-section">
|
||
<div class="paai-section-title">⚡ 복용 주의사항</div>
|
||
<div class="paai-section-content paai-caution">
|
||
<ul class="paai-list">
|
||
${analysis.cautions.map(c => `<li>${c}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// OTC 추천
|
||
if (analysis.otc_recommendations && analysis.otc_recommendations.length > 0) {
|
||
html += `
|
||
<div class="paai-section">
|
||
<div class="paai-section-title">💊 OTC 추천</div>
|
||
<div>
|
||
${analysis.otc_recommendations.map(rec => `
|
||
<div class="paai-otc-rec">
|
||
<div class="product">${rec.product}</div>
|
||
<div class="reason">${rec.reason}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 상담 포인트
|
||
if (analysis.counseling_points && analysis.counseling_points.length > 0) {
|
||
html += `
|
||
<div class="paai-section">
|
||
<div class="paai-section-title">💬 상담 포인트</div>
|
||
<div class="paai-section-content">
|
||
<ul class="paai-list">
|
||
${analysis.counseling_points.map(p => `<li>${p}</li>`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// fallback 메시지
|
||
if (analysis._fallback) {
|
||
html += `
|
||
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:0.9rem;">
|
||
⚠️ AI 서비스 연결 불가 - KIMS 데이터만 표시됨
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
body.innerHTML = html || '<div style="text-align:center;padding:40px;color:#9ca3af;">분석 결과가 없습니다.</div>';
|
||
|
||
// 타이밍 정보
|
||
if (result.timing) {
|
||
timing.textContent = `KIMS: ${result.timing.kims_ms}ms / AI: ${result.timing.ai_ms}ms / 총: ${result.timing.total_ms}ms`;
|
||
}
|
||
|
||
// 피드백 버튼 초기화
|
||
document.getElementById('paaiUseful').classList.remove('selected');
|
||
document.getElementById('paaiNotUseful').classList.remove('selected');
|
||
|
||
footer.style.display = 'flex';
|
||
}
|
||
|
||
function closePaaiModal() {
|
||
document.getElementById('paaiModal').classList.remove('show');
|
||
}
|
||
|
||
async function sendPaaiFeedback(useful) {
|
||
if (!currentPaaiLogId) return;
|
||
|
||
// 버튼 즉시 반영
|
||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||
|
||
try {
|
||
await fetch('/pmr/api/paai/feedback', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
log_id: currentPaaiLogId,
|
||
useful: useful
|
||
})
|
||
});
|
||
} catch (err) {
|
||
console.error('Feedback error:', err);
|
||
}
|
||
|
||
// 0.5초 후 모달 닫기
|
||
setTimeout(() => closePaaiModal(), 500);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────
|
||
|
||
// 상세 초기화
|
||
function clearDetail() {
|
||
document.getElementById('detailHeader').style.display = 'none';
|
||
document.getElementById('actionBar').style.display = 'none';
|
||
document.getElementById('historySection').style.display = 'none';
|
||
document.getElementById('compareToggle').style.display = 'none';
|
||
document.getElementById('compareMode').checked = false;
|
||
document.getElementById('compareLegend').style.display = 'none';
|
||
document.getElementById('otcModal').style.display = 'none';
|
||
document.getElementById('medicationList').innerHTML = `
|
||
<div class="empty-state">
|
||
<div class="icon">👈</div>
|
||
<div class="text">환자를 선택하세요</div>
|
||
</div>
|
||
`;
|
||
currentPrescriptionId = null;
|
||
currentPatientCode = null;
|
||
currentMedications = [];
|
||
historyData = [];
|
||
historyIndex = 0;
|
||
compareMode = false;
|
||
otcData = null;
|
||
}
|
||
|
||
// 전체 선택 토글
|
||
function toggleAll(checkbox) {
|
||
document.querySelectorAll('.med-check').forEach(c => c.checked = checkbox.checked);
|
||
}
|
||
|
||
function selectAll() {
|
||
document.querySelectorAll('.med-check').forEach(c => c.checked = true);
|
||
const checkAll = document.getElementById('checkAll');
|
||
if (checkAll) checkAll.checked = true;
|
||
}
|
||
|
||
// 라벨 미리보기
|
||
async function previewLabels() {
|
||
const checkboxes = document.querySelectorAll('.med-check:checked');
|
||
if (checkboxes.length === 0) {
|
||
alert('미리보기할 약품을 선택하세요');
|
||
return;
|
||
}
|
||
|
||
const container = document.getElementById('previewContent');
|
||
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
document.getElementById('previewModal').style.display = 'block';
|
||
|
||
// 현재 선택된 환자명
|
||
const patientName = document.getElementById('detailName').textContent;
|
||
|
||
container.innerHTML = '';
|
||
|
||
for (const checkbox of checkboxes) {
|
||
const tr = checkbox.closest('tr');
|
||
const cells = tr.querySelectorAll('td');
|
||
|
||
// 약품명: 두 번째 셀의 .med-name
|
||
const medName = tr.querySelector('.med-name')?.textContent?.trim() || '';
|
||
const addInfo = tr.dataset.addInfo || '';
|
||
// 용량: 네 번째 셀 (index 3)
|
||
const dosageText = cells[3]?.textContent?.replace(/[^0-9.]/g, '') || '0';
|
||
const dosage = parseFloat(dosageText) || 0;
|
||
// 횟수: 다섯 번째 셀 (index 4)
|
||
const freqText = cells[4]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||
const frequency = parseInt(freqText) || 0;
|
||
// 일수: 여섯 번째 셀 (index 5)
|
||
const durText = cells[5]?.textContent?.replace(/[^0-9]/g, '') || '0';
|
||
const duration = parseInt(durText) || 0;
|
||
|
||
console.log('Preview data:', { patientName, medName, addInfo, dosage, frequency, duration });
|
||
|
||
try {
|
||
const res = await fetch('/pmr/api/label/preview', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
patient_name: patientName,
|
||
med_name: medName,
|
||
add_info: addInfo,
|
||
dosage: dosage,
|
||
frequency: frequency,
|
||
duration: duration,
|
||
unit: '정'
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
console.log('Preview response:', data.success, data.error);
|
||
|
||
if (data.success && data.image) {
|
||
const img = document.createElement('img');
|
||
img.src = data.image;
|
||
img.style.cssText = 'max-width:100%;border:1px solid #ddd;border-radius:8px;';
|
||
container.appendChild(img);
|
||
} else {
|
||
console.error('Preview failed:', data.error);
|
||
}
|
||
} catch (err) {
|
||
console.error('Preview error:', err);
|
||
}
|
||
}
|
||
|
||
if (container.children.length === 0) {
|
||
container.innerHTML = '<p style="color:#999;">미리보기 생성 실패 - 콘솔(F12) 확인</p>';
|
||
}
|
||
}
|
||
|
||
function closePreview() {
|
||
document.getElementById('previewModal').style.display = 'none';
|
||
}
|
||
|
||
// 라벨 인쇄 (TODO: 구현)
|
||
function printLabels() {
|
||
const selected = Array.from(document.querySelectorAll('.med-check:checked')).map(c => c.dataset.code);
|
||
if (selected.length === 0) {
|
||
alert('인쇄할 약품을 선택하세요');
|
||
return;
|
||
}
|
||
alert(`선택된 약품 ${selected.length}개 인쇄 기능은 추후 구현 예정입니다.\n\n${selected.join('\n')}`);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 처방감지 트리거 WebSocket 클라이언트
|
||
// ws://localhost:8765 (prescription_trigger.py에서 실행)
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
(function() {
|
||
const TRIGGER_WS_URL = 'ws://localhost:8765';
|
||
const TRIGGER_DEBUG = true;
|
||
|
||
let triggerWs = null;
|
||
let triggerConnected = false;
|
||
let triggerReconnectTimer = null;
|
||
const triggerToastMap = new Map(); // pre_serial → toast element
|
||
|
||
function triggerLog(msg, ...args) {
|
||
if (TRIGGER_DEBUG) console.log(`[Trigger] ${msg}`, ...args);
|
||
}
|
||
|
||
// WebSocket 연결
|
||
function triggerConnect() {
|
||
if (triggerWs && triggerWs.readyState === WebSocket.OPEN) return;
|
||
|
||
try {
|
||
triggerLog('연결 시도:', TRIGGER_WS_URL);
|
||
triggerWs = new WebSocket(TRIGGER_WS_URL);
|
||
|
||
triggerWs.onopen = () => {
|
||
triggerLog('✅ 연결됨');
|
||
triggerConnected = true;
|
||
updateTriggerIndicator(true);
|
||
if (triggerReconnectTimer) {
|
||
clearTimeout(triggerReconnectTimer);
|
||
triggerReconnectTimer = null;
|
||
}
|
||
};
|
||
|
||
triggerWs.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
triggerLog('이벤트:', data.event, data.data?.patient_name);
|
||
handleTriggerEvent(data);
|
||
} catch (e) {
|
||
console.error('[Trigger] 파싱 실패:', e);
|
||
}
|
||
};
|
||
|
||
triggerWs.onclose = () => {
|
||
triggerLog('연결 해제');
|
||
triggerConnected = false;
|
||
triggerWs = null;
|
||
updateTriggerIndicator(false);
|
||
|
||
// 3초 후 재연결
|
||
triggerReconnectTimer = setTimeout(() => {
|
||
triggerLog('재연결 시도...');
|
||
triggerConnect();
|
||
}, 3000);
|
||
};
|
||
|
||
triggerWs.onerror = (error) => {
|
||
console.error('[Trigger] 오류:', error);
|
||
};
|
||
|
||
} catch (e) {
|
||
console.error('[Trigger] 연결 실패:', e);
|
||
}
|
||
}
|
||
|
||
// 이벤트 처리
|
||
function handleTriggerEvent(eventData) {
|
||
const { event, data } = eventData;
|
||
|
||
switch (event) {
|
||
case 'prescription_detected':
|
||
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋');
|
||
break;
|
||
case 'prescription_updated':
|
||
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
|
||
break;
|
||
case 'prescription_deleted':
|
||
removeTriggerToast(data.pre_serial);
|
||
break;
|
||
case 'analysis_started':
|
||
showTriggerToast(data.pre_serial, data.patient_name, 'generating', 'AI 분석 중...', '🤖');
|
||
break;
|
||
case 'analysis_completed':
|
||
// 캐시에 저장
|
||
paaiResultCache[data.pre_serial] = {
|
||
result: {
|
||
success: true,
|
||
analysis: data.analysis,
|
||
kims_summary: data.kims_summary,
|
||
log_id: data.log_id
|
||
},
|
||
patientName: data.patient_name,
|
||
timestamp: Date.now()
|
||
};
|
||
|
||
const hasSevere = data.kims_summary?.has_severe;
|
||
showTriggerToast(
|
||
data.pre_serial,
|
||
data.patient_name,
|
||
'completed',
|
||
hasSevere ? '⚠️ 주의 필요!' : '분석 완료! 클릭하여 확인',
|
||
hasSevere ? '⚠️' : '✅',
|
||
true // clickable
|
||
);
|
||
playTriggerSound();
|
||
break;
|
||
case 'analysis_failed':
|
||
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
|
||
break;
|
||
case 'job_cancelled':
|
||
showTriggerToast(data.pre_serial, data.patient_name, 'cancelled', data.reason || '취소됨', '🚫');
|
||
setTimeout(() => removeTriggerToast(data.pre_serial), 3000);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 토스트 컨테이너
|
||
function getTriggerToastContainer() {
|
||
let container = document.getElementById('triggerToastStack');
|
||
if (!container) {
|
||
container = document.createElement('div');
|
||
container.id = 'triggerToastStack';
|
||
container.style.cssText = `
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column-reverse;
|
||
gap: 10px;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
pointer-events: none;
|
||
`;
|
||
document.body.appendChild(container);
|
||
}
|
||
return container;
|
||
}
|
||
|
||
// 토스트 표시/업데이트
|
||
function showTriggerToast(preSerial, patientName, status, message, icon, clickable = false) {
|
||
let toast = triggerToastMap.get(preSerial);
|
||
|
||
if (!toast) {
|
||
toast = document.createElement('div');
|
||
toast.dataset.preSerial = preSerial;
|
||
triggerToastMap.set(preSerial, toast);
|
||
getTriggerToastContainer().appendChild(toast);
|
||
}
|
||
|
||
// 상태별 색상
|
||
let bg = 'linear-gradient(135deg, #10b981, #059669)';
|
||
let shadow = 'rgba(16, 185, 129, 0.4)';
|
||
|
||
if (status === 'pending') {
|
||
bg = 'linear-gradient(135deg, #f59e0b, #d97706)';
|
||
shadow = 'rgba(245, 158, 11, 0.4)';
|
||
} else if (status === 'generating') {
|
||
bg = 'linear-gradient(135deg, #3b82f6, #2563eb)';
|
||
shadow = 'rgba(59, 130, 246, 0.4)';
|
||
} else if (status === 'failed' || (status === 'completed' && icon === '⚠️')) {
|
||
bg = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
||
shadow = 'rgba(239, 68, 68, 0.4)';
|
||
} else if (status === 'cancelled') {
|
||
bg = 'linear-gradient(135deg, #6b7280, #4b5563)';
|
||
shadow = 'rgba(107, 114, 128, 0.4)';
|
||
}
|
||
|
||
toast.style.cssText = `
|
||
pointer-events: auto;
|
||
background: ${bg};
|
||
color: #fff;
|
||
padding: 15px 20px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 25px ${shadow};
|
||
cursor: ${clickable ? 'pointer' : 'default'};
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
min-width: 300px;
|
||
max-width: 400px;
|
||
transition: transform 0.2s;
|
||
animation: triggerSlideIn 0.3s ease-out;
|
||
`;
|
||
|
||
toast.innerHTML = `
|
||
<div style="font-size: 1.5rem;">${icon}</div>
|
||
<div style="flex: 1;">
|
||
<div style="font-weight: 700; font-size: 0.95rem;">${escapeHtml(patientName)}님</div>
|
||
<div style="font-size: 0.8rem; opacity: 0.9; margin-top: 2px;">${escapeHtml(message)}</div>
|
||
${status === 'generating' ? '<div style="margin-top: 5px; width: 100%; height: 3px; background: rgba(255,255,255,0.3); border-radius: 2px; overflow: hidden;"><div style="width: 30%; height: 100%; background: #fff; animation: triggerProgress 1s ease-in-out infinite;"></div></div>' : ''}
|
||
</div>
|
||
<button onclick="event.stopPropagation(); window.removeTriggerToast('${preSerial}');" style="
|
||
background: rgba(255,255,255,0.2);
|
||
border: none;
|
||
color: #fff;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 50%;
|
||
font-size: 1rem;
|
||
cursor: pointer;
|
||
">×</button>
|
||
`;
|
||
|
||
// 호버 효과
|
||
toast.onmouseenter = () => toast.style.transform = 'translateX(-5px)';
|
||
toast.onmouseleave = () => toast.style.transform = 'translateX(0)';
|
||
|
||
// 클릭 이벤트
|
||
if (clickable && status === 'completed') {
|
||
toast.onclick = () => {
|
||
if (typeof openPaaiResultModal === 'function') {
|
||
openPaaiResultModal(preSerial);
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// 토스트 제거
|
||
window.removeTriggerToast = function(preSerial) {
|
||
const toast = triggerToastMap.get(preSerial);
|
||
if (!toast) return;
|
||
|
||
toast.style.animation = 'triggerSlideOut 0.3s ease-in forwards';
|
||
setTimeout(() => {
|
||
if (toast.parentElement) toast.parentElement.removeChild(toast);
|
||
triggerToastMap.delete(preSerial);
|
||
}, 300);
|
||
};
|
||
|
||
// 연결 상태 표시
|
||
function updateTriggerIndicator(isConnected) {
|
||
let indicator = document.getElementById('triggerIndicator');
|
||
if (!indicator) {
|
||
const controls = document.querySelector('.header .controls');
|
||
if (controls) {
|
||
indicator = document.createElement('div');
|
||
indicator.id = 'triggerIndicator';
|
||
indicator.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
background: #f1f5f9;
|
||
border-radius: 8px;
|
||
font-size: 0.8rem;
|
||
color: #64748b;
|
||
`;
|
||
controls.insertBefore(indicator, controls.firstChild);
|
||
}
|
||
}
|
||
|
||
if (indicator) {
|
||
indicator.innerHTML = `
|
||
<span style="
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: ${isConnected ? '#10b981' : '#ef4444'};
|
||
"></span>
|
||
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 알림 소리
|
||
function playTriggerSound() {
|
||
try {
|
||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const osc = ctx.createOscillator();
|
||
const gain = ctx.createGain();
|
||
osc.connect(gain);
|
||
gain.connect(ctx.destination);
|
||
osc.frequency.value = 800;
|
||
osc.type = 'sine';
|
||
gain.gain.value = 0.1;
|
||
osc.start();
|
||
osc.stop(ctx.currentTime + 0.15);
|
||
} catch (e) {}
|
||
}
|
||
|
||
// CSS 애니메이션 주입
|
||
const triggerStyle = document.createElement('style');
|
||
triggerStyle.textContent = `
|
||
@keyframes triggerSlideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes triggerSlideOut {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
@keyframes triggerProgress {
|
||
0% { transform: translateX(-100%); }
|
||
50% { transform: translateX(200%); }
|
||
100% { transform: translateX(-100%); }
|
||
}
|
||
`;
|
||
document.head.appendChild(triggerStyle);
|
||
|
||
// 초기화
|
||
triggerConnect();
|
||
triggerLog('처방감지 클라이언트 초기화 완료');
|
||
|
||
})();
|
||
|
||
</script>
|
||
</body>
|
||
</html>
|