feat(purchase): 구매신청 검색/직접입력/사진첨부/HEIC 지원/마스터 자동등록
- 소모품 select → 검색형 드롭다운 (debounce + 키보드 탐색) - 미등록 품목 직접 입력 + 분류 선택 지원 - 사진 첨부 (base64 업로드, HEIC→JPEG 프론트 변환) - 구매 처리 시 미등록 품목 소모품 마스터 자동 등록 - item_id NULL 허용, LEFT JOIN, custom_item_name/custom_category/photo_path 컬럼 - DB 마이그레이션 필요: ALTER TABLE purchase_requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net')
|
||||
|
||||
const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' };
|
||||
const CAT_COLORS = { consumable: 'badge-blue', safety: 'badge-green', repair: 'badge-amber', equipment: 'badge-purple' };
|
||||
const CAT_BG = { consumable: '#dbeafe', safety: '#dcfce7', repair: '#fef3c7', equipment: '#f3e8ff' };
|
||||
const CAT_FG = { consumable: '#1e40af', safety: '#166534', repair: '#92400e', equipment: '#7e22ce' };
|
||||
const STATUS_LABELS = { pending: '대기', purchased: '구매완료', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', purchased: 'badge-green', hold: 'badge-gray' };
|
||||
|
||||
@@ -14,6 +16,9 @@ let requestsList = [];
|
||||
let currentRequestForPurchase = null;
|
||||
let currentRequestForHold = null;
|
||||
let isAdmin = false;
|
||||
let photoBase64 = null;
|
||||
let searchDebounceTimer = null;
|
||||
let dropdownActiveIndex = -1;
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
@@ -23,45 +28,141 @@ async function loadInitialData() {
|
||||
]);
|
||||
consumableItems = itemsRes.data || [];
|
||||
vendorsList = vendorsRes.data || [];
|
||||
populateItemSelect();
|
||||
populateVendorSelect();
|
||||
} catch (e) {
|
||||
console.error('초기 데이터 로드 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateItemSelect() {
|
||||
const sel = document.getElementById('prItemSelect');
|
||||
const groups = {};
|
||||
consumableItems.forEach(item => {
|
||||
const cat = CAT_LABELS[item.category] || item.category;
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(item);
|
||||
});
|
||||
let html = '<option value="">소모품 선택</option>';
|
||||
for (const [cat, items] of Object.entries(groups)) {
|
||||
html += `<optgroup label="${cat}">`;
|
||||
items.forEach(item => {
|
||||
const maker = item.maker ? ` (${escapeHtml(item.maker)})` : '';
|
||||
html += `<option value="${item.item_id}">${escapeHtml(item.item_name)}${maker}</option>`;
|
||||
});
|
||||
html += '</optgroup>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
|
||||
function populateVendorSelect() {
|
||||
const sel = document.getElementById('pmVendor');
|
||||
sel.innerHTML = '<option value="">업체 선택 (선택사항)</option>' +
|
||||
vendorsList.map(v => `<option value="${v.vendor_id}">${escapeHtml(v.vendor_name)}</option>`).join('');
|
||||
}
|
||||
|
||||
function onItemSelect() {
|
||||
const itemId = parseInt(document.getElementById('prItemSelect').value);
|
||||
const preview = document.getElementById('prItemPreview');
|
||||
const item = consumableItems.find(i => i.item_id === itemId);
|
||||
if (!item) { preview.classList.add('hidden'); return; }
|
||||
/* ===== 검색형 품목 선택 ===== */
|
||||
function initItemSearch() {
|
||||
const input = document.getElementById('prItemSearch');
|
||||
const dropdown = document.getElementById('prItemDropdown');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(searchDebounceTimer);
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
const query = input.value.trim();
|
||||
if (query.length === 0) {
|
||||
// 빈 입력: 전체 목록 보여주기
|
||||
showDropdown(consumableItems.slice(0, 30), '');
|
||||
} else {
|
||||
const lower = query.toLowerCase();
|
||||
const filtered = consumableItems.filter(item =>
|
||||
item.item_name.toLowerCase().includes(lower) ||
|
||||
(item.maker && item.maker.toLowerCase().includes(lower))
|
||||
);
|
||||
showDropdown(filtered, query);
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
const query = input.value.trim();
|
||||
if (query.length === 0) {
|
||||
showDropdown(consumableItems.slice(0, 30), '');
|
||||
} else {
|
||||
const lower = query.toLowerCase();
|
||||
const filtered = consumableItems.filter(item =>
|
||||
item.item_name.toLowerCase().includes(lower) ||
|
||||
(item.maker && item.maker.toLowerCase().includes(lower))
|
||||
);
|
||||
showDropdown(filtered, query);
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = dropdown.querySelectorAll('.item-dropdown-item, .item-dropdown-custom');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
dropdownActiveIndex = Math.min(dropdownActiveIndex + 1, items.length - 1);
|
||||
updateDropdownActive(items);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
dropdownActiveIndex = Math.max(dropdownActiveIndex - 1, 0);
|
||||
updateDropdownActive(items);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (dropdownActiveIndex >= 0 && dropdownActiveIndex < items.length) {
|
||||
items[dropdownActiveIndex].click();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
// 외부 클릭 시 드롭다운 닫기
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#prItemSearch') && !e.target.closest('#prItemDropdown')) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropdown(items, query) {
|
||||
const dropdown = document.getElementById('prItemDropdown');
|
||||
dropdownActiveIndex = -1;
|
||||
|
||||
let html = '';
|
||||
if (items.length > 0) {
|
||||
items.forEach((item, idx) => {
|
||||
const catLabel = CAT_LABELS[item.category] || item.category;
|
||||
const bg = CAT_BG[item.category] || '#f3f4f6';
|
||||
const fg = CAT_FG[item.category] || '#374151';
|
||||
const maker = item.maker ? ` (${escapeHtml(item.maker)})` : '';
|
||||
html += `<div class="item-dropdown-item" data-item-id="${item.item_id}" onclick="selectItem(${item.item_id})">
|
||||
<span class="cat-tag" style="background:${bg};color:${fg}">${catLabel}</span>
|
||||
<span>${escapeHtml(item.item_name)}${maker}</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 직접 입력 옵션 (검색어가 있을 때만)
|
||||
if (query.length > 0) {
|
||||
html += `<div class="item-dropdown-custom" onclick="selectCustomItem()">
|
||||
<i class="fas fa-plus-circle"></i>
|
||||
<span>"${escapeHtml(query)}"(으)로 직접 신청</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (html) {
|
||||
dropdown.innerHTML = html;
|
||||
dropdown.classList.add('open');
|
||||
} else {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function updateDropdownActive(items) {
|
||||
items.forEach((el, idx) => {
|
||||
el.classList.toggle('active', idx === dropdownActiveIndex);
|
||||
if (idx === dropdownActiveIndex) el.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
document.getElementById('prItemDropdown').classList.remove('open');
|
||||
dropdownActiveIndex = -1;
|
||||
}
|
||||
|
||||
function selectItem(itemId) {
|
||||
const item = consumableItems.find(i => i.item_id === itemId);
|
||||
if (!item) return;
|
||||
|
||||
const input = document.getElementById('prItemSearch');
|
||||
input.value = item.item_name + (item.maker ? ' (' + item.maker + ')' : '');
|
||||
document.getElementById('prItemId').value = item.item_id;
|
||||
document.getElementById('prCustomItemName').value = '';
|
||||
closeDropdown();
|
||||
|
||||
// 미리보기
|
||||
const preview = document.getElementById('prItemPreview');
|
||||
preview.classList.remove('hidden');
|
||||
const photoEl = document.getElementById('prItemPhoto');
|
||||
if (item.photo_path) {
|
||||
@@ -74,27 +175,114 @@ function onItemSelect() {
|
||||
document.getElementById('prItemInfo').textContent = `${item.item_name} ${item.maker ? '(' + item.maker + ')' : ''}`;
|
||||
const price = item.base_price ? Number(item.base_price).toLocaleString() + '원/' + (item.unit || 'EA') : '기준가 미설정';
|
||||
document.getElementById('prItemPrice').textContent = price;
|
||||
|
||||
// 분류 선택 숨김
|
||||
document.getElementById('prCustomCategoryWrap').classList.add('hidden');
|
||||
}
|
||||
|
||||
function selectCustomItem() {
|
||||
const input = document.getElementById('prItemSearch');
|
||||
const customName = input.value.trim();
|
||||
if (!customName) return;
|
||||
|
||||
document.getElementById('prItemId').value = '';
|
||||
document.getElementById('prCustomItemName').value = customName;
|
||||
closeDropdown();
|
||||
|
||||
// 미리보기 숨기고 분류 선택 표시
|
||||
document.getElementById('prItemPreview').classList.add('hidden');
|
||||
document.getElementById('prCustomCategoryWrap').classList.remove('hidden');
|
||||
}
|
||||
|
||||
/* ===== 사진 첨부 ===== */
|
||||
async function onPhotoSelected(inputEl) {
|
||||
const file = inputEl.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const statusEl = document.getElementById('prPhotoStatus');
|
||||
let processFile = file;
|
||||
|
||||
// HEIC/HEIF 변환
|
||||
const isHeic = file.type === 'image/heic' || file.type === 'image/heif' ||
|
||||
file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif');
|
||||
if (isHeic) {
|
||||
if (typeof heic2any === 'undefined') {
|
||||
showToast('HEIC 변환 라이브러리를 불러오지 못했습니다.', 'error');
|
||||
return;
|
||||
}
|
||||
statusEl.textContent = 'HEIC 변환 중...';
|
||||
try {
|
||||
const blob = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.85 });
|
||||
processFile = new File([blob], file.name.replace(/\.heic$/i, '.jpg').replace(/\.heif$/i, '.jpg'), { type: 'image/jpeg' });
|
||||
} catch (e) {
|
||||
console.error('HEIC 변환 실패:', e);
|
||||
showToast('HEIC 이미지 변환에 실패했습니다.', 'error');
|
||||
statusEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 크기 확인 (10MB)
|
||||
if (processFile.size > 10 * 1024 * 1024) {
|
||||
showToast('파일 크기는 10MB 이하만 가능합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.textContent = '처리 중...';
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
photoBase64 = e.target.result;
|
||||
document.getElementById('prPhotoPreviewImg').src = photoBase64;
|
||||
document.getElementById('prPhotoPreview').classList.remove('hidden');
|
||||
statusEl.textContent = '';
|
||||
};
|
||||
reader.readAsDataURL(processFile);
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
photoBase64 = null;
|
||||
document.getElementById('prPhotoPreview').classList.add('hidden');
|
||||
document.getElementById('prPhotoInput').value = '';
|
||||
document.getElementById('prPhotoStatus').textContent = '';
|
||||
}
|
||||
|
||||
/* ===== 구매신청 제출 ===== */
|
||||
async function submitPurchaseRequest() {
|
||||
const item_id = document.getElementById('prItemSelect').value;
|
||||
const itemId = document.getElementById('prItemId').value;
|
||||
const customItemName = document.getElementById('prCustomItemName').value;
|
||||
const quantity = parseInt(document.getElementById('prQuantity').value) || 0;
|
||||
const notes = document.getElementById('prNotes').value.trim();
|
||||
|
||||
if (!item_id) { showToast('소모품을 선택해주세요.', 'error'); return; }
|
||||
if (!itemId && !customItemName) { showToast('소모품을 선택하거나 품목명을 입력해주세요.', 'error'); return; }
|
||||
if (quantity < 1) { showToast('수량은 1 이상이어야 합니다.', 'error'); return; }
|
||||
|
||||
const body = { quantity, notes };
|
||||
if (itemId) {
|
||||
body.item_id = parseInt(itemId);
|
||||
} else {
|
||||
body.custom_item_name = customItemName;
|
||||
body.custom_category = document.getElementById('prCustomCategory').value;
|
||||
}
|
||||
if (photoBase64) {
|
||||
body.photo = photoBase64;
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/purchase-requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ item_id: parseInt(item_id), quantity, notes })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
showToast('구매신청이 등록되었습니다.');
|
||||
document.getElementById('prItemSelect').value = '';
|
||||
// 폼 초기화
|
||||
document.getElementById('prItemSearch').value = '';
|
||||
document.getElementById('prItemId').value = '';
|
||||
document.getElementById('prCustomItemName').value = '';
|
||||
document.getElementById('prQuantity').value = '1';
|
||||
document.getElementById('prNotes').value = '';
|
||||
document.getElementById('prItemPreview').classList.add('hidden');
|
||||
document.getElementById('prCustomCategoryWrap').classList.add('hidden');
|
||||
removePhoto();
|
||||
await loadRequests();
|
||||
} catch (e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
@@ -122,11 +310,22 @@ function renderRequests() {
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = requestsList.map(r => {
|
||||
const catLabel = CAT_LABELS[r.category] || r.category;
|
||||
const catColor = CAT_COLORS[r.category] || 'badge-gray';
|
||||
// 등록 품목이면 ci 데이터, 미등록이면 custom 데이터 사용
|
||||
const itemName = r.item_name || r.custom_item_name || '-';
|
||||
const category = r.category || r.custom_category;
|
||||
const catLabel = CAT_LABELS[category] || category || '-';
|
||||
const catColor = CAT_COLORS[category] || 'badge-gray';
|
||||
const statusLabel = STATUS_LABELS[r.status] || r.status;
|
||||
const statusColor = STATUS_COLORS[r.status] || 'badge-gray';
|
||||
const photoSrc = r.photo_path ? TKUSER_BASE_URL + r.photo_path : '';
|
||||
const isCustom = !r.item_id && r.custom_item_name;
|
||||
|
||||
// 사진: 구매신청 첨부 사진 우선, 없으면 소모품 마스터 사진
|
||||
let photoSrc = '';
|
||||
if (r.pr_photo_path) {
|
||||
photoSrc = r.pr_photo_path;
|
||||
} else if (r.ci_photo_path) {
|
||||
photoSrc = TKUSER_BASE_URL + r.ci_photo_path;
|
||||
}
|
||||
|
||||
let actions = '';
|
||||
if (isAdmin && r.status === 'pending') {
|
||||
@@ -144,7 +343,7 @@ function renderRequests() {
|
||||
<div class="flex items-center gap-2">
|
||||
${photoSrc ? `<img src="${photoSrc}" class="w-8 h-8 rounded object-cover" onerror="this.style.display='none'">` : ''}
|
||||
<div>
|
||||
<div class="font-medium text-gray-800">${escapeHtml(r.item_name)}</div>
|
||||
<div class="font-medium text-gray-800">${escapeHtml(itemName)}${isCustom ? ' <span class="text-xs text-orange-500">(직접입력)</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(r.maker || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,16 +367,31 @@ function openPurchaseModal(requestId) {
|
||||
if (!r) return;
|
||||
currentRequestForPurchase = r;
|
||||
|
||||
const itemName = r.item_name || r.custom_item_name || '-';
|
||||
const category = r.category || r.custom_category;
|
||||
const isCustom = !r.item_id && r.custom_item_name;
|
||||
const basePrice = r.base_price ? Number(r.base_price).toLocaleString() + '원' : '-';
|
||||
|
||||
document.getElementById('purchaseModalInfo').innerHTML = `
|
||||
<div class="font-medium">${escapeHtml(r.item_name)} ${r.maker ? '(' + escapeHtml(r.maker) + ')' : ''}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">분류: ${CAT_LABELS[r.category] || r.category} | 기준가: ${basePrice} | 신청수량: ${r.quantity}</div>
|
||||
<div class="font-medium">${escapeHtml(itemName)} ${r.maker ? '(' + escapeHtml(r.maker) + ')' : ''}${isCustom ? ' <span class="text-orange-500 text-xs">(직접입력)</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">분류: ${CAT_LABELS[category] || category || '-'} | 기준가: ${basePrice} | 신청수량: ${r.quantity}</div>
|
||||
${r.pr_photo_path ? `<img src="${r.pr_photo_path}" class="mt-2 w-20 h-20 rounded object-cover" onerror="this.style.display='none'">` : ''}
|
||||
`;
|
||||
document.getElementById('pmUnitPrice').value = r.base_price || '';
|
||||
document.getElementById('pmQuantity').value = r.quantity;
|
||||
document.getElementById('pmDate').value = new Date().toISOString().substring(0, 10);
|
||||
document.getElementById('pmNotes').value = '';
|
||||
document.getElementById('pmPriceDiffArea').innerHTML = '';
|
||||
|
||||
// 마스터 등록 체크박스: 미등록 품목일 때만 표시
|
||||
const masterWrap = document.getElementById('pmMasterRegisterWrap');
|
||||
if (isCustom) {
|
||||
masterWrap.classList.remove('hidden');
|
||||
document.getElementById('pmRegisterToMaster').checked = true;
|
||||
} else {
|
||||
masterWrap.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('purchaseModal').classList.remove('hidden');
|
||||
showPriceDiff();
|
||||
}
|
||||
@@ -218,14 +432,18 @@ async function submitPurchase() {
|
||||
if (!purchase_date) { showToast('구매일을 입력해주세요.', 'error'); return; }
|
||||
|
||||
const updateCheckbox = document.getElementById('pmUpdateBasePrice');
|
||||
const registerCheckbox = document.getElementById('pmRegisterToMaster');
|
||||
const isCustom = !currentRequestForPurchase.item_id && currentRequestForPurchase.custom_item_name;
|
||||
|
||||
const body = {
|
||||
request_id: currentRequestForPurchase.request_id,
|
||||
item_id: currentRequestForPurchase.item_id,
|
||||
item_id: currentRequestForPurchase.item_id || null,
|
||||
vendor_id: parseInt(document.getElementById('pmVendor').value) || null,
|
||||
quantity: parseInt(document.getElementById('pmQuantity').value) || currentRequestForPurchase.quantity,
|
||||
unit_price,
|
||||
purchase_date,
|
||||
update_base_price: updateCheckbox ? updateCheckbox.checked : false,
|
||||
register_to_master: isCustom ? (registerCheckbox ? registerCheckbox.checked : true) : undefined,
|
||||
notes: document.getElementById('pmNotes').value.trim()
|
||||
};
|
||||
|
||||
@@ -289,6 +507,7 @@ async function deleteRequest(requestId) {
|
||||
(async function() {
|
||||
if (!await initAuth()) return;
|
||||
isAdmin = currentUser && ['admin', 'system', 'system admin'].includes(currentUser.role);
|
||||
initItemSearch();
|
||||
await loadInitialData();
|
||||
await loadRequests();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user