diff --git a/system1-factory/api/controllers/purchaseRequestController.js b/system1-factory/api/controllers/purchaseRequestController.js index 2e6ae05..d89cc82 100644 --- a/system1-factory/api/controllers/purchaseRequestController.js +++ b/system1-factory/api/controllers/purchaseRequestController.js @@ -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 { diff --git a/system1-factory/api/routes/purchaseRequestRoutes.js b/system1-factory/api/routes/purchaseRequestRoutes.js index ca46250..3e31498 100644 --- a/system1-factory/api/routes/purchaseRequestRoutes.js +++ b/system1-factory/api/routes/purchaseRequestRoutes.js @@ -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); diff --git a/system1-factory/api/services/imageUploadService.js b/system1-factory/api/services/imageUploadService.js index 73e9dc7..d494525 100644 --- a/system1-factory/api/services/imageUploadService.js +++ b/system1-factory/api/services/imageUploadService.js @@ -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 }; diff --git a/system1-factory/web/css/purchase-mobile.css b/system1-factory/web/css/purchase-mobile.css index 506e37b..ccc8550 100644 --- a/system1-factory/web/css/purchase-mobile.css +++ b/system1-factory/web/css/purchase-mobile.css @@ -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; diff --git a/system1-factory/web/pages/purchase/request-mobile.html b/system1-factory/web/pages/purchase/request-mobile.html index 6e51717..c555a9a 100644 --- a/system1-factory/web/pages/purchase/request-mobile.html +++ b/system1-factory/web/pages/purchase/request-mobile.html @@ -6,8 +6,8 @@ 소모품 신청 - TK 공장관리 - - + + @@ -97,8 +97,8 @@
- - + + diff --git a/system1-factory/web/pages/purchase/request.html b/system1-factory/web/pages/purchase/request.html index 88a3c7d..010ef2e 100644 --- a/system1-factory/web/pages/purchase/request.html +++ b/system1-factory/web/pages/purchase/request.html @@ -312,7 +312,7 @@ - - + + diff --git a/system1-factory/web/static/js/purchase-request-mobile.js b/system1-factory/web/static/js/purchase-request-mobile.js index 83b0880..7cbfab3 100644 --- a/system1-factory/web/static/js/purchase-request-mobile.js +++ b/system1-factory/web/static/js/purchase-request-mobile.js @@ -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 += `
+ ${photoUrl ? `` : '
'}
${escapeHtml(item.item_name)}${spec}${maker}
${catLabel ? `
${catLabel}
` : ''} @@ -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 = ``; + } else if (c.photo_path) { + const url = c.photo_path.startsWith('http') ? c.photo_path : TKUSER_BASE_URL + c.photo_path; + thumbHtml = ``; + } + + let extraFields = ''; if (c.is_new) { - newFields = `
+ extraFields = `
+
+
+ +
`; + } else if (!c.photo_path) { + // 기존 품목이지만 사진 없음 → 사진 등록 가능 + extraFields = `
+
`; } return `
+ ${thumbHtml}
${escapeHtml(c.item_name)}${spec}${maker}
${catLabel}${c.is_new ? ' (신규등록)' : ''}
- ${newFields} + ${extraFields}
@@ -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 }; }); diff --git a/system1-factory/web/static/js/purchase-request.js b/system1-factory/web/static/js/purchase-request.js index e4ac3d2..520d263 100644 --- a/system1-factory/web/static/js/purchase-request.js +++ b/system1-factory/web/static/js/purchase-request.js @@ -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 += `
+ ${photoSrc ? `` : ''} ${catLabel} ${escapeHtml(item.item_name)}${spec}${maker}
`;