pharmacy-pos-qr-system/backend/templates/admin_products.html
thug0bin e7096f7bed feat: 제품 검색에 위치 컬럼 추가
- CD_item_position.CD_NM_sale 조회 (person-lookup-web-local 참고)
- 3개 쿼리 모두 LEFT JOIN CD_item_position 추가
- 위치 뱃지 스타일 (노란색 배경)
2026-03-04 14:28:41 +09:00

1408 lines
53 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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>?<3F>품 검??- <20>?<3F><>?<3F></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;
}
/* ?<3F>?<3F> ?<3F>더 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> 컨텐<ECBBA8>??<3F>?<3F> */
.content {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ?<3F>?<3F> ?<3F>로??챗봇 ?<3F>?<3F> */
.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; }
}
/* ?<3F>?<3F> 챗봇 ?<3F><>? 버튼 (??<3F><> ?<3F>시) ?<3F>?<3F> */
.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; }
}
/* ?<3F>?<3F> 검???<3F>역 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> 결과 카운???<3F>?<3F> */
.result-count {
margin-bottom: 16px;
font-size: 14px;
color: #64748b;
}
.result-count strong {
color: #8b5cf6;
font-weight: 700;
}
/* ?<3F>?<3F> ?<3F><EFBFBD>??<3F>?<3F> */
.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; }
/* ?<3F>?<3F> ?<3F>품 ?<3F>보 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> 코드/바코???<3F>?<3F> */
.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-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;
}
/* ?<3F>?<3F><>??<3F>?<3F> */
.price {
font-weight: 600;
color: #1e293b;
white-space: nowrap;
}
/* ?<3F>?<3F> ?<3F>고 ?<3F>?<3F> */
.stock {
font-weight: 600;
white-space: nowrap;
text-align: center;
}
.stock.in-stock { color: #10b981; }
.stock.out-stock { color: #ef4444; }
/* ?<3F>?<3F> QR 버튼 ?<3F>?<3F> */
.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); }
/* ?<3F>?<3F> <20>??<3F>태 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> 모달 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> ?<3F>량 ?<3F><EFBFBD>??<3F>?<3F> */
.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; }
/* ?<3F>?<3F> ?<3F>품 ?<3F><>?지 ?<3F>?<3F> */
.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;
}
/* ?<3F>?<3F> ?<3F><>?지 모달 ?<3F>?<3F> */
.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; }
/* ?<3F>?<3F> 반응???<3F>?<3F> */
@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;">?<3F>매 조회</a>
<a href="/admin/sales">?<3F>매 ?<3F></a>
</div>
</div>
<h1>?<3F><> ?<3F>품 검??/h1>
<p>?<3F>체 ?<3F>품 검??· QR ?<3F>벨 ?<3F>쇄 · ?<3F><> ?<3F>물??AI ?<3F></p>
</div>
<div class="content">
<!-- 검??-->
<div class="search-section">
<div class="search-box">
<input type="text" class="search-input" id="searchInput"
placeholder="?<3F><EFBFBD>? 바코?? ?<3F>품코드<ECBD94>?검??.."
onkeypress="if(event.key==='Enter')searchProducts()">
<button class="search-btn" onclick="searchProducts()">?<3F><> 검??/button>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 12px;">
<div class="search-hint">
<span>?<3F></span> ?<3F>?<3F>레?<3F>, 벤포?<3F>워, 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;">
?<3F><> <strong style="color: #8b5cf6;">?<3F>용?<3F><EFBFBD>?/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;">
?<3F><> <strong style="color: #10b981;">?<3F>물?<3F></strong>
</span>
</label>
</div>
</div>
</div>
<!-- 결과 -->
<div class="result-count" id="resultCount" style="display:none;">
검??결과: <strong id="resultNum">0</strong><EFBFBD>?
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:50px;">?<3F><>?지</th>
<th>?<3F><EFBFBD>?/th>
<th>?<3F>품코드</th>
<th>바코??/th>
<th>?<3F></th>
<th>?<3F></th>
<th>?<3F>매가</th>
<th>QR</th>
</tr>
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="8" class="empty-state">
<div class="icon">?<3F><></div>
<p>?<3F><EFBFBD>? 바코?? ?<3F>품코드<ECBD94>?검?<3F>하?<3F></p>
</td>
</tr>
</tbody>
</table>
</div>
</div><!-- /.content -->
<!-- ?<3F>물??챗봇 ?<3F>-->
<div class="chatbot-panel" id="chatbotPanel">
<div class="chatbot-header">
<h2>?<3F><> ?<3F>물??AI ?<3F></h2>
<p>?<3F>장?<3F><EFBFBD>? ?<3F><>?기생<EAB8B0>? 구충????무엇?<3F>든 물어보세??/p>
</div>
<div class="chatbot-suggestions">
<button class="suggestion-chip" onclick="sendSuggestion('강아지 ?<3F>장?<3F><EFBFBD>???추천?<3F>줘')">?<3F><> ?<3F>장?<3F>상충약</button>
<button class="suggestion-chip" onclick="sendSuggestion('?<3F>스가?<3F>랑 브라벡토 차이??)">?<3F>스가??vs 브라벡토</button>
<button class="suggestion-chip" onclick="sendSuggestion('고양??벼룩 ??뭐<>? 좋아?')">?<3F><> 고양??벼룩??/button>
<button class="suggestion-chip" onclick="sendSuggestion('강아지 구충??추천')">?<3F><> 구충??/button>
</div>
<div class="chatbot-messages" id="chatMessages">
<div class="chat-message assistant">
?<3F>녕?<3F>세?? ?<3F><> ?<3F>물???<3F>담 AI?<3F>니??<br><br>
반려?<3F>물??<strong>?<3F>장?<3F><EFBFBD>??<3F></strong>, <strong>벼룩/진드<ECA784>??<3F></strong>, <strong>구충??/strong> ?<3F>에 ?<3F>??무엇?<3F>든 물어보세??
</div>
</div>
<div class="chatbot-input">
<input type="text" id="chatInput" placeholder="?? 5kg 강아지 ?<3F>장?<3F><EFBFBD>???추천?<3F>줘"
onkeypress="if(event.key==='Enter')sendChat()">
<button onclick="sendChat()" id="chatSendBtn">??/button>
</div>
</div>
<!-- 챗봇 ?<3F><>? 버튼 -->
<button class="chatbot-toggle" id="chatbotToggle" onclick="toggleChatbot()">?<3F><></button>
<!-- QR ?<3F>쇄 모달 -->
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
<div class="modal-box">
<div class="modal-title">?<3F><><EFBFBD>?QR ?<3F>벨 ?<3F></div>
<div id="qrInfo" style="margin-bottom:12px;"></div>
<div class="modal-preview" id="qrPreview">
<p style="color:#64748b;">미리보기 로딩 <20>?..</p>
</div>
<div class="qty-label">?<3F>쇄 매수</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">?<3F></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;
// ?<3F>물?<3F>만 체크??검?<3F>어 ?<3F>어???<3F>체 조회 가??
if (!animalOnly) {
if (!search) {
alert('검?<3F><EFBFBD>??<3F>력?<3F>세??);
return;
}
if (search.length < 2) {
alert('2???<EFBFBD> ?<EFBFBD>?<EFBFBD>??);
return;
}
}
const tbody = document.getElementById('productsTableBody');
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><p>검??<3F>?..</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>?<3F>류: ${data.error}</p></td></tr>`;
}
})
.catch(err => {
tbody.innerHTML = '<tr><td colspan="8" class="empty-state"><p>검???<3F>패</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">?<3F><></div><p>검??결과가 ?<3F>습?<3F>다</p></td></tr>';
return;
}
tbody.innerHTML = productsData.map((item, idx) => {
// 분류 뱃<>? (?<3F>물?<3F>만)
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>`
: '';
// ?<3F>매???<3F>고 ?<3F>시 (?<3F>물?<3F>만)
const wsStock = (item.wholesaler_stock && item.wholesaler_stock > 0)
? `<span style="color:#3b82f6;font-size:11px;margin-left:4px;">(?<3F>${item.wholesaler_stock})</span>`
: '';
return `
<tr>
<td style="text-align:center;">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="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 style="display:inline-block;background:#10b981;color:#fff;font-size:11px;padding:2px 6px;border-radius:4px;margin-left:6px;">?<3F><> ?<3F>물??/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.barcode
? `<span class="code code-barcode">${item.barcode}</span>`
: `<span class="code code-na">?<3F>음</span>`}</td>
<td class="location">${item.location ? `<span class="location-badge">${escapeHtml(item.location)}</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="printQR(${idx})">?<3F><><EFBFBD>?QR</button>
</td>
</tr>
`}).join('');
}
// ?<3F>?<3F> QR ?<3F>쇄 관???<3F>?<3F>
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}???<3F>` : '?<3F>쇄';
}
function printQR(idx) {
selectedItem = productsData[idx];
printQty = 1;
const modal = document.getElementById('qrModal');
const preview = document.getElementById('qrPreview');
const info = document.getElementById('qrInfo');
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 <20>?..</p>';
info.innerHTML = `
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
<span style="color:#64748b;font-size:13px;">
바코?? ${selectedItem.barcode || selectedItem.drug_code || '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: selectedItem.barcode || '',
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;">미리보기 ?<3F>패</p>';
}
})
.catch(() => {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 ?<3F>류</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 = `?<3F><20>?.. (${i + 1}/${totalQty})`;
try {
const res = await fetch('/api/qr-print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: selectedItem.barcode || '',
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.sale_price || 0
})
});
const data = await res.json();
if (data.success) {
successCount++;
} else {
errorMsg = data.error || '?????<3F>는 ?<3F>류';
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 ?<3F>${totalQty}???<3F>쇄 ?<3F>료!`);
closeQRModal();
} else if (successCount > 0) {
alert(`?<3F> ${successCount}/${totalQty}???<3F>쇄 ?<3F>\n?<3F>류: ${errorMsg}`);
} else {
alert(`???<3F>쇄 ?<3F>패: ${errorMsg}`);
}
}
// ?<3F>이지 로드 ??검?<3F>창 ?<3F>커??
document.getElementById('searchInput').focus();
// ?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>
// ?<3F>물??챗봇
// ?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>═?<3F>
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 ? '?? : '?<EFBFBD><EFBFBD>';
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;
// ?<3F>용??메시지 ?<3F>
addChatMessage('user', message);
input.value = '';
// ?<3F>스?<3F>리??추<>?
chatHistory.push({ role: 'user', content: message });
// 로딩 ?<3F>
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 ?<3F>답 ?<3F>
addChatMessage('assistant', data.message, data.products);
// ?<3F>스?<3F>리??추<>?
chatHistory.push({ role: 'assistant', content: data.message });
// ?<3F>스?<3F>리 길이 ?<3F>한 (최근 20<32>?
if (chatHistory.length > 20) {
chatHistory = chatHistory.slice(-20);
}
} else {
addChatMessage('system', '?<EFBFBD> ' + (data.message || '?<EFBFBD>류가 발생?<EFBFBD>?<EFBFBD>'));
}
} catch (error) {
hideTypingIndicator();
addChatMessage('system', '?<EFBFBD> ?<EFBFBD>?<EFBFBD> ?<EFBFBD>류가 발생?<EFBFBD>?<EFBFBD>');
}
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}`;
// 줄바<ECA484>?처리
let htmlContent = escapeHtml(content).replace(/\n/g, '<br>');
// 마크?<3F>운 굵게 처리
htmlContent = htmlContent.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
msgDiv.innerHTML = htmlContent;
// ?<3F>급???<3F>품 ?<3F>시 (?<3F><>?지 ?<3F>함)
if (products && products.length > 0) {
const productsDiv = document.createElement('div');
productsDiv.className = 'products-mentioned';
productsDiv.innerHTML = '<small style="color:#64748b;">?<3F><> 관???<3F>품:</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);
// ?<3F><>?지 컨테?<3F>
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() {
// ?<3F><>?지 로드 ?<3F>패 ???<3F>이콘으<ECBD98>??<3F><>?
imgContainer.innerHTML = '<div style="width:40px;height:40px;background:#f1f5f9;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;">?<3F><></div>';
};
imgContainer.appendChild(img);
} else {
// ?<3F><>?지 ?<3F><EFBFBD>??<3F><EFBFBD>?
imgContainer.innerHTML = '<div style="width:40px;height:40px;background:#f1f5f9;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;">?<3F><></div>';
}
// ?<3F>스??(카테고리 뱃<>? + ?<3F>국/?<3F>매 ?<3F>고)
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) ? `?<3F>${pharmacyStock}` : '?<3F>절';
const wholesalerText = (wholesalerStock > 0) ? `?<3F>${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) {
// 챗봇?<3F>서 ?<3F>품 ?<3F>릭 ??검?<3F>창???<3F>력?<3F>고 검??
document.getElementById('searchInput').value = productName;
document.getElementById('animalOnly').checked = true;
searchProducts();
// 모바?<3F>에??챗봇 ?<3F>
if (window.innerWidth <= 1100) {
document.getElementById('chatbotPanel').classList.remove('open');
}
}
// ?<3F>?<3F> ?<3F><>?지 ?<3F>록 모달 ?<3F>?<3F>
let imgModalBarcode = null;
let imgModalDrugCode = null;
let imgModalName = null;
let cameraStream = null;
let capturedImageData = null;
function openImageModal(barcode, drugCode, productName) {
if (!barcode && !drugCode) {
alert('?<3F>품 코드 ?<3F>보가 ?<3F>습?<3F>다');
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('카메?<3F>에 ?<3F>근?????<3F>습?<3F>다');
}
}
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('촬영???<3F><>?지가 ?<3F>습?<3F>다'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" ?<3F><>?지 ?<3F>??<3F>?..`);
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('???<3F><>?지 ?<3F>???<3F>료!', 'success'); searchProducts(); }
else showToast(data.error || '?<3F>???<3F>패', 'error');
} catch (err) { showToast('?<3F>류: ' + err.message, 'error'); }
}
async function submitImageUrl() {
const url = document.getElementById('imgUrlInput').value.trim();
if (!url) { alert('?<3F><>?지 URL???<3F>력?<3F>세??); return; }
if (!url.startsWith('http')) { alert('?<EFBFBD><EFBFBD>?URL???<EFBFBD>?<EFBFBD>??); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" ?<3F><>?지 ?<3F>운로드 <20>?..`);
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('???<3F><>?지 ?<3F>록 ?<3F>료!', 'success'); searchProducts(); }
else showToast(data.error || '?<3F>록 ?<3F>패', 'error');
} catch (err) { showToast('?<3F>류: ' + 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>
<!-- ?<3F><>?지 ?<3F>록 모달 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<h3>?<3F><> ?<3F>품 ?<3F><>?지 ?<3F></h3>
<div style="background:#f8fafc;border-radius:8px;padding:12px;margin-bottom:16px;">
<div style="font-weight:600;" id="imgModalProductName">?<3F><EFBFBD>?/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')">?<3F><> URL ?<3F></button>
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">?<3F><> 촬영</button>
</div>
<div class="img-tab-content active" id="imgTabUrl">
<input type="text" class="img-input" id="imgUrlInput" placeholder="?<3F><>?지 URL???<3F>력?<3F>세??..">
<div class="img-modal-btns">
<button class="img-modal-btn secondary" onclick="closeImageModal()">취소</button>
<button class="img-modal-btn primary" onclick="submitImageUrl()">?<3F>록?<3F></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()">?<3F><> 촬영</button>
</div>
<div class="img-modal-btns" id="previewBtns" style="display:none;">
<button class="img-modal-btn secondary" onclick="retakePhoto()">?<3F>시 촬영</button>
<button class="img-modal-btn primary" onclick="submitCapturedImage()">?<3F>?<3F><EFBFBD>?/button>
</div>
</div>
</div>
</div>
</body>
</html>