fix: 처방목록 조회 기준을 발행일에서 조제일로 변경

문제:
- PMR 처방 목록이 PassDay(처방전 발행일) 기준으로 조회되어
  발행일과 조제일이 다른 처방(예: 3일 전 발행, 오늘 조제)이
  오늘 목록에 표시되지 않는 버그

해결:
- PS_MAIN 테이블 조회 시 PassDay 대신 Indate(조제일) 기준으로 변경
- issue_date(발행일), dispense_date(조제일) 필드 추가로 구분 명확화

추가 변경:
- WebSocket 연결/해제 시 토스트 알림 추가
- WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
This commit is contained in:
thug0bin
2026-03-05 10:56:24 +09:00
parent f3b6496c91
commit cb90d4a7a6
3 changed files with 771 additions and 12 deletions

View File

@@ -161,6 +161,51 @@
transform: scale(1.05);
}
/* 환자 정보 행 */
.detail-header .patient-row {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
/* 특이사항 인라인 (환자명 옆) */
.detail-header .cusetc-inline {
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
line-height: 1.4;
max-width: 400px;
padding: 6px 12px;
border-radius: 8px;
margin-left: auto;
}
.detail-header .cusetc-inline.has-note {
background: linear-gradient(135deg, #fae8ff, #f5d0fe);
color: #86198f;
border: 1px solid #e879f9;
}
.detail-header .cusetc-inline.no-note {
background: #f9fafb;
color: #9ca3af;
border: 1px dashed #d1d5db;
font-size: 0.85rem;
}
.detail-header .cusetc-inline:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.detail-header .cusetc-inline .cusetc-label {
font-weight: 600;
margin-right: 6px;
}
.detail-header .cusetc-inline .cusetc-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* PAAI 버튼 */
.detail-header .rx-info .paai-badge {
background: linear-gradient(135deg, #10b981, #059669) !important;
@@ -412,6 +457,115 @@
vertical-align: middle;
}
/* 특이사항 모달 */
.cusetc-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
align-items: center;
justify-content: center;
}
.cusetc-modal-content {
background: #fff;
border-radius: 16px;
width: 90%;
max-width: 500px;
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
overflow: hidden;
}
.cusetc-modal-header {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
padding: 18px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.cusetc-modal-header h3 {
font-size: 1.2rem;
margin: 0;
}
.cusetc-modal-close {
background: rgba(255,255,255,0.2);
border: none;
color: #fff;
width: 32px;
height: 32px;
border-radius: 50%;
font-size: 1.3rem;
cursor: pointer;
transition: background 0.2s;
}
.cusetc-modal-close:hover { background: rgba(255,255,255,0.3); }
.cusetc-modal-body {
padding: 24px;
}
.cusetc-patient-info {
background: #f9fafb;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.95rem;
color: #374151;
}
.cusetc-modal-body textarea {
width: 100%;
min-height: 120px;
padding: 14px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
}
.cusetc-modal-body textarea:focus {
outline: none;
border-color: #f59e0b;
}
.cusetc-hint {
font-size: 0.85rem;
color: #6b7280;
margin-top: 10px;
}
.cusetc-modal-footer {
padding: 16px 24px;
background: #f9fafb;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.cusetc-btn-cancel {
padding: 10px 20px;
background: #fff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s;
}
.cusetc-btn-cancel:hover {
background: #f3f4f6;
}
.cusetc-btn-save {
padding: 10px 24px;
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cusetc-btn-save:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 158, 11, 0.4);
}
/* OTC 모달 */
.otc-modal {
display: none;
@@ -820,8 +974,11 @@
<!-- 오른쪽: 처방 상세 -->
<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="patient-row">
<div class="patient-name" id="detailName">-</div>
<div class="patient-info" id="detailInfo">-</div>
<div class="cusetc-inline" id="cusetcInline" onclick="openCusetcModal()"></div>
</div>
<div class="rx-info" id="rxInfo"></div>
</div>
<div class="medication-list" id="medicationList">
@@ -871,6 +1028,25 @@
</div>
</div>
<!-- 특이사항 모달 -->
<div class="cusetc-modal" id="cusetcModal">
<div class="cusetc-modal-content">
<div class="cusetc-modal-header">
<h3>📝 환자 특이사항</h3>
<button class="cusetc-modal-close" onclick="closeCusetcModal()">×</button>
</div>
<div class="cusetc-modal-body">
<div class="cusetc-patient-info" id="cusetcPatientInfo"></div>
<textarea id="cusetcTextarea" placeholder="특이사항을 입력하세요... (예: 약 삼키기 어려움, 당뇨 주의, 수유 중 등)"></textarea>
<div class="cusetc-hint">💡 Tip: 복약지도 시 참고할 정보를 기록하세요</div>
</div>
<div class="cusetc-modal-footer">
<button class="cusetc-btn-cancel" onclick="closeCusetcModal()">취소</button>
<button class="cusetc-btn-save" onclick="saveCusetc()">💾 저장</button>
</div>
</div>
</div>
<!-- OTC 구매 이력 모달 -->
<div class="otc-modal" id="otcModal">
<div class="otc-modal-content">
@@ -1001,6 +1177,44 @@
}
}
// 환자 목록 새로고침 (현재 선택 유지)
async function refreshPatientList() {
const container = document.getElementById('patientItems');
const date = document.getElementById('dateSelect').value;
const selectedId = currentPrescriptionId; // 현재 선택된 환자 ID 저장
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 ${p.prescription_id === selectedId ? 'active' : ''}"
onclick="selectPatient('${p.prescription_id}', this)"
data-id="${p.prescription_id}">
<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 + '명';
// 통계도 갱신
loadStats(date);
console.log('[PMR] 환자 목록 갱신됨:', data.count + '명');
}
} catch (err) {
console.error('목록 갱신 오류:', err);
}
}
// 환자 선택
async function selectPatient(prescriptionId, element) {
// UI 활성화
@@ -1019,13 +1233,14 @@
// PAAI용 처방 데이터 저장
currentPrescriptionData = {
pre_serial: prescriptionId,
cus_code: data.patient.cus_code,
cus_code: data.patient.cus_code || data.patient.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 || []
medications: data.medications || [],
cusetc: data.patient.cusetc || '' // 특이사항
};
// 헤더 업데이트
@@ -1046,6 +1261,19 @@
}
}
// 특이사항 (환자명 옆 인라인 표시)
const cusetc = data.patient.cusetc || '';
const cusetcInline = document.getElementById('cusetcInline');
if (cusetc) {
cusetcInline.className = 'cusetc-inline has-note';
cusetcInline.innerHTML = `<span class="cusetc-label">📝</span><span class="cusetc-text">${escapeHtml(cusetc)}</span>`;
cusetcInline.title = cusetc;
} else {
cusetcInline.className = 'cusetc-inline no-note';
cusetcInline.innerHTML = '+ 특이사항 추가';
cusetcInline.title = '';
}
document.getElementById('rxInfo').innerHTML = `
<span>🏥 ${data.prescription.hospital || '-'}</span>
<span>👨‍⚕️ ${data.prescription.doctor || '-'}</span>
@@ -1375,6 +1603,8 @@
// OTC 구매 이력 체크
async function checkOtcHistory(cusCode) {
const preSerial = currentPrescriptionData?.pre_serial;
try {
const res = await fetch(`/pmr/api/patient/${cusCode}/otc?limit=20`);
const data = await res.json();
@@ -1389,12 +1619,17 @@
otcData = null;
}
// PAAI 버튼 추가 (항상 표시)
// 서버에서 기존 PAAI 결과 확인 (트리거가 미리 생성한 결과)
await checkPaaiStatus(preSerial);
// PAAI 버튼 추가 (캐시/진행중/기본 상태에 따라)
addPaaiButton();
} catch (err) {
console.error('OTC check error:', err);
otcData = null;
// OTC 오류여도 PAAI 버튼은 추가
// OTC 오류여도 PAAI 상태 확인 및 버튼 추가
await checkPaaiStatus(preSerial);
addPaaiButton();
}
}
@@ -1468,6 +1703,88 @@
document.getElementById('otcModal').style.display = 'none';
}
// ─────────────────────────────────────────────────────────────
// 특이사항 모달 함수들
// ─────────────────────────────────────────────────────────────
function openCusetcModal() {
if (!currentPrescriptionData) return;
const modal = document.getElementById('cusetcModal');
const patientInfo = document.getElementById('cusetcPatientInfo');
const textarea = document.getElementById('cusetcTextarea');
// 환자 정보 표시
patientInfo.innerHTML = `
<strong>${currentPrescriptionData.name || '환자'}</strong>
<span style="margin-left: 10px; color: #6b7280;">고객코드: ${currentPrescriptionData.cus_code || '-'}</span>
`;
// 기존 특이사항 로드
textarea.value = currentPrescriptionData.cusetc || '';
// 모달 표시
modal.style.display = 'flex';
textarea.focus();
}
function closeCusetcModal() {
document.getElementById('cusetcModal').style.display = 'none';
}
async function saveCusetc() {
if (!currentPrescriptionData || !currentPrescriptionData.cus_code) {
alert('❌ 환자 정보가 없습니다.');
return;
}
const textarea = document.getElementById('cusetcTextarea');
const newCusetc = textarea.value.trim();
const cusCode = currentPrescriptionData.cus_code;
try {
const res = await fetch(`/api/members/${cusCode}/cusetc`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cusetc: newCusetc })
});
const data = await res.json();
if (data.success) {
// 로컬 데이터 업데이트
currentPrescriptionData.cusetc = newCusetc;
// 뱃지 업데이트
updateCusetcBadge(newCusetc);
closeCusetcModal();
// 토스트 알림
showPaaiToast(currentPrescriptionData.name, '특이사항이 저장되었습니다.', 'completed');
} else {
alert('❌ ' + (data.error || '저장 실패'));
}
} catch (err) {
alert('❌ 오류: ' + err.message);
}
}
function updateCusetcBadge(cusetc) {
const cusetcInline = document.getElementById('cusetcInline');
if (!cusetcInline) return;
if (cusetc) {
cusetcInline.className = 'cusetc-inline has-note';
cusetcInline.innerHTML = `<span class="cusetc-label">📝</span><span class="cusetc-text">${escapeHtml(cusetc)}</span>`;
cusetcInline.title = cusetc;
} else {
cusetcInline.className = 'cusetc-inline no-note';
cusetcInline.innerHTML = '+ 특이사항 추가';
cusetcInline.title = '';
}
}
// ─────────────────────────────────────────────────────────────
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
// ─────────────────────────────────────────────────────────────
@@ -1476,6 +1793,43 @@
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
const paaiPendingRequests = new Set(); // 진행 중인 요청
// 서버에서 기존 PAAI 결과 조회 (트리거 모듈이 미리 생성한 결과)
async function checkPaaiStatus(preSerial) {
if (!preSerial) return;
// 이미 캐시에 있으면 스킵
if (paaiResultCache[preSerial]) return;
try {
const res = await fetch(`/pmr/api/paai/result/${preSerial}`);
const data = await res.json();
if (data.exists) {
if (data.status === 'success' && data.result) {
// 성공한 결과 캐시에 저장
const patientName = data.result.patient_name || '환자';
paaiResultCache[preSerial] = {
result: {
success: true,
log_id: data.log_id,
analysis: data.result.analysis,
kims_summary: data.result.kims_summary,
timing: data.result.timing
},
patientName: patientName
};
console.log(`[PAAI] 기존 결과 로드: ${preSerial} (${patientName})`);
} else if (data.status === 'generating') {
// 생성 중인 요청으로 표시
paaiPendingRequests.add(preSerial);
console.log(`[PAAI] 생성 중: ${preSerial}`);
}
}
} catch (err) {
console.error('PAAI status check error:', err);
}
}
function addPaaiButton() {
const rxInfo = document.getElementById('rxInfo');
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
@@ -1967,7 +2321,12 @@
// ═══════════════════════════════════════════════════════════════════════════
(function() {
const TRIGGER_WS_URL = 'ws://localhost:8765';
// WebSocket 연결 URL 결정
// - HTTPS(프록시) 접속 → wss:// 프록시 경로 사용
// - HTTP(내부) 접속 → ws:// 직접 연결
const TRIGGER_WS_URL = location.protocol === 'https:'
? `wss://${location.host}/ws` // NPM 프록시 경로
: `ws://${location.hostname}:8765`; // 내부 직접 연결
const TRIGGER_DEBUG = true;
let triggerWs = null;
@@ -1991,6 +2350,7 @@
triggerLog('✅ 연결됨');
triggerConnected = true;
updateTriggerIndicator(true);
showToast('🔗 처방감지 연결됨', 'success');
if (triggerReconnectTimer) {
clearTimeout(triggerReconnectTimer);
triggerReconnectTimer = null;
@@ -2012,6 +2372,7 @@
triggerConnected = false;
triggerWs = null;
updateTriggerIndicator(false);
showToast('🔌 처방감지 연결 끊김 (재연결 중...)', 'warning');
// 3초 후 재연결
triggerReconnectTimer = setTimeout(() => {
@@ -2036,9 +2397,13 @@
switch (event) {
case 'prescription_detected':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋');
// 환자 목록 자동 갱신 (현재 선택 유지)
refreshPatientList();
break;
case 'prescription_updated':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
// 환자 목록 갱신 (수정된 정보 반영)
refreshPatientList();
break;
case 'prescription_deleted':
removeTriggerToast(data.pre_serial);