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 = '|||||||||||
검색 중... | ||||||||||||
검색 중... | ||||||||||||
오류: ${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();
+ });
+
+
+
+
+
| |||||||||||