diff --git a/backend/app.py b/backend/app.py index 4e426b1..f9fe199 100644 --- a/backend/app.py +++ b/backend/app.py @@ -3447,7 +3447,8 @@ def api_products(): 'stock': int(row.stock) if row.stock else 0, 'apc': apc, 'category': None, # PostgreSQL에서 lazy fetch - 'wholesaler_stock': None + 'wholesaler_stock': None, + 'thumbnail': None # 아래에서 채움 }) # 동물약 분류 Lazy Fetch (PostgreSQL) - 실패해도 무시 @@ -3482,6 +3483,47 @@ def api_products(): logging.warning(f"PostgreSQL 분류 조회 실패 (무시): {pg_err}") # PostgreSQL 실패해도 MSSQL 데이터는 정상 반환 + # 제품 이미지 조회 (product_images.db) + try: + images_db_path = Path(__file__).parent / 'db' / 'product_images.db' + if images_db_path.exists(): + img_conn = sqlite3.connect(str(images_db_path)) + img_cursor = img_conn.cursor() + + barcodes = [item['barcode'] for item in items if item['barcode']] + drug_codes = [item['drug_code'] for item in items if item['drug_code']] + + image_map = {} + if barcodes: + placeholders = ','.join(['?' for _ in barcodes]) + img_cursor.execute(f''' + SELECT barcode, thumbnail_base64 + FROM product_images + WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL + ''', barcodes) + for row in img_cursor.fetchall(): + image_map[f'bc:{row[0]}'] = row[1] + + if drug_codes: + placeholders = ','.join(['?' for _ in drug_codes]) + img_cursor.execute(f''' + SELECT drug_code, thumbnail_base64 + FROM product_images + WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL + ''', drug_codes) + for row in img_cursor.fetchall(): + if f'dc:{row[0]}' not in image_map: + image_map[f'dc:{row[0]}'] = row[1] + + img_conn.close() + + for item in items: + thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}') + if thumb: + item['thumbnail'] = thumb + except Exception as img_err: + logging.warning(f"제품 이미지 조회 오류: {img_err}") + return jsonify({ 'success': True, 'items': items, diff --git a/backend/templates/admin_products.html b/backend/templates/admin_products.html index bf1ec0e..1acf396 100644 --- a/backend/templates/admin_products.html +++ b/backend/templates/admin_products.html @@ -547,6 +547,129 @@ .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; } @@ -607,6 +730,7 @@ + @@ -617,7 +741,7 @@ - @@ -710,7 +834,7 @@ } const tbody = document.getElementById('productsTableBody'); - tbody.innerHTML = ''; + tbody.innerHTML = ''; const inStockOnly = document.getElementById('inStockOnly').checked; let url = `/api/products?search=${encodeURIComponent(search)}`; @@ -725,11 +849,11 @@ document.getElementById('resultNum').textContent = productsData.length; renderTable(); } else { - tbody.innerHTML = ``; + tbody.innerHTML = ``; } }) .catch(err => { - tbody.innerHTML = ''; + tbody.innerHTML = ''; }); } @@ -737,7 +861,7 @@ const tbody = document.getElementById('productsTableBody'); if (productsData.length === 0) { - tbody.innerHTML = ''; + tbody.innerHTML = ''; return; } @@ -753,6 +877,12 @@ return ` +
이미지 상품명 상품코드 바코드
+
🔍

상품명, 바코드, 상품코드로 검색하세요

검색 중...

검색 중...

오류: ${data.error}

오류: ${data.error}

검색 실패

검색 실패

📭

검색 결과가 없습니다

📭

검색 결과가 없습니다

+ ${item.thumbnail + ? `` + : `
` + } +
${escapeHtml(item.product_name)} @@ -1069,6 +1199,197 @@ 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(); + }); + + +
+
+

📷 제품 이미지 등록

+
+
제품명
+
코드
+
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+
+ + +
+ + + +
+
+
+ + +
+ +
+
+