1550 lines
60 KiB
HTML
1550 lines
60 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;600&display=swap" rel="stylesheet">
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg-primary: #0f172a;
|
|
--bg-secondary: #1e293b;
|
|
--bg-card: #1e293b;
|
|
--bg-card-hover: #334155;
|
|
--border: #334155;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #94a3b8;
|
|
--text-muted: #64748b;
|
|
--accent-teal: #14b8a6;
|
|
--accent-blue: #3b82f6;
|
|
--accent-purple: #a855f7;
|
|
--accent-amber: #f59e0b;
|
|
--accent-emerald: #10b981;
|
|
--accent-rose: #f43f5e;
|
|
--accent-orange: #f97316;
|
|
--accent-cyan: #06b6d4;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
-webkit-font-smoothing: antialiased;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* ══════════════════ 헤더 ══════════════════ */
|
|
.header {
|
|
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #c084fc 100%);
|
|
padding: 20px 24px;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
|
}
|
|
.header-inner {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.header-left h1 {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
letter-spacing: -0.5px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.header-left p {
|
|
font-size: 13px;
|
|
opacity: 0.85;
|
|
margin-top: 4px;
|
|
}
|
|
.header-nav {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
.header-nav a {
|
|
color: rgba(255,255,255,0.85);
|
|
text-decoration: none;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
background: rgba(255,255,255,0.1);
|
|
transition: all 0.2s;
|
|
}
|
|
.header-nav a:hover {
|
|
background: rgba(255,255,255,0.2);
|
|
color: #fff;
|
|
}
|
|
.header-nav a.active {
|
|
background: rgba(255,255,255,0.25);
|
|
}
|
|
|
|
/* ══════════════════ 컨텐츠 ══════════════════ */
|
|
.content {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
/* ══════════════════ 검색 영역 ══════════════════ */
|
|
.search-bar {
|
|
background: var(--bg-card);
|
|
border-radius: 16px;
|
|
padding: 20px 24px;
|
|
margin-bottom: 20px;
|
|
border: 1px solid var(--border);
|
|
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: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.search-group input, .search-group select {
|
|
padding: 10px 14px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
color: var(--text-primary);
|
|
min-width: 140px;
|
|
transition: all 0.2s;
|
|
}
|
|
.search-group input[type="date"] {
|
|
min-width: 160px;
|
|
}
|
|
.search-group input:focus, .search-group select:focus {
|
|
outline: none;
|
|
border-color: var(--accent-purple);
|
|
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.2);
|
|
}
|
|
.search-group input::placeholder { color: var(--text-muted); }
|
|
.search-btn {
|
|
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
|
color: #fff;
|
|
border: none;
|
|
padding: 10px 28px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.search-btn:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.4);
|
|
}
|
|
|
|
/* ══════════════════ 통계 카드 ══════════════════ */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.stat-card {
|
|
background: var(--bg-card);
|
|
border-radius: 14px;
|
|
padding: 20px;
|
|
border: 1px solid var(--border);
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.stat-card::before {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 3px;
|
|
}
|
|
.stat-card.purple::before { background: var(--accent-purple); }
|
|
.stat-card.blue::before { background: var(--accent-blue); }
|
|
.stat-card.emerald::before { background: var(--accent-emerald); }
|
|
.stat-card.amber::before { background: var(--accent-amber); }
|
|
|
|
.stat-icon {
|
|
font-size: 24px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.stat-value {
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
letter-spacing: -1px;
|
|
margin-bottom: 4px;
|
|
}
|
|
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
|
.stat-card.blue .stat-value { color: var(--accent-blue); }
|
|
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
|
|
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
|
|
|
.stat-label {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
/* ══════════════════ 차트 영역 ══════════════════ */
|
|
.chart-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.chart-card {
|
|
background: var(--bg-card);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
border: 1px solid var(--border);
|
|
}
|
|
.chart-card.full-width {
|
|
grid-column: 1 / -1;
|
|
}
|
|
.chart-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
.chart-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.chart-badge {
|
|
background: linear-gradient(135deg, var(--accent-purple), #7c3aed);
|
|
color: #fff;
|
|
padding: 4px 10px;
|
|
border-radius: 6px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
.chart-container {
|
|
position: relative;
|
|
height: 300px;
|
|
}
|
|
.chart-container.tall {
|
|
height: 400px;
|
|
}
|
|
|
|
/* ══════════════════ 약품 검색 자동완성 ══════════════════ */
|
|
.drug-search-wrap {
|
|
position: relative;
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}
|
|
.drug-search-results {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
z-index: 50;
|
|
display: none;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
}
|
|
.drug-search-results.show {
|
|
display: block;
|
|
}
|
|
.drug-item {
|
|
padding: 12px 16px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
transition: background 0.2s;
|
|
}
|
|
.drug-item:hover {
|
|
background: var(--bg-card-hover);
|
|
}
|
|
.drug-item-name {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
margin-bottom: 2px;
|
|
}
|
|
.drug-item-info {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
.drug-item-stock {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
color: var(--accent-emerald);
|
|
}
|
|
|
|
/* ══════════════════ 선택된 약품 태그 ══════════════════ */
|
|
.selected-drug {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.1));
|
|
border: 1px solid var(--accent-purple);
|
|
padding: 8px 14px;
|
|
border-radius: 20px;
|
|
margin-left: 12px;
|
|
}
|
|
.selected-drug-name {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--accent-purple);
|
|
}
|
|
.selected-drug-remove {
|
|
background: none;
|
|
border: none;
|
|
color: var(--accent-rose);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
padding: 2px;
|
|
}
|
|
|
|
/* ══════════════════ TOP 10 테이블 ══════════════════ */
|
|
.ranking-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.ranking-table th {
|
|
padding: 12px 16px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
text-align: left;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.ranking-table th.center { text-align: center; }
|
|
.ranking-table th.right { text-align: right; }
|
|
.ranking-table td {
|
|
padding: 12px 16px;
|
|
font-size: 13px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
|
}
|
|
.ranking-table tr:hover {
|
|
background: rgba(255,255,255,0.02);
|
|
}
|
|
.rank-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 8px;
|
|
font-weight: 700;
|
|
font-size: 12px;
|
|
}
|
|
.rank-badge.gold { background: linear-gradient(135deg, #f59e0b, #d97706); color: #fff; }
|
|
.rank-badge.silver { background: linear-gradient(135deg, #94a3b8, #64748b); color: #fff; }
|
|
.rank-badge.bronze { background: linear-gradient(135deg, #f97316, #ea580c); color: #fff; }
|
|
.rank-badge.normal { background: var(--bg-primary); color: var(--text-muted); }
|
|
|
|
.product-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
.product-name {
|
|
font-weight: 500;
|
|
}
|
|
.product-code {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
}
|
|
.qty-cell {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
|
|
.loading-state, .empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
.loading-spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--border);
|
|
border-top-color: var(--accent-purple);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 16px;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
.empty-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* ══════════════════ 토스트 ══════════════════ */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 32px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(20px);
|
|
padding: 14px 28px;
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
|
opacity: 0;
|
|
transition: all 0.3s;
|
|
z-index: 300;
|
|
}
|
|
.toast.show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
}
|
|
.toast.success { border-color: var(--accent-emerald); }
|
|
.toast.error { border-color: var(--accent-rose); }
|
|
|
|
/* ══════════════════ 예측 카드 ══════════════════ */
|
|
.forecast-card {
|
|
background: var(--bg-card);
|
|
border-radius: 14px;
|
|
padding: 20px;
|
|
border: 1px solid var(--border);
|
|
height: 100%;
|
|
}
|
|
.forecast-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.forecast-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
.forecast-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
}
|
|
.forecast-label {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
}
|
|
.forecast-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
}
|
|
.forecast-divider {
|
|
border: none;
|
|
border-top: 1px dashed var(--border);
|
|
margin: 12px 0;
|
|
}
|
|
.forecast-highlight {
|
|
background: linear-gradient(135deg, rgba(168, 85, 247, 0.15), rgba(124, 58, 237, 0.1));
|
|
border-radius: 10px;
|
|
padding: 14px;
|
|
margin: 12px 0;
|
|
}
|
|
.forecast-highlight-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.forecast-highlight-row:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.forecast-highlight-icon {
|
|
font-size: 16px;
|
|
}
|
|
.forecast-highlight-label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
flex: 1;
|
|
}
|
|
.forecast-highlight-value {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--accent-purple);
|
|
}
|
|
.forecast-highlight-sub {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
margin-left: 4px;
|
|
font-weight: 400;
|
|
}
|
|
.forecast-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding-top: 12px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
.forecast-tag {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
}
|
|
.forecast-tag.trend-increasing {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: #f87171;
|
|
}
|
|
.forecast-tag.trend-stable {
|
|
background: rgba(16, 185, 129, 0.15);
|
|
color: #34d399;
|
|
}
|
|
.forecast-tag.trend-decreasing {
|
|
background: rgba(59, 130, 246, 0.15);
|
|
color: #60a5fa;
|
|
}
|
|
.forecast-tag.trend-unknown {
|
|
background: rgba(100, 116, 139, 0.15);
|
|
color: #94a3b8;
|
|
}
|
|
.forecast-tag.confidence-high {
|
|
background: rgba(16, 185, 129, 0.15);
|
|
color: #34d399;
|
|
}
|
|
.forecast-tag.confidence-medium {
|
|
background: rgba(245, 158, 11, 0.15);
|
|
color: #fbbf24;
|
|
}
|
|
.forecast-tag.confidence-low {
|
|
background: rgba(239, 68, 68, 0.15);
|
|
color: #f87171;
|
|
}
|
|
.forecast-tag.confidence-none {
|
|
background: rgba(100, 116, 139, 0.15);
|
|
color: #94a3b8;
|
|
}
|
|
.forecast-empty {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
color: var(--text-muted);
|
|
}
|
|
.forecast-empty-icon {
|
|
font-size: 36px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* ══════════════════ 반응형 ══════════════════ */
|
|
@media (max-width: 1200px) {
|
|
.chart-grid { grid-template-columns: 1fr; }
|
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.header-nav { display: none; }
|
|
.search-bar { flex-direction: column; }
|
|
.search-group { width: 100%; }
|
|
.search-group input, .search-group select { width: 100%; }
|
|
.chart-container { height: 250px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-inner">
|
|
<div class="header-left">
|
|
<h1>📊 재고 시계열 분석</h1>
|
|
<p>입출고 추이 및 재고 변화 시각화</p>
|
|
</div>
|
|
<nav class="header-nav">
|
|
<a href="/admin">📊 대시보드</a>
|
|
<a href="/admin/usage">🏪 OTC 사용량</a>
|
|
<a href="/admin/rx-usage">💊 Rx 사용량</a>
|
|
<a href="/admin/stock-analytics" class="active">📈 재고분석</a>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- 검색 -->
|
|
<div class="search-bar">
|
|
<div class="search-group">
|
|
<label>시작일</label>
|
|
<input type="date" id="startDate">
|
|
</div>
|
|
<div class="search-group">
|
|
<label>종료일</label>
|
|
<input type="date" id="endDate">
|
|
</div>
|
|
<div class="search-group drug-search-wrap">
|
|
<label>약품 검색 (선택)</label>
|
|
<input type="text" id="drugSearch" placeholder="약품명 또는 코드 입력..." autocomplete="off">
|
|
<div class="drug-search-results" id="drugSearchResults"></div>
|
|
</div>
|
|
<div class="search-group">
|
|
<label>분석 기간</label>
|
|
<select id="periodSelect">
|
|
<option value="daily">일별</option>
|
|
<option value="weekly">주별</option>
|
|
<option value="monthly" selected>월별</option>
|
|
</select>
|
|
</div>
|
|
<button class="search-btn" onclick="loadAllData()">📊 분석</button>
|
|
|
|
<div id="selectedDrugWrap" style="display:none;">
|
|
<div class="selected-drug">
|
|
<span class="selected-drug-name" id="selectedDrugName"></span>
|
|
<button class="selected-drug-remove" onclick="clearSelectedDrug()">✕</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 통계 -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card purple">
|
|
<div class="stat-icon">📦</div>
|
|
<div class="stat-value" id="statTotalInbound">-</div>
|
|
<div class="stat-label">총 입고량</div>
|
|
</div>
|
|
<div class="stat-card blue">
|
|
<div class="stat-icon">📤</div>
|
|
<div class="stat-value" id="statTotalOutbound">-</div>
|
|
<div class="stat-label">총 출고량</div>
|
|
</div>
|
|
<div class="stat-card emerald">
|
|
<div class="stat-icon">📈</div>
|
|
<div class="stat-value" id="statNetChange">-</div>
|
|
<div class="stat-label">순 재고 변화</div>
|
|
</div>
|
|
<div class="stat-card amber">
|
|
<div class="stat-icon">📅</div>
|
|
<div class="stat-value" id="statDataCount">-</div>
|
|
<div class="stat-label">데이터 일수</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 차트 -->
|
|
<div class="chart-grid">
|
|
<!-- 일별 입출고 추이 -->
|
|
<div class="chart-card full-width">
|
|
<div class="chart-header">
|
|
<div class="chart-title">
|
|
<span class="chart-badge">📈</span>
|
|
<span>일별 입출고 추이</span>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container tall">
|
|
<canvas id="dailyTrendChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 기간별 사용량 TOP 10 -->
|
|
<div class="chart-card">
|
|
<div class="chart-header">
|
|
<div class="chart-title">
|
|
<span class="chart-badge">🏆</span>
|
|
<span>출고량 TOP 10</span>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container">
|
|
<canvas id="topUsageChart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 재고량 vs 사용량 비교 차트 (이중 Y축) -->
|
|
<div class="chart-card full-width">
|
|
<div class="chart-header">
|
|
<div class="chart-title">
|
|
<span class="chart-badge">📊</span>
|
|
<span id="stockLevelTitle">재고량 vs 처방 사용량 비교</span>
|
|
</div>
|
|
<div class="chart-legend-custom" id="chartLegendCustom" style="display:none;">
|
|
<span style="color:#a855f7;font-size:12px;margin-right:16px;">━ 재고량 (좌)</span>
|
|
<span style="color:#3b82f6;font-size:12px;">▮ 처방 사용량 (우)</span>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container tall" id="stockLevelContainer">
|
|
<div class="empty-state">
|
|
<div class="empty-icon">💊</div>
|
|
<div>약품을 검색하여 선택하세요</div>
|
|
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 재고 소진 예측 카드 -->
|
|
<div class="chart-card">
|
|
<div id="forecastContainer">
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">🔮</div>
|
|
<div>약품을 선택하면 소진 예측을 표시합니다</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 추세 해석 카드 -->
|
|
<div class="chart-card">
|
|
<div id="trendAnalysisContainer">
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">📈</div>
|
|
<div>약품을 선택하면 추세 분석을 표시합니다</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TOP 10 테이블 -->
|
|
<div class="chart-card">
|
|
<div class="chart-header">
|
|
<div class="chart-title">
|
|
<span class="chart-badge">📋</span>
|
|
<span>출고량 TOP 10 상세</span>
|
|
</div>
|
|
</div>
|
|
<table class="ranking-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:60px;">순위</th>
|
|
<th>약품</th>
|
|
<th class="right">입고량</th>
|
|
<th class="right">출고량</th>
|
|
<th class="right">현재고</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="topUsageTableBody">
|
|
<tr>
|
|
<td colspan="5">
|
|
<div class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
<div>데이터 로딩 중...</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 토스트 -->
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
// ══════════════════ 전역 변수 ══════════════════
|
|
let dailyTrendChart = null;
|
|
let topUsageChart = null;
|
|
let stockLevelChart = null;
|
|
let selectedDrugCode = null;
|
|
let searchTimeout = null;
|
|
|
|
// ══════════════════ 초기화 ══════════════════
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// 기본 날짜 설정 (최근 30일)
|
|
const today = new Date();
|
|
const thirtyDaysAgo = new Date(today);
|
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
|
|
|
document.getElementById('startDate').value = thirtyDaysAgo.toISOString().split('T')[0];
|
|
document.getElementById('endDate').value = today.toISOString().split('T')[0];
|
|
|
|
// 약품 검색 이벤트
|
|
const drugSearchInput = document.getElementById('drugSearch');
|
|
drugSearchInput.addEventListener('input', function() {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => searchDrugs(this.value), 300);
|
|
});
|
|
|
|
drugSearchInput.addEventListener('focus', function() {
|
|
if (this.value.length >= 2) {
|
|
searchDrugs(this.value);
|
|
}
|
|
});
|
|
|
|
// 검색창 외부 클릭 시 결과 숨기기
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.drug-search-wrap')) {
|
|
document.getElementById('drugSearchResults').classList.remove('show');
|
|
}
|
|
});
|
|
|
|
// 초기 데이터 로드
|
|
loadAllData();
|
|
});
|
|
|
|
// ══════════════════ 약품 검색 ══════════════════
|
|
async function searchDrugs(query) {
|
|
const resultsDiv = document.getElementById('drugSearchResults');
|
|
|
|
if (!query || query.length < 2) {
|
|
resultsDiv.classList.remove('show');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/stock-analytics/search-drugs?q=${encodeURIComponent(query)}&limit=15`);
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.items.length > 0) {
|
|
resultsDiv.innerHTML = data.items.map(item => `
|
|
<div class="drug-item" onclick="selectDrug('${item.drug_code}', '${escapeHtml(item.product_name)}')">
|
|
<div class="drug-item-name">${escapeHtml(item.product_name)}</div>
|
|
<div class="drug-item-info">
|
|
${item.drug_code} · ${item.supplier || '-'} ·
|
|
재고: <span class="drug-item-stock">${item.current_stock.toLocaleString()}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
resultsDiv.classList.add('show');
|
|
} else {
|
|
resultsDiv.innerHTML = '<div class="drug-item"><div class="drug-item-name">검색 결과 없음</div></div>';
|
|
resultsDiv.classList.add('show');
|
|
}
|
|
} catch (err) {
|
|
console.error('약품 검색 실패:', err);
|
|
}
|
|
}
|
|
|
|
function selectDrug(drugCode, productName) {
|
|
selectedDrugCode = drugCode;
|
|
|
|
document.getElementById('drugSearch').value = '';
|
|
document.getElementById('drugSearchResults').classList.remove('show');
|
|
|
|
document.getElementById('selectedDrugWrap').style.display = 'inline-block';
|
|
document.getElementById('selectedDrugName').textContent = productName;
|
|
|
|
// 재고 변화 차트 로드
|
|
loadStockLevel();
|
|
// 재고 소진 예측 로드
|
|
loadForecast();
|
|
// 일별 추이도 해당 약품으로 갱신
|
|
loadDailyTrend();
|
|
}
|
|
|
|
function clearSelectedDrug() {
|
|
selectedDrugCode = null;
|
|
document.getElementById('selectedDrugWrap').style.display = 'none';
|
|
document.getElementById('stockLevelTitle').textContent = '재고량 vs 처방 사용량 비교';
|
|
document.getElementById('chartLegendCustom').style.display = 'none';
|
|
|
|
// 재고 변화 차트 초기화
|
|
document.getElementById('stockLevelContainer').innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-icon">💊</div>
|
|
<div>약품을 검색하여 선택하세요</div>
|
|
<div style="margin-top:8px;font-size:12px;color:var(--text-muted);">재고 추이와 처방 사용량을 함께 분석합니다</div>
|
|
</div>`;
|
|
|
|
// 예측 카드 초기화
|
|
document.getElementById('forecastContainer').innerHTML = `
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">🔮</div>
|
|
<div>약품을 선택하면 소진 예측을 표시합니다</div>
|
|
</div>`;
|
|
|
|
// 추세 분석 카드 초기화
|
|
document.getElementById('trendAnalysisContainer').innerHTML = `
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">📈</div>
|
|
<div>약품을 선택하면 추세 분석을 표시합니다</div>
|
|
</div>`;
|
|
|
|
if (stockLevelChart) {
|
|
stockLevelChart.destroy();
|
|
stockLevelChart = null;
|
|
}
|
|
|
|
// 전체 데이터로 갱신
|
|
loadDailyTrend();
|
|
}
|
|
|
|
// ══════════════════ 데이터 로드 ══════════════════
|
|
function loadAllData() {
|
|
loadDailyTrend();
|
|
loadTopUsage();
|
|
if (selectedDrugCode) {
|
|
loadStockLevel();
|
|
}
|
|
}
|
|
|
|
async function loadDailyTrend() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
|
|
let url = `/api/stock-analytics/daily-trend?start_date=${startDate}&end_date=${endDate}`;
|
|
if (selectedDrugCode) {
|
|
url += `&drug_code=${selectedDrugCode}`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// 통계 업데이트
|
|
document.getElementById('statTotalInbound').textContent = data.stats.total_inbound.toLocaleString();
|
|
document.getElementById('statTotalOutbound').textContent = data.stats.total_outbound.toLocaleString();
|
|
|
|
const netChange = data.stats.net_change;
|
|
const netChangeStr = (netChange >= 0 ? '+' : '') + netChange.toLocaleString();
|
|
document.getElementById('statNetChange').textContent = netChangeStr;
|
|
document.getElementById('statNetChange').style.color = netChange >= 0 ? 'var(--accent-emerald)' : 'var(--accent-rose)';
|
|
|
|
document.getElementById('statDataCount').textContent = data.stats.data_count + '일';
|
|
|
|
// 차트 렌더링
|
|
renderDailyTrendChart(data.items);
|
|
}
|
|
} catch (err) {
|
|
console.error('일별 추이 로드 실패:', err);
|
|
showToast('데이터 로드 실패', 'error');
|
|
}
|
|
}
|
|
|
|
async function loadTopUsage() {
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
|
|
try {
|
|
const response = await fetch(`/api/stock-analytics/top-usage?start_date=${startDate}&end_date=${endDate}&limit=10`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
renderTopUsageChart(data.items);
|
|
renderTopUsageTable(data.items);
|
|
}
|
|
} catch (err) {
|
|
console.error('TOP 사용량 로드 실패:', err);
|
|
}
|
|
}
|
|
|
|
async function loadStockLevel() {
|
|
if (!selectedDrugCode) return;
|
|
|
|
const startDate = document.getElementById('startDate').value;
|
|
const endDate = document.getElementById('endDate').value;
|
|
const period = document.getElementById('periodSelect').value;
|
|
|
|
// 캔버스 준비
|
|
document.getElementById('stockLevelContainer').innerHTML = '<canvas id="stockLevelChart"></canvas>';
|
|
document.getElementById('chartLegendCustom').style.display = 'flex';
|
|
|
|
try {
|
|
const response = await fetch(`/api/stock-analytics/stock-with-usage?drug_code=${selectedDrugCode}&start_date=${startDate}&end_date=${endDate}&period=${period}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const periodLabel = {daily: '일별', weekly: '주별', monthly: '월별'}[period] || '';
|
|
document.getElementById('stockLevelTitle').textContent = `${data.product_name} - ${periodLabel} 재고량 vs 사용량`;
|
|
renderStockLevelChart(data.items, data.product_name, data.stats);
|
|
}
|
|
} catch (err) {
|
|
console.error('재고 변화 로드 실패:', err);
|
|
showToast('재고 데이터 로드 실패', 'error');
|
|
}
|
|
}
|
|
|
|
// ══════════════════ 재고 소진 예측 ══════════════════
|
|
async function loadForecast() {
|
|
if (!selectedDrugCode) return;
|
|
|
|
const container = document.getElementById('forecastContainer');
|
|
container.innerHTML = `
|
|
<div class="forecast-empty">
|
|
<div class="loading-spinner"></div>
|
|
<div>예측 데이터 로딩 중...</div>
|
|
</div>`;
|
|
|
|
try {
|
|
const response = await fetch(`/api/stock-analytics/forecast?drug_code=${selectedDrugCode}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
renderForecastCard(data);
|
|
} else {
|
|
container.innerHTML = `
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">⚠️</div>
|
|
<div>${data.error || '예측 데이터를 불러올 수 없습니다'}</div>
|
|
</div>`;
|
|
}
|
|
} catch (err) {
|
|
console.error('예측 로드 실패:', err);
|
|
container.innerHTML = `
|
|
<div class="forecast-empty">
|
|
<div class="forecast-empty-icon">❌</div>
|
|
<div>예측 로드 실패</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderForecastCard(data) {
|
|
const container = document.getElementById('forecastContainer');
|
|
|
|
// 추세 표시
|
|
const trendMap = {
|
|
'increasing': { label: '증가 추세', icon: '📈', class: 'trend-increasing' },
|
|
'stable': { label: '안정적', icon: '➡️', class: 'trend-stable' },
|
|
'decreasing': { label: '감소 추세', icon: '📉', class: 'trend-decreasing' },
|
|
'unknown': { label: '분석 불가', icon: '❓', class: 'trend-unknown' }
|
|
};
|
|
const trend = trendMap[data.trend] || trendMap['unknown'];
|
|
|
|
// 신뢰도 표시
|
|
const confidenceMap = {
|
|
'high': { label: '높음', icon: '✅', class: 'confidence-high' },
|
|
'medium': { label: '보통', icon: '⚠️', class: 'confidence-medium' },
|
|
'low': { label: '낮음', icon: '❗', class: 'confidence-low' },
|
|
'none': { label: '없음', icon: '❌', class: 'confidence-none' }
|
|
};
|
|
const confidence = confidenceMap[data.confidence] || confidenceMap['none'];
|
|
|
|
// 데이터 없는 경우
|
|
if (data.confidence === 'none' || !data.days_until_empty) {
|
|
container.innerHTML = `
|
|
<div class="forecast-card">
|
|
<div class="forecast-header">
|
|
<span>🔮</span>
|
|
<span class="forecast-title">재고 소진 예측</span>
|
|
</div>
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">현재 재고</span>
|
|
<span class="forecast-value" style="color: var(--accent-emerald);">${data.current_stock.toLocaleString()}개</span>
|
|
</div>
|
|
<hr class="forecast-divider">
|
|
<div class="forecast-empty" style="padding: 20px;">
|
|
<div class="forecast-empty-icon">📊</div>
|
|
<div>${data.message || '출고 데이터가 없어 예측할 수 없습니다'}</div>
|
|
</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
// 소진 예상일까지 남은 일수 색상
|
|
let daysColor = 'var(--accent-emerald)';
|
|
if (data.days_until_empty <= 7) {
|
|
daysColor = 'var(--accent-rose)';
|
|
} else if (data.days_until_empty <= 14) {
|
|
daysColor = 'var(--accent-amber)';
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="forecast-card">
|
|
<div class="forecast-header">
|
|
<span>🔮</span>
|
|
<span class="forecast-title">재고 소진 예측</span>
|
|
</div>
|
|
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">현재 재고</span>
|
|
<span class="forecast-value" style="color: var(--accent-emerald);">${data.current_stock.toLocaleString()}개</span>
|
|
</div>
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">평균 일일 출고</span>
|
|
<span class="forecast-value" style="color: var(--accent-blue);">${data.avg_daily_outbound}개</span>
|
|
</div>
|
|
|
|
<div class="forecast-highlight">
|
|
<div class="forecast-highlight-row">
|
|
<span class="forecast-highlight-icon">🗓️</span>
|
|
<span class="forecast-highlight-label">예상 소진일</span>
|
|
<span class="forecast-highlight-value">${data.estimated_empty_date}<span class="forecast-highlight-sub">(${data.days_until_empty}일 후)</span></span>
|
|
</div>
|
|
<div class="forecast-highlight-row">
|
|
<span class="forecast-highlight-icon">🛒</span>
|
|
<span class="forecast-highlight-label">발주 권장일</span>
|
|
<span class="forecast-highlight-value" style="color: ${daysColor};">${data.recommended_order_date}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="forecast-meta">
|
|
<span class="forecast-tag ${trend.class}">${trend.icon} ${trend.label}</span>
|
|
<span class="forecast-tag ${confidence.class}">${confidence.icon} 신뢰도: ${confidence.label}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ══════════════════ 차트 렌더링 ══════════════════
|
|
function renderDailyTrendChart(items) {
|
|
const ctx = document.getElementById('dailyTrendChart').getContext('2d');
|
|
|
|
if (dailyTrendChart) {
|
|
dailyTrendChart.destroy();
|
|
}
|
|
|
|
const labels = items.map(item => item.date);
|
|
const inboundData = items.map(item => item.inbound);
|
|
const outboundData = items.map(item => item.outbound);
|
|
|
|
dailyTrendChart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: '입고량',
|
|
data: inboundData,
|
|
borderColor: '#3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 6
|
|
},
|
|
{
|
|
label: '출고량',
|
|
data: outboundData,
|
|
borderColor: '#f43f5e',
|
|
backgroundColor: 'rgba(244, 63, 94, 0.1)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 6
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index'
|
|
},
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: { color: '#94a3b8' }
|
|
},
|
|
tooltip: {
|
|
backgroundColor: '#1e293b',
|
|
titleColor: '#f1f5f9',
|
|
bodyColor: '#94a3b8',
|
|
borderColor: '#334155',
|
|
borderWidth: 1
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#64748b' }
|
|
},
|
|
y: {
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#64748b' },
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTopUsageChart(items) {
|
|
const ctx = document.getElementById('topUsageChart').getContext('2d');
|
|
|
|
if (topUsageChart) {
|
|
topUsageChart.destroy();
|
|
}
|
|
|
|
// 약품명 줄이기 (10자 초과 시)
|
|
const labels = items.map(item => {
|
|
const name = item.product_name || '';
|
|
return name.length > 12 ? name.substring(0, 12) + '...' : name;
|
|
});
|
|
const outboundData = items.map(item => item.total_outbound);
|
|
|
|
// 그라데이션 색상
|
|
const colors = [
|
|
'#f59e0b', '#fb923c', '#fbbf24', '#fcd34d', '#fde68a',
|
|
'#a3e635', '#84cc16', '#65a30d', '#4d7c0f', '#3f6212'
|
|
];
|
|
|
|
topUsageChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: '출고량',
|
|
data: outboundData,
|
|
backgroundColor: colors,
|
|
borderRadius: 6
|
|
}]
|
|
},
|
|
options: {
|
|
indexAxis: 'y',
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
backgroundColor: '#1e293b',
|
|
callbacks: {
|
|
title: function(context) {
|
|
return items[context[0].dataIndex].product_name;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: { color: '#64748b' },
|
|
beginAtZero: true
|
|
},
|
|
y: {
|
|
grid: { display: false },
|
|
ticks: { color: '#94a3b8', font: { size: 11 } }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderStockLevelChart(items, productName, stats) {
|
|
const ctx = document.getElementById('stockLevelChart').getContext('2d');
|
|
|
|
if (stockLevelChart) {
|
|
stockLevelChart.destroy();
|
|
}
|
|
|
|
const labels = items.map(item => item.date);
|
|
const stockData = items.map(item => item.stock);
|
|
const rxUsageData = items.map(item => item.rx_usage);
|
|
|
|
// 재고가 늘어나는데 사용량은 줄어드는지 등 해석용 데이터
|
|
const hasUsageData = rxUsageData.some(v => v > 0);
|
|
|
|
stockLevelChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
type: 'line',
|
|
label: '재고량',
|
|
data: stockData,
|
|
borderColor: '#a855f7',
|
|
backgroundColor: 'rgba(168, 85, 247, 0.15)',
|
|
fill: true,
|
|
tension: 0.3,
|
|
pointRadius: 4,
|
|
pointHoverRadius: 7,
|
|
pointBackgroundColor: '#a855f7',
|
|
borderWidth: 3,
|
|
yAxisID: 'y',
|
|
order: 1
|
|
},
|
|
{
|
|
type: 'bar',
|
|
label: '처방 사용량',
|
|
data: rxUsageData,
|
|
backgroundColor: 'rgba(59, 130, 246, 0.7)',
|
|
borderColor: '#3b82f6',
|
|
borderWidth: 1,
|
|
borderRadius: 4,
|
|
yAxisID: 'y1',
|
|
order: 2
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: {
|
|
intersect: false,
|
|
mode: 'index'
|
|
},
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: {
|
|
backgroundColor: '#1e293b',
|
|
titleColor: '#f1f5f9',
|
|
bodyColor: '#94a3b8',
|
|
borderColor: '#334155',
|
|
borderWidth: 1,
|
|
callbacks: {
|
|
afterBody: function(context) {
|
|
const idx = context[0].dataIndex;
|
|
const item = items[idx];
|
|
const lines = [];
|
|
lines.push(`입고: ${item.inbound.toLocaleString()} / 출고: ${item.outbound.toLocaleString()}`);
|
|
if (item.rx_count > 0) {
|
|
lines.push(`처방 건수: ${item.rx_count}건`);
|
|
}
|
|
return lines;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: {
|
|
color: '#64748b',
|
|
maxRotation: 45,
|
|
minRotation: 0
|
|
}
|
|
},
|
|
y: {
|
|
type: 'linear',
|
|
position: 'left',
|
|
grid: { color: 'rgba(255,255,255,0.05)' },
|
|
ticks: {
|
|
color: '#a855f7',
|
|
callback: function(value) {
|
|
return value.toLocaleString();
|
|
}
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: '재고량',
|
|
color: '#a855f7',
|
|
font: { weight: 600 }
|
|
},
|
|
beginAtZero: false
|
|
},
|
|
y1: {
|
|
type: 'linear',
|
|
position: 'right',
|
|
grid: { drawOnChartArea: false },
|
|
ticks: {
|
|
color: '#3b82f6',
|
|
callback: function(value) {
|
|
return value.toLocaleString();
|
|
}
|
|
},
|
|
title: {
|
|
display: true,
|
|
text: '처방 사용량',
|
|
color: '#3b82f6',
|
|
font: { weight: 600 }
|
|
},
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 데이터 해석 힌트 표시
|
|
if (stats && items.length >= 2) {
|
|
displayTrendAnalysis(items, stats);
|
|
}
|
|
}
|
|
|
|
function displayTrendAnalysis(items, stats) {
|
|
const container = document.getElementById('trendAnalysisContainer');
|
|
|
|
// 재고 추세 계산 (단순 선형)
|
|
const firstStock = items[0].stock;
|
|
const lastStock = items[items.length - 1].stock;
|
|
const stockChange = lastStock - firstStock;
|
|
const stockChangePercent = firstStock > 0 ? Math.round((stockChange / firstStock) * 100) : 0;
|
|
const stockTrend = stockChange > 0 ? 'increasing' : (stockChange < 0 ? 'decreasing' : 'stable');
|
|
|
|
// 사용량 추세 계산 (최근 3개 기간 vs 이전 3개 기간 평균 비교)
|
|
const recentCount = Math.min(3, Math.floor(items.length / 2));
|
|
const recentItems = items.slice(-recentCount);
|
|
const previousItems = items.slice(-recentCount * 2, -recentCount);
|
|
|
|
const recentAvg = recentItems.length > 0 ? recentItems.reduce((sum, i) => sum + i.rx_usage, 0) / recentItems.length : 0;
|
|
const previousAvg = previousItems.length > 0 ? previousItems.reduce((sum, i) => sum + i.rx_usage, 0) / previousItems.length : 0;
|
|
|
|
const usageChange = Math.round(recentAvg - previousAvg);
|
|
const usageChangePercent = previousAvg > 0 ? Math.round((usageChange / previousAvg) * 100) : 0;
|
|
const usageTrend = usageChangePercent > 10 ? 'increasing' : (usageChangePercent < -10 ? 'decreasing' : 'stable');
|
|
|
|
// 해석 메시지 및 상태 결정
|
|
let interpretation = '';
|
|
let statusClass = '';
|
|
let statusIcon = '';
|
|
|
|
if (stockTrend === 'increasing' && usageTrend === 'stable') {
|
|
interpretation = '재고 증가 중, 사용량 유지 → 과잉재고 주의';
|
|
statusClass = 'warning';
|
|
statusIcon = '⚠️';
|
|
} else if (stockTrend === 'increasing' && usageTrend === 'increasing') {
|
|
interpretation = '재고·사용량 동반 증가 → 건강한 성장';
|
|
statusClass = 'success';
|
|
statusIcon = '✅';
|
|
} else if (stockTrend === 'increasing' && usageTrend === 'decreasing') {
|
|
interpretation = '재고 증가, 사용량 감소 → 재고 과잉 위험!';
|
|
statusClass = 'danger';
|
|
statusIcon = '🚨';
|
|
} else if (stockTrend === 'decreasing' && usageTrend === 'increasing') {
|
|
interpretation = '재고 감소, 사용량 증가 → 재고 부족 위험!';
|
|
statusClass = 'danger';
|
|
statusIcon = '🚨';
|
|
} else if (stockTrend === 'decreasing' && usageTrend === 'stable') {
|
|
interpretation = '재고 감소 추세, 발주 검토 필요';
|
|
statusClass = 'warning';
|
|
statusIcon = '📉';
|
|
} else if (stockTrend === 'decreasing' && usageTrend === 'decreasing') {
|
|
interpretation = '재고·사용량 동반 감소 → 수요 감소 추세';
|
|
statusClass = 'info';
|
|
statusIcon = '📉';
|
|
} else {
|
|
interpretation = '안정적인 재고 운영';
|
|
statusClass = 'success';
|
|
statusIcon = '➡️';
|
|
}
|
|
|
|
// 추세 아이콘
|
|
const trendIcon = {
|
|
'increasing': '📈',
|
|
'decreasing': '📉',
|
|
'stable': '➡️'
|
|
};
|
|
|
|
// 추세 색상
|
|
const trendColor = {
|
|
'increasing': 'var(--accent-emerald)',
|
|
'decreasing': 'var(--accent-rose)',
|
|
'stable': 'var(--accent-blue)'
|
|
};
|
|
|
|
// 상태 배경색
|
|
const statusBg = {
|
|
'success': 'rgba(16, 185, 129, 0.15)',
|
|
'warning': 'rgba(245, 158, 11, 0.15)',
|
|
'danger': 'rgba(239, 68, 68, 0.15)',
|
|
'info': 'rgba(59, 130, 246, 0.15)'
|
|
};
|
|
|
|
const statusBorder = {
|
|
'success': 'var(--accent-emerald)',
|
|
'warning': 'var(--accent-amber)',
|
|
'danger': 'var(--accent-rose)',
|
|
'info': 'var(--accent-blue)'
|
|
};
|
|
|
|
container.innerHTML = `
|
|
<div class="forecast-card">
|
|
<div class="forecast-header">
|
|
<span>📈</span>
|
|
<span class="forecast-title">추세 분석</span>
|
|
</div>
|
|
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">재고 추세</span>
|
|
<span class="forecast-value" style="color: ${trendColor[stockTrend]};">
|
|
${trendIcon[stockTrend]} ${stockChange >= 0 ? '+' : ''}${stockChange.toLocaleString()} (${stockChangePercent >= 0 ? '+' : ''}${stockChangePercent}%)
|
|
</span>
|
|
</div>
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">사용량 추세 (최근 평균)</span>
|
|
<span class="forecast-value" style="color: ${trendColor[usageTrend]};">
|
|
${trendIcon[usageTrend]} ${usageChange >= 0 ? '+' : ''}${usageChange.toLocaleString()} (${usageChangePercent >= 0 ? '+' : ''}${usageChangePercent}%)
|
|
</span>
|
|
</div>
|
|
|
|
<hr class="forecast-divider">
|
|
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">총 처방 사용량</span>
|
|
<span class="forecast-value" style="color: var(--accent-blue);">${stats.total_rx_usage.toLocaleString()}개</span>
|
|
</div>
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">처방 건수</span>
|
|
<span class="forecast-value">${stats.total_rx_count.toLocaleString()}건</span>
|
|
</div>
|
|
<div class="forecast-row">
|
|
<span class="forecast-label">기간 평균 사용량</span>
|
|
<span class="forecast-value">${stats.avg_rx_usage}개/기간</span>
|
|
</div>
|
|
|
|
<div class="forecast-highlight" style="background: ${statusBg[statusClass]}; border-left: 3px solid ${statusBorder[statusClass]}; margin-top: 16px;">
|
|
<div style="display:flex; align-items:center; gap:8px;">
|
|
<span style="font-size:20px;">${statusIcon}</span>
|
|
<span style="font-size:13px; font-weight:500;">${interpretation}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderTopUsageTable(items) {
|
|
const tbody = document.getElementById('topUsageTableBody');
|
|
|
|
if (items.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr><td colspan="5">
|
|
<div class="empty-state">
|
|
<div class="empty-icon">📊</div>
|
|
<div>데이터가 없습니다</div>
|
|
</div>
|
|
</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = items.map((item, idx) => {
|
|
let rankClass = 'normal';
|
|
if (idx === 0) rankClass = 'gold';
|
|
else if (idx === 1) rankClass = 'silver';
|
|
else if (idx === 2) rankClass = 'bronze';
|
|
|
|
const stockColor = item.current_stock <= 0 ? 'var(--accent-rose)' :
|
|
item.current_stock < 100 ? 'var(--accent-amber)' : 'var(--accent-emerald)';
|
|
|
|
return `
|
|
<tr onclick="selectDrug('${item.drug_code}', '${escapeHtml(item.product_name)}')" style="cursor:pointer;">
|
|
<td><span class="rank-badge ${rankClass}">${idx + 1}</span></td>
|
|
<td>
|
|
<div class="product-cell">
|
|
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
|
<span class="product-code">${item.drug_code} · ${item.supplier || '-'}</span>
|
|
</div>
|
|
</td>
|
|
<td class="qty-cell" style="text-align:right;color:var(--accent-blue);">
|
|
${item.total_inbound.toLocaleString()}
|
|
</td>
|
|
<td class="qty-cell" style="text-align:right;color:var(--accent-rose);">
|
|
${item.total_outbound.toLocaleString()}
|
|
</td>
|
|
<td class="qty-cell" style="text-align:right;color:${stockColor};">
|
|
${item.current_stock.toLocaleString()}
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
// ══════════════════ 유틸리티 ══════════════════
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.className = 'toast ' + type + ' show';
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|