feat(purchase): 소모품 사진 기능 — 검색 썸네일 + 신규/기존 품목 마스터 사진 등록

- 모바일 검색 결과에 품목 사진 썸네일 표시 (photo_path 있으면 이미지, 없으면 아이콘)
- 데스크탑 검색 드롭다운에도 사진 썸네일 추가
- 신규 품목 등록 시 사진 촬영 → consumable_items.photo_path에 저장 (bulk API)
- 기존 품목에 사진 없을 때 장바구니에서 "품목 사진 등록" → PUT /consumable-items/:id/photo
- imageUploadService에 consumables 디렉토리 추가
- HEIC 변환 + 폴백 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-01 12:31:05 +09:00
parent ba2e3481e9
commit 6cd613c071
8 changed files with 164 additions and 14 deletions

View File

@@ -225,9 +225,14 @@ const PurchaseRequestController = {
if (existing.length > 0) {
itemId = existing[0].item_id;
} else {
// 신규 품목 사진 저장 (마스터에)
let itemPhotoPath = null;
if (item.item_photo) {
itemPhotoPath = await saveBase64Image(item.item_photo, 'item', 'consumables');
}
const [ins] = await conn.query(
`INSERT INTO consumable_items (item_name, spec, maker, category, is_active) VALUES (?, ?, ?, ?, 1)`,
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable']
`INSERT INTO consumable_items (item_name, spec, maker, category, photo_path, is_active) VALUES (?, ?, ?, ?, ?, 1)`,
[item.item_name.trim(), item.spec || null, item.maker || null, item.category || 'consumable', itemPhotoPath]
);
itemId = ins.insertId;
newItemRegistered = true;
@@ -261,6 +266,25 @@ const PurchaseRequestController = {
}
},
// 품목 마스터 사진 등록/업데이트
updateItemPhoto: async (req, res) => {
try {
const { photo } = req.body;
if (!photo) return res.status(400).json({ success: false, message: '사진을 첨부해주세요.' });
const itemPhotoPath = await saveBase64Image(photo, 'item', 'consumables');
if (!itemPhotoPath) return res.status(500).json({ success: false, message: '사진 저장에 실패했습니다.' });
const { getDb } = require('../dbPool');
const db = await getDb();
await db.query('UPDATE consumable_items SET photo_path = ? WHERE item_id = ?', [itemPhotoPath, req.params.id]);
koreanSearch.clearCache();
res.json({ success: true, data: { photo_path: itemPhotoPath }, message: '품목 사진이 등록되었습니다.' });
} catch (err) {
logger.error('updateItemPhoto error:', err);
res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' });
}
},
// 스마트 검색 (초성 + 별칭 + substring)
search: async (req, res) => {
try {

View File

@@ -7,6 +7,7 @@ const requirePage = createRequirePage(getDb);
// 보조 데이터
router.get('/consumable-items', ctrl.getConsumableItems);
router.put('/consumable-items/:id/photo', ctrl.updateItemPhoto);
router.get('/vendors', ctrl.getVendors);
router.get('/search', ctrl.search);

View File

@@ -24,7 +24,8 @@ const UPLOAD_DIRS = {
issues: path.join(__dirname, '../uploads/issues'),
equipments: path.join(__dirname, '../uploads/equipments'),
purchase_requests: path.join(__dirname, '../uploads/purchase_requests'),
purchase_received: path.join(__dirname, '../uploads/purchase_received')
purchase_received: path.join(__dirname, '../uploads/purchase_received'),
consumables: path.join(__dirname, '../uploads/consumables')
};
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 };

View File

@@ -184,6 +184,26 @@
display: none;
}
.pm-search-results.open { display: block; }
.pm-search-thumb {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.pm-search-thumb-empty {
width: 36px;
height: 36px;
border-radius: 6px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #d1d5db;
font-size: 14px;
flex-shrink: 0;
}
.pm-search-item {
padding: 10px 12px;
font-size: 14px;
@@ -255,6 +275,25 @@
font-size: 12px;
flex-shrink: 0;
}
.pm-cart-thumb {
width: 40px;
height: 40px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: #f3f4f6;
}
.pm-cart-photo-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px dashed #d1d5db;
border-radius: 4px;
font-size: 11px;
color: #6b7280;
cursor: pointer;
}
.pm-cart-remove {
width: 24px;
height: 24px;

View File

@@ -6,8 +6,8 @@
<title>소모품 신청 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/purchase-mobile.css?v=2026040103">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040104">
<link rel="stylesheet" href="/css/purchase-mobile.css?v=2026040104">
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
</head>
<body class="bg-gray-50">
@@ -97,8 +97,8 @@
<div class="pm-sheet-body" id="detailContent"></div>
</div>
<script src="/static/js/tkfb-core.js?v=2026040103"></script>
<script src="/static/js/purchase-request-mobile.js?v=2026040103"></script>
<script src="/static/js/tkfb-core.js?v=2026040104"></script>
<script src="/static/js/purchase-request-mobile.js?v=2026040104"></script>
<script src="/static/js/shared-bottom-nav.js?v=2026040102"></script>
</body>
</html>

View File

@@ -312,7 +312,7 @@
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026040103"></script>
<script src="/static/js/purchase-request.js?v=2026040103"></script>
<script src="/static/js/tkfb-core.js?v=2026040104"></script>
<script src="/static/js/purchase-request.js?v=2026040104"></script>
</body>
</html>

View File

@@ -186,7 +186,9 @@ function renderSearchResults(items, query) {
const matchLabel = MATCH_LABELS[item._matchType] || '';
const spec = item.spec ? ' [' + escapeHtml(item.spec) + ']' : '';
const maker = item.maker ? ' (' + escapeHtml(item.maker) + ')' : '';
const photoUrl = item.photo_path ? (item.photo_path.startsWith('http') ? item.photo_path : TKUSER_BASE_URL + item.photo_path) : '';
html += `<div class="pm-search-item" onclick="addToCart(${item.item_id})">
${photoUrl ? `<img src="${photoUrl}" class="pm-search-thumb" onerror="this.style.display='none'">` : '<div class="pm-search-thumb-empty"><i class="fas fa-box"></i></div>'}
<div class="flex-1">
<div class="text-sm font-medium">${escapeHtml(item.item_name)}${spec}${maker}</div>
${catLabel ? `<div class="text-xs text-gray-400 mt-0.5">${catLabel}</div>` : ''}
@@ -219,6 +221,7 @@ function addToCart(itemId) {
spec: item.spec,
maker: item.maker,
category: item.category,
photo_path: item.photo_path,
quantity: 1,
notes: '',
is_new: false
@@ -239,6 +242,8 @@ function addNewToCart() {
spec: '',
maker: '',
category: '',
photo_path: null,
item_photo: null, // base64 — 마스터 사진으로 저장될 사진
quantity: 1,
notes: '',
is_new: true
@@ -285,9 +290,18 @@ function renderCart() {
const maker = c.maker ? ' (' + escapeHtml(c.maker) + ')' : '';
const catLabel = CAT_LABELS[c.category] || '';
let newFields = '';
// 사진 썸네일
let thumbHtml = '';
if (c.item_photo) {
thumbHtml = `<img src="${c.item_photo}" class="pm-cart-thumb">`;
} else if (c.photo_path) {
const url = c.photo_path.startsWith('http') ? c.photo_path : TKUSER_BASE_URL + c.photo_path;
thumbHtml = `<img src="${url}" class="pm-cart-thumb" onerror="this.style.display='none'">`;
}
let extraFields = '';
if (c.is_new) {
newFields = `<div class="pm-cart-new-fields">
extraFields = `<div class="pm-cart-new-fields">
<input type="text" placeholder="규격" value="${escapeHtml(c.spec || '')}" oninput="updateCartNewField(${idx},'spec',this.value)">
<input type="text" placeholder="제조사" value="${escapeHtml(c.maker || '')}" oninput="updateCartNewField(${idx},'maker',this.value)">
<select onchange="updateCartNewField(${idx},'category',this.value)">
@@ -297,14 +311,29 @@ function renderCart() {
<option value="repair" ${c.category==='repair'?'selected':''}>수선비</option>
<option value="equipment" ${c.category==='equipment'?'selected':''}>설비</option>
</select>
</div>
<div class="pm-cart-new-fields" style="margin-top:4px">
<label class="pm-cart-photo-btn">
<i class="fas fa-camera"></i> ${c.item_photo ? '사진 변경' : '품목 사진'}
<input type="file" accept="image/*,.heic,.heif" capture="environment" class="hidden" onchange="onItemPhotoSelected(${idx},this)">
</label>
</div>`;
} else if (!c.photo_path) {
// 기존 품목이지만 사진 없음 → 사진 등록 가능
extraFields = `<div class="pm-cart-new-fields" style="margin-top:4px">
<label class="pm-cart-photo-btn">
<i class="fas fa-camera"></i> 품목 사진 등록
<input type="file" accept="image/*,.heic,.heif" capture="environment" class="hidden" onchange="uploadExistingItemPhoto(${idx},this)">
</label>
</div>`;
}
return `<div class="pm-cart-item">
${thumbHtml}
<div class="pm-cart-item-info">
<div class="pm-cart-item-name">${escapeHtml(c.item_name)}${spec}${maker}</div>
<div class="pm-cart-item-meta">${catLabel}${c.is_new ? ' <span class="pm-cart-item-new">(신규등록)</span>' : ''}</div>
${newFields}
${extraFields}
</div>
<input type="number" class="pm-cart-qty" value="${c.quantity}" min="1" inputmode="numeric" onchange="updateCartQty(${idx},this.value)">
<input type="text" class="pm-cart-memo" placeholder="메모" value="${escapeHtml(c.notes || '')}" oninput="updateCartNotes(${idx},this.value)">
@@ -324,7 +353,61 @@ function updateSubmitBtn() {
}
}
/* ===== 사진 ===== */
/* ===== 품목 사진 (장바구니 내 신규/기존 품목) ===== */
async function onItemPhotoSelected(idx, inputEl) {
const file = inputEl.files[0];
if (!file) return;
let processFile = file;
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' ||
file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic && typeof heic2any !== 'undefined') {
try {
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.85 });
processFile = new File([blob], file.name.replace(/\.hei[cf]$/i, '.jpg'), { type: 'image/jpeg' });
} catch (e) { console.warn('HEIC 변환 실패:', e); }
}
if (processFile.size > 10 * 1024 * 1024) { showToast('10MB 이하만 가능합니다.', 'error'); return; }
const reader = new FileReader();
reader.onload = (e) => {
cartItems[idx].item_photo = e.target.result;
renderCart();
};
reader.readAsDataURL(processFile);
}
// 기존 품목(사진 없음)에 사진 업로드 → 마스터에 저장
async function uploadExistingItemPhoto(idx, inputEl) {
const file = inputEl.files[0];
if (!file) return;
let processFile = file;
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' ||
file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
if (isHeic && typeof heic2any !== 'undefined') {
try {
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.85 });
processFile = new File([blob], file.name.replace(/\.hei[cf]$/i, '.jpg'), { type: 'image/jpeg' });
} catch (e) { console.warn('HEIC 변환 실패:', e); }
}
if (processFile.size > 10 * 1024 * 1024) { showToast('10MB 이하만 가능합니다.', 'error'); return; }
const reader = new FileReader();
reader.onload = async (e) => {
const base64 = e.target.result;
try {
const res = await api(`/purchase-requests/consumable-items/${cartItems[idx].item_id}/photo`, {
method: 'PUT', body: JSON.stringify({ photo: base64 })
});
if (res.data?.photo_path) {
cartItems[idx].photo_path = res.data.photo_path;
cartItems[idx].item_photo = null;
}
showToast('품목 사진이 등록되었습니다.');
renderCart();
} catch (err) { showToast(err.message, 'error'); }
};
reader.readAsDataURL(processFile);
}
/* ===== 참고 사진 (신청 공통) ===== */
async function onMobilePhotoSelected(inputEl) {
const file = inputEl.files[0];
if (!file) return;
@@ -364,7 +447,7 @@ async function submitRequest() {
try {
const items = cartItems.map(c => {
if (c.is_new) {
return { item_name: c.item_name, spec: c.spec || null, maker: c.maker || null, category: c.category || null, quantity: c.quantity, notes: c.notes || null, is_new: true };
return { item_name: c.item_name, spec: c.spec || null, maker: c.maker || null, category: c.category || null, quantity: c.quantity, notes: c.notes || null, is_new: true, item_photo: c.item_photo || null };
}
return { item_id: c.item_id, quantity: c.quantity, notes: c.notes || null };
});

View File

@@ -130,7 +130,9 @@ function showDropdown(items, query) {
const fg = CAT_FG[item.category] || '#374151';
const spec = _fmtSpec(item.spec ? escapeHtml(item.spec) : '');
const maker = item.maker ? ` (${escapeHtml(item.maker)})` : '';
const photoSrc = item.photo_path ? (item.photo_path.startsWith('http') ? item.photo_path : TKUSER_BASE_URL + item.photo_path) : '';
html += `<div class="item-dropdown-item" data-item-id="${item.item_id}" onclick="selectItem(${item.item_id})">
${photoSrc ? `<img src="${photoSrc}" style="width:28px;height:28px;border-radius:4px;object-fit:cover" onerror="this.style.display='none'">` : ''}
<span class="cat-tag" style="background:${bg};color:${fg}">${catLabel}</span>
<span>${escapeHtml(item.item_name)}${spec}${maker}</span>
</div>`;