pharmacy-pos-qr-system/backend/templates/admin_rx_usage.html
thug0bin 09948c234f feat(rx-usage): 선호 도매상 컬럼 추가
- 테이블에 '선호도매상' 컬럼 추가
- 입고장 기반 최다/최근 주문 도매상 표시
- API: /api/order/drugs/preferred-vendors 연동
- Python 스크립트로 안전하게 수정
2026-03-07 23:12:42 +09:00

3176 lines
121 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>
<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">
<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;
}
/* ══════════════════ 주문량 로딩 ══════════════════ */
.order-loading {
display: inline-block;
color: var(--accent-cyan);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* ══════════════════ 헤더 ══════════════════ */
.header {
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 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-cyan);
box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.2);
}
.search-group input::placeholder { color: var(--text-muted); }
.search-btn {
background: linear-gradient(135deg, var(--accent-cyan), #0891b2);
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(6, 182, 212, 0.4);
}
/* ══════════════════ 통계 카드 ══════════════════ */
.stats-grid {
display: grid;
grid-template-columns: repeat(6, 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.cyan::before { background: var(--accent-cyan); }
.stat-card.blue::before { background: var(--accent-blue); }
.stat-card.purple::before { background: var(--accent-purple); }
.stat-card.amber::before { background: var(--accent-amber); }
.stat-card.emerald::before { background: var(--accent-emerald); }
.stat-card.orange::before { background: var(--accent-orange); }
.stat-icon {
font-size: 24px;
margin-bottom: 12px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 4px;
}
.stat-card.cyan .stat-value { color: var(--accent-cyan); }
.stat-card.blue .stat-value { color: var(--accent-blue); }
.stat-card.purple .stat-value { color: var(--accent-purple); }
.stat-card.amber .stat-value { color: var(--accent-amber); }
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
.stat-card.orange .stat-value { color: var(--accent-orange); }
.stat-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ══════════════════ 뷰 컨트롤 ══════════════════ */
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.view-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.rx-badge {
background: linear-gradient(135deg, var(--accent-cyan), #0891b2);
color: #fff;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
}
.view-actions {
display: flex;
gap: 8px;
}
.action-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.action-btn.primary {
background: linear-gradient(135deg, var(--accent-emerald), var(--accent-teal));
color: #fff;
}
.action-btn.primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.action-btn.secondary {
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.action-btn.secondary:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* ══════════════════ 테이블 ══════════════════ */
.table-wrap {
background: var(--bg-card);
border-radius: 16px;
border: 1px solid var(--border);
overflow: hidden;
}
.usage-table {
width: 100%;
border-collapse: collapse;
}
.usage-table th {
padding: 14px 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);
position: sticky;
top: 0;
}
.usage-table th.center { text-align: center; }
.usage-table th.right { text-align: right; }
.usage-table td {
padding: 14px 16px;
font-size: 13px;
border-bottom: 1px solid rgba(255,255,255,0.05);
vertical-align: middle;
}
.usage-table tr:hover {
background: rgba(255,255,255,0.02);
}
.usage-table tr.selected {
background: rgba(6, 182, 212, 0.1);
}
/* 체크박스 */
.check-col {
width: 40px;
text-align: center;
}
.custom-check {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-radius: 4px;
cursor: pointer;
appearance: none;
background: var(--bg-primary);
transition: all 0.2s;
}
.custom-check:checked {
background: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.custom-check:checked::after {
content: '✓';
display: block;
text-align: center;
color: #fff;
font-size: 12px;
font-weight: bold;
line-height: 16px;
}
/* 제품 셀 */
.product-cell {
display: flex;
align-items: center;
gap: 12px;
}
.product-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 8px;
background: var(--bg-secondary);
flex-shrink: 0;
}
.product-thumb-placeholder {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0c4a6e 0%, #164e63 100%);
border-radius: 8px;
flex-shrink: 0;
border: 1px solid rgba(6, 182, 212, 0.2);
font-size: 18px;
}
.product-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.product-name {
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.product-code {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
}
.location-badge {
display: inline-block;
background: rgba(99, 102, 241, 0.15);
color: var(--accent-indigo);
font-size: 10px;
font-weight: 500;
padding: 1px 5px;
border-radius: 4px;
margin-left: 4px;
font-family: inherit;
}
.patient-badge {
display: inline-block;
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
font-size: 10px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
margin-left: 4px;
font-family: inherit;
}
.patient-badge.has-today {
background: rgba(236, 72, 153, 0.2);
color: #ec4899;
}
.today-patient {
color: #ec4899;
font-weight: 700;
}
/* 수량 관련 */
.qty-cell {
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.qty-high { color: var(--accent-rose); }
.qty-mid { color: var(--accent-amber); }
.qty-low { color: var(--accent-emerald); }
/* 주문 수량 입력 */
.order-input {
width: 70px;
padding: 8px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-primary);
text-align: center;
}
.order-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
/* 공급업체 뱃지 */
.supplier-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
background: rgba(6, 182, 212, 0.2);
color: #22d3ee;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
.loading-state, .empty-state {
text-align: center;
padding: 80px 20px;
color: var(--text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent-cyan);
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;
}
/* ══════════════════ 주문 장바구니 ══════════════════ */
.cart-drawer {
position: fixed;
right: 0;
top: 0;
bottom: 0;
width: 420px;
background: var(--bg-secondary);
border-left: 1px solid var(--border);
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 200;
display: flex;
flex-direction: column;
}
.cart-drawer.open {
transform: translateX(0);
}
.cart-header {
padding: 20px 24px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-header h2 {
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.cart-close {
background: rgba(255,255,255,0.2);
border: none;
width: 32px;
height: 32px;
border-radius: 8px;
color: #fff;
font-size: 18px;
cursor: pointer;
}
.cart-body {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.cart-item {
background: var(--bg-card);
border-radius: 12px;
padding: 14px;
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-item-info {
flex: 1;
min-width: 0;
}
.cart-item-name {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cart-item-qty {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.cart-item-actions {
display: flex;
gap: 6px;
align-items: center;
}
.cart-item-order {
background: rgba(16, 185, 129, 0.2);
border: none;
color: var(--accent-emerald);
padding: 4px 8px;
border-radius: 6px;
cursor: pointer;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.cart-item-order:hover {
background: rgba(16, 185, 129, 0.4);
}
.cart-item-remove {
background: rgba(244, 63, 94, 0.2);
border: none;
color: var(--accent-rose);
width: 28px;
height: 28px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.cart-footer {
padding: 20px 24px;
background: var(--bg-card);
border-top: 1px solid var(--border);
}
.cart-summary {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.cart-total-label {
font-size: 14px;
color: var(--text-secondary);
}
.cart-total-value {
font-size: 24px;
font-weight: 700;
color: var(--accent-cyan);
}
.cart-submit {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
border: none;
border-radius: 10px;
color: #fff;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.cart-submit:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(6, 182, 212, 0.4);
}
.cart-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ══════════════════ 장바구니 FAB ══════════════════ */
.cart-fab {
position: fixed;
bottom: 32px;
right: 32px;
width: 64px;
height: 64px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
border: none;
border-radius: 20px;
color: #fff;
font-size: 28px;
cursor: pointer;
box-shadow: 0 8px 24px rgba(6, 182, 212, 0.4);
transition: all 0.2s;
z-index: 150;
}
.cart-fab:hover {
transform: scale(1.05);
}
.cart-badge {
position: absolute;
top: -6px;
right: -6px;
width: 24px;
height: 24px;
background: var(--accent-rose);
border-radius: 50%;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
/* ══════════════════ 토스트 ══════════════════ */
.toast {
position: fixed;
bottom: 100px;
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); }
/* ══════════════════ 반응형 ══════════════════ */
@media (max-width: 1400px) {
.stats-grid { grid-template-columns: repeat(3, 1fr); }
}
/* 세로 모니터 (좁은 화면) 전용 */
@media (max-width: 1100px) {
.usage-table th,
.usage-table td {
padding: 10px 6px;
font-size: 11px;
}
.usage-table th {
font-size: 10px;
letter-spacing: 0;
}
.usage-table th:nth-child(2) { width: auto !important; } /* 약품 */
.product-cell { gap: 6px; }
.product-thumb,
.product-thumb-placeholder {
width: 28px;
height: 28px;
font-size: 12px;
}
.product-name { font-size: 11px; }
.product-code { font-size: 9px; }
.qty-cell { font-size: 11px; }
.order-input {
width: 55px;
padding: 5px 2px;
font-size: 12px;
}
}
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.header-nav { display: none; }
.search-bar { flex-direction: column; }
.search-group { width: 100%; }
.search-group input, .search-group select { width: 100%; }
.cart-drawer { width: 100%; }
.view-controls { flex-direction: column; gap: 12px; }
}
</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" class="active">💊 Rx 사용량</a>
<a href="/pmr">📋 처방전</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">
<label>검색어</label>
<input type="text" id="searchInput" placeholder="약품명, 코드...">
</div>
<div class="search-group">
<label>정렬</label>
<select id="sortSelect">
<option value="qty_desc">투약량 많은순</option>
<option value="qty_asc">투약량 적은순</option>
<option value="rx_desc">처방건수 많은순</option>
<option value="name_asc">이름순</option>
<option value="amount_desc">금액 높은순</option>
</select>
</div>
<button class="search-btn" onclick="loadUsageData(); loadOrderData();">🔍 조회</button>
<button class="search-btn" style="background: linear-gradient(135deg, #a855f7, #7c3aed);" onclick="openBalanceModal()">💰 도매상 잔고</button>
</div>
<!-- 통계 -->
<div class="stats-grid">
<div class="stat-card cyan">
<div class="stat-icon">📅</div>
<div class="stat-value" id="statPeriod">-</div>
<div class="stat-label">조회 기간</div>
</div>
<div class="stat-card blue">
<div class="stat-icon">💊</div>
<div class="stat-value" id="statProducts">-</div>
<div class="stat-label">처방 품목</div>
</div>
<div class="stat-card purple">
<div class="stat-icon">🔢</div>
<div class="stat-value" id="statTotalQty">-</div>
<div class="stat-label">총 처방수량</div>
</div>
<div class="stat-card amber">
<div class="stat-icon">📊</div>
<div class="stat-value" id="statTotalDose">-</div>
<div class="stat-label">총 투약량</div>
</div>
<div class="stat-card emerald">
<div class="stat-icon">💰</div>
<div class="stat-value" id="statTotalAmount">-</div>
<div class="stat-label">총 매출액</div>
</div>
<div class="stat-card orange">
<div class="stat-icon">🛒</div>
<div class="stat-value" id="statCartCount">0</div>
<div class="stat-label">주문 품목</div>
</div>
</div>
<!-- 컨트롤 -->
<div class="view-controls">
<div class="view-title">
<span class="rx-badge">Rx</span>
<span>품목별 사용량</span>
<span id="resultCount" style="font-size:13px;color:var(--text-muted);font-weight:400;"></span>
</div>
<div class="view-actions">
<button class="action-btn secondary" onclick="selectAll()">☑️ 전체 선택</button>
<button class="action-btn secondary" onclick="selectNone()">⬜ 선택 해제</button>
<button class="action-btn primary" onclick="addSelectedToCart()">🛒 선택 항목 장바구니 추가</button>
</div>
</div>
<!-- 테이블 -->
<div class="table-wrap">
<table class="usage-table">
<thead>
<tr>
<th class="check-col"><input type="checkbox" class="custom-check" id="checkAll" onchange="toggleCheckAll()"></th>
<th style="width:28%">약품</th>
<th class="center">현재고</th>
<th class="center">처방횟수</th>
<th class="center">투약량</th>
<th class="center" style="color:var(--accent-cyan);">주문량</th>
<th class="center" style="color:var(--accent-purple);font-size:11px;">선호도매상</th>
<th class="right">매출액</th>
<th class="center" style="width:90px">주문수량</th>
</tr>
</thead>
<tbody id="usageTableBody">
<tr>
<td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 장바구니 FAB -->
<button class="cart-fab" onclick="openCart()">
🛒
<span class="cart-badge" id="cartBadge" style="display:none;">0</span>
</button>
<!-- 장바구니 Drawer -->
<div class="cart-drawer" id="cartDrawer">
<div class="cart-header">
<h2>🛒 전문약 주문</h2>
<button class="cart-close" onclick="closeCart()"></button>
</div>
<div class="cart-body" id="cartBody">
<div class="empty-state">
<div class="empty-icon">🛒</div>
<div>장바구니가 비었습니다</div>
</div>
</div>
<div class="cart-footer">
<div class="cart-summary">
<span class="cart-total-label">총 주문 품목</span>
<span class="cart-total-value" id="cartTotalItems">0개</span>
</div>
<button class="cart-submit" id="submitOrderBtn" onclick="submitOrder()" disabled>
📤 주문서 생성하기
</button>
</div>
</div>
<!-- 토스트 -->
<div class="toast" id="toast"></div>
<script>
let usageData = [];
let cart = [];
let orderDataByKd = {};
let preferredVendors = {}; // 약품별 선호 도매상
// 선호 도매상 표시
function getPreferredVendor(drugCode) {
const v = preferredVendors[drugCode];
if (!v || !v.success) return '-';
const recent = v.recent_vendor;
const most = v.most_frequent_vendor;
if (!recent && !most) return '-';
const shorten = (n) => n ? n.replace('강원','').replace('(주)','').replace('지점','').replace('약품','').substring(0,3) : '';
const rn = recent ? shorten(recent.vendor_name) : '';
const mn = most ? shorten(most.vendor_name) : '';
if (rn === mn && rn) return `<span style="color:#10b981" title="${most?.vendor_name}">${rn}</span>`;
let h = '';
if (mn) h += `<span style="color:#a855f7" title="최다(${most.order_count}회)">★${mn}</span>`;
if (rn && rn !== mn) h += `<br><span style="color:#888" title="최근">▸${rn}</span>`;
return h || '-';
}
// 선호 도매상 로드
async function loadPreferredVendors(codes) {
if (!codes || !codes.length) return;
try {
const r = await fetch('/api/order/drugs/preferred-vendors', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({drug_codes: codes, period: 365})
});
const d = await r.json();
if (d.success) { preferredVendors = d.results; renderTable(); }
} catch(e) { console.error('선호도매상 로드 실패:', e); }
} // 도매상 주문량 합산 (KD코드별) - 지오영 + 수인
// 초기화
document.addEventListener('DOMContentLoaded', function() {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
document.getElementById('startDate').value = todayStr;
document.getElementById('endDate').value = todayStr;
loadUsageData();
loadOrderData(); // 수인약품 주문량 로드
});
// ──────────────── 도매상 주문량 조회 (지오영 + 수인 + 백제 합산) ────────────────
async function loadOrderData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
orderDataLoading = true;
orderDataByKd = {};
try {
// 지오영 + 수인 + 백제 병렬 조회
const [geoRes, sooinRes, baekjeRes] = await Promise.all([
fetch(`/api/geoyoung/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/sooin/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false })),
fetch(`/api/baekje/orders/summary-by-kd?start_date=${startDate}&end_date=${endDate}`).then(r => r.json()).catch(() => ({ success: false }))
]);
let totalOrders = 0;
// 지오영 데이터 합산
if (geoRes.success && geoRes.by_kd_code) {
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('지오영');
}
totalOrders += geoRes.order_count || 0;
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
}
// 수인 데이터 합산
if (sooinRes.success && sooinRes.by_kd_code) {
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('수인');
}
totalOrders += sooinRes.order_count || 0;
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
}
// 백제 데이터 합산
if (baekjeRes.success && baekjeRes.by_kd_code) {
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
if (!orderDataByKd[kd]) {
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
}
orderDataByKd[kd].boxes += data.boxes || 0;
orderDataByKd[kd].units += data.units || 0;
orderDataByKd[kd].sources.push('백제');
}
totalOrders += baekjeRes.order_count || 0;
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
}
console.log('📦 3사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
} catch(err) {
console.warn('주문량 조회 실패:', err);
orderDataByKd = {};
} finally {
orderDataLoading = false;
renderTable(); // 로딩 완료 후 테이블 갱신
}
}
// KD코드로 주문량 조회
let orderDataLoading = true; // 로딩 상태
function getOrderedQty(kdCode) {
if (orderDataLoading) return '<span class="order-loading">···</span>';
const order = orderDataByKd[kdCode];
if (!order) return '-';
return order.units.toLocaleString();
}
// ──────────────── 데이터 로드 ────────────────
function loadUsageData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const search = document.getElementById('searchInput').value;
const sort = document.getElementById('sortSelect').value;
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="9">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>데이터 로딩 중...</div>
</div>
</td></tr>`;
let url = `/api/rx-usage?start_date=${startDate}&end_date=${endDate}&sort=${sort}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
fetch(url)
.then(res => res.json())
.then(data => {
if (data.success) {
usageData = data.items;
// 통계 업데이트
document.getElementById('statPeriod').textContent = data.stats.period_days + '일';
document.getElementById('statProducts').textContent = data.stats.product_count.toLocaleString();
document.getElementById('statTotalQty').textContent = data.stats.total_qty.toLocaleString();
document.getElementById('statTotalDose').textContent = data.stats.total_dose.toLocaleString();
document.getElementById('statTotalAmount').textContent = formatPrice(data.stats.total_amount);
document.getElementById('resultCount').textContent = `(${data.items.length}개)`;
renderTable();
// 선호 도매상 로드 (백그라운드)
loadPreferredVendors(data.items.map(i => i.drug_code));
} else {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${data.error}</div>
</div>
</td></tr>`;
}
})
.catch(err => {
document.getElementById('usageTableBody').innerHTML = `
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">❌</div>
<div>데이터 로드 실패</div>
</div>
</td></tr>`;
});
}
// ──────────────── 테이블 렌더링 ────────────────
function renderTable() {
const tbody = document.getElementById('usageTableBody');
if (usageData.length === 0) {
tbody.innerHTML = `
<tr><td colspan="9">
<div class="empty-state">
<div class="empty-icon">💊</div>
<div>해당 기간 처방 내역이 없습니다</div>
</div>
</td></tr>`;
return;
}
tbody.innerHTML = usageData.map((item, idx) => {
const qtyClass = item.total_dose >= 100 ? 'qty-high' : item.total_dose >= 30 ? 'qty-mid' : 'qty-low';
const inCart = cart.find(c => c.drug_code === item.drug_code);
return `
<tr data-idx="${idx}" class="${inCart ? 'selected' : ''}">
<td class="check-col">
<input type="checkbox" class="custom-check item-check" data-idx="${idx}" ${inCart ? 'checked' : ''}>
</td>
<td>
<div class="product-cell">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" alt="">`
: `<div class="product-thumb-placeholder">💊</div>`
}
<div class="product-info">
<span class="product-name">${escapeHtml(item.product_name)}</span>
<span class="product-code">${item.drug_code}${item.supplier ? ` · ${escapeHtml(item.supplier)}` : ''}${item.location ? ` <span class="location-badge">📍${escapeHtml(item.location)}</span>` : ''}${item.patient_names ? ` <span class="patient-badge ${item.today_patients ? 'has-today' : ''}" title="${item.patient_count}명 사용${item.today_patients ? ' (오늘: ' + item.today_patients + ')' : ''}">👤${formatPatientNames(item.patient_names, item.today_patients)}</span>` : ''}</span>
</div>
</div>
</td>
<td class="qty-cell" style="color:${item.current_stock <= 0 ? 'var(--accent-rose)' : item.current_stock < item.total_dose ? 'var(--accent-amber)' : 'var(--accent-emerald)'};">
${item.current_stock.toLocaleString()}
</td>
<td class="qty-cell" style="color:var(--text-secondary);">${item.prescription_count}건</td>
<td class="qty-cell ${qtyClass}">${item.total_dose}</td>
<td class="qty-cell" style="color:var(--accent-cyan);">${getOrderedQty(item.drug_code)}</td>
<td class="qty-cell" style="font-size:10px;">${getPreferredVendor(item.drug_code)}</td>
<td style="text-align:right;font-family:'JetBrains Mono',monospace;font-size:12px;">
${formatPrice(item.total_amount)}
</td>
<td style="text-align:center;">
<input type="number" class="order-input"
data-idx="${idx}"
min="0"
value="${inCart ? inCart.qty : Math.ceil(item.total_dose / 10) * 10}"
placeholder="0">
</td>
</tr>
`;
}).join('');
}
// ──────────────── 선택 ────────────────
function toggleCheckAll() {
const checked = document.getElementById('checkAll').checked;
document.querySelectorAll('.item-check').forEach(cb => {
cb.checked = checked;
const row = cb.closest('tr');
row.classList.toggle('selected', checked);
});
}
function selectAll() {
document.getElementById('checkAll').checked = true;
toggleCheckAll();
}
function selectNone() {
document.getElementById('checkAll').checked = false;
toggleCheckAll();
}
// ──────────────── 장바구니 ────────────────
function addSelectedToCart() {
const checked = document.querySelectorAll('.item-check:checked');
if (checked.length === 0) {
showToast('선택된 품목이 없습니다', 'error');
return;
}
let added = 0;
checked.forEach(cb => {
const idx = parseInt(cb.dataset.idx);
const item = usageData[idx];
const qtyInput = document.querySelector(`.order-input[data-idx="${idx}"]`);
const qty = parseInt(qtyInput.value) || Math.ceil(item.total_dose / 10) * 10;
if (qty > 0) {
const existing = cart.find(c => c.drug_code === item.drug_code);
if (existing) {
existing.qty = qty;
} else {
cart.push({
drug_code: item.drug_code,
product_name: item.product_name,
supplier: item.supplier,
qty: qty
});
added++;
}
}
});
updateCartUI();
renderTable();
showToast(`${added}개 품목이 장바구니에 추가되었습니다`, 'success');
}
function removeFromCart(drugCode) {
cart = cart.filter(c => c.drug_code !== drugCode);
updateCartUI();
renderTable();
}
function updateCartUI() {
const badge = document.getElementById('cartBadge');
const count = cart.length;
badge.textContent = count;
badge.style.display = count > 0 ? 'flex' : 'none';
document.getElementById('statCartCount').textContent = count;
document.getElementById('cartTotalItems').textContent = count + '개';
document.getElementById('submitOrderBtn').disabled = count === 0;
const body = document.getElementById('cartBody');
if (count === 0) {
body.innerHTML = `
<div class="empty-state">
<div class="empty-icon">🛒</div>
<div>장바구니가 비었습니다</div>
</div>`;
} else {
body.innerHTML = cart.map(item => `
<div class="cart-item">
<div class="cart-item-info">
<div class="cart-item-name">${escapeHtml(item.product_name)}</div>
<div class="cart-item-qty">${item.supplier || '-'} · ${item.qty}개</div>
</div>
<div class="cart-item-actions">
<button class="cart-item-order" onclick="orderSingleItem('${item.drug_code}')">📤주문</button>
<button class="cart-item-remove" onclick="removeFromCart('${item.drug_code}')">✕</button>
</div>
</div>
`).join('');
}
}
function openCart() {
document.getElementById('cartDrawer').classList.add('open');
}
function closeCart() {
document.getElementById('cartDrawer').classList.remove('open');
}
// ──────────────── 도매상 설정 (확장 가능) ────────────────
const WHOLESALERS = {
geoyoung: {
id: 'geoyoung',
name: '지오영',
icon: '🏭',
logo: '/static/img/logo_geoyoung.ico',
color: '#06b6d4',
gradient: 'linear-gradient(135deg, #0891b2, #06b6d4)',
filterFn: (item) => item.supplier === '지오영' || item.wholesaler === 'geoyoung',
getCode: (item) => item.geoyoung_code || item.drug_code
},
sooin: {
id: 'sooin',
name: '수인약품',
icon: '💊',
logo: '/static/img/logo_sooin.svg',
color: '#a855f7',
gradient: 'linear-gradient(135deg, #7c3aed, #a855f7)',
filterFn: (item) => item.supplier === '수인약품' || item.wholesaler === 'sooin',
getCode: (item) => item.sooin_code || item.drug_code
},
baekje: {
id: 'baekje',
name: '백제약품',
icon: '💉',
logo: '/static/img/logo_baekje.png',
color: '#f59e0b',
gradient: 'linear-gradient(135deg, #d97706, #f59e0b)',
filterFn: (item) => item.supplier === '백제약품' || item.wholesaler === 'baekje',
getCode: (item) => item.baekje_code || item.drug_code
}
};
// ──────────────── 주문 제출 ────────────────
let currentOrderWholesaler = null;
function submitOrder() {
if (cart.length === 0) return;
// 도매상별 분류
const itemsByWholesaler = {};
let otherItems = [...cart];
for (const [wsId, ws] of Object.entries(WHOLESALERS)) {
const wsItems = cart.filter(ws.filterFn);
if (wsItems.length > 0) {
itemsByWholesaler[wsId] = wsItems;
otherItems = otherItems.filter(item => !wsItems.includes(item));
}
}
const wsIds = Object.keys(itemsByWholesaler);
if (wsIds.length === 0) {
// API 지원 안 되는 품목만 있으면 클립보드
submitOrderClipboard();
return;
}
// 여러 도매상 품목이 있을 때 선택 모달
if (wsIds.length > 1) {
openWholesalerSelectModal(itemsByWholesaler, otherItems);
} else {
openOrderConfirmModal(wsIds[0], itemsByWholesaler[wsIds[0]]);
}
}
// 다중 도매상 선택을 위한 전역 변수
let pendingWholesalerItems = {};
let pendingOtherItems = [];
let wholesalerLimits = {}; // 도매상 한도 캐시
async function openWholesalerSelectModal(itemsByWholesaler, otherItems) {
pendingWholesalerItems = itemsByWholesaler;
pendingOtherItems = otherItems;
const modal = document.getElementById('multiWholesalerModal');
const body = document.getElementById('multiWholesalerBody');
// 로딩 표시
body.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text-muted);">📊 한도 및 월 매출 조회 중...</div>';
modal.classList.add('show');
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
// 1. 도매상 한도 정보 가져오기
try {
const res = await fetch('/api/order/wholesaler/limits');
const data = await res.json();
if (data.success) {
data.limits.forEach(l => {
wholesalerLimits[l.wholesaler_id] = l;
});
}
} catch (e) {
console.warn('한도 조회 실패:', e);
}
// 2. 실제 월 매출 가져오기 (도매상 API 호출)
const wholesalerConfigs = WHOLESALER_ORDER.map(id => WHOLESALER_CONFIG[id]);
await Promise.all(wholesalerConfigs.map(async ws => {
try {
const salesRes = await fetch(`${ws.salesApi}?year=${year}&month=${month}`);
const salesData = await salesRes.json();
if (salesData.success && wholesalerLimits[ws.id]) {
// 실제 월 매출로 current_usage 업데이트
wholesalerLimits[ws.id].current_usage = salesData.total_amount || 0;
wholesalerLimits[ws.id].usage_percent = wholesalerLimits[ws.id].monthly_limit > 0
? Math.round((salesData.total_amount || 0) / wholesalerLimits[ws.id].monthly_limit * 1000) / 10
: 0;
wholesalerLimits[ws.id].remaining = wholesalerLimits[ws.id].monthly_limit - (salesData.total_amount || 0);
}
} catch (e) {
console.warn(`${ws.id} 월매출 조회 실패:`, e);
}
}));
const wsIds = Object.keys(itemsByWholesaler);
// 전체 총액 계산
let grandTotal = 0;
wsIds.forEach(wsId => {
itemsByWholesaler[wsId].forEach(item => {
grandTotal += (item.unit_price || 0) * item.qty;
});
});
let html = `
<div class="multi-ws-summary">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<p style="color:var(--text-secondary);margin:0;">
장바구니에 <b>${wsIds.length}개 도매상</b>의 품목이 있습니다.
</p>
<div style="font-size:18px;font-weight:700;color:var(--accent-emerald);font-family:'JetBrains Mono',monospace;">
${grandTotal > 0 ? '₩' + grandTotal.toLocaleString() : ''}
</div>
</div>
`;
// 각 도매상별 품목 표시
wsIds.forEach(wsId => {
const ws = WHOLESALERS[wsId];
const items = itemsByWholesaler[wsId];
const limit = wholesalerLimits[wsId];
// 도매상별 소계
const wsTotal = items.reduce((sum, item) => sum + (item.unit_price || 0) * item.qty, 0);
// 한도 정보 계산
let limitHtml = '';
if (limit) {
const afterOrder = limit.current_usage + wsTotal;
const afterPercent = (afterOrder / limit.monthly_limit * 100).toFixed(1);
const isOver = afterOrder > limit.monthly_limit;
const isWarning = afterPercent >= (limit.warning_threshold * 100);
limitHtml = `
<div style="margin-top:8px;padding:8px 12px;background:var(--bg-tertiary);border-radius:6px;font-size:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>월 한도</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.monthly_limit/10000).toLocaleString()}만원</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
<span>이번달 사용</span>
<span style="font-family:'JetBrains Mono',monospace;">${(limit.current_usage/10000).toLocaleString()}만원 (${limit.usage_percent}%)</span>
</div>
<div style="display:flex;justify-content:space-between;color:${isOver ? 'var(--accent-red)' : isWarning ? 'var(--accent-amber)' : 'var(--accent-emerald)'};">
<span>주문 후</span>
<span style="font-family:'JetBrains Mono',monospace;font-weight:600;">
${(afterOrder/10000).toLocaleString()}만원 (${afterPercent}%)
${isOver ? ' ⚠️ 초과!' : isWarning ? ' ⚠️' : ' ✓'}
</span>
</div>
</div>
`;
}
html += `
<div class="multi-ws-card ${wsId}">
<div class="multi-ws-header">
<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;margin-right:8px;">
<span class="multi-ws-name">${ws.name}</span>
<span class="multi-ws-count">${items.length}개 품목</span>
${wsTotal > 0 ? `<span style="margin-left:auto;margin-right:12px;font-family:'JetBrains Mono',monospace;font-size:13px;color:var(--accent-cyan);">₩${wsTotal.toLocaleString()}</span>` : ''}
<label class="multi-ws-checkbox">
<input type="checkbox" id="ws_check_${wsId}" checked>
<span>포함</span>
</label>
</div>
<div class="multi-ws-items">
${items.slice(0, 3).map(item => {
const itemAmt = (item.unit_price || 0) * item.qty;
return `<div class="multi-ws-item">· ${item.product_name} (${item.qty}개)${itemAmt > 0 ? ` <span style="color:var(--text-muted);">${itemAmt.toLocaleString()}원</span>` : ''}</div>`;
}).join('')}
${items.length > 3 ? `<div class="multi-ws-item more">... 외 ${items.length - 3}개</div>` : ''}
</div>
${limitHtml}
</div>
`;
});
if (otherItems.length > 0) {
html += `
<div class="multi-ws-card other">
<div class="multi-ws-header">
<span class="multi-ws-icon">📋</span>
<span class="multi-ws-name">기타 (API 미지원)</span>
<span class="multi-ws-count">${otherItems.length}개 품목</span>
</div>
<div class="multi-ws-items">
<div class="multi-ws-item" style="color:var(--text-muted);">클립보드 복사로 처리됩니다</div>
</div>
</div>
`;
}
html += `</div>`;
body.innerHTML = html;
modal.classList.add('show');
}
function closeMultiWholesalerModal() {
document.getElementById('multiWholesalerModal').classList.remove('show');
}
// 선택된 도매상 전체 일괄 처리
async function executeAllWholesalers(dryRun = false, cartOnly = false) {
const wsIds = Object.keys(pendingWholesalerItems);
// 체크된 도매상만 필터
const selectedWsIds = wsIds.filter(wsId => {
const checkbox = document.getElementById(`ws_check_${wsId}`);
return checkbox && checkbox.checked;
});
if (selectedWsIds.length === 0) {
showToast('선택된 도매상이 없습니다', 'error');
return;
}
// 버튼 비활성화
const btnTest = document.getElementById('btnMultiTest');
const btnCart = document.getElementById('btnMultiCart');
const btnReal = document.getElementById('btnMultiReal');
btnTest.disabled = true;
btnCart.disabled = true;
btnReal.disabled = true;
// 진행 상태 표시
if (dryRun) {
btnTest.textContent = '처리 중...';
} else if (cartOnly) {
btnCart.textContent = '처리 중...';
} else {
btnReal.textContent = '처리 중...';
}
const allResults = [];
let totalSuccess = 0;
let totalFailed = 0;
// 각 도매상 순차 처리
for (const wsId of selectedWsIds) {
const ws = WHOLESALERS[wsId];
const items = pendingWholesalerItems[wsId];
showToast(`${ws.icon} ${ws.name} 처리 중... (${selectedWsIds.indexOf(wsId) + 1}/${selectedWsIds.length})`, 'info');
try {
const payload = {
wholesaler_id: wsId,
items: items.map(item => ({
drug_code: item.drug_code,
kd_code: item.geoyoung_code || item.sooin_code || item.drug_code,
internal_code: item.internal_code,
product_name: item.product_name,
manufacturer: item.supplier,
specification: item.specification || '',
order_qty: item.qty,
usage_qty: item.usage_qty || 0,
current_stock: item.current_stock || 0
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun,
cart_only: cartOnly // true=장바구니만, false=즉시주문
};
const response = await fetch('/api/order/quick-submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(180000) // 주문 확정까지 3분
});
const result = await response.json();
result.wholesaler_id = wsId;
allResults.push(result);
if (result.success) {
totalSuccess += result.success_count || 0;
totalFailed += result.failed_count || 0;
} else {
totalFailed += items.length;
}
} catch (err) {
allResults.push({
wholesaler_id: wsId,
success: false,
error: err.message,
success_count: 0,
failed_count: items.length
});
totalFailed += items.length;
}
}
// 기타 품목 클립보드 처리
if (pendingOtherItems.length > 0) {
submitOrderClipboardItems(pendingOtherItems);
}
closeMultiWholesalerModal();
showMultiOrderResultModal(allResults, totalSuccess, totalFailed, dryRun, cartOnly);
// 버튼 복원
btnTest.disabled = false;
btnCart.disabled = false;
btnReal.disabled = false;
btnTest.textContent = '🧪 테스트';
btnCart.textContent = '🛒 장바구니만';
btnReal.textContent = '🚀 전체 즉시주문';
}
// 특정 품목만 클립보드 복사
function submitOrderClipboardItems(items) {
let orderText = `📋 기타 품목 (API 미지원)\n━━━━━━━━━━━━━━━━━━\n`;
items.forEach((item, i) => {
orderText += `${i+1}. ${item.product_name} - ${item.qty}\n`;
});
navigator.clipboard.writeText(orderText);
}
// 다중 도매상 결과 모달
function showMultiOrderResultModal(results, totalSuccess, totalFailed, isDryRun, isCartOnly = false) {
const modal = document.getElementById('orderResultModal');
const content = document.getElementById('orderResultContent');
const header = modal.querySelector('.order-modal-header h3');
const headerDiv = modal.querySelector('.order-modal-header');
headerDiv.style.background = 'linear-gradient(135deg, #059669, #10b981)';
header.innerHTML = '📋 전체 주문 결과';
const statusEmoji = totalFailed === 0 ? '✅' : totalSuccess === 0 ? '❌' : '⚠️';
const modeText = isDryRun ? '[테스트]' : isCartOnly ? '[장바구니]' : '';
let html = `
<div class="result-header ${totalFailed === 0 ? 'success' : 'partial'}">
<span class="result-emoji">${statusEmoji}</span>
<span class="result-title">${modeText} ${results.length}개 도매상 처리 완료</span>
</div>
<div class="result-summary">
<div class="result-stat">
<span class="stat-label">도매상</span>
<span class="stat-value">${results.length}개</span>
</div>
<div class="result-stat">
<span class="stat-label">총 성공</span>
<span class="stat-value success">${totalSuccess}개</span>
</div>
<div class="result-stat">
<span class="stat-label">총 실패</span>
<span class="stat-value ${totalFailed > 0 ? 'failed' : ''}">${totalFailed}개</span>
</div>
</div>
`;
// 각 도매상별 결과
results.forEach(result => {
const wsId = result.wholesaler_id || result.wholesaler;
const ws = WHOLESALERS[wsId] || {icon: '📦', name: wsId, gradient: 'var(--bg-card)'};
const isSuccess = result.success && result.failed_count === 0;
html += `
<div class="multi-result-card" style="margin-bottom:12px;">
<div class="ws-header ${wsId}" style="margin-bottom:8px;">
<span>${ws.icon}</span>
<span class="ws-name">${ws.name}</span>
<span style="margin-left:auto;font-size:12px;color:${isSuccess ? 'var(--accent-emerald)' : 'var(--accent-rose)'};">
${isSuccess ? '✓ 완료' : '⚠ 일부실패'}
</span>
</div>
<div style="padding:0 12px 12px;">
${result.success ? `
<div style="font-size:12px;color:var(--text-secondary);">
성공: ${result.success_count}개 / 실패: ${result.failed_count}
${result.order_no ? ` · 주문번호: ${result.order_no}` : ''}
</div>
` : `
<div style="font-size:12px;color:var(--accent-rose);">
오류: ${result.error || '처리 실패'}
</div>
`}
</div>
</div>
`;
});
if (isDryRun) {
html += `<div class="result-note">💡 테스트 모드입니다. "전체 장바구니 담기" 버튼으로 실제 진행하세요.</div>`;
} else if (totalSuccess > 0) {
html += `<div class="result-note" style="background:rgba(16,185,129,0.1);color:#10b981;">
🛒 각 도매상 장바구니에 담겼습니다. <b>각 사이트에서 최종 확정</b>이 필요합니다.
</div>`;
}
content.innerHTML = html;
modal.classList.add('show');
}
function openOrderConfirmModal(wholesalerId, items) {
currentOrderWholesaler = wholesalerId;
const ws = WHOLESALERS[wholesalerId];
const modal = document.getElementById('orderConfirmModal');
const tbody = document.getElementById('orderConfirmBody');
const headerDiv = modal.querySelector('.order-modal-header');
// 도매상별 헤더 및 본문 텍스트 변경
document.getElementById('orderConfirmTitle').innerHTML = `<img src="${ws.logo}" alt="${ws.name}" style="width:24px;height:24px;object-fit:contain;vertical-align:middle;margin-right:8px;">${ws.name} 주문 확인`;
document.getElementById('orderConfirmWholesaler').textContent = ws.name;
headerDiv.style.background = ws.gradient;
let html = '';
let totalAmount = 0;
items.forEach((item, idx) => {
// 예상 금액 계산 (단가 × 수량)
const unitPrice = item.unit_price || item.price || 0;
const itemAmount = unitPrice * item.qty;
totalAmount += itemAmount;
html += `
<tr>
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.specification || '-'}</td>
<td class="mono" style="text-align:center;">${item.qty}</td>
<td class="mono" style="text-align:right;">${itemAmount > 0 ? itemAmount.toLocaleString() + '원' : '-'}</td>
</tr>`;
});
tbody.innerHTML = html;
document.getElementById('orderConfirmCount').textContent = items.length;
document.getElementById('orderConfirmTotal').innerHTML = totalAmount > 0
? `${totalAmount.toLocaleString()}`
: '<span style="font-size:12px;color:var(--text-muted);">금액 미정</span>';
modal.classList.add('show');
}
function closeOrderConfirmModal() {
document.getElementById('orderConfirmModal').classList.remove('show');
currentOrderWholesaler = null;
}
// ──────────────── 개별 품목 실제 주문 ────────────────
async function orderSingleItem(drugCode) {
const item = cart.find(i => i.drug_code === drugCode);
if (!item) {
showToast('품목을 찾을 수 없습니다', 'error');
return;
}
// 도매상 결정
let wholesaler = 'geoyoung';
for (const [wsId, ws] of Object.entries(WHOLESALERS)) {
if (ws.filterFn(item)) {
wholesaler = wsId;
break;
}
}
const ws = WHOLESALERS[wholesaler];
// 확인 다이얼로그
if (!confirm(`${ws.icon} ${ws.name}에 실제 주문하시겠습니까?\n\n품목: ${item.product_name}\n수량: ${item.qty}\n\n⚠️ 이 작업은 실제 주문을 진행합니다!`)) {
return;
}
showToast(`📤 ${item.product_name} 주문 중...`, 'info');
// 🔍 디버그: 장바구니 아이템 확인
console.log('[DEBUG] orderSingleItem - cart item:', JSON.stringify(item, null, 2));
console.log('[DEBUG] internal_code:', item.internal_code);
try {
const payload = {
wholesaler_id: wholesaler,
items: [{
drug_code: item.drug_code,
kd_code: item.geoyoung_code || item.sooin_code || item.drug_code,
internal_code: item.internal_code,
product_name: item.product_name,
manufacturer: item.supplier,
specification: item.specification || '',
order_qty: item.qty,
usage_qty: item.usage_qty || 0,
current_stock: item.current_stock || 0
}],
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: false,
cart_only: false // 실제 주문까지!
};
const response = await fetch('/api/order/quick-submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await response.json();
if (result.success && result.success_count > 0) {
showToast(`${item.product_name} 주문 완료!`, 'success');
// 장바구니에서 제거
removeFromCart(drugCode);
} else {
const errorMsg = result.results?.[0]?.result_message || result.error || '주문 실패';
showToast(`${errorMsg}`, 'error');
}
} catch (err) {
showToast(`❌ 오류: ${err.message}`, 'error');
}
}
async function executeOrder(dryRun = true, cartOnly = false) {
const wholesaler = currentOrderWholesaler || 'geoyoung';
// 해당 도매상 품목 필터
const ws = WHOLESALERS[wholesaler];
const items = cart.filter(ws.filterFn);
if (items.length === 0) {
showToast(`${ws.name} 품목이 없습니다`, 'error');
return;
}
// 버튼 비활성화
const btnTest = document.getElementById('btnOrderTest');
const btnCart = document.getElementById('btnOrderCart');
const btnReal = document.getElementById('btnOrderReal');
btnTest.disabled = true;
btnCart.disabled = true;
btnReal.disabled = true;
// 진행 상태 표시
if (dryRun) {
btnTest.textContent = '처리 중...';
} else if (cartOnly) {
btnCart.textContent = '처리 중...';
} else {
btnReal.textContent = '처리 중...';
}
try {
const payload = {
wholesaler_id: wholesaler,
items: items.map(item => ({
drug_code: item.drug_code,
kd_code: item.geoyoung_code || item.sooin_code || item.drug_code,
internal_code: item.internal_code,
product_name: item.product_name,
manufacturer: item.supplier,
specification: item.specification || '',
order_qty: item.qty,
usage_qty: item.usage_qty || 0,
current_stock: item.current_stock || 0
})),
reference_period: `${document.getElementById('startDate').value}~${document.getElementById('endDate').value}`,
dry_run: dryRun,
cart_only: cartOnly // true=장바구니만, false=즉시주문
};
// 타임아웃 설정
const timeoutMs = dryRun ? 120000 : 180000; // 테스트 2분, 실제 3분
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch('/api/order/quick-submit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
signal: controller.signal
});
clearTimeout(timeoutId);
const result = await response.json();
closeOrderConfirmModal();
if (result.success) {
showOrderResultModal(result);
} else {
showToast(`❌ 주문 실패: ${result.error}`, 'error');
}
} catch (err) {
showToast(`❌ 오류: ${err.message}`, 'error');
} finally {
btnTest.disabled = false;
btnCart.disabled = false;
btnReal.disabled = false;
btnTest.textContent = '🧪 테스트';
btnCart.textContent = '🛒 장바구니만';
btnReal.textContent = '🚀 즉시주문';
}
}
function showOrderResultModal(result) {
const modal = document.getElementById('orderResultModal');
const content = document.getElementById('orderResultContent');
const header = modal.querySelector('.order-modal-header h3');
const headerDiv = modal.querySelector('.order-modal-header');
// 도매상 정보 가져오기
const wholesalerId = result.wholesaler || currentOrderWholesaler || 'geoyoung';
const ws = WHOLESALERS[wholesalerId] || WHOLESALERS.geoyoung;
// 결과 모달 헤더 스타일 적용
header.innerHTML = `${ws.icon} ${ws.name} 주문 결과`;
headerDiv.style.background = ws.gradient;
const isDryRun = result.dry_run;
const isCartOnly = result.cart_only;
const statusEmoji = result.failed_count === 0 ? '✅' : result.success_count === 0 ? '❌' : '⚠️';
// 결과 타이틀
let resultTitle = '';
if (isDryRun) {
resultTitle = `[테스트] ${ws.name} 시뮬레이션 완료`;
} else if (isCartOnly) {
resultTitle = `${ws.name} 장바구니 담기 ${result.failed_count === 0 ? '완료' : '처리됨'}`;
} else {
resultTitle = `${ws.name} 주문 ${result.failed_count === 0 ? '완료' : '처리됨'}`;
}
let html = `
<div class="result-header ${result.failed_count === 0 ? 'success' : 'partial'}">
<span class="result-emoji">${statusEmoji}</span>
<span class="result-title">${resultTitle}</span>
</div>
<div class="result-summary">
<div class="result-stat">
<span class="stat-label">도매상</span>
<span class="stat-value">${ws.icon} ${ws.name}</span>
</div>
<div class="result-stat">
<span class="stat-label">주문번호</span>
<span class="stat-value mono">${result.order_no}</span>
</div>
<div class="result-stat">
<span class="stat-label">성공</span>
<span class="stat-value success">${result.success_count}개</span>
</div>
<div class="result-stat">
<span class="stat-label">실패</span>
<span class="stat-value ${result.failed_count > 0 ? 'failed' : ''}">${result.failed_count}개</span>
</div>
</div>
<table class="result-table">
<thead><tr><th>품목</th><th>수량</th><th>결과</th></tr></thead>
<tbody>`;
(result.results || []).forEach(item => {
const isSuccess = item.status === 'success';
html += `
<tr class="${isSuccess ? '' : 'failed-row'}">
<td>${escapeHtml(item.product_name)}</td>
<td class="mono">${item.order_qty}</td>
<td class="${isSuccess ? 'result-ok' : 'result-fail'}">
${isSuccess ? '✓' : '✗'} ${item.result_code}
${item.result_message ? `<br><small>${escapeHtml(item.result_message)}</small>` : ''}
${item.price ? `<br><small style="color:var(--accent-amber)">단가: ${item.price.toLocaleString()}원</small>` : ''}
</td>
</tr>`;
});
html += '</tbody></table>';
if (isDryRun && result.success_count > 0) {
html += `<div class="result-note">💡 테스트 모드입니다. "장바구니 담기" 버튼으로 실제 진행하세요.</div>`;
}
// 장바구니만 담은 경우 안내
if (!isDryRun && isCartOnly && result.success_count > 0) {
html += `<div class="result-note" style="background:rgba(6,182,212,0.1);color:#06b6d4;">
🛒 ${ws.name} 장바구니에 담겼습니다. <b>사이트에서 최종 확정</b>이 필요합니다.
${wholesalerId === 'geoyoung' ? '<br>개별 품목은 📤주문 버튼으로 바로 주문할 수 있습니다.' : ''}
</div>`;
}
// 도매상별 안내
if (!isDryRun && result.note) {
const noteColors = {
geoyoung: 'rgba(6,182,212,0.1);color:#06b6d4',
sooin: 'rgba(168,85,247,0.1);color:#a855f7',
baekje: 'rgba(245,158,11,0.1);color:#f59e0b'
};
const noteStyle = noteColors[wholesalerId] || 'rgba(100,100,100,0.1);color:#888';
html += `<div class="result-note" style="background:${noteStyle};">📌 ${result.note}</div>`;
}
content.innerHTML = html;
modal.classList.add('show');
}
function closeOrderResultModal() {
document.getElementById('orderResultModal').classList.remove('show');
}
// 기존 클립보드 방식 (지오영 아닌 품목용)
function submitOrderClipboard() {
if (cart.length === 0) return;
const bySupplier = {};
cart.forEach(item => {
const sup = item.supplier || '미지정';
if (!bySupplier[sup]) bySupplier[sup] = [];
bySupplier[sup].push(item);
});
let orderText = `💊 청춘약국 전문의약품 발주서\n`;
orderText += `━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `📅 작성일: ${new Date().toLocaleDateString('ko-KR')}\n`;
orderText += `📋 조회기간: ${document.getElementById('startDate').value} ~ ${document.getElementById('endDate').value}\n\n`;
Object.entries(bySupplier).forEach(([supplier, items]) => {
orderText += `\n${supplier}\n`;
orderText += `─────────────────────────\n`;
items.forEach((item, i) => {
orderText += `${i+1}. ${item.product_name}\n`;
orderText += ` 주문수량: ${item.qty}\n`;
});
});
orderText += `\n━━━━━━━━━━━━━━━━━━━━━━━━\n`;
orderText += `${cart.length}개 품목\n`;
navigator.clipboard.writeText(orderText).then(() => {
showToast('📋 주문서가 클립보드에 복사되었습니다!', 'success');
}).catch(() => {
const ta = document.createElement('textarea');
ta.value = orderText;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
showToast('📋 주문서가 클립보드에 복사되었습니다!', 'success');
});
}
// ──────────────── 유틸 ────────────────
function formatPrice(num) {
return new Intl.NumberFormat('ko-KR').format(num);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
}
// 환자 이름 포맷 (오늘 사용 환자 강조)
function formatPatientNames(allNames, todayNames) {
if (!allNames) return '';
if (!todayNames) return escapeHtml(allNames);
const todaySet = new Set(todayNames.split(', ').map(n => n.trim()));
const names = allNames.split(', ');
return names.map(name => {
const trimmed = name.trim();
if (todaySet.has(trimmed)) {
return `<strong class="today-patient">${escapeHtml(trimmed)}</strong>`;
}
return escapeHtml(trimmed);
}).join(', ');
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'toast ' + type + ' show';
setTimeout(() => toast.classList.remove('show'), 3000);
}
// 엔터키 검색
document.getElementById('searchInput').addEventListener('keypress', e => {
if (e.key === 'Enter') loadUsageData();
});
// ──────────────── 도매상 재고 조회 (지오영 + 수인 + 백제) ────────────────
let currentWholesaleItem = null;
window.wholesaleItems = { geoyoung: [], sooin: [], baekje: [] };
function openWholesaleModal(idx) {
const item = usageData[idx];
if (!item) return;
currentWholesaleItem = item;
// 모달 열기
document.getElementById('geoModalProductName').textContent = item.product_name;
document.getElementById('geoModalDrugCode').textContent = item.drug_code;
document.getElementById('geoModalUsage').textContent = item.total_dose.toLocaleString() + '개';
document.getElementById('geoModalStock').textContent = item.current_stock.toLocaleString() + '개';
document.getElementById('geoyoungModal').classList.add('show');
// 로딩 표시
document.getElementById('geoResultBody').innerHTML = `
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>도매상 재고 조회 중... (지오영 + 수인 + 백제)</div>
</div>`;
document.getElementById('geoSearchKeyword').style.display = 'none';
// 세 도매상 동시 호출
searchAllWholesalers(item.drug_code, item.product_name);
}
function closeGeoyoungModal() {
document.getElementById('geoyoungModal').classList.remove('show');
currentWholesaleItem = null;
}
async function searchAllWholesalers(kdCode, productName) {
const resultBody = document.getElementById('geoResultBody');
// 세 도매상 동시 호출
const [geoResult, sooinResult, baekjeResult] = await Promise.all([
searchGeoyoungAPI(kdCode, productName),
searchSooinAPI(kdCode),
searchBaekjeAPI(kdCode)
]);
// 결과 저장
window.wholesaleItems = {
geoyoung: geoResult.items || [],
sooin: sooinResult.items || [],
baekje: baekjeResult.items || []
};
// 통합 렌더링
renderWholesaleResults(geoResult, sooinResult, baekjeResult);
}
async function searchGeoyoungAPI(kdCode, productName) {
try {
// 1차: 보험코드로 검색
let response = await fetch(`/api/geoyoung/stock?kd_code=${encodeURIComponent(kdCode)}`);
let data = await response.json();
// 결과 없으면 성분명으로 재검색
if (data.success && data.count === 0) {
response = await fetch(`/api/geoyoung/stock-by-name?product_name=${encodeURIComponent(productName)}`);
data = await response.json();
}
return data;
} catch (err) {
return { success: false, error: err.message, items: [] };
}
}
async function searchSooinAPI(kdCode) {
try {
const response = await fetch(`/api/sooin/stock?keyword=${encodeURIComponent(kdCode)}`);
const data = await response.json();
return data;
} catch (err) {
return { success: false, error: err.message, items: [] };
}
}
async function searchBaekjeAPI(kdCode) {
try {
const response = await fetch(`/api/baekje/stock?keyword=${encodeURIComponent(kdCode)}`);
const data = await response.json();
return data;
} catch (err) {
return { success: false, error: err.message, items: [] };
}
}
function renderWholesaleResults(geoResult, sooinResult, baekjeResult) {
const resultBody = document.getElementById('geoResultBody');
const geoItems = geoResult.items || [];
const sooinItems = sooinResult.items || [];
const baekjeItems = (baekjeResult && baekjeResult.items) || [];
// 재고 있는 것 먼저 정렬
geoItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
sooinItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
baekjeItems.sort((a, b) => (b.stock > 0 ? 1 : 0) - (a.stock > 0 ? 1 : 0) || b.stock - a.stock);
let html = '';
// ═══════ 지오영 섹션 ═══════
html += `<div class="ws-section">
<div class="ws-header geoyoung">
<img class="ws-logo" src="/static/img/logo_geoyoung.ico" alt="지오영">
<span class="ws-name">지오영</span>
<span class="ws-count">${geoItems.length}건</span>
</div>`;
if (geoItems.length > 0) {
html += `<table class="geo-table">
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
<tbody>`;
geoItems.forEach((item, idx) => {
const hasStock = item.stock > 0;
const priceText = item.price ? item.price.toLocaleString() + '원' : '-';
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.product_name)}</span>
<span class="geo-code">${item.insurance_code}</span>
</div>
</td>
<td class="geo-spec">${item.specification}</td>
<td class="geo-price">${priceText}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>${hasStock ? `<button class="geo-add-btn" onclick="addToCartFromWholesale('geoyoung', ${idx})">담기</button>` : ''}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
}
html += '</div>';
// ═══════ 수인약품 섹션 ═══════
html += `<div class="ws-section">
<div class="ws-header sooin">
<img class="ws-logo" src="/static/img/logo_sooin.svg" alt="수인">
<span class="ws-name">수인약품</span>
<span class="ws-count">${sooinItems.length}건</span>
</div>`;
if (sooinItems.length > 0) {
html += `<table class="geo-table">
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
<tbody>`;
sooinItems.forEach((item, idx) => {
const hasStock = item.stock > 0;
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.name)}</span>
<span class="geo-code">${item.code} · ${item.manufacturer || ''}</span>
</div>
</td>
<td class="geo-spec">${item.spec || '-'}</td>
<td class="geo-price">${item.price ? item.price.toLocaleString() + '원' : '-'}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>${hasStock ? `<button class="geo-add-btn sooin" onclick="addToCartFromWholesale('sooin', ${idx})">담기</button>` : ''}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
}
html += '</div>';
// ═══════ 백제약품 섹션 ═══════
html += `<div class="ws-section">
<div class="ws-header baekje">
<span class="ws-logo">💉</span>
<span class="ws-name">백제약품</span>
<span class="ws-count">${baekjeItems.length}건</span>
</div>`;
if (baekjeItems.length > 0) {
html += `<table class="geo-table">
<thead><tr><th>제품명</th><th>규격</th><th>단가</th><th>재고</th><th></th></tr></thead>
<tbody>`;
baekjeItems.forEach((item, idx) => {
const hasStock = item.stock > 0;
html += `
<tr class="${hasStock ? '' : 'no-stock'}">
<td>
<div class="geo-product">
<span class="geo-name">${escapeHtml(item.name)}</span>
<span class="geo-code">${item.insurance_code || ''} · ${item.manufacturer || ''}</span>
</div>
</td>
<td class="geo-spec">${item.spec || '-'}</td>
<td class="geo-price">${item.price ? item.price.toLocaleString() + '원' : '-'}</td>
<td class="geo-stock ${hasStock ? 'in-stock' : 'out-stock'}">${item.stock}</td>
<td>${hasStock ? `<button class="geo-add-btn baekje" onclick="addToCartFromWholesale('baekje', ${idx})">담기</button>` : ''}</td>
</tr>`;
});
html += '</tbody></table>';
} else {
html += `<div class="ws-empty">📭 검색 결과 없음</div>`;
}
html += '</div>';
resultBody.innerHTML = html;
}
function addToCartFromWholesale(wholesaler, idx) {
const items = window.wholesaleItems[wholesaler];
const item = items[idx];
if (!item || !currentWholesaleItem) return;
// 규격에서 수량 추출
const spec = wholesaler === 'geoyoung' ? item.specification : (item.spec || '');
const specMatch = spec.match(/(\d+)/);
const specQty = specMatch ? parseInt(specMatch[1]) : 1;
// 필요 수량 계산
const needed = currentWholesaleItem.total_dose;
const suggestedQty = Math.ceil(needed / specQty);
const supplierNames = { geoyoung: '지오영', sooin: '수인약품', baekje: '백제약품' };
const supplierName = supplierNames[wholesaler] || wholesaler;
const productName = wholesaler === 'geoyoung' ? item.product_name : item.name;
const qty = prompt(`[${supplierName}] 주문 수량 (${spec} 기준)\n\n필요량: ${needed}\n규격: ${specQty}개/단위\n추천: ${suggestedQty}단위 (${suggestedQty * specQty}개)`, suggestedQty);
if (!qty || isNaN(qty)) return;
// 장바구니에 추가
const unitPrice = item.price || item.unit_price || 0;
const cartItem = {
drug_code: currentWholesaleItem.drug_code,
product_name: productName,
supplier: supplierName,
qty: parseInt(qty),
specification: spec,
wholesaler: wholesaler,
internal_code: item.internal_code,
geoyoung_code: wholesaler === 'geoyoung' ? item.insurance_code : null,
sooin_code: wholesaler === 'sooin' ? item.code : null,
baekje_code: wholesaler === 'baekje' ? item.internal_code : null,
unit_price: unitPrice // 💰 단가 추가
};
// 🔍 디버그: 장바구니 추가 시 internal_code 확인
console.log('[DEBUG] addToCartFromWholesale');
console.log('[DEBUG] wholesaler item:', JSON.stringify(item, null, 2));
console.log('[DEBUG] cartItem internal_code:', cartItem.internal_code);
// 기존 항목 체크 (같은 도매상 + 같은 규격)
const existing = cart.find(c =>
c.drug_code === currentWholesaleItem.drug_code &&
c.specification === spec &&
c.wholesaler === wholesaler
);
if (existing) {
existing.qty = parseInt(qty);
} else {
cart.push(cartItem);
}
updateCartUI();
closeGeoyoungModal();
showToast(`✅ [${supplierName}] ${productName} (${spec}) ${qty}개 추가`, 'success');
}
// 하위 호환 (기존 함수명 유지)
function openGeoyoungModal(idx) { openWholesaleModal(idx); }
function addGeoyoungToCart(idx) { addToCartFromWholesale('geoyoung', idx); }
// 테이블 행 더블클릭으로 도매상 모달 열기
document.addEventListener('dblclick', function(e) {
const row = e.target.closest('tr[data-idx]');
if (row) {
const idx = parseInt(row.dataset.idx);
openWholesaleModal(idx);
}
});
</script>
<!-- 도매상 재고 조회 모달 (지오영 + 수인) -->
<div class="geo-modal" id="geoyoungModal">
<div class="geo-modal-content">
<div class="geo-modal-header">
<h3>📦 도매상 재고 조회</h3>
<button class="geo-close" onclick="closeGeoyoungModal()"></button>
</div>
<div class="geo-modal-info">
<div class="geo-info-row">
<span class="geo-label">약품명</span>
<span class="geo-value" id="geoModalProductName">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">보험코드</span>
<span class="geo-value mono" id="geoModalDrugCode">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">사용량</span>
<span class="geo-value highlight" id="geoModalUsage">-</span>
</div>
<div class="geo-info-row">
<span class="geo-label">현재고</span>
<span class="geo-value" id="geoModalStock">-</span>
</div>
</div>
<div class="geo-search-info" id="geoSearchKeyword" style="display:none;"></div>
<div class="geo-result" id="geoResultBody">
<div class="geo-loading">
<div class="loading-spinner"></div>
<div>지오영 재고 조회 중...</div>
</div>
</div>
</div>
</div>
<style>
/* 지오영 모달 스타일 */
.geo-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 500;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.geo-modal.show { display: flex; }
.geo-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-cyan);
box-shadow: 0 8px 32px rgba(6, 182, 212, 0.3);
}
.geo-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #0891b2, var(--accent-cyan));
display: flex;
justify-content: space-between;
align-items: center;
}
.geo-modal-header h3 {
font-size: 16px;
font-weight: 700;
}
.geo-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px;
height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.geo-modal-info {
padding: 16px 20px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
}
.geo-info-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
}
.geo-label {
color: var(--text-muted);
font-size: 12px;
}
.geo-value {
font-weight: 600;
font-size: 13px;
}
.geo-value.mono {
font-family: 'JetBrains Mono', monospace;
}
.geo-value.highlight {
color: var(--accent-cyan);
}
.geo-search-info {
padding: 8px 20px;
background: rgba(6, 182, 212, 0.1);
font-size: 12px;
color: var(--accent-cyan);
}
.geo-result {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.geo-loading, .geo-error, .geo-empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.geo-error { color: var(--accent-rose); }
.geo-table {
width: 100%;
border-collapse: collapse;
}
.geo-table th {
padding: 10px 12px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.geo-table td {
padding: 12px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.geo-table tr:hover {
background: rgba(255,255,255,0.02);
}
.geo-table tr.no-stock {
opacity: 0.5;
}
.geo-product {
display: flex;
flex-direction: column;
gap: 2px;
}
.geo-name {
font-weight: 500;
}
.geo-code {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--text-muted);
}
.geo-spec {
font-weight: 600;
color: var(--accent-amber);
}
.geo-stock {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
text-align: center;
}
.geo-stock.in-stock { color: var(--accent-emerald); }
.geo-stock.out-stock { color: var(--text-muted); }
.geo-add-btn {
padding: 6px 12px;
background: var(--accent-cyan);
border: none;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.geo-add-btn:hover {
background: #0891b2;
transform: scale(1.05);
}
.geo-add-btn.sooin {
background: var(--accent-purple);
}
.geo-add-btn.sooin:hover {
background: #7c3aed;
}
.geo-add-btn.baekje {
background: var(--accent-amber);
}
.geo-add-btn.baekje:hover {
background: #d97706;
}
.geo-price {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
}
/* 도매상 섹션 구분 */
.ws-section {
margin-bottom: 20px;
}
.ws-section:last-child {
margin-bottom: 0;
}
.ws-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 8px;
font-weight: 600;
}
.ws-header.geoyoung {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.2), rgba(8, 145, 178, 0.1));
border-left: 3px solid var(--accent-cyan);
}
.ws-header.sooin {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.1));
border-left: 3px solid var(--accent-purple);
}
.ws-header.baekje {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.2), rgba(217, 119, 6, 0.1));
border-left: 3px solid var(--accent-amber);
}
.ws-logo {
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: contain;
}
.ws-name {
flex: 1;
font-size: 14px;
}
.ws-count {
font-size: 12px;
color: var(--text-muted);
background: rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: 10px;
}
.ws-empty {
text-align: center;
padding: 20px;
color: var(--text-muted);
font-size: 13px;
}
/* 주문 확인 모달 */
.order-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 600;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.order-modal.show { display: flex; }
.order-modal-content {
background: var(--bg-secondary);
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
border: 1px solid var(--accent-violet);
box-shadow: 0 8px 32px rgba(139, 92, 246, 0.3);
}
.order-modal-header {
padding: 16px 20px;
background: linear-gradient(135deg, #7c3aed, var(--accent-violet));
display: flex;
justify-content: space-between;
align-items: center;
}
.order-modal-header h3 { font-size: 16px; font-weight: 700; }
.order-close {
background: rgba(255,255,255,0.2);
border: none;
width: 28px; height: 28px;
border-radius: 6px;
color: #fff;
cursor: pointer;
}
.order-modal-body {
padding: 20px;
overflow-y: auto;
}
.order-confirm-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.order-confirm-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.order-confirm-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.order-confirm-table .mono {
font-family: 'JetBrains Mono', monospace;
}
.order-modal-footer {
padding: 16px 20px;
background: var(--bg-card);
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn-order-test {
padding: 10px 20px;
background: var(--accent-amber);
border: none;
border-radius: 8px;
color: #000;
font-weight: 600;
cursor: pointer;
}
.btn-order-real {
padding: 10px 20px;
background: var(--accent-emerald);
border: none;
border-radius: 8px;
color: #fff;
font-weight: 600;
cursor: pointer;
}
.btn-order-test:disabled, .btn-order-real:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 주문 결과 모달 */
.result-header {
text-align: center;
padding: 20px;
border-radius: 12px;
margin-bottom: 16px;
}
.result-header.success { background: rgba(16, 185, 129, 0.1); }
.result-header.partial { background: rgba(245, 158, 11, 0.1); }
.result-emoji { font-size: 32px; display: block; margin-bottom: 8px; }
.result-title { font-size: 16px; font-weight: 600; }
.result-summary {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.result-stat {
flex: 1;
text-align: center;
padding: 12px;
background: var(--bg-card);
border-radius: 8px;
}
.stat-label { display: block; font-size: 11px; color: var(--text-muted); }
.stat-value { display: block; font-size: 16px; font-weight: 700; margin-top: 4px; }
.stat-value.success { color: var(--accent-emerald); }
.stat-value.failed { color: var(--accent-rose); }
.result-table {
width: 100%;
border-collapse: collapse;
}
.result-table th {
padding: 8px;
font-size: 11px;
color: var(--text-muted);
text-align: left;
border-bottom: 1px solid var(--border);
}
.result-table td {
padding: 10px 8px;
font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.result-table .failed-row { background: rgba(244, 63, 94, 0.1); }
.result-ok { color: var(--accent-emerald); }
.result-fail { color: var(--accent-rose); }
.result-note {
margin-top: 16px;
padding: 12px;
background: rgba(6, 182, 212, 0.1);
border-radius: 8px;
font-size: 12px;
color: var(--accent-cyan);
}
/* 다중 도매상 선택 모달 스타일 */
.multi-ws-summary {
padding: 0 4px;
}
.multi-ws-card {
background: var(--bg-card);
border-radius: 12px;
margin-bottom: 12px;
overflow: hidden;
border: 1px solid var(--border);
}
.multi-ws-card.geoyoung {
border-left: 3px solid var(--accent-cyan);
}
.multi-ws-card.sooin {
border-left: 3px solid var(--accent-purple);
}
.multi-ws-card.baekje {
border-left: 3px solid var(--accent-amber);
}
.multi-ws-card.other {
border-left: 3px solid var(--text-muted);
opacity: 0.7;
}
.multi-ws-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: rgba(255,255,255,0.02);
}
.multi-ws-icon {
font-size: 20px;
}
.multi-ws-name {
font-weight: 600;
font-size: 14px;
flex: 1;
}
.multi-ws-count {
font-size: 12px;
color: var(--text-muted);
background: rgba(255,255,255,0.1);
padding: 3px 10px;
border-radius: 12px;
}
.multi-ws-checkbox {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--accent-emerald);
cursor: pointer;
}
.multi-ws-checkbox input {
width: 18px;
height: 18px;
accent-color: var(--accent-emerald);
}
.multi-ws-items {
padding: 10px 16px 14px;
border-top: 1px solid rgba(255,255,255,0.05);
}
.multi-ws-item {
font-size: 12px;
color: var(--text-secondary);
padding: 3px 0;
}
.multi-ws-item.more {
color: var(--text-muted);
font-style: italic;
}
</style>
<!-- 주문 확인 모달 -->
<div class="order-modal" id="orderConfirmModal">
<div class="order-modal-content" style="max-width:600px;">
<div class="order-modal-header">
<h3 id="orderConfirmTitle">🏭 지오영 주문 확인</h3>
<button class="order-close" onclick="closeOrderConfirmModal()"></button>
</div>
<div class="order-modal-body">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<p style="color:var(--text-secondary);margin:0;">
<span id="orderConfirmCount">0</span>개 품목을 <span id="orderConfirmWholesaler">지오영</span> 장바구니에 담습니다.
</p>
<div id="orderConfirmTotal" style="font-size:18px;font-weight:700;color:var(--accent-cyan);font-family:'JetBrains Mono',monospace;">
₩0
</div>
</div>
<p style="margin-bottom:12px;font-size:12px;color:var(--accent-amber);">
⚠️ 장바구니 담기만 진행됩니다. 도매상 사이트에서 최종 확정이 필요합니다.
</p>
<table class="order-confirm-table">
<thead><tr><th>품목명</th><th>규격</th><th>수량</th><th style="text-align:right;">예상금액</th></tr></thead>
<tbody id="orderConfirmBody"></tbody>
</table>
</div>
<div class="order-modal-footer" style="gap:8px;">
<button class="btn-order-test" id="btnOrderTest" onclick="executeOrder(true, false)">🧪 테스트</button>
<button class="btn-order-cart" id="btnOrderCart" onclick="executeOrder(false, true)" style="background:linear-gradient(135deg, #0891b2, #06b6d4);">🛒 장바구니만</button>
<button class="btn-order-real" id="btnOrderReal" onclick="executeOrder(false, false)">🚀 즉시주문</button>
</div>
</div>
</div>
<!-- 주문 결과 모달 -->
<div class="order-modal" id="orderResultModal">
<div class="order-modal-content" style="max-width:600px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #059669, var(--accent-emerald));">
<h3>📋 주문 결과</h3>
<button class="order-close" onclick="closeOrderResultModal()"></button>
</div>
<div class="order-modal-body" id="orderResultContent">
</div>
<div class="order-modal-footer">
<button class="btn-order-test" onclick="closeOrderResultModal()">닫기</button>
</div>
</div>
</div>
<!-- 다중 도매상 선택 모달 -->
<div class="order-modal" id="multiWholesalerModal">
<div class="order-modal-content" style="max-width:650px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #059669, #10b981);">
<h3>🛒 전체 도매상 주문</h3>
<button class="order-close" onclick="closeMultiWholesalerModal()"></button>
</div>
<div class="order-modal-body" id="multiWholesalerBody">
</div>
<div class="order-modal-footer" style="gap:8px;">
<button class="btn-order-test" id="btnMultiTest" onclick="executeAllWholesalers(true, false)">🧪 테스트</button>
<button class="btn-order-cart" id="btnMultiCart" onclick="executeAllWholesalers(false, true)" style="background:linear-gradient(135deg, #0891b2, #06b6d4);">🛒 장바구니만</button>
<button class="btn-order-real" id="btnMultiReal" onclick="executeAllWholesalers(false, false)">🚀 전체 즉시주문</button>
</div>
</div>
</div>
<!-- 잔고 조회 모달 -->
<div class="order-modal" id="balanceModal">
<div class="order-modal-content" style="max-width:500px;">
<div class="order-modal-header" style="background:linear-gradient(135deg, #7c3aed, #a855f7);">
<h3>💰 도매상 잔고 현황</h3>
<button class="order-close" onclick="closeBalanceModal()"></button>
</div>
<div class="order-modal-body" id="balanceContent">
<div class="loading-state">
<div class="loading-spinner"></div>
<div>잔고 조회 중...</div>
</div>
</div>
<div class="order-modal-footer">
<button class="btn-order-test" onclick="loadBalances()">🔄 새로고침</button>
<button class="btn-order-real" onclick="closeBalanceModal()">닫기</button>
</div>
</div>
</div>
<style>
/* 잔고 모달 스타일 */
.balance-grid {
display: flex;
flex-direction: column;
gap: 16px;
}
.balance-card {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 16px;
}
.balance-card.loading {
opacity: 0.6;
}
.balance-card.error {
border-color: var(--accent-rose);
background: rgba(244, 63, 94, 0.1);
}
.balance-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
flex-shrink: 0;
}
.balance-icon.baekje { background: linear-gradient(135deg, #d97706, #f59e0b); }
.balance-icon.geoyoung { background: linear-gradient(135deg, #0891b2, #06b6d4); }
.balance-icon.sooin { background: linear-gradient(135deg, #7c3aed, #a855f7); }
.balance-logo-wrap {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.balance-logo {
width: 40px;
height: 40px;
object-fit: contain;
border-radius: 8px;
}
.balance-info {
flex: 1;
min-width: 0;
}
.balance-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.balance-detail {
font-size: 11px;
color: var(--text-muted);
}
.balance-amount {
text-align: right;
}
.balance-value {
font-size: 22px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.balance-value.baekje { color: #f59e0b; }
.balance-value.geoyoung { color: #06b6d4; }
.balance-value.sooin { color: #a855f7; }
.balance-label {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.balance-summary {
background: linear-gradient(135deg, rgba(168, 85, 247, 0.1), rgba(6, 182, 212, 0.1));
border-radius: 12px;
padding: 20px;
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 12px;
gap: 16px;
}
.summary-item {
text-align: center;
flex: 1;
}
.summary-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 8px;
}
.summary-value {
font-size: 24px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
}
.summary-value.sales {
color: #10b981;
}
.summary-value.balance {
background: linear-gradient(135deg, #a855f7, #06b6d4);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.summary-divider {
width: 1px;
height: 50px;
background: var(--border);
}
.balance-updated {
text-align: center;
font-size: 11px;
color: var(--text-muted);
margin-top: 12px;
}
</style>
<script>
// ──────────────── 잔고 조회 ────────────────
function openBalanceModal() {
document.getElementById('balanceModal').classList.add('show');
loadBalances();
}
function closeBalanceModal() {
document.getElementById('balanceModal').classList.remove('show');
}
// 도매상 설정 (중앙 관리)
const WHOLESALER_CONFIG = {
baekje: {
id: 'baekje', name: '백제약품', icon: '💉',
logo: '/static/img/logo_baekje.svg',
color: '#f59e0b',
balanceApi: '/api/baekje/balance',
salesApi: '/api/baekje/monthly-sales'
},
geoyoung: {
id: 'geoyoung', name: '지오영', icon: '🏭',
logo: '/static/img/logo_geoyoung.ico',
color: '#06b6d4',
balanceApi: '/api/geoyoung/balance',
salesApi: '/api/geoyoung/monthly-sales'
},
sooin: {
id: 'sooin', name: '수인약품', icon: '💊',
logo: '/static/img/logo_sooin.svg',
color: '#a855f7',
balanceApi: '/api/sooin/balance',
salesApi: '/api/sooin/monthly-sales'
}
};
const WHOLESALER_ORDER = ['baekje', 'geoyoung', 'sooin'];
async function loadBalances() {
const content = document.getElementById('balanceContent');
content.innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<div>잔고 및 매출 조회 중...</div>
</div>`;
const wholesalers = WHOLESALER_ORDER.map(id => WHOLESALER_CONFIG[id]);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const balanceResults = {};
const salesResults = {};
let totalBalance = 0;
let totalSales = 0;
// 병렬로 잔고 + 월간매출 조회
await Promise.all(wholesalers.flatMap(ws => [
// 잔고 조회
fetch(ws.balanceApi).then(r => r.json()).then(data => {
balanceResults[ws.id] = data;
if (data.success && data.balance) totalBalance += data.balance;
}).catch(err => {
balanceResults[ws.id] = { success: false, error: err.message };
}),
// 월간 매출 조회
fetch(`${ws.salesApi}?year=${year}&month=${month}`).then(r => r.json()).then(data => {
salesResults[ws.id] = data;
if (data.success && data.total_amount) totalSales += data.total_amount;
}).catch(err => {
salesResults[ws.id] = { success: false, error: err.message };
})
]));
// 결과 렌더링
let html = '<div class="balance-grid">';
wholesalers.forEach(ws => {
const balData = balanceResults[ws.id] || {};
const salesData = salesResults[ws.id] || {};
const isError = !balData.success;
const balance = balData.balance || 0;
const monthlySales = salesData.total_amount || 0;
const monthlyPaid = salesData.total_paid || 0;
html += `
<div class="balance-card ${isError ? 'error' : ''}">
<div class="balance-logo-wrap">
<img src="${ws.logo}" alt="${ws.name}" class="balance-logo"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="balance-icon ${ws.id}" style="display:none;">${ws.icon}</div>
</div>
<div class="balance-info">
<div class="balance-name">${ws.name}</div>
<div class="balance-detail">
${isError
? `${balData.error || '조회 실패'}`
: `${month}월 매출: <span style="color:${ws.color};font-weight:600;">${monthlySales.toLocaleString()}원</span>
${monthlyPaid > 0 ? ` · 입금: ${monthlyPaid.toLocaleString()}` : ''}`}
</div>
</div>
<div class="balance-amount">
<div class="balance-value ${ws.id}">
${isError ? '-' : balance.toLocaleString()}
</div>
<div class="balance-label">현재 잔고</div>
</div>
</div>`;
});
html += `
<div class="balance-summary">
<div class="summary-item">
<div class="summary-label">📊 ${month}월 총 주문</div>
<div class="summary-value sales">${totalSales.toLocaleString()}원</div>
</div>
<div class="summary-divider"></div>
<div class="summary-item">
<div class="summary-label">💰 총 미수금</div>
<div class="summary-value balance">${totalBalance.toLocaleString()}원</div>
</div>
</div>
<div class="balance-updated">🕐 ${new Date().toLocaleString('ko-KR')}</div>
</div>`;
content.innerHTML = html;
}
</script>
</body>
</html>