pharmacy-pos-qr-system/backend/templates/pmr.html
thug0bin cb90d4a7a6 fix: 처방목록 조회 기준을 발행일에서 조제일로 변경
문제:
- PMR 처방 목록이 PassDay(처방전 발행일) 기준으로 조회되어
  발행일과 조제일이 다른 처방(예: 3일 전 발행, 오늘 조제)이
  오늘 목록에 표시되지 않는 버그

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

추가 변경:
- WebSocket 연결/해제 시 토스트 알림 추가
- WebSocket 프록시 트러블슈팅 문서 추가 (NPM 설정 가이드)
2026-03-05 10:56:24 +09:00

2641 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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);
}
/* 환자 정보 행 */
.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;
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;
}
/* 특이사항 모달 */
.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;
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-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">
<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>
<!-- 특이사항 모달 -->
<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">
<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 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 활성화
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 || 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 || [],
cusetc: data.patient.cusetc || '' // 특이사항
};
// 헤더 업데이트
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>`;
}
}
// 특이사항 (환자명 옆 인라인 표시)
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>
<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) {
const preSerial = currentPrescriptionData?.pre_serial;
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 결과 확인 (트리거가 미리 생성한 결과)
await checkPaaiStatus(preSerial);
// PAAI 버튼 추가 (캐시/진행중/기본 상태에 따라)
addPaaiButton();
} catch (err) {
console.error('OTC check error:', err);
otcData = null;
// OTC 오류여도 PAAI 상태 확인 및 버튼 추가
await checkPaaiStatus(preSerial);
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';
}
// ─────────────────────────────────────────────────────────────
// 특이사항 모달 함수들
// ─────────────────────────────────────────────────────────────
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) 함수들 - 비동기 토스트 방식
// ─────────────────────────────────────────────────────────────
let currentPaaiLogId = null;
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;
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() {
// 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;
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);
showToast('🔗 처방감지 연결됨', 'success');
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);
showToast('🔌 처방감지 연결 끊김 (재연결 중...)', 'warning');
// 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', '처방 감지됨...', '📋');
// 환자 목록 자동 갱신 (현재 선택 유지)
refreshPatientList();
break;
case 'prescription_updated':
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
// 환자 목록 갱신 (수정된 정보 반영)
refreshPatientList();
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>