Files
tk-factory-services/system1-factory/web/static/js/purchase-request-mobile.js
Hyungi Ahn 6cd613c071 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>
2026-04-01 12:31:05 +09:00

484 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===== 소모품 신청 모바일 (장바구니 방식) ===== */
const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net')
? 'https://tkuser.technicalkorea.net'
: location.protocol + '//' + location.hostname + ':30180';
const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' };
const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' };
const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' };
const MATCH_LABELS = { exact: '정확', name: '이름', alias: '별칭', spec: '규격', chosung: '초성', chosung_alias: '초성' };
let currentPage = 1;
let currentStatus = '';
let totalPages = 1;
let isLoadingMore = false;
let requestsList = [];
let photoBase64 = null;
let searchTimer = null;
let lastSearchResults = [];
let cartItems = []; // [{item_id, item_name, spec, maker, category, quantity, notes, is_new, ...}]
/* ===== 상태 탭 필터 ===== */
function filterByStatus(btn) {
document.querySelectorAll('.pm-tab').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
currentStatus = btn.dataset.status;
currentPage = 1;
requestsList = [];
loadRequests();
}
/* ===== 신청 목록 로드 ===== */
async function loadRequests(append = false) {
try {
if (!append) {
document.getElementById('requestCards').innerHTML = '<div class="pm-loading">불러오는 중...</div>';
}
const params = new URLSearchParams({ page: currentPage, limit: 20 });
if (currentStatus) params.set('status', currentStatus);
const res = await api('/purchase-requests/my-requests?' + params.toString());
const items = res.data || [];
totalPages = res.pagination?.totalPages || 1;
if (append) { requestsList = requestsList.concat(items); } else { requestsList = items; }
renderCards();
} catch (e) {
document.getElementById('requestCards').innerHTML = `<div class="pm-empty"><i class="fas fa-exclamation-circle"></i>${escapeHtml(e.message)}</div>`;
}
}
function renderCards() {
const container = document.getElementById('requestCards');
if (!requestsList.length) {
container.innerHTML = '<div class="pm-empty"><i class="fas fa-box-open"></i>신청 내역이 없습니다.</div>';
return;
}
container.innerHTML = requestsList.map(r => {
const itemName = r.item_name || r.custom_item_name || '-';
const category = r.category || r.custom_category;
const catLabel = CAT_LABELS[category] || category || '';
const statusLabel = STATUS_LABELS[r.status] || r.status;
const statusColor = STATUS_COLORS[r.status] || 'badge-gray';
const isCustom = !r.item_id && r.custom_item_name;
return `<div class="pm-card" onclick="openDetail(${r.request_id})">
<div class="pm-card-header">
<div>
<div class="pm-card-name">${escapeHtml(itemName)}${isCustom ? '<span class="pm-card-custom">(직접입력)</span>' : ''}</div>
${catLabel ? `<span class="badge ${STATUS_COLORS[category] || 'badge-gray'} mt-1" style="font-size:11px">${catLabel}</span>` : ''}
</div>
<span class="badge ${statusColor}">${statusLabel}</span>
</div>
<div class="pm-card-meta">
<span>수량: <span class="pm-card-qty">${r.quantity}</span></span>
<span>${formatDate(r.request_date)}</span>
${r.batch_name ? `<span><i class="fas fa-layer-group"></i> ${escapeHtml(r.batch_name)}</span>` : ''}
</div>
</div>`;
}).join('');
}
/* ===== 무한 스크롤 ===== */
window.addEventListener('scroll', () => {
if (isLoadingMore || currentPage >= totalPages) return;
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
isLoadingMore = true;
currentPage++;
document.getElementById('loadingMore').classList.remove('hidden');
loadRequests(true).finally(() => {
isLoadingMore = false;
document.getElementById('loadingMore').classList.add('hidden');
});
}
});
/* ===== 상세 바텀시트 ===== */
function openDetail(requestId) {
const r = requestsList.find(x => x.request_id === requestId);
if (!r) return;
const itemName = r.item_name || r.custom_item_name || '-';
const category = r.category || r.custom_category;
const catLabel = CAT_LABELS[category] || category || '-';
const statusLabel = STATUS_LABELS[r.status] || r.status;
const statusColor = STATUS_COLORS[r.status] || 'badge-gray';
let html = `
<div class="text-center mb-4">
<div class="text-lg font-bold">${escapeHtml(itemName)}</div>
<span class="badge ${statusColor} mt-2">${statusLabel}</span>
</div>
<div class="pm-detail-row"><span class="pm-detail-label">분류</span><span class="pm-detail-value">${catLabel}</span></div>
<div class="pm-detail-row"><span class="pm-detail-label">수량</span><span class="pm-detail-value">${r.quantity}</span></div>
<div class="pm-detail-row"><span class="pm-detail-label">신청일</span><span class="pm-detail-value">${formatDate(r.request_date)}</span></div>`;
if (r.notes) html += `<div class="pm-detail-row"><span class="pm-detail-label">메모</span><span class="pm-detail-value">${escapeHtml(r.notes)}</span></div>`;
if (r.batch_name) html += `<div class="pm-detail-row"><span class="pm-detail-label">구매 그룹</span><span class="pm-detail-value">${escapeHtml(r.batch_name)}</span></div>`;
if (r.hold_reason) html += `<div class="pm-detail-row"><span class="pm-detail-label">보류 사유</span><span class="pm-detail-value text-red-600">${escapeHtml(r.hold_reason)}</span></div>`;
if (r.status === 'received') {
html += `<div class="mt-4 p-3 bg-teal-50 rounded-lg"><div class="text-sm font-semibold text-teal-700 mb-2"><i class="fas fa-box-open mr-1"></i>입고 완료</div>`;
if (r.received_location) html += `<div class="text-sm text-gray-700"><i class="fas fa-map-marker-alt mr-1 text-teal-500"></i>보관위치: ${escapeHtml(r.received_location)}</div>`;
if (r.received_at) html += `<div class="text-xs text-gray-500 mt-1">${formatDateTime(r.received_at)}${r.received_by_name ? ' · ' + escapeHtml(r.received_by_name) : ''}</div>`;
if (r.received_photo_path) html += `<img src="${r.received_photo_path}" class="pm-received-photo" onerror="this.style.display='none'">`;
html += `</div>`;
}
if (r.pr_photo_path) html += `<div class="mt-3"><div class="text-xs text-gray-500 mb-1">첨부 사진</div><img src="${r.pr_photo_path}" class="pm-received-photo" onerror="this.style.display='none'"></div>`;
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailOverlay').classList.add('open');
document.getElementById('detailSheet').classList.add('open');
}
function closeDetailSheet() {
document.getElementById('detailOverlay').classList.remove('open');
document.getElementById('detailSheet').classList.remove('open');
}
/* ===== 신청 바텀시트 ===== */
function openRequestSheet() {
document.getElementById('requestOverlay').classList.add('open');
document.getElementById('requestSheet').classList.add('open');
document.getElementById('searchInput').focus();
}
function closeRequestSheet() {
document.getElementById('requestOverlay').classList.remove('open');
document.getElementById('requestSheet').classList.remove('open');
resetRequestForm();
}
function resetRequestForm() {
document.getElementById('searchInput').value = '';
document.getElementById('searchResults').classList.remove('open');
cartItems = [];
renderCart();
document.getElementById('reqPhotoInput').value = '';
document.getElementById('reqPhotoPreview').classList.add('hidden');
document.getElementById('photoLabel').textContent = '사진 촬영/선택';
photoBase64 = null;
updateSubmitBtn();
}
/* ===== 서버 스마트 검색 ===== */
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('searchInput');
input.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = input.value.trim();
if (q.length === 0) {
document.getElementById('searchResults').classList.remove('open');
document.getElementById('searchSpinner').classList.remove('show');
return;
}
document.getElementById('searchSpinner').classList.add('show');
searchTimer = setTimeout(async () => {
try {
const res = await api('/purchase-requests/search?q=' + encodeURIComponent(q));
renderSearchResults(res.data || [], q);
} catch (e) { console.error('검색 오류:', e); }
finally { document.getElementById('searchSpinner').classList.remove('show'); }
}, 300);
});
});
function renderSearchResults(items, query) {
lastSearchResults = items;
const container = document.getElementById('searchResults');
let html = '';
items.forEach(item => {
const catLabel = CAT_LABELS[item.category] || '';
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>` : ''}
</div>
${matchLabel ? `<span class="match-type">${matchLabel}</span>` : ''}
</div>`;
});
html += `<div class="pm-search-register" onclick="addNewToCart()">
<i class="fas fa-plus-circle"></i>
<span>"${escapeHtml(query)}" 새 품목으로 추가</span>
</div>`;
container.innerHTML = html;
container.classList.add('open');
}
/* ===== 장바구니 ===== */
function addToCart(itemId) {
const item = lastSearchResults.find(i => i.item_id === itemId);
if (!item) return;
// 동일 품목이면 수량 +1
const existing = cartItems.find(c => c.item_id === item.item_id && !c.is_new);
if (existing) {
existing.quantity++;
showToast(`${item.item_name} 수량이 ${existing.quantity}개로 추가되었습니다.`);
} else {
cartItems.push({
item_id: item.item_id,
item_name: item.item_name,
spec: item.spec,
maker: item.maker,
category: item.category,
photo_path: item.photo_path,
quantity: 1,
notes: '',
is_new: false
});
}
document.getElementById('searchInput').value = '';
document.getElementById('searchResults').classList.remove('open');
renderCart();
updateSubmitBtn();
}
function addNewToCart() {
const query = document.getElementById('searchInput').value.trim();
if (!query) return;
cartItems.push({
item_id: null,
item_name: query,
spec: '',
maker: '',
category: '',
photo_path: null,
item_photo: null, // base64 — 마스터 사진으로 저장될 사진
quantity: 1,
notes: '',
is_new: true
});
document.getElementById('searchInput').value = '';
document.getElementById('searchResults').classList.remove('open');
renderCart();
updateSubmitBtn();
}
function removeFromCart(idx) {
cartItems.splice(idx, 1);
renderCart();
updateSubmitBtn();
}
function updateCartQty(idx, val) {
const qty = parseInt(val) || 1;
cartItems[idx].quantity = Math.max(1, qty);
}
function updateCartNotes(idx, val) {
cartItems[idx].notes = val;
}
function updateCartNewField(idx, field, val) {
cartItems[idx][field] = val;
}
function renderCart() {
const wrap = document.getElementById('cartWrap');
const list = document.getElementById('cartList');
const count = document.getElementById('cartCount');
if (cartItems.length === 0) {
wrap.classList.add('hidden');
return;
}
wrap.classList.remove('hidden');
count.textContent = cartItems.length + '건';
list.innerHTML = cartItems.map((c, idx) => {
const spec = c.spec ? ' [' + escapeHtml(c.spec) + ']' : '';
const maker = c.maker ? ' (' + escapeHtml(c.maker) + ')' : '';
const catLabel = CAT_LABELS[c.category] || '';
// 사진 썸네일
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) {
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)">
<option value="">분류</option>
<option value="consumable" ${c.category==='consumable'?'selected':''}>소모품</option>
<option value="safety" ${c.category==='safety'?'selected':''}>안전용품</option>
<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>
${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)">
<button class="pm-cart-remove" onclick="removeFromCart(${idx})">×</button>
</div>`;
}).join('');
}
function updateSubmitBtn() {
const btn = document.getElementById('submitBtn');
if (cartItems.length > 0) {
btn.disabled = false;
btn.textContent = cartItems.length + '건 신청하기';
} else {
btn.disabled = true;
btn.textContent = '품목을 추가해주세요';
}
}
/* ===== 품목 사진 (장바구니 내 신규/기존 품목) ===== */
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;
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') {
document.getElementById('photoLabel').textContent = '변환 중...';
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');
document.getElementById('photoLabel').textContent = '사진 촬영/선택';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
photoBase64 = e.target.result;
document.getElementById('reqPhotoPreview').src = photoBase64;
document.getElementById('reqPhotoPreview').classList.remove('hidden');
document.getElementById('photoLabel').textContent = '사진 변경';
};
reader.readAsDataURL(processFile);
}
/* ===== 일괄 신청 제출 ===== */
async function submitRequest() {
if (cartItems.length === 0) { showToast('품목을 추가해주세요.', 'error'); return; }
const btn = document.getElementById('submitBtn');
btn.disabled = true;
btn.textContent = '처리 중...';
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, item_photo: c.item_photo || null };
}
return { item_id: c.item_id, quantity: c.quantity, notes: c.notes || null };
});
const body = { items };
if (photoBase64) body.photo = photoBase64;
await api('/purchase-requests/bulk', { method: 'POST', body: JSON.stringify(body) });
showToast(`${cartItems.length}건 소모품 신청이 등록되었습니다.`);
closeRequestSheet();
currentPage = 1;
requestsList = [];
await loadRequests();
} catch (e) {
showToast(e.message, 'error');
} finally {
btn.disabled = false;
updateSubmitBtn();
}
}
/* ===== URL 파라미터로 상세 열기 ===== */
function checkViewParam() {
const urlParams = new URLSearchParams(location.search);
const viewId = urlParams.get('view');
if (viewId) setTimeout(() => openDetail(parseInt(viewId)), 500);
}
/* ===== Init ===== */
(async function() {
if (!await initAuth()) return;
await loadRequests();
checkViewParam();
})();