pharmacy-pos-qr-system/backend/templates/admin_products.html
thug0bin 91f8dea5b4 feat(재고): 약품 더블클릭 시 입고이력 모달 추가
- 새 API: GET /api/drugs/<drug_code>/purchase-history
  - WH_sub + WH_main + PM_BASE.CD_custom 조인
  - 도매상명, 입고일, 수량, 단가, 전화번호 반환
- admin_products.html 업데이트:
  - tr ondblclick → openPurchaseModal()
  - 입고이력 모달 UI/스타일 추가
  - 도매상 전화번호 클릭 시 복사 기능
  - 결과 카운트 옆에 더블클릭 힌트 추가
- 기타 onclick에 event.stopPropagation() 추가 (충돌 방지)
2026-03-08 10:33:21 +09:00

2056 lines
78 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&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #7c3aed 0%, #8b5cf6 50%, #a78bfa 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
opacity: 0.85;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 플로팅 챗봇 ── */
.chatbot-panel {
position: fixed;
right: 24px;
bottom: 90px;
width: 370px;
background: #fff;
border-radius: 20px;
border: 1px solid #e2e8f0;
box-shadow: 0 8px 40px rgba(0,0,0,0.15);
display: none;
flex-direction: column;
height: 520px;
max-height: calc(100vh - 120px);
z-index: 998;
animation: chatSlideUp 0.3s ease;
}
.chatbot-panel.open {
display: flex;
}
@keyframes chatSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.chatbot-header {
padding: 16px 20px;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border-radius: 16px 16px 0 0;
color: #fff;
}
.chatbot-header h2 {
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.chatbot-header p {
font-size: 12px;
opacity: 0.85;
margin-top: 4px;
}
.chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-message {
max-width: 85%;
padding: 12px 16px;
border-radius: 16px;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.chat-message.user {
background: #8b5cf6;
color: #fff;
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.chat-message.assistant {
background: #f1f5f9;
color: #334155;
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.chat-message.system {
background: #fef3c7;
color: #92400e;
align-self: center;
font-size: 12px;
padding: 8px 16px;
}
.chat-message .products-mentioned {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #cbd5e1;
}
.chat-message .product-chip {
display: inline-block;
background: #10b981;
color: #fff;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
margin: 2px 4px 2px 0;
cursor: pointer;
}
.chat-message .product-chip:hover {
background: #059669;
}
.chatbot-input {
padding: 16px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 10px;
}
.chatbot-input input {
flex: 1;
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 24px;
font-size: 14px;
font-family: inherit;
transition: all 0.2s;
}
.chatbot-input input:focus {
outline: none;
border-color: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
}
.chatbot-input button {
width: 44px;
height: 44px;
border-radius: 50%;
background: #10b981;
color: #fff;
border: none;
cursor: pointer;
font-size: 18px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.chatbot-input button:hover { background: #059669; }
.chatbot-input button:disabled { background: #94a3b8; cursor: not-allowed; }
.chatbot-suggestions {
padding: 12px 16px;
background: #f8fafc;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.suggestion-chip {
background: #e0e7ff;
color: #4338ca;
padding: 6px 12px;
border-radius: 16px;
font-size: 12px;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.suggestion-chip:hover {
background: #c7d2fe;
}
.typing-indicator {
display: flex;
gap: 4px;
padding: 12px 16px;
align-self: flex-start;
}
.typing-indicator span {
width: 8px;
height: 8px;
background: #94a3b8;
border-radius: 50%;
animation: typing 1.4s infinite;
}
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-6px); opacity: 1; }
}
/* ── 챗봇 토글 버튼 (항상 표시) ── */
.chatbot-toggle {
position: fixed;
bottom: 24px;
right: 24px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: #fff;
border: none;
font-size: 28px;
cursor: pointer;
box-shadow: 0 4px 20px rgba(16, 185, 129, 0.4);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.chatbot-toggle:hover {
transform: scale(1.1);
box-shadow: 0 6px 28px rgba(16, 185, 129, 0.5);
}
.chatbot-toggle.active {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
}
/* 모바일 */
@media (max-width: 640px) {
.chatbot-panel {
right: 0;
bottom: 0;
left: 0;
width: 100%;
height: 75vh;
border-radius: 20px 20px 0 0;
}
.chatbot-toggle { bottom: 16px; right: 16px; }
}
/* ── 검색 영역 ── */
.search-section {
background: #fff;
border-radius: 14px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
}
.search-box {
display: flex;
gap: 12px;
}
.search-input {
flex: 1;
padding: 14px 18px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 16px;
font-family: inherit;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
}
.search-input::placeholder {
color: #94a3b8;
}
.search-btn {
background: #8b5cf6;
color: #fff;
border: none;
padding: 14px 32px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:hover { background: #7c3aed; }
.search-btn:active { transform: scale(0.98); }
.search-hint {
margin-top: 12px;
font-size: 13px;
color: #94a3b8;
}
.search-hint span {
background: #f1f5f9;
padding: 2px 8px;
border-radius: 4px;
margin-right: 8px;
}
/* ── 결과 카운트 ── */
.result-count {
margin-bottom: 16px;
font-size: 14px;
color: #64748b;
}
.result-count strong {
color: #8b5cf6;
font-weight: 700;
}
/* ── 테이블 ── */
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 14px 16px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 16px;
font-size: 14px;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr:hover { background: #faf5ff; }
tbody tr:last-child td { border-bottom: none; }
/* ── 상품 정보 ── */
.product-name {
font-weight: 600;
color: #1e293b;
margin-bottom: 2px;
}
.product-supplier {
font-size: 12px;
color: #94a3b8;
}
.product-supplier.set {
color: #8b5cf6;
font-weight: 500;
}
/* ── 코드/바코드 ── */
.code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 4px 8px;
border-radius: 6px;
display: inline-block;
}
.code-drug {
background: #ede9fe;
color: #6d28d9;
}
.code-barcode {
background: #d1fae5;
color: #065f46;
}
.code-apc {
background: #ede9fe;
color: #7c3aed;
}
.code-apc-na {
background: #fef2f2;
color: #dc2626;
border: 1px dashed #fca5a5;
}
.unit-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
margin-left: 4px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 600;
border-radius: 9px;
vertical-align: middle;
}
.animal-badge {
display: inline-block;
background: #10b981;
color: #fff;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
}
.animal-badge.clickable {
cursor: pointer;
transition: all 0.2s;
}
.animal-badge.clickable:hover {
background: #059669;
box-shadow: 0 2px 8px rgba(16,185,129,0.4);
transform: scale(1.05);
}
.code-na {
background: #f1f5f9;
color: #94a3b8;
}
.location-badge {
display: inline-block;
background: #fef3c7;
color: #92400e;
font-size: 11px;
padding: 3px 8px;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.location-badge:hover {
background: #fcd34d;
transform: scale(1.05);
}
.location-badge.unset {
background: #f1f5f9;
color: #94a3b8;
border: 1px dashed #cbd5e1;
}
.location-badge.unset:hover {
background: #e2e8f0;
border-color: #8b5cf6;
color: #7c3aed;
}
/* 위치 모달 */
.location-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.location-modal.show { display: flex; }
.location-modal-content {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: modalSlideIn 0.2s ease;
}
@keyframes modalSlideIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
.location-modal-content h3 {
margin: 0 0 16px 0;
color: #92400e;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.location-product-info {
background: #fffbeb;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
border: 1px solid #fef3c7;
}
.location-product-info .name {
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.location-product-info .code {
font-size: 12px;
color: #94a3b8;
font-family: 'JetBrains Mono', monospace;
}
.location-select-wrapper {
margin-bottom: 12px;
}
.location-select-wrapper label {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-bottom: 6px;
}
.location-select {
width: 100%;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
background: #fff;
cursor: pointer;
transition: border-color 0.2s;
}
.location-select:focus {
outline: none;
border-color: #f59e0b;
}
.location-input-wrapper {
margin-bottom: 16px;
}
.location-input-wrapper label {
display: block;
font-size: 12px;
font-weight: 500;
color: #64748b;
margin-bottom: 6px;
}
.location-input {
width: 100%;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s;
}
.location-input:focus {
outline: none;
border-color: #f59e0b;
}
.location-hint {
font-size: 11px;
color: #94a3b8;
margin-top: 6px;
}
.location-modal-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.location-modal-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: all 0.15s;
}
.location-modal-btn.secondary { background: #f1f5f9; color: #64748b; }
.location-modal-btn.secondary:hover { background: #e2e8f0; }
.location-modal-btn.danger { background: #fef2f2; color: #dc2626; }
.location-modal-btn.danger:hover { background: #fee2e2; }
.location-modal-btn.primary { background: #f59e0b; color: #fff; }
.location-modal-btn.primary:hover { background: #d97706; }
/* ── 입고이력 모달 ── */
.purchase-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.purchase-modal.show { display: flex; }
.purchase-modal-content {
background: #fff;
border-radius: 16px;
padding: 0;
max-width: 600px;
width: 95%;
max-height: 80vh;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: modalSlideIn 0.2s ease;
overflow: hidden;
display: flex;
flex-direction: column;
}
.purchase-modal-header {
padding: 20px 24px;
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: #fff;
}
.purchase-modal-header h3 {
margin: 0 0 6px 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.purchase-modal-header .drug-name {
font-size: 14px;
opacity: 0.9;
}
.purchase-modal-body {
padding: 16px 24px 24px;
overflow-y: auto;
flex: 1;
}
.purchase-history-table {
width: 100%;
border-collapse: collapse;
}
.purchase-history-table th {
background: #f8fafc;
padding: 12px 10px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 2px solid #e2e8f0;
position: sticky;
top: 0;
}
.purchase-history-table td {
padding: 14px 10px;
font-size: 14px;
border-bottom: 1px solid #f1f5f9;
}
.purchase-history-table tr:hover td {
background: #f8fafc;
}
.supplier-name {
font-weight: 600;
color: #1e293b;
}
.supplier-tel {
font-size: 12px;
color: #3b82f6;
cursor: pointer;
}
.supplier-tel:hover {
text-decoration: underline;
}
.purchase-date {
color: #64748b;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
}
.purchase-qty {
font-weight: 600;
color: #10b981;
}
.purchase-price {
color: #6b7280;
}
.purchase-empty {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
}
.purchase-empty .icon {
font-size: 40px;
margin-bottom: 12px;
}
.purchase-modal-footer {
padding: 16px 24px;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
}
.purchase-modal-btn {
padding: 10px 24px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
font-size: 14px;
background: #f1f5f9;
color: #64748b;
transition: all 0.15s;
}
.purchase-modal-btn:hover {
background: #e2e8f0;
}
tbody tr {
cursor: pointer;
}
tbody tr:active {
background: #ede9fe;
}
/* ── 가격 ── */
.price {
font-weight: 600;
color: #1e293b;
white-space: nowrap;
}
/* ── 재고 ── */
.stock {
font-weight: 600;
white-space: nowrap;
text-align: center;
}
.stock.in-stock { color: #10b981; }
.stock.out-stock { color: #ef4444; }
/* ── QR 버튼 ── */
.btn-qr {
background: #8b5cf6;
color: #fff;
border: none;
padding: 8px 14px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-qr:hover { background: #7c3aed; }
.btn-qr:active { transform: scale(0.95); }
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-state .icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-state p {
font-size: 15px;
}
/* ── 모달 ── */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-box {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.modal-preview {
margin: 16px 0;
}
.modal-preview img {
max-width: 200px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
/* ── 수량 선택기 ── */
.qty-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: 20px 0;
}
.qty-btn {
width: 44px;
height: 44px;
border: none;
background: #f1f5f9;
font-size: 24px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.qty-btn:first-child { border-radius: 12px 0 0 12px; }
.qty-btn:last-child { border-radius: 0 12px 12px 0; }
.qty-btn:hover { background: #e2e8f0; color: #334155; }
.qty-btn:active { transform: scale(0.95); background: #cbd5e1; }
.qty-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.qty-value {
width: 64px;
height: 44px;
background: #fff;
border: 2px solid #e2e8f0;
border-left: none;
border-right: none;
font-size: 20px;
font-weight: 700;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
}
.qty-label {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.modal-btns {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.modal-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.modal-btn.cancel { background: #f1f5f9; color: #64748b; }
.modal-btn.cancel:hover { background: #e2e8f0; }
.modal-btn.confirm { background: #8b5cf6; color: #fff; }
.modal-btn.confirm:hover { background: #7c3aed; }
/* ── 제품 이미지 ── */
.product-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.product-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(139,92,246,0.3);
}
.product-thumb-placeholder {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
border: 1px solid #e2e8f0;
margin: 0 auto;
}
.product-thumb-placeholder:hover {
transform: scale(1.15);
border-color: #8b5cf6;
}
.product-thumb-placeholder svg {
width: 20px;
height: 20px;
fill: #94a3b8;
}
/* ── 이미지 모달 ── */
.image-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.image-modal.show { display: flex; }
.image-modal-content {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 420px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.image-modal-content h3 {
margin: 0 0 16px 0;
color: #7c3aed;
font-size: 18px;
}
.image-modal-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.img-tab-btn {
flex: 1;
padding: 10px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.img-tab-btn.active {
background: #8b5cf6;
color: #fff;
border-color: #8b5cf6;
}
.img-tab-content { display: none; }
.img-tab-content.active { display: block; }
.img-input {
width: 100%;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
}
.img-input:focus {
outline: none;
border-color: #8b5cf6;
}
.camera-box {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.camera-box video, .camera-box canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.img-modal-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.img-modal-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.img-modal-btn.secondary { background: #f1f5f9; color: #64748b; }
.img-modal-btn.primary { background: #8b5cf6; color: #fff; }
/* ── 반응형 ── */
@media (max-width: 768px) {
.search-box { flex-direction: column; }
.table-wrap { overflow-x: auto; }
table { min-width: 700px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<div>
<a href="/admin/sales-detail" style="margin-right: 16px;">판매 조회</a>
<a href="/admin/sales">판매 내역</a>
</div>
</div>
<h1>🔍 제품 검색</h1>
<p>전체 제품 검색 · QR 라벨 인쇄 · 🐾 동물약 AI 상담</p>
</div>
<div class="content">
<!-- 검색 -->
<div class="search-section">
<div class="search-box">
<input type="text" class="search-input" id="searchInput"
placeholder="상품명, 바코드, 상품코드로 검색..."
onkeypress="if(event.key==='Enter')searchProducts()">
<button class="search-btn" onclick="searchProducts()">🔍 검색</button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
<div class="search-hint">
<span>예시</span> 타이레놀, 벤포파워, 8806418067510, LB000001423
</div>
<div style="display: flex; gap: 20px;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #475569;">
<input type="checkbox" id="inStockOnly" checked style="width: 18px; height: 18px; accent-color: #8b5cf6; cursor: pointer;">
<span style="display: flex; align-items: center; gap: 4px;">
📦 <strong style="color: #8b5cf6;">사용약품만</strong>
</span>
</label>
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; color: #475569;">
<input type="checkbox" id="animalOnly" style="width: 18px; height: 18px; accent-color: #10b981; cursor: pointer;">
<span style="display: flex; align-items: center; gap: 4px;">
🐾 <strong style="color: #10b981;">동물약만</strong>
</span>
</label>
</div>
</div>
</div>
<!-- 결과 -->
<div class="result-count" id="resultCount" style="display:none;">
검색 결과: <strong id="resultNum">0</strong>
<span style="margin-left: 16px; color: #94a3b8; font-size: 12px;">💡 행 더블클릭 → 입고이력</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:50px;">이미지</th>
<th>상품명</th>
<th>상품코드</th>
<th>바코드/APC</th>
<th>위치</th>
<th>재고</th>
<th>판매가</th>
<th>QR</th>
</tr>
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="8" class="empty-state">
<div class="icon">🔍</div>
<p>상품명, 바코드, 상품코드로 검색하세요</p>
</td>
</tr>
</tbody>
</table>
</div>
</div><!-- /.content -->
<!-- 동물약 챗봇 패널 -->
<div class="chatbot-panel" id="chatbotPanel">
<div class="chatbot-header">
<h2>🐾 동물약 AI 상담</h2>
<p>심장사상충, 외부기생충, 구충제 등 무엇이든 물어보세요</p>
</div>
<div class="chatbot-suggestions">
<button class="suggestion-chip" onclick="sendSuggestion('강아지 심장사상충 약 추천해줘')">🐕 심장사상충약</button>
<button class="suggestion-chip" onclick="sendSuggestion('넥스가드랑 브라벡토 차이점')">넥스가드 vs 브라벡토</button>
<button class="suggestion-chip" onclick="sendSuggestion('고양이 벼룩 약 뭐가 좋아?')">🐱 고양이 벼룩약</button>
<button class="suggestion-chip" onclick="sendSuggestion('강아지 구충제 추천')">🪱 구충제</button>
</div>
<div class="chatbot-messages" id="chatMessages">
<div class="chat-message assistant">
안녕하세요! 🐾 동물약 상담 AI입니다.<br><br>
반려동물의 <strong>심장사상충 예방</strong>, <strong>벼룩/진드기 예방</strong>, <strong>구충제</strong> 등에 대해 무엇이든 물어보세요!
</div>
</div>
<div class="chatbot-input">
<input type="text" id="chatInput" placeholder="예: 5kg 강아지 심장사상충 약 추천해줘"
onkeypress="if(event.key==='Enter')sendChat()">
<button onclick="sendChat()" id="chatSendBtn"></button>
</div>
</div>
<!-- 챗봇 토글 버튼 -->
<button class="chatbot-toggle" id="chatbotToggle" onclick="toggleChatbot()">🐾</button>
<!-- QR 인쇄 모달 -->
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
<div class="modal-box">
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
<div id="qrInfo" style="margin-bottom:12px;"></div>
<div class="modal-preview" id="qrPreview">
<p style="color:#64748b;">미리보기 로딩 중...</p>
</div>
<div class="qty-label">인쇄 매수</div>
<div class="qty-selector">
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus"></button>
<div class="qty-value" id="qtyValue">1</div>
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
</div>
<div class="modal-btns">
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
</div>
</div>
</div>
<!-- 입고이력 모달 -->
<div class="purchase-modal" id="purchaseModal" onclick="if(event.target===this)closePurchaseModal()">
<div class="purchase-modal-content">
<div class="purchase-modal-header">
<h3>📦 입고 이력</h3>
<div class="drug-name" id="purchaseDrugName">-</div>
</div>
<div class="purchase-modal-body">
<table class="purchase-history-table">
<thead>
<tr>
<th>도매상</th>
<th>입고일</th>
<th>수량</th>
<th>단가</th>
</tr>
</thead>
<tbody id="purchaseHistoryBody">
<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>로딩 중...</p></td></tr>
</tbody>
</table>
</div>
<div class="purchase-modal-footer">
<button class="purchase-modal-btn" onclick="closePurchaseModal()">닫기</button>
</div>
</div>
</div>
<script>
let productsData = [];
let selectedItem = null;
let printQty = 1;
const MAX_QTY = 10;
const MIN_QTY = 1;
function formatPrice(num) {
if (!num) return '-';
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 searchProducts() {
const search = document.getElementById('searchInput').value.trim();
const animalOnly = document.getElementById('animalOnly').checked;
// 동물약만 체크시 검색어 없어도 전체 조회 가능
if (!animalOnly) {
if (!search) {
alert('검색어를 입력하세요');
return;
}
if (search.length < 2) {
alert('2글자 이상 입력하세요');
return;
}
}
const tbody = document.getElementById('productsTableBody');
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><p>검색 중...</p></td></tr>';
const inStockOnly = document.getElementById('inStockOnly').checked;
let url = `/api/products?search=${encodeURIComponent(search)}`;
if (animalOnly) url += '&animal_only=1';
if (inStockOnly) url += '&in_stock_only=1';
fetch(url)
.then(res => res.json())
.then(data => {
if (data.success) {
productsData = data.items;
document.getElementById('resultCount').style.display = 'block';
document.getElementById('resultNum').textContent = productsData.length;
renderTable();
} else {
tbody.innerHTML = `<tr><td colspan="8" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
}
})
.catch(err => {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><p>검색 실패</p></td></tr>';
});
}
function renderTable() {
const tbody = document.getElementById('productsTableBody');
if (productsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
return;
}
tbody.innerHTML = productsData.map((item, idx) => {
// 분류 뱃지 (동물약만)
const categoryBadge = item.category
? `<span style="display:inline-block;background:#8b5cf6;color:#fff;font-size:10px;padding:2px 5px;border-radius:3px;margin-left:4px;">${escapeHtml(item.category)}</span>`
: '';
// 도매상 재고 표시 (동물약만)
const wsStock = (item.wholesaler_stock && item.wholesaler_stock > 0)
? `<span style="color:#3b82f6;font-size:11px;margin-left:4px;">(도매 ${item.wholesaler_stock})</span>`
: '';
return `
<tr ondblclick="openPurchaseModal('${item.drug_code}', '${escapeHtml(item.product_name).replace(/'/g, "\\'")}')">
<td style="text-align:center;">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="event.stopPropagation();openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
${item.is_animal_drug ? `<span class="animal-badge ${item.apc ? 'clickable' : ''}" ${item.apc ? `onclick="event.stopPropagation();printAnimalDrugInfo('${item.apc}', '${escapeHtml(item.product_name)}')" title="클릭하면 약품 안내서 인쇄"` : 'title="APC 없음"'}>🐾 동물약</span>` : ''}
${categoryBadge}
</div>
<div class="product-supplier ${item.is_set ? 'set' : ''}">${escapeHtml(item.supplier) || ''}</div>
</td>
<td><span class="code code-drug">${item.drug_code}</span></td>
<td>${item.is_animal_drug
? `<div>${item.barcode
? `<span class="code code-barcode">${item.barcode}</span>`
: `<span class="code code-na">없음</span>`}${item.unit_barcode_count > 0 ? `<span class="unit-badge" title="단위바코드 ${item.unit_barcode_count}개">${item.unit_barcode_count}</span>` : ''}</div>
<div style="margin-top:4px;">${item.apc ? `<span class="code code-apc">${item.apc}</span>` : `<span class="code code-apc-na">APC미지정</span>`}</div>`
: (item.barcode ? `<span class="code code-barcode">${item.barcode}</span>` : `<span class="code code-na">없음</span>`)}</td>
<td>${item.location
? `<span class="location-badge" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '${escapeHtml(item.location)}')">${escapeHtml(item.location)}</span>`
: `<span class="location-badge unset" onclick="event.stopPropagation();openLocationModal('${item.drug_code}', '${escapeHtml(item.product_name)}', '')">미지정</span>`}</td>
<td class="stock ${(item.stock || 0) > 0 ? 'in-stock' : 'out-stock'}">${item.stock || 0}${wsStock}</td>
<td class="price">${formatPrice(item.sale_price)}</td>
<td>
<button class="btn-qr" onclick="event.stopPropagation();printQR(${idx})">🏷️ QR</button>
</td>
</tr>
`}).join('');
}
// ── 동물약 안내서 ──
async function printAnimalDrugInfo(apc, productName) {
if (!apc) {
alert('APC 코드가 없습니다.');
return;
}
// 미리보기 API 호출
try {
const res = await fetch('/api/animal-drug-info/preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ apc })
});
const data = await res.json();
if (data.success && data.data) {
showAnimalDrugModal(data.data, apc);
} else {
alert(`정보 조회 실패: ${data.error}`);
}
} catch (err) {
alert(`조회 오류: ${err.message}`);
}
}
function showAnimalDrugModal(info, apc) {
// 기존 모달이 있으면 제거
let modal = document.getElementById('animalDrugModal');
if (modal) modal.remove();
// 모달 생성
modal = document.createElement('div');
modal.id = 'animalDrugModal';
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-box" style="max-width:520px;max-height:80vh;overflow-y:auto;">
<h3 style="margin:0 0 15px;color:#7c3aed;">🐾 동물약 안내서</h3>
<div style="background:#f8fafc;padding:15px;border-radius:8px;margin-bottom:15px;">
<h4 style="margin:0 0 8px;color:#1e293b;">${escapeHtml(info.product_name)}</h4>
<p style="margin:0;color:#64748b;font-size:13px;">제조: ${escapeHtml(info.company_name || '-')}</p>
${info.main_ingredient ? `<p style="margin:8px 0 0;color:#64748b;font-size:12px;">주성분: ${escapeHtml(info.main_ingredient)}</p>` : ''}
</div>
${info.efficacy_effect ? `
<div style="margin-bottom:12px;background:#f0fdf4;padding:12px;border-radius:8px;">
<h5 style="margin:0 0 6px;color:#059669;">▶ 효능효과</h5>
<p style="margin:0;font-size:13px;line-height:1.7;color:#334155;white-space:pre-line;">${escapeHtml(info.efficacy_effect)}</p>
</div>` : ''}
${info.dosage_instructions ? `
<div style="margin-bottom:12px;background:#eff6ff;padding:12px;border-radius:8px;">
<h5 style="margin:0 0 6px;color:#0284c7;">▶ 용법용량</h5>
<div style="margin:0;font-size:13px;line-height:1.7;color:#334155;white-space:pre-line;">${info.dosage_has_table ? info.dosage_instructions : escapeHtml(info.dosage_instructions)}</div>
</div>` : ''}
${info.dosing_interval ? `
<div style="margin-bottom:12px;background:#faf5ff;padding:12px;border-radius:8px;border:2px solid #c084fc;">
<h5 style="margin:0 0 6px;color:#7c3aed;">▶ 투약 주기 ⭐</h5>
<p style="margin:0;font-size:13px;line-height:1.7;color:#334155;white-space:pre-line;">${escapeHtml(info.dosing_interval)}</p>
</div>` : ''}
${info.companion_drugs ? `
<div style="margin-bottom:12px;background:#ecfdf5;padding:12px;border-radius:8px;border:2px solid #34d399;">
<h5 style="margin:0 0 6px;color:#059669;">▶ 함께 투약 권장 💊</h5>
<p style="margin:0;font-size:13px;line-height:1.7;color:#334155;">${escapeHtml(info.companion_drugs)}</p>
</div>` : ''}
${info.precautions ? `
<div style="margin-bottom:12px;background:#fef2f2;padding:12px;border-radius:8px;">
<h5 style="margin:0 0 6px;color:#dc2626;">▶ 주의사항</h5>
<p style="margin:0;font-size:13px;line-height:1.7;color:#334155;white-space:pre-line;">${escapeHtml(info.precautions.substring(0, 800))}${info.precautions.length > 800 ? '...' : ''}</p>
</div>` : ''}
<div style="display:flex;gap:10px;margin-top:20px;">
<button onclick="document.getElementById('animalDrugModal').remove()"
style="flex:1;padding:10px;border:1px solid #e2e8f0;background:#fff;border-radius:6px;cursor:pointer;">닫기</button>
<button onclick="printAnimalDrugSheet('${apc}')"
style="flex:1;padding:10px;border:none;background:#7c3aed;color:#fff;border-radius:6px;cursor:pointer;">🖨️ 인쇄</button>
</div>
</div>
`;
modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
modal.classList.add('active');
}
async function printAnimalDrugSheet(apc) {
const btn = event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '인쇄 중...';
try {
const res = await fetch('/api/animal-drug-info/print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ apc })
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '✓ 인쇄 완료!';
btn.style.background = '#059669';
setTimeout(() => {
document.getElementById('animalDrugModal')?.remove();
}, 1500);
} else {
alert(`인쇄 실패: ${data.error}`);
btn.innerHTML = originalText;
btn.disabled = false;
}
} catch (err) {
alert(`인쇄 오류: ${err.message}`);
btn.innerHTML = originalText;
btn.disabled = false;
}
}
// ── QR 인쇄 관련 ──
function adjustQty(delta) {
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
updateQtyUI();
}
function updateQtyUI() {
document.getElementById('qtyValue').textContent = printQty;
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
document.getElementById('printBtn').textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
}
function printQR(idx) {
selectedItem = productsData[idx];
printQty = 1;
const modal = document.getElementById('qrModal');
const preview = document.getElementById('qrPreview');
const info = document.getElementById('qrInfo');
// QR에 들어갈 바코드 우선순위: 대표바코드 > 단위바코드 > 제품코드
const qrBarcode = selectedItem.barcode || selectedItem.unit_barcode || selectedItem.drug_code || '';
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
info.innerHTML = `
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
<span style="color:#64748b;font-size:13px;">
바코드: ${qrBarcode || 'N/A'}<br>
가격: ${formatPrice(selectedItem.sale_price)}
</span>
`;
updateQtyUI();
modal.classList.add('active');
fetch('/api/qr-preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: qrBarcode,
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.sale_price || 0
})
})
.then(res => res.json())
.then(data => {
if (data.success && data.image) {
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
} else {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
}
})
.catch(() => {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
});
}
function closeQRModal() {
document.getElementById('qrModal').classList.remove('active');
selectedItem = null;
printQty = 1;
}
async function confirmPrintQR() {
if (!selectedItem) return;
const btn = document.getElementById('printBtn');
const totalQty = printQty;
btn.disabled = true;
let successCount = 0;
let errorMsg = '';
for (let i = 0; i < totalQty; i++) {
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
try {
// QR에 들어갈 바코드 우선순위: 대표바코드 > 단위바코드 > 제품코드
const qrBarcode = selectedItem.barcode || selectedItem.unit_barcode || selectedItem.drug_code || '';
const res = await fetch('/api/qr-print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: qrBarcode,
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.sale_price || 0
})
});
const data = await res.json();
if (data.success) {
successCount++;
} else {
errorMsg = data.error || '알 수 없는 오류';
break;
}
if (i < totalQty - 1) {
await new Promise(r => setTimeout(r, 500));
}
} catch (err) {
errorMsg = err.message;
break;
}
}
btn.disabled = false;
updateQtyUI();
if (successCount === totalQty) {
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
closeQRModal();
} else if (successCount > 0) {
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
} else {
alert(`❌ 인쇄 실패: ${errorMsg}`);
}
}
// 페이지 로드 시 검색창 포커스
document.getElementById('searchInput').focus();
// ══════════════════════════════════════════════════════════════════
// 동물약 챗봇
// ══════════════════════════════════════════════════════════════════
let chatHistory = [];
let isChatLoading = false;
function toggleChatbot() {
const panel = document.getElementById('chatbotPanel');
const btn = document.getElementById('chatbotToggle');
const isOpen = panel.classList.toggle('open');
btn.classList.toggle('active', isOpen);
btn.innerHTML = isOpen ? '✕' : '🐾';
if (isOpen) {
document.getElementById('chatInput').focus();
}
}
function sendSuggestion(text) {
document.getElementById('chatInput').value = text;
sendChat();
}
async function sendChat() {
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message || isChatLoading) return;
// 사용자 메시지 표시
addChatMessage('user', message);
input.value = '';
// 히스토리에 추가
chatHistory.push({ role: 'user', content: message });
// 로딩 표시
isChatLoading = true;
document.getElementById('chatSendBtn').disabled = true;
showTypingIndicator();
try {
const response = await fetch('/api/animal-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: chatHistory })
});
const data = await response.json();
hideTypingIndicator();
if (data.success) {
// AI 응답 표시
addChatMessage('assistant', data.message, data.products);
// 히스토리에 추가
chatHistory.push({ role: 'assistant', content: data.message });
// 히스토리 길이 제한 (최근 20개)
if (chatHistory.length > 20) {
chatHistory = chatHistory.slice(-20);
}
} else {
addChatMessage('system', '⚠️ ' + (data.message || '오류가 발생했습니다'));
}
} catch (error) {
hideTypingIndicator();
addChatMessage('system', '⚠️ 네트워크 오류가 발생했습니다');
}
isChatLoading = false;
document.getElementById('chatSendBtn').disabled = false;
}
function addChatMessage(role, content, products) {
const container = document.getElementById('chatMessages');
const msgDiv = document.createElement('div');
msgDiv.className = `chat-message ${role}`;
// 줄바꿈 처리
let htmlContent = escapeHtml(content).replace(/\n/g, '<br>');
// 마크다운 굵게 처리
htmlContent = htmlContent.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
msgDiv.innerHTML = htmlContent;
// 언급된 제품 표시 (이미지 포함)
if (products && products.length > 0) {
const productsDiv = document.createElement('div');
productsDiv.className = 'products-mentioned';
productsDiv.innerHTML = '<small style="color:#64748b;">📦 관련 제품:</small>';
const productsGrid = document.createElement('div');
productsGrid.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;margin-top:8px;';
products.forEach(p => {
const card = document.createElement('div');
card.className = 'product-card-mini';
card.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;background:#f8fafc;border-radius:8px;cursor:pointer;border:1px solid #e2e8f0;';
card.onclick = () => searchProductFromChat(p.name);
// 이미지 컨테이너
const imgContainer = document.createElement('div');
imgContainer.style.cssText = 'width:40px;height:40px;flex-shrink:0;';
if (p.image_url) {
const img = document.createElement('img');
img.style.cssText = 'width:40px;height:40px;object-fit:cover;border-radius:4px;background:#e2e8f0;';
img.src = p.image_url;
img.alt = p.name;
img.onerror = function() {
// 이미지 로드 실패 시 아이콘으로 대체
imgContainer.innerHTML = '<div style="width:40px;height:40px;background:#f1f5f9;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;">💊</div>';
};
imgContainer.appendChild(img);
} else {
// 이미지 없으면 아이콘
imgContainer.innerHTML = '<div style="width:40px;height:40px;background:#f1f5f9;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;">💊</div>';
}
// 텍스트 (카테고리 뱃지 + 약국/도매 재고)
const textDiv = document.createElement('div');
const pharmacyStock = p.stock || 0;
const wholesalerStock = p.wholesaler_stock || 0;
const stockColor = (pharmacyStock > 0) ? '#10b981' : '#ef4444';
const pharmacyText = (pharmacyStock > 0) ? `약국 ${pharmacyStock}` : '품절';
const wholesalerText = (wholesalerStock > 0) ? `도매 ${wholesalerStock}` : '';
const stockDisplay = wholesalerText ? `${pharmacyText} / ${wholesalerText}` : pharmacyText;
// 카테고리 뱃지
const categoryBadge = p.category
? `<span style="display:inline-block;background:#8b5cf6;color:#fff;font-size:10px;padding:2px 5px;border-radius:3px;margin-left:4px;">${p.category}</span>`
: '';
textDiv.innerHTML = `<div style="font-size:13px;font-weight:500;color:#334155;">${p.name}${categoryBadge}</div><div style="font-size:12px;"><span style="color:#10b981;">${formatPrice(p.price)}</span> <span style="color:${stockColor};margin-left:6px;">${stockDisplay}</span></div>`;
card.appendChild(imgContainer);
card.appendChild(textDiv);
productsGrid.appendChild(card);
});
productsDiv.appendChild(productsGrid);
msgDiv.appendChild(productsDiv);
}
container.appendChild(msgDiv);
container.scrollTop = container.scrollHeight;
}
function showTypingIndicator() {
const container = document.getElementById('chatMessages');
const indicator = document.createElement('div');
indicator.className = 'typing-indicator';
indicator.id = 'typingIndicator';
indicator.innerHTML = '<span></span><span></span><span></span>';
container.appendChild(indicator);
container.scrollTop = container.scrollHeight;
}
function hideTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) indicator.remove();
}
function searchProductFromChat(productName) {
// 챗봇에서 제품 클릭 시 검색창에 입력하고 검색
document.getElementById('searchInput').value = productName;
document.getElementById('animalOnly').checked = true;
searchProducts();
// 모바일에서 챗봇 닫기
if (window.innerWidth <= 1100) {
document.getElementById('chatbotPanel').classList.remove('open');
}
}
// ── 이미지 등록 모달 ──
let imgModalBarcode = null;
let imgModalDrugCode = null;
let imgModalName = null;
let cameraStream = null;
let capturedImageData = null;
function openImageModal(barcode, drugCode, productName) {
if (!barcode && !drugCode) {
alert('제품 코드 정보가 없습니다');
return;
}
imgModalBarcode = barcode || null;
imgModalDrugCode = drugCode || null;
imgModalName = productName || (barcode || drugCode);
document.getElementById('imgModalProductName').textContent = imgModalName;
document.getElementById('imgModalCode').textContent = barcode || drugCode;
document.getElementById('imgUrlInput').value = '';
switchImageTab('url');
document.getElementById('imageModal').classList.add('show');
document.getElementById('imgUrlInput').focus();
}
function closeImageModal() {
stopCamera();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
imgModalName = null;
capturedImageData = null;
}
function switchImageTab(tab) {
document.querySelectorAll('.img-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
document.querySelectorAll('.img-tab-content').forEach(c => c.classList.toggle('active', c.id === 'imgTab' + tab.charAt(0).toUpperCase() + tab.slice(1)));
if (tab === 'camera') startCamera(); else stopCamera();
}
async function startCamera() {
try {
stopCamera();
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1920 } },
audio: false
});
const video = document.getElementById('camVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
document.getElementById('camCanvas').style.display = 'none';
document.getElementById('camGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'flex';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
} catch (err) {
alert('카메라에 접근할 수 없습니다');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(t => t.stop());
cameraStream = null;
}
const video = document.getElementById('camVideo');
if (video) video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('camVideo');
const canvas = document.getElementById('camCanvas');
const ctx = canvas.getContext('2d');
const vw = video.videoWidth, vh = video.videoHeight;
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2, sy = (vh - cropSize) / 2;
canvas.width = 800; canvas.height = 800;
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('camGuide').style.display = 'none';
document.getElementById('captureBtn').style.display = 'none';
document.getElementById('previewBtns').style.display = 'flex';
}
function retakePhoto() {
document.getElementById('camVideo').style.display = 'block';
document.getElementById('camCanvas').style.display = 'none';
document.getElementById('camGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'flex';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
}
async function submitCapturedImage() {
if (!capturedImageData) { alert('촬영된 이미지가 없습니다'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 저장 중...`);
try {
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_data: capturedImageData, product_name: name, drug_code: imgModalDrugCode })
});
const data = await res.json();
if (data.success) { showToast('✅ 이미지 저장 완료!', 'success'); searchProducts(); }
else showToast(data.error || '저장 실패', 'error');
} catch (err) { showToast('오류: ' + err.message, 'error'); }
}
async function submitImageUrl() {
const url = document.getElementById('imgUrlInput').value.trim();
if (!url) { alert('이미지 URL을 입력하세요'); return; }
if (!url.startsWith('http')) { alert('올바른 URL을 입력하세요'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 다운로드 중...`);
try {
const res = await fetch(`/api/admin/product-images/${code}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: url, product_name: name, drug_code: imgModalDrugCode })
});
const data = await res.json();
if (data.success) { showToast('✅ 이미지 등록 완료!', 'success'); searchProducts(); }
else showToast(data.error || '등록 실패', 'error');
} catch (err) { showToast('오류: ' + err.message, 'error'); }
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.style.cssText = `position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 24px;background:${type==='success'?'#10b981':type==='error'?'#ef4444':'#8b5cf6'};color:#fff;border-radius:8px;font-size:14px;z-index:3000;`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
document.getElementById('imageModal')?.addEventListener('click', e => {
if (e.target.id === 'imageModal') closeImageModal();
});
</script>
<!-- 이미지 등록 모달 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<h3>📷 제품 이미지 등록</h3>
<div style="background:#f8fafc;border-radius:8px;padding:12px;margin-bottom:16px;">
<div style="font-weight:600;" id="imgModalProductName">제품명</div>
<div style="font-size:12px;color:#94a3b8;font-family:monospace;" id="imgModalCode">코드</div>
</div>
<div class="image-modal-tabs">
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
</div>
<div class="img-tab-content active" id="imgTabUrl">
<input type="text" class="img-input" id="imgUrlInput" placeholder="이미지 URL을 입력하세요...">
<div class="img-modal-btns">
<button class="img-modal-btn secondary" onclick="closeImageModal()">취소</button>
<button class="img-modal-btn primary" onclick="submitImageUrl()">등록하기</button>
</div>
</div>
<div class="img-tab-content" id="imgTabCamera">
<div class="camera-box">
<video id="camVideo" autoplay playsinline></video>
<canvas id="camCanvas" style="display:none;"></canvas>
<div id="camGuide" style="position:absolute;top:0;left:0;right:0;bottom:0;pointer-events:none;">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<rect x="10" y="10" width="80" height="80" fill="none" stroke="rgba(139,92,246,0.5)" stroke-width="0.5" stroke-dasharray="2,2"/>
</svg>
</div>
</div>
<div class="img-modal-btns" id="captureBtn">
<button class="img-modal-btn secondary" onclick="closeImageModal()">취소</button>
<button class="img-modal-btn primary" onclick="capturePhoto()">📸 촬영</button>
</div>
<div class="img-modal-btns" id="previewBtns" style="display:none;">
<button class="img-modal-btn secondary" onclick="retakePhoto()">다시 촬영</button>
<button class="img-modal-btn primary" onclick="submitCapturedImage()">저장하기</button>
</div>
</div>
</div>
</div>
<!-- 위치 편집 모달 -->
<div class="location-modal" id="locationModal">
<div class="location-modal-content">
<h3>📍 위치 설정</h3>
<div class="location-product-info">
<div class="name" id="locModalProductName">제품명</div>
<div class="code" id="locModalDrugCode">상품코드</div>
</div>
<div class="location-select-wrapper">
<label>기존 위치에서 선택</label>
<select class="location-select" id="locationSelect" onchange="onLocationSelectChange()">
<option value="">-- 선택하세요 --</option>
</select>
</div>
<div class="location-input-wrapper">
<label>또는 직접 입력</label>
<input type="text" class="location-input" id="locationInput"
placeholder="예: 진열대1-3, 냉장고, 창고A" maxlength="20">
<div class="location-hint">최대 20자 / 새 위치를 입력하면 목록에 추가됩니다</div>
</div>
<div class="location-modal-btns">
<button class="location-modal-btn danger" onclick="clearLocation()" id="locClearBtn" style="display:none;">삭제</button>
<div style="flex:1;"></div>
<button class="location-modal-btn secondary" onclick="closeLocationModal()">취소</button>
<button class="location-modal-btn primary" onclick="saveLocation()">저장</button>
</div>
</div>
</div>
<script>
// ── 위치 모달 ──
let locModalDrugCode = null;
let locModalCurrentLocation = null;
let allLocations = [];
async function openLocationModal(drugCode, productName, currentLocation) {
locModalDrugCode = drugCode;
locModalCurrentLocation = currentLocation;
document.getElementById('locModalProductName').textContent = productName;
document.getElementById('locModalDrugCode').textContent = drugCode;
document.getElementById('locationInput').value = currentLocation || '';
// 삭제 버튼 표시 (현재 위치가 있을 때만)
document.getElementById('locClearBtn').style.display = currentLocation ? 'block' : 'none';
// 위치 목록 로드
try {
const res = await fetch('/api/locations');
const data = await res.json();
if (data.success) {
allLocations = data.locations || [];
const select = document.getElementById('locationSelect');
select.innerHTML = '<option value="">-- 선택하세요 --</option>' +
allLocations.map(loc =>
`<option value="${escapeHtml(loc)}" ${loc === currentLocation ? 'selected' : ''}>${escapeHtml(loc)}</option>`
).join('');
}
} catch (e) {
console.error('위치 목록 로드 실패:', e);
}
document.getElementById('locationModal').classList.add('show');
document.getElementById('locationInput').focus();
}
function closeLocationModal() {
document.getElementById('locationModal').classList.remove('show');
locModalDrugCode = null;
locModalCurrentLocation = null;
}
function onLocationSelectChange() {
const selected = document.getElementById('locationSelect').value;
if (selected) {
document.getElementById('locationInput').value = selected;
}
}
async function saveLocation() {
const newLocation = document.getElementById('locationInput').value.trim();
if (!locModalDrugCode) return;
try {
const res = await fetch(`/api/drugs/${locModalDrugCode}/location`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ location_name: newLocation })
});
const data = await res.json();
if (data.success) {
showToast(newLocation ? `✅ 위치가 "${newLocation}"(으)로 설정되었습니다` : '✅ 위치가 삭제되었습니다', 'success');
closeLocationModal();
searchProducts(); // 테이블 새로고침
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (e) {
showToast('오류: ' + e.message, 'error');
}
}
async function clearLocation() {
if (!confirm('위치 정보를 삭제하시겠습니까?')) return;
document.getElementById('locationInput').value = '';
await saveLocation();
}
// 모달 외부 클릭 시 닫기
document.getElementById('locationModal')?.addEventListener('click', e => {
if (e.target.id === 'locationModal') closeLocationModal();
});
// ══════════════════════════════════════════════════════════════════
// 입고이력 모달
// ══════════════════════════════════════════════════════════════════
async function openPurchaseModal(drugCode, drugName) {
const modal = document.getElementById('purchaseModal');
const nameEl = document.getElementById('purchaseDrugName');
const tbody = document.getElementById('purchaseHistoryBody');
nameEl.textContent = drugName || drugCode;
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">⏳</div><p>입고이력 조회 중...</p></td></tr>';
modal.classList.add('show');
try {
const res = await fetch(`/api/drugs/${drugCode}/purchase-history`);
const data = await res.json();
if (data.success) {
if (data.history.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="purchase-empty"><div class="icon">📭</div><p>입고 이력이 없습니다</p></td></tr>';
} else {
tbody.innerHTML = data.history.map(h => `
<tr>
<td>
<div class="supplier-name">${escapeHtml(h.supplier)}</div>
${h.supplier_tel ? `<div class="supplier-tel" onclick="copyToClipboard('${h.supplier_tel}')" title="클릭하여 복사">📞 ${h.supplier_tel}</div>` : ''}
</td>
<td class="purchase-date">${h.date}</td>
<td class="purchase-qty">${h.quantity.toLocaleString()}</td>
<td class="purchase-price">${h.unit_price ? formatPrice(h.unit_price) : '-'}</td>
</tr>
`).join('');
}
} else {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">⚠️</div><p>조회 실패: ${data.error}</p></td></tr>`;
}
} catch (err) {
tbody.innerHTML = `<tr><td colspan="4" class="purchase-empty"><div class="icon">❌</div><p>오류: ${err.message}</p></td></tr>`;
}
}
function closePurchaseModal() {
document.getElementById('purchaseModal').classList.remove('show');
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
showToast(`📋 ${text} 복사됨`, 'success');
}).catch(() => {
// fallback
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
showToast(`📋 ${text} 복사됨`, 'success');
});
}
</script>
</body>
</html>