pharmacy-pos-qr-system/backend/templates/pmr.html
thug0bin c21aa956da feat: PMR 라벨 미리보기 기능
- /pmr/api/label/preview: PIL 렌더링 → Base64 이미지
- 미리보기 버튼 + 모달 추가
- 29mm 용지 기준 라벨 이미지 생성
2026-03-04 22:52:42 +09:00

553 lines
22 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;
}
/* 약품 목록 */
.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); } }
</style>
</head>
<body>
<!-- 헤더 -->
<header class="header">
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
<div class="controls">
<input type="date" id="dateSelect" class="date-picker">
<div class="stats-box">
<div class="stat-item">
<div class="num" id="statPrescriptions">-</div>
<div class="label">처방</div>
</div>
<div class="stat-item">
<div class="num" id="statPatients">-</div>
<div class="label">환자</div>
</div>
<div class="stat-item">
<div class="num" id="statMedications">-</div>
<div class="label">금액</div>
</div>
</div>
</div>
</header>
<!-- 메인 -->
<div class="main-container">
<!-- 왼쪽: 환자 목록 -->
<div class="patient-list">
<div class="patient-list-header">
<span>📋 환자 목록</span>
<span class="count" id="patientCount">0명</span>
</div>
<div class="patient-items" id="patientItems">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- 오른쪽: 처방 상세 -->
<div class="prescription-detail">
<div class="detail-header" id="detailHeader" style="display:none;">
<div class="patient-name" id="detailName">-</div>
<div class="patient-info" id="detailInfo">-</div>
<div class="rx-info" id="rxInfo"></div>
</div>
<div class="medication-list" id="medicationList">
<div class="empty-state">
<div class="icon">👈</div>
<div class="text">환자를 선택하세요</div>
</div>
</div>
<div class="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>
<!-- 미리보기 모달 -->
<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>
</div>
<script>
let currentPrescriptionId = null;
// 초기화
document.addEventListener('DOMContentLoaded', () => {
const today = new Date().toISOString().split('T')[0];
document.getElementById('dateSelect').value = today;
loadPatients(today);
loadStats(today);
});
// 날짜 변경
document.getElementById('dateSelect').addEventListener('change', (e) => {
loadPatients(e.target.value);
loadStats(e.target.value);
clearDetail();
});
// 처방전 목록 로드
async function loadPatients(date) {
const container = document.getElementById('patientItems');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const res = await fetch(`/pmr/api/prescriptions?date=${date}`);
const data = await res.json();
if (data.success && data.prescriptions.length > 0) {
container.innerHTML = data.prescriptions.map(p => `
<div class="patient-card" onclick="selectPatient('${p.prescription_id}', this)">
<div class="top">
<span class="name">${p.patient_name || '이름없음'}</span>
<span class="order">${p.order_number || '-'}</span>
</div>
<div class="info">
${p.age ? p.age + '세' : ''} ${p.gender || ''}
${p.time ? '• ' + p.time : ''}
</div>
<div class="hospital">${p.hospital || ''} ${p.doctor ? '(' + p.doctor + ')' : ''}</div>
</div>
`).join('');
document.getElementById('patientCount').textContent = data.count + '명';
} else {
container.innerHTML = `
<div class="empty-state">
<div class="icon">📭</div>
<div class="text">해당 날짜에 처방이 없습니다</div>
</div>
`;
document.getElementById('patientCount').textContent = '0명';
}
} catch (err) {
container.innerHTML = `<div class="empty-state"><div class="text">오류: ${err.message}</div></div>`;
}
}
// 통계 로드
async function loadStats(date) {
try {
const res = await fetch(`/pmr/api/stats?date=${date}`);
const data = await res.json();
if (data.success) {
document.getElementById('statPatients').textContent = '-';
document.getElementById('statPrescriptions').textContent = data.stats.total_prescriptions;
document.getElementById('statMedications').textContent = Math.round(data.stats.total_amount / 10000) + '만';
}
} catch (err) {
console.error('Stats error:', err);
}
}
// 환자 선택
async function selectPatient(prescriptionId, element) {
// UI 활성화
document.querySelectorAll('.patient-card').forEach(c => c.classList.remove('active'));
element.classList.add('active');
currentPrescriptionId = prescriptionId;
const medList = document.getElementById('medicationList');
medList.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const res = await fetch(`/pmr/api/prescription/${prescriptionId}`);
const data = await res.json();
if (data.success) {
// 헤더 업데이트
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 || '-'}`;
document.getElementById('rxInfo').innerHTML = `
<span>🏥 ${data.prescription.hospital || '-'}</span>
<span>👨‍⚕️ ${data.prescription.doctor || '-'}</span>
<span>📅 ${data.prescription.date}</span>
<span>💊 ${data.medication_count}종</span>
`;
// 약품 테이블
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>
<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>
</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';
}
} catch (err) {
medList.innerHTML = `<div class="empty-state"><div class="text">오류: ${err.message}</div></div>`;
}
}
// 상세 초기화
function clearDetail() {
document.getElementById('detailHeader').style.display = 'none';
document.getElementById('actionBar').style.display = 'none';
document.getElementById('medicationList').innerHTML = `
<div class="empty-state">
<div class="icon">👈</div>
<div class="text">환자를 선택하세요</div>
</div>
`;
currentPrescriptionId = 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 rows = document.querySelectorAll('.med-check:checked');
if (rows.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 rows) {
const tr = checkbox.closest('tr');
const medName = tr.querySelector('.med-name')?.textContent || '';
const dosage = parseFloat(tr.cells[3]?.querySelector('.med-dosage')?.textContent) || 0;
const frequency = parseInt(tr.cells[4]?.textContent) || 0;
const duration = parseInt(tr.cells[5]?.textContent) || 0;
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,
dosage: dosage,
frequency: frequency,
duration: duration,
unit: '정'
})
});
const data = await res.json();
if (data.success) {
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);
}
} catch (err) {
console.error('Preview error:', err);
}
}
if (container.children.length === 0) {
container.innerHTML = '<p style="color:#999;">미리보기 생성 실패</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')}`);
}
</script>
</body>
</html>