feat: 처방감지 WebSocket 클라이언트 통합 + days 버그 수정
- WebSocket 클라이언트 추가 (ws://localhost:8765) - 처방 감지 시 자동 토스트 알림 (누적 표시) - 연결 상태 표시 (자동감지 ON/OFF) - fix: med.days → med.duration 필드명 수정 (복용일수 0 버그)
This commit is contained in:
parent
1b33f82fd4
commit
4275689c29
@ -322,6 +322,96 @@
|
|||||||
color: #9ca3af;
|
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 모달 */
|
||||||
.otc-modal {
|
.otc-modal {
|
||||||
display: none;
|
display: none;
|
||||||
@ -689,6 +779,9 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- PAAI 토스트 컨테이너 -->
|
||||||
|
<div class="paai-toast-container" id="paaiToastContainer"></div>
|
||||||
|
|
||||||
<!-- 헤더 -->
|
<!-- 헤더 -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
||||||
@ -829,6 +922,7 @@
|
|||||||
let historyIndex = 0;
|
let historyIndex = 0;
|
||||||
let compareMode = false;
|
let compareMode = false;
|
||||||
let otcData = null;
|
let otcData = null;
|
||||||
|
let currentPrescriptionData = null; // PAAI용 처방 데이터
|
||||||
|
|
||||||
// HTML 이스케이프
|
// HTML 이스케이프
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@ -922,6 +1016,18 @@
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success) {
|
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('detailHeader').style.display = 'block';
|
||||||
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
|
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
|
||||||
@ -1363,74 +1469,111 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// PAAI (Pharmacist Assistant AI) 함수들
|
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let currentPaaiLogId = null;
|
let currentPaaiLogId = null;
|
||||||
|
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
|
||||||
|
const paaiPendingRequests = new Set(); // 진행 중인 요청
|
||||||
|
|
||||||
function addPaaiButton() {
|
function addPaaiButton() {
|
||||||
const rxInfo = document.getElementById('rxInfo');
|
const rxInfo = document.getElementById('rxInfo');
|
||||||
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
|
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
|
||||||
|
|
||||||
|
const preSerial = currentPrescriptionData?.pre_serial;
|
||||||
const paaiBtn = document.createElement('span');
|
const paaiBtn = document.createElement('span');
|
||||||
paaiBtn.className = 'paai-badge';
|
paaiBtn.className = 'paai-badge';
|
||||||
paaiBtn.textContent = '🤖 PAAI 분석';
|
paaiBtn.id = 'paaiBtn';
|
||||||
paaiBtn.onclick = showPaaiModal;
|
|
||||||
|
// 캐시에 결과가 있으면 "결과 보기" 버튼
|
||||||
|
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);
|
rxInfo.appendChild(paaiBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showPaaiModal() {
|
// 비동기 분석 트리거 (모달 열지 않음)
|
||||||
if (!currentPrescription) return;
|
async function triggerPaaiAnalysis() {
|
||||||
|
if (!currentPrescriptionData) return;
|
||||||
|
|
||||||
const modal = document.getElementById('paaiModal');
|
const preSerial = currentPrescriptionData.pre_serial;
|
||||||
const body = document.getElementById('paaiBody');
|
const patientName = currentPrescriptionData.name || '환자';
|
||||||
const footer = document.getElementById('paaiFooter');
|
|
||||||
|
|
||||||
// 초기화
|
// 이미 진행 중이면 무시
|
||||||
body.innerHTML = `
|
if (paaiPendingRequests.has(preSerial)) {
|
||||||
<div class="paai-loading">
|
showPaaiToast(patientName, '이미 분석 중입니다...', 'pending', preSerial);
|
||||||
<div class="spinner"></div>
|
return;
|
||||||
<div>AI 분석 중...</div>
|
}
|
||||||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">KIMS 상호작용 확인 + AI 분석</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
footer.style.display = 'none';
|
|
||||||
modal.classList.add('show');
|
|
||||||
|
|
||||||
|
// 캐시에 있으면 바로 모달 열기
|
||||||
|
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 {
|
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', {
|
const response = await fetch('/pmr/api/paai/analyze', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
@ -1440,24 +1583,134 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
currentPaaiLogId = result.log_id;
|
// 캐시에 저장
|
||||||
displayPaaiResult(result);
|
paaiResultCache[preSerial] = {
|
||||||
|
result: result,
|
||||||
|
patientName: patientName,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 토스트 알림
|
||||||
|
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || '분석 실패');
|
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PAAI error:', err);
|
console.error('PAAI error:', err);
|
||||||
body.innerHTML = `
|
showPaaiToast(patientName, '분석 오류: ' + err.message, 'error', preSerial);
|
||||||
<div style="text-align:center;padding:40px;color:#ef4444;">
|
} finally {
|
||||||
<div style="font-size:2rem;margin-bottom:15px;">⚠️</div>
|
paaiPendingRequests.delete(preSerial);
|
||||||
<div>분석 중 오류가 발생했습니다</div>
|
|
||||||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">${err.message}</div>
|
// 현재 보고 있는 환자면 버튼 상태 업데이트
|
||||||
</div>
|
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) {
|
function displayPaaiResult(result) {
|
||||||
const body = document.getElementById('paaiBody');
|
const body = document.getElementById('paaiBody');
|
||||||
const footer = document.getElementById('paaiFooter');
|
const footer = document.getElementById('paaiFooter');
|
||||||
@ -1565,6 +1818,10 @@
|
|||||||
async function sendPaaiFeedback(useful) {
|
async function sendPaaiFeedback(useful) {
|
||||||
if (!currentPaaiLogId) return;
|
if (!currentPaaiLogId) return;
|
||||||
|
|
||||||
|
// 버튼 즉시 반영
|
||||||
|
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||||||
|
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/pmr/api/paai/feedback', {
|
await fetch('/pmr/api/paai/feedback', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -1574,14 +1831,12 @@
|
|||||||
useful: useful
|
useful: useful
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// 버튼 표시 업데이트
|
|
||||||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
|
||||||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Feedback error:', err);
|
console.error('Feedback error:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0.5초 후 모달 닫기
|
||||||
|
setTimeout(() => closePaaiModal(), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@ -1705,6 +1960,316 @@
|
|||||||
}
|
}
|
||||||
alert(`선택된 약품 ${selected.length}개 인쇄 기능은 추후 구현 예정입니다.\n\n${selected.join('\n')}`);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user