- drug_unit.py: SUNG_CODE 기반 단위 판별 함수 추가 - 조제 상세에 총사용량 + 단위 표시 (예: 1,230정) - API 순차 호출로 DB 세션 충돌 방지
1039 lines
37 KiB
HTML
1039 lines
37 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>기간별 사용약품 조회 - 청춘약국</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: #f8fafc;
|
|
-webkit-font-smoothing: antialiased;
|
|
color: #1e293b;
|
|
}
|
|
|
|
/* ── 헤더 ── */
|
|
.header {
|
|
background: linear-gradient(135deg, #10b981 0%, #059669 50%, #047857 100%);
|
|
padding: 28px 32px 24px;
|
|
color: #fff;
|
|
}
|
|
.header-nav {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.header-nav a {
|
|
color: rgba(255,255,255,0.8);
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
}
|
|
.header-nav a:hover { color: #fff; }
|
|
.header h1 {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.5px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.header p {
|
|
font-size: 14px;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* ── 컨텐츠 ── */
|
|
.content {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 24px 20px 60px;
|
|
}
|
|
|
|
/* ── 검색 영역 ── */
|
|
.search-section {
|
|
background: #fff;
|
|
border-radius: 14px;
|
|
padding: 24px;
|
|
margin-bottom: 20px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
.search-row {
|
|
display: flex;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
align-items: flex-end;
|
|
}
|
|
.search-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
.search-group label {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
}
|
|
.date-range {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.date-input {
|
|
padding: 12px 14px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
transition: all 0.2s;
|
|
width: 160px;
|
|
}
|
|
.date-input:focus {
|
|
outline: none;
|
|
border-color: #10b981;
|
|
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
|
|
}
|
|
.date-range span {
|
|
color: #94a3b8;
|
|
font-size: 14px;
|
|
}
|
|
.radio-group {
|
|
display: flex;
|
|
gap: 16px;
|
|
padding: 10px 0;
|
|
}
|
|
.radio-group label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
color: #475569;
|
|
}
|
|
.radio-group input[type="radio"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
accent-color: #10b981;
|
|
cursor: pointer;
|
|
}
|
|
.search-input {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
padding: 12px 16px;
|
|
border: 2px solid #e2e8f0;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
transition: all 0.2s;
|
|
}
|
|
.search-input:focus {
|
|
outline: none;
|
|
border-color: #10b981;
|
|
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
|
|
}
|
|
.search-input::placeholder {
|
|
color: #94a3b8;
|
|
}
|
|
.search-btn {
|
|
background: #10b981;
|
|
color: #fff;
|
|
border: none;
|
|
padding: 12px 28px;
|
|
border-radius: 10px;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
.search-btn:hover { background: #059669; }
|
|
.search-btn:active { transform: scale(0.98); }
|
|
.search-btn:disabled {
|
|
background: #94a3b8;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* ── 결과 카운트 ── */
|
|
.result-count {
|
|
margin-bottom: 16px;
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.result-count strong {
|
|
color: #10b981;
|
|
font-weight: 700;
|
|
}
|
|
.result-period {
|
|
font-size: 13px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
/* ── 테이블 ── */
|
|
.table-wrap {
|
|
background: #fff;
|
|
border-radius: 14px;
|
|
border: 1px solid #e2e8f0;
|
|
overflow: hidden;
|
|
}
|
|
.table-scroll {
|
|
overflow-x: auto;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
min-width: 800px;
|
|
}
|
|
thead th {
|
|
background: #f8fafc;
|
|
padding: 14px 16px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: background 0.15s;
|
|
}
|
|
thead th:hover {
|
|
background: #f1f5f9;
|
|
}
|
|
thead th.sortable::after {
|
|
content: ' ↕';
|
|
color: #cbd5e1;
|
|
}
|
|
thead th.sort-asc::after {
|
|
content: ' ↑';
|
|
color: #10b981;
|
|
}
|
|
thead th.sort-desc::after {
|
|
content: ' ↓';
|
|
color: #10b981;
|
|
}
|
|
tbody td {
|
|
padding: 14px 16px;
|
|
font-size: 14px;
|
|
color: #334155;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
vertical-align: middle;
|
|
}
|
|
tbody tr:hover { background: #f0fdf4; }
|
|
tbody tr:last-child td { border-bottom: none; }
|
|
|
|
/* ── 코드 스타일 ── */
|
|
.code {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 12px;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
display: inline-block;
|
|
background: #d1fae5;
|
|
color: #065f46;
|
|
}
|
|
.category-badge {
|
|
display: inline-block;
|
|
background: #ede9fe;
|
|
color: #7c3aed;
|
|
font-size: 11px;
|
|
padding: 3px 8px;
|
|
border-radius: 4px;
|
|
font-weight: 500;
|
|
}
|
|
.product-name {
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
}
|
|
|
|
/* ── 숫자 스타일 ── */
|
|
.num-cell {
|
|
text-align: right;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
.num-highlight {
|
|
color: #10b981;
|
|
font-weight: 600;
|
|
}
|
|
.num-secondary {
|
|
color: #3b82f6;
|
|
}
|
|
.negative-stock {
|
|
color: #dc2626 !important;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ── 빈 상태 ── */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #94a3b8;
|
|
}
|
|
.empty-state .icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.empty-state p {
|
|
font-size: 15px;
|
|
}
|
|
|
|
/* ── 로딩 상태 ── */
|
|
.loading-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #64748b;
|
|
}
|
|
.loading-spinner {
|
|
display: inline-block;
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid #e2e8f0;
|
|
border-top-color: #10b981;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin-bottom: 16px;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
/* ── 페이지네이션 ── */
|
|
.pagination {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.pagination-btn {
|
|
padding: 8px 14px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
background: #fff;
|
|
color: #475569;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: all 0.15s;
|
|
}
|
|
.pagination-btn:hover:not(:disabled) {
|
|
background: #f1f5f9;
|
|
border-color: #cbd5e1;
|
|
}
|
|
.pagination-btn.active {
|
|
background: #10b981;
|
|
color: #fff;
|
|
border-color: #10b981;
|
|
}
|
|
.pagination-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.pagination-info {
|
|
color: #64748b;
|
|
font-size: 13px;
|
|
margin: 0 12px;
|
|
}
|
|
|
|
/* ── 상세 패널 ── */
|
|
.detail-row td {
|
|
/* padding removed - handled by detail-table */
|
|
background: #f0fdf4;
|
|
}
|
|
.detail-panel {
|
|
display: flex;
|
|
gap: 16px;
|
|
padding: 20px;
|
|
border-top: 2px solid #10b981;
|
|
}
|
|
.detail-left, .detail-right {
|
|
flex: 1;
|
|
background: #fff;
|
|
border-radius: 10px;
|
|
padding: 16px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
.detail-left h4, .detail-right h4 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #1e293b;
|
|
margin-bottom: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
.detail-left h4 .count, .detail-right h4 .count {
|
|
font-size: 12px;
|
|
color: #10b981;
|
|
font-weight: 500;
|
|
}
|
|
.patient-info {
|
|
display: inline-flex;
|
|
gap: 4px;
|
|
margin-left: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.patient-badge {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
font-size: 11px;
|
|
font-weight: 500;
|
|
background: #e0f2fe;
|
|
color: #0369a1;
|
|
border-radius: 12px;
|
|
}
|
|
.patient-badge.more {
|
|
background: #fef3c7;
|
|
color: #92400e;
|
|
}
|
|
.usage-badge {
|
|
display: inline-block;
|
|
padding: 2px 10px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
background: linear-gradient(135deg, #10b981, #059669);
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
margin-left: 8px;
|
|
}
|
|
.detail-table-wrapper {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
}
|
|
.detail-table {
|
|
width: 100%;
|
|
border-collapse: separate;
|
|
border-spacing: 0;
|
|
font-size: 12px;
|
|
min-width: 0;
|
|
table-layout: fixed;
|
|
}
|
|
.detail-table thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: #f8fafc;
|
|
z-index: 1;
|
|
box-shadow: 0 1px 0 #e2e8f0;
|
|
}
|
|
.detail-table td, .detail-table th {
|
|
padding: 4px 8px;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
.truncate-cell {
|
|
max-width: 120px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.detail-table thead th {
|
|
background: #f8fafc;
|
|
padding: 8px 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
text-align: left;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
white-space: nowrap;
|
|
}
|
|
.detail-table tbody td {
|
|
padding: 8px 10px;
|
|
font-size: 12px;
|
|
color: #334155;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
}
|
|
.detail-table tbody tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
.detail-loading {
|
|
text-align: center;
|
|
padding: 30px;
|
|
color: #94a3b8;
|
|
}
|
|
.detail-empty {
|
|
text-align: center;
|
|
padding: 30px;
|
|
color: #94a3b8;
|
|
font-size: 13px;
|
|
}
|
|
tbody tr.clickable {
|
|
cursor: pointer;
|
|
}
|
|
tbody tr.clickable:hover {
|
|
background: #ecfdf5;
|
|
}
|
|
tbody tr.expanded {
|
|
background: #d1fae5 !important;
|
|
}
|
|
|
|
/* ── 반응형 ── */
|
|
@media (max-width: 768px) {
|
|
.search-row { flex-direction: column; }
|
|
.date-range { flex-wrap: wrap; }
|
|
.date-input { width: 100%; }
|
|
.search-input { width: 100%; }
|
|
.detail-panel { flex-direction: column; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-nav">
|
|
<a href="/admin">← 관리자 홈</a>
|
|
<div>
|
|
<a href="/admin/rx-usage" style="margin-right: 16px;">전문약 사용량</a>
|
|
<a href="/admin/products">제품 검색</a>
|
|
</div>
|
|
</div>
|
|
<h1>📊 기간별 사용약품 조회</h1>
|
|
<p>기간 내 조제 및 입고 현황을 한눈에 확인</p>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- 검색 -->
|
|
<div class="search-section">
|
|
<div class="search-row">
|
|
<div class="search-group">
|
|
<label>📅 기간</label>
|
|
<div class="date-range">
|
|
<input type="date" class="date-input" id="startDate">
|
|
<span>~</span>
|
|
<input type="date" class="date-input" id="endDate">
|
|
</div>
|
|
</div>
|
|
<div class="search-group">
|
|
<label>📌 기준</label>
|
|
<div class="radio-group">
|
|
<label>
|
|
<input type="radio" name="dateType" value="dispense" checked>
|
|
조제일
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="dateType" value="expiry">
|
|
소진일
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="search-group" style="flex: 1;">
|
|
<label>🔍 약품명 검색</label>
|
|
<input type="text" class="search-input" id="searchInput"
|
|
placeholder="약품명 입력 (선택사항)"
|
|
onkeypress="if(event.key==='Enter')searchDrugUsage()">
|
|
</div>
|
|
<button class="search-btn" id="searchBtn" onclick="searchDrugUsage()">🔍 조회</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 결과 카운트 -->
|
|
<div class="result-count" id="resultInfo" style="display:none;">
|
|
<div>
|
|
검색 결과: <strong id="resultNum">0</strong>개 약품
|
|
</div>
|
|
<div class="result-period" id="resultPeriod"></div>
|
|
</div>
|
|
|
|
<!-- 테이블 -->
|
|
<div class="table-wrap">
|
|
<div class="table-scroll">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" data-sort="drug_code">약품코드</th>
|
|
<th class="sortable" data-sort="goods_name">약품명</th>
|
|
<th class="sortable" data-sort="category">분류</th>
|
|
<th class="sortable sort-desc" data-sort="rx_count">조제건수</th>
|
|
<th class="sortable" data-sort="import_count">입고건수</th>
|
|
<th class="sortable" data-sort="rx_total_qty">조제량</th>
|
|
<th class="sortable" data-sort="import_total_qty">입고량</th>
|
|
<th class="sortable" data-sort="current_stock">현재고</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="drugUsageBody">
|
|
<tr>
|
|
<td colspan="8" class="empty-state">
|
|
<div class="icon">📊</div>
|
|
<p>기간을 선택하고 조회 버튼을 클릭하세요</p>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 페이지네이션 -->
|
|
<div class="pagination" id="pagination" style="display:none;"></div>
|
|
</div>
|
|
|
|
<script>
|
|
// ═══ 상태 변수 ═══
|
|
let allData = [];
|
|
let filteredData = [];
|
|
let currentPage = 1;
|
|
const pageSize = 50;
|
|
let currentSort = { field: 'rx_count', dir: 'desc' };
|
|
|
|
// ═══ 초기화 ═══
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// 기본 날짜 설정 (최근 3개월)
|
|
const today = new Date();
|
|
const threeMonthsAgo = new Date();
|
|
threeMonthsAgo.setMonth(today.getMonth() - 3);
|
|
|
|
document.getElementById('endDate').value = formatDate(today);
|
|
document.getElementById('startDate').value = formatDate(threeMonthsAgo);
|
|
|
|
// 정렬 헤더 이벤트
|
|
document.querySelectorAll('thead th.sortable').forEach(th => {
|
|
th.addEventListener('click', () => sortBy(th.dataset.sort));
|
|
});
|
|
});
|
|
|
|
function formatDate(date) {
|
|
return date.toISOString().split('T')[0];
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
if (!num && num !== 0) return '-';
|
|
return new Intl.NumberFormat('ko-KR').format(num);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
|
}
|
|
|
|
// ═══ 조회 ═══
|
|
async function searchDrugUsage() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const dateType = document.querySelector('input[name="dateType"]:checked').value;
|
|
const search = document.getElementById('searchInput').value.trim();
|
|
|
|
if (!startDate || !endDate) {
|
|
alert('기간을 선택하세요');
|
|
return;
|
|
}
|
|
|
|
const tbody = document.getElementById('drugUsageBody');
|
|
const btn = document.getElementById('searchBtn');
|
|
|
|
// 로딩 상태
|
|
btn.disabled = true;
|
|
btn.textContent = '조회 중...';
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<p>데이터를 불러오는 중...</p>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
start_date: startDate.replace(/-/g, ''),
|
|
end_date: endDate.replace(/-/g, ''),
|
|
date_type: dateType,
|
|
limit: 5000 // 전체 조회 후 클라이언트에서 페이징
|
|
});
|
|
if (search) params.append('search', search);
|
|
|
|
const res = await fetch(`/api/drug-usage?${params}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success) {
|
|
allData = data.items || [];
|
|
filteredData = [...allData];
|
|
currentPage = 1;
|
|
|
|
// 결과 정보 표시
|
|
document.getElementById('resultInfo').style.display = 'flex';
|
|
document.getElementById('resultNum').textContent = filteredData.length;
|
|
document.getElementById('resultPeriod').textContent =
|
|
`${data.period?.start || startDate} ~ ${data.period?.end || endDate} (${dateType === 'dispense' ? '조제일' : '소진일'} 기준)`;
|
|
|
|
// 기본 정렬 적용
|
|
sortData();
|
|
renderTable();
|
|
renderPagination();
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="empty-state">
|
|
<div class="icon">⚠️</div>
|
|
<p>오류: ${escapeHtml(data.error || '알 수 없는 오류')}</p>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
} catch (err) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="empty-state">
|
|
<div class="icon">❌</div>
|
|
<p>조회 실패: ${escapeHtml(err.message)}</p>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = '🔍 조회';
|
|
}
|
|
}
|
|
|
|
// ═══ 정렬 ═══
|
|
function sortBy(field) {
|
|
// 같은 필드면 방향 토글
|
|
if (currentSort.field === field) {
|
|
currentSort.dir = currentSort.dir === 'desc' ? 'asc' : 'desc';
|
|
} else {
|
|
currentSort.field = field;
|
|
currentSort.dir = 'desc';
|
|
}
|
|
|
|
// 헤더 표시 업데이트
|
|
document.querySelectorAll('thead th.sortable').forEach(th => {
|
|
th.classList.remove('sort-asc', 'sort-desc');
|
|
if (th.dataset.sort === field) {
|
|
th.classList.add(currentSort.dir === 'asc' ? 'sort-asc' : 'sort-desc');
|
|
}
|
|
});
|
|
|
|
sortData();
|
|
currentPage = 1;
|
|
renderTable();
|
|
renderPagination();
|
|
}
|
|
|
|
function sortData() {
|
|
const { field, dir } = currentSort;
|
|
|
|
filteredData.sort((a, b) => {
|
|
let aVal = a[field];
|
|
let bVal = b[field];
|
|
|
|
// 숫자 필드
|
|
if (['rx_count', 'import_count', 'rx_total_qty', 'import_total_qty'].includes(field)) {
|
|
aVal = Number(aVal) || 0;
|
|
bVal = Number(bVal) || 0;
|
|
} else {
|
|
// 문자열 필드
|
|
aVal = String(aVal || '').toLowerCase();
|
|
bVal = String(bVal || '').toLowerCase();
|
|
}
|
|
|
|
if (aVal < bVal) return dir === 'asc' ? -1 : 1;
|
|
if (aVal > bVal) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
// ═══ 상세 패널 상태 ═══
|
|
let expandedDrugCode = null;
|
|
|
|
// ═══ 렌더링 ═══
|
|
function renderTable() {
|
|
const tbody = document.getElementById('drugUsageBody');
|
|
|
|
if (filteredData.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="empty-state">
|
|
<div class="icon">📭</div>
|
|
<p>조회 결과가 없습니다</p>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
document.getElementById('pagination').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// 페이지 데이터 추출
|
|
const start = (currentPage - 1) * pageSize;
|
|
const pageData = filteredData.slice(start, start + pageSize);
|
|
|
|
tbody.innerHTML = pageData.map(item => `
|
|
<tr class="clickable ${expandedDrugCode === item.drug_code ? 'expanded' : ''}"
|
|
data-drug-code="${escapeHtml(item.drug_code)}"
|
|
onclick="toggleDetail('${escapeHtml(item.drug_code)}')">
|
|
<td><span class="code">${escapeHtml(item.drug_code)}</span></td>
|
|
<td><span class="product-name">${escapeHtml(item.goods_name)}</span></td>
|
|
<td>${item.category ? `<span class="category-badge">${escapeHtml(item.category)}</span>` : '<span style="color:#94a3b8;">-</span>'}</td>
|
|
<td class="num-cell num-highlight">${formatNumber(item.rx_count)}</td>
|
|
<td class="num-cell num-secondary">${formatNumber(item.import_count)}</td>
|
|
<td class="num-cell num-highlight">${formatNumber(item.rx_total_qty)}</td>
|
|
<td class="num-cell num-secondary">${formatNumber(item.import_total_qty)}</td>
|
|
<td class="num-cell ${item.current_stock < 0 ? 'negative-stock' : ''}">${formatNumber(item.current_stock)}</td>
|
|
</tr>
|
|
<tr class="detail-row" id="detail-${escapeHtml(item.drug_code)}" style="display:${expandedDrugCode === item.drug_code ? 'table-row' : 'none'};">
|
|
<td colspan="8">
|
|
<div class="detail-panel">
|
|
<div class="detail-left" id="imports-${escapeHtml(item.drug_code)}">
|
|
<h4>📦 입고목록 <span class="count"></span></h4>
|
|
<div class="detail-loading">불러오는 중...</div>
|
|
</div>
|
|
<div class="detail-right" id="prescriptions-${escapeHtml(item.drug_code)}">
|
|
<h4>💊 조제목록 <span class="count"></span></h4>
|
|
<div class="detail-loading">불러오는 중...</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
// ═══ 상세 패널 토글 ═══
|
|
async function toggleDetail(drugCode) {
|
|
const detailRow = document.getElementById(`detail-${drugCode}`);
|
|
const mainRow = document.querySelector(`tr[data-drug-code="${drugCode}"]`);
|
|
|
|
// 같은 행 클릭 시 닫기
|
|
if (expandedDrugCode === drugCode) {
|
|
detailRow.style.display = 'none';
|
|
mainRow.classList.remove('expanded');
|
|
expandedDrugCode = null;
|
|
return;
|
|
}
|
|
|
|
// 다른 행이 열려있으면 닫기
|
|
if (expandedDrugCode) {
|
|
const prevDetail = document.getElementById(`detail-${expandedDrugCode}`);
|
|
const prevMain = document.querySelector(`tr[data-drug-code="${expandedDrugCode}"]`);
|
|
if (prevDetail) prevDetail.style.display = 'none';
|
|
if (prevMain) prevMain.classList.remove('expanded');
|
|
}
|
|
|
|
// 현재 행 열기
|
|
detailRow.style.display = 'table-row';
|
|
mainRow.classList.add('expanded');
|
|
expandedDrugCode = drugCode;
|
|
|
|
// 데이터 로드 (순차 호출 - DB 세션 충돌 방지)
|
|
const startDate = document.getElementById('startDate').value.replace(/-/g, '');
|
|
const endDate = document.getElementById('endDate').value.replace(/-/g, '');
|
|
|
|
// 입고 데이터 로드 후 조제 데이터 로드 (순차)
|
|
await loadImports(drugCode, startDate, endDate);
|
|
await loadPrescriptions(drugCode, startDate, endDate);
|
|
}
|
|
|
|
// ═══ 입고 데이터 로드 ═══
|
|
async function loadImports(drugCode, startDate, endDate) {
|
|
const container = document.getElementById(`imports-${drugCode}`);
|
|
|
|
try {
|
|
const res = await fetch(`/api/drug-usage/${drugCode}/imports?start_date=${startDate}&end_date=${endDate}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success && data.items.length > 0) {
|
|
container.innerHTML = `
|
|
<h4>📦 입고목록 <span class="count">(${data.total_count}건)</span></h4>
|
|
<div class="detail-table-wrapper">
|
|
<table class="detail-table" style="table-layout:fixed;">
|
|
<colgroup>
|
|
<col style="width:70px;">
|
|
<col style="width:55px;">
|
|
<col style="width:45px;">
|
|
<col style="width:65px;">
|
|
<col style="width:auto;">
|
|
<col style="width:50px;">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>입고일자</th>
|
|
<th>수량</th>
|
|
<th>단가</th>
|
|
<th>금액</th>
|
|
<th>거래처</th>
|
|
<th>담당자</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.items.map(item => `
|
|
<tr>
|
|
<td style="white-space:nowrap;padding:4px 6px;">${formatDetailDate(item.import_date)}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${formatNumber(item.quantity)}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${formatNumber(item.unit_price)}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;border-right:1px solid #ccc;">${formatNumber(item.amount)}</td>
|
|
<td class="truncate-cell" style="padding:4px 6px;" title="${escapeHtml(item.supplier_name)}">${escapeHtml(item.supplier_name)}</td>
|
|
<td style="white-space:nowrap;padding:4px 6px;">${escapeHtml(item.person_name)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `
|
|
<h4>📦 입고목록 <span class="count">(0건)</span></h4>
|
|
<div class="detail-empty">입고 내역이 없습니다</div>
|
|
`;
|
|
}
|
|
} catch (err) {
|
|
container.innerHTML = `
|
|
<h4>📦 입고목록</h4>
|
|
<div class="detail-empty">⚠️ 로드 실패: ${escapeHtml(err.message)}</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// ═══ 조제 데이터 로드 ═══
|
|
async function loadPrescriptions(drugCode, startDate, endDate) {
|
|
const container = document.getElementById(`prescriptions-${drugCode}`);
|
|
|
|
try {
|
|
const res = await fetch(`/api/drug-usage/${drugCode}/prescriptions?start_date=${startDate}&end_date=${endDate}`);
|
|
const data = await res.json();
|
|
|
|
if (data.success && data.items.length > 0) {
|
|
// 환자 뱃지 생성
|
|
const uniqueCount = data.unique_patients || 0;
|
|
const recentPatients = data.recent_patients || [];
|
|
let patientBadges = '';
|
|
|
|
if (uniqueCount <= 3) {
|
|
// 3명 이하: 전체 표시
|
|
patientBadges = recentPatients.map(p =>
|
|
`<span class="patient-badge">${escapeHtml(p)}</span>`
|
|
).join('');
|
|
} else {
|
|
// 3명 초과: 최근 3명 + 외 N명
|
|
patientBadges = recentPatients.map(p =>
|
|
`<span class="patient-badge">${escapeHtml(p)}</span>`
|
|
).join('') + `<span class="patient-badge more">외 ${uniqueCount - 3}명</span>`;
|
|
}
|
|
|
|
// 총 사용량 + 단위
|
|
const totalUsage = data.total_usage || 0;
|
|
const unit = data.unit || '개';
|
|
const usageBadge = `<span class="usage-badge">${formatNumber(totalUsage)}${unit}</span>`;
|
|
|
|
container.innerHTML = `
|
|
<h4>💊 조제목록 <span class="count">(${data.total_count}건)</span> ${usageBadge} <span class="patient-info">${patientBadges}</span></h4>
|
|
<div class="detail-table-wrapper">
|
|
<table class="detail-table" style="table-layout:fixed;">
|
|
<colgroup>
|
|
<col style="width:65px;">
|
|
<col style="width:65px;">
|
|
<col style="width:55px;">
|
|
<col style="width:auto;">
|
|
<col style="width:45px;">
|
|
<col style="width:35px;">
|
|
<col style="width:35px;">
|
|
<col style="width:55px;">
|
|
</colgroup>
|
|
<thead>
|
|
<tr>
|
|
<th>조제일</th>
|
|
<th>만료일</th>
|
|
<th>고객명</th>
|
|
<th>발행기관</th>
|
|
<th>투약량</th>
|
|
<th>횟수</th>
|
|
<th>일수</th>
|
|
<th>총투약량</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.items.map(item => `
|
|
<tr>
|
|
<td style="white-space:nowrap;padding:4px 6px;">${formatDetailDate(item.rx_date)}</td>
|
|
<td style="white-space:nowrap;padding:4px 6px;">${formatDetailDate(item.expiry_date)}</td>
|
|
<td style="white-space:nowrap;padding:4px 6px;">${escapeHtml(item.patient_name)}</td>
|
|
<td class="truncate-cell" style="padding:4px 6px;" title="${escapeHtml(item.institution_name)}">${escapeHtml(item.institution_name)}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${item.dosage}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${item.frequency}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${item.days}</td>
|
|
<td style="text-align:right;white-space:nowrap;padding:4px 6px;">${formatNumber(item.total_qty)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `
|
|
<h4>💊 조제목록 <span class="count">(0건)</span></h4>
|
|
<div class="detail-empty">조제 내역이 없습니다</div>
|
|
`;
|
|
}
|
|
} catch (err) {
|
|
container.innerHTML = `
|
|
<h4>💊 조제목록</h4>
|
|
<div class="detail-empty">⚠️ 로드 실패: ${escapeHtml(err.message)}</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// ═══ 날짜 포맷 (YYYYMMDD → YYYY-MM-DD) ═══
|
|
function formatDetailDate(dateStr) {
|
|
if (!dateStr || dateStr.length !== 8) return dateStr || '-';
|
|
return `${dateStr.slice(2,4)}-${dateStr.slice(4,6)}-${dateStr.slice(6,8)}`;
|
|
}
|
|
|
|
function renderPagination() {
|
|
const pagination = document.getElementById('pagination');
|
|
const totalPages = Math.ceil(filteredData.length / pageSize);
|
|
|
|
if (totalPages <= 1) {
|
|
pagination.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
pagination.style.display = 'flex';
|
|
|
|
let html = '';
|
|
|
|
// 이전 버튼
|
|
html += `<button class="pagination-btn" ${currentPage <= 1 ? 'disabled' : ''} onclick="goToPage(${currentPage - 1})">◀ 이전</button>`;
|
|
|
|
// 페이지 번호
|
|
const maxVisible = 5;
|
|
let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2));
|
|
let endPage = Math.min(totalPages, startPage + maxVisible - 1);
|
|
if (endPage - startPage < maxVisible - 1) {
|
|
startPage = Math.max(1, endPage - maxVisible + 1);
|
|
}
|
|
|
|
if (startPage > 1) {
|
|
html += `<button class="pagination-btn" onclick="goToPage(1)">1</button>`;
|
|
if (startPage > 2) html += `<span style="color:#94a3b8;">...</span>`;
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
html += `<button class="pagination-btn ${i === currentPage ? 'active' : ''}" onclick="goToPage(${i})">${i}</button>`;
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
if (endPage < totalPages - 1) html += `<span style="color:#94a3b8;">...</span>`;
|
|
html += `<button class="pagination-btn" onclick="goToPage(${totalPages})">${totalPages}</button>`;
|
|
}
|
|
|
|
// 다음 버튼
|
|
html += `<button class="pagination-btn" ${currentPage >= totalPages ? 'disabled' : ''} onclick="goToPage(${currentPage + 1})">다음 ▶</button>`;
|
|
|
|
// 페이지 정보
|
|
html += `<span class="pagination-info">총 ${filteredData.length}개</span>`;
|
|
|
|
pagination.innerHTML = html;
|
|
}
|
|
|
|
function goToPage(page) {
|
|
currentPage = page;
|
|
renderTable();
|
|
renderPagination();
|
|
|
|
// 테이블 상단으로 스크롤
|
|
document.querySelector('.table-wrap').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|