From 4275689c296a8ea952aac6e361ab5a7dc3447b81 Mon Sep 17 00:00:00 2001 From: thug0bin Date: Thu, 5 Mar 2026 09:30:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B2=98=EB=B0=A9=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?WebSocket=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20+=20days=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebSocket 클라이언트 추가 (ws://localhost:8765) - 처방 감지 시 자동 토스트 알림 (누적 표시) - 연결 상태 표시 (자동감지 ON/OFF) - fix: med.days → med.duration 필드명 수정 (복용일수 0 버그) --- backend/templates/pmr.html | 697 +++++++++++++++++++++++++++++++++---- 1 file changed, 631 insertions(+), 66 deletions(-) diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index fc69b80..2e28f20 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.html @@ -322,6 +322,96 @@ 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; @@ -689,6 +779,9 @@ + +
+

💊 조제관리 청춘라벨 v2

@@ -829,6 +922,7 @@ let historyIndex = 0; let compareMode = false; let otcData = null; + let currentPrescriptionData = null; // PAAI용 처방 데이터 // HTML 이스케이프 function escapeHtml(text) { @@ -922,6 +1016,18 @@ 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 || '이름없음'; @@ -1363,74 +1469,111 @@ } // ───────────────────────────────────────────────────────────── - // PAAI (Pharmacist Assistant AI) 함수들 + // 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.textContent = '🤖 PAAI 분석'; - paaiBtn.onclick = showPaaiModal; + 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 = '분석 중...'; + paaiBtn.onclick = null; + } + // 기본: 분석 버튼 + else { + paaiBtn.innerHTML = '🤖 PAAI 분석'; + paaiBtn.onclick = triggerPaaiAnalysis; + } + rxInfo.appendChild(paaiBtn); } - async function showPaaiModal() { - if (!currentPrescription) return; + // 비동기 분석 트리거 (모달 열지 않음) + async function triggerPaaiAnalysis() { + if (!currentPrescriptionData) return; - const modal = document.getElementById('paaiModal'); - const body = document.getElementById('paaiBody'); - const footer = document.getElementById('paaiFooter'); + const preSerial = currentPrescriptionData.pre_serial; + const patientName = currentPrescriptionData.name || '환자'; - // 초기화 - body.innerHTML = ` -
-
-
AI 분석 중...
-
KIMS 상호작용 확인 + AI 분석
-
- `; - footer.style.display = 'none'; - modal.classList.add('show'); + // 이미 진행 중이면 무시 + 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 = '분석 중...'; + } + + // 진행 중 표시 + 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 requestData = { - pre_serial: currentPrescription.pre_serial, - cus_code: currentPrescription.cus_code, - patient_name: currentPrescription.name, - disease_info: { - code_1: currentPrescription.st1 || '', - name_1: currentPrescription.st1_name || '', - code_2: currentPrescription.st2 || '', - name_2: currentPrescription.st2_name || '' - }, - current_medications: (currentPrescription.medications || []).map(med => ({ - code: med.medication_code, - name: med.med_name, - dosage: med.dosage, - frequency: med.frequency, - days: med.days - })), - previous_serial: currentPrescription.previous_serial || '', - previous_medications: (currentPrescription.previous_medications || []).map(med => ({ - code: med.medication_code, - name: med.med_name, - dosage: med.dosage, - frequency: med.frequency, - days: med.days - })), - otc_history: otcData ? { - visit_count: otcData.summary?.total_visits || 0, - frequent_items: otcData.summary?.frequent_items || [], - purchases: otcData.purchases || [] - } : {} - }; - const response = await fetch('/pmr/api/paai/analyze', { method: 'POST', headers: {'Content-Type': 'application/json'}, @@ -1440,24 +1583,134 @@ const result = await response.json(); if (result.success) { - currentPaaiLogId = result.log_id; - displayPaaiResult(result); + // 캐시에 저장 + paaiResultCache[preSerial] = { + result: result, + patientName: patientName, + timestamp: Date.now() + }; + + // 토스트 알림 + showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial); } else { - throw new Error(result.error || '분석 실패'); + showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial); } } catch (err) { console.error('PAAI error:', err); - body.innerHTML = ` -
-
⚠️
-
분석 중 오류가 발생했습니다
-
${err.message}
-
- `; + 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 = '분석 중...'; + 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 = ` +
${icon}
+
+
${escapeHtml(patientName)}님
+
${escapeHtml(message)}
+
+ + `; + + // 성공이면 클릭 시 모달 열기 + 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'); @@ -1565,6 +1818,10 @@ 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', @@ -1574,14 +1831,12 @@ useful: useful }) }); - - // 버튼 표시 업데이트 - document.getElementById('paaiUseful').classList.toggle('selected', useful); - document.getElementById('paaiNotUseful').classList.toggle('selected', !useful); - } catch (err) { console.error('Feedback error:', err); } + + // 0.5초 후 모달 닫기 + setTimeout(() => closePaaiModal(), 500); } // ───────────────────────────────────────────────────────────── @@ -1705,6 +1960,316 @@ } 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 = ` +
${icon}
+
+
${escapeHtml(patientName)}님
+
${escapeHtml(message)}
+ ${status === 'generating' ? '
' : ''} +
+ + `; + + // 호버 효과 + 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 = ` + + ${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('처방감지 클라이언트 초기화 완료'); + + })(); +