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:
Hyungi Ahn
2026-03-13 21:49:41 +09:00
parent 13e177e818
commit cae735f243
8 changed files with 414 additions and 69 deletions

View File

@@ -17,7 +17,7 @@ RUN apk add --no-cache --virtual .build-deps python3 make g++ && \
COPY . .
# 로그 디렉토리 생성
RUN mkdir -p logs uploads
RUN mkdir -p logs uploads/issues uploads/equipments uploads/purchase_requests
# 실행 권한 설정
RUN chown -R node:node /usr/src/app

View File

@@ -6,16 +6,37 @@ const PurchaseController = {
// 구매 처리 (신청 → 구매)
create: async (req, res) => {
try {
const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, notes } = req.body;
const { request_id, item_id, vendor_id, quantity, unit_price, purchase_date, update_base_price, register_to_master, notes } = req.body;
if (!item_id) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' });
// item_id가 없으면 custom item → register_to_master로 자동 등록 가능
let effectiveItemId = item_id;
if (!effectiveItemId && request_id) {
// 미등록 품목의 구매 처리 — 마스터 등록 처리
const requestData = await PurchaseRequestModel.getById(request_id);
if (requestData && requestData.custom_item_name) {
if (register_to_master !== false) {
// 마스터에 등록
const newItemId = await PurchaseModel.registerToMaster(
requestData.custom_item_name,
requestData.custom_category,
null // maker
);
effectiveItemId = newItemId;
// purchase_requests.item_id 업데이트
await PurchaseRequestModel.updateItemId(request_id, newItemId);
}
}
}
if (!effectiveItemId) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' });
if (!unit_price) return res.status(400).json({ success: false, message: '구매 단가를 입력해주세요.' });
if (!purchase_date) return res.status(400).json({ success: false, message: '구매일을 입력해주세요.' });
// 구매 내역 생성
const purchaseId = await PurchaseModel.createFromRequest({
request_id: request_id || null,
item_id,
item_id: effectiveItemId,
vendor_id: vendor_id || null,
quantity: quantity || 1,
unit_price,
@@ -27,9 +48,9 @@ const PurchaseController = {
// 기준가 업데이트 요청 시
if (update_base_price) {
const items = await PurchaseModel.getConsumableItems(false);
const item = items.find(i => i.item_id === parseInt(item_id));
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
if (item) {
await PurchaseModel.updateBasePrice(item_id, unit_price, item.base_price, req.user.id);
await PurchaseModel.updateBasePrice(effectiveItemId, unit_price, item.base_price, req.user.id);
}
}
@@ -37,9 +58,10 @@ const PurchaseController = {
let equipmentResult = null;
if (request_id) {
const requestData = await PurchaseRequestModel.getById(request_id);
if (requestData && requestData.category === 'equipment') {
const category = requestData?.category || requestData?.custom_category;
if (category === 'equipment') {
equipmentResult = await PurchaseModel.tryAutoRegisterEquipment({
item_name: requestData.item_name,
item_name: requestData.item_name || requestData.custom_item_name,
maker: requestData.maker,
vendor_name: null,
unit_price,
@@ -51,7 +73,7 @@ const PurchaseController = {
} else {
// 직접 구매 시에도 category 확인
const items = await PurchaseModel.getConsumableItems(false);
const item = items.find(i => i.item_id === parseInt(item_id));
const item = items.find(i => i.item_id === parseInt(effectiveItemId));
if (item && item.category === 'equipment') {
const vendors = await PurchaseModel.getVendors();
const vendor = vendors.find(v => v.vendor_id === parseInt(vendor_id));

View File

@@ -1,5 +1,6 @@
const PurchaseRequestModel = require('../models/purchaseRequestModel');
const PurchaseModel = require('../models/purchaseModel');
const { saveBase64Image } = require('../services/imageUploadService');
const logger = require('../utils/logger');
const PurchaseRequestController = {
@@ -33,16 +34,34 @@ const PurchaseRequestController = {
// 구매신청 생성
create: async (req, res) => {
try {
const { item_id, quantity, notes } = req.body;
if (!item_id) return res.status(400).json({ success: false, message: '소모품을 선택해주세요.' });
if (!quantity || quantity < 1) return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
const { item_id, custom_item_name, custom_category, quantity, notes, photo } = req.body;
// item_id 또는 custom_item_name 중 하나 필수
if (!item_id && !custom_item_name) {
return res.status(400).json({ success: false, message: '소모품을 선택하거나 품목명을 입력해주세요.' });
}
if (!quantity || quantity < 1) {
return res.status(400).json({ success: false, message: '수량은 1 이상이어야 합니다.' });
}
if (!item_id && custom_item_name && !custom_category) {
return res.status(400).json({ success: false, message: '직접 입력 시 분류를 선택해주세요.' });
}
// 사진 업로드
let photo_path = null;
if (photo) {
photo_path = await saveBase64Image(photo, 'pr', 'purchase_requests');
}
const request = await PurchaseRequestModel.create({
item_id,
item_id: item_id || null,
custom_item_name: custom_item_name || null,
custom_category: custom_category || null,
quantity,
requester_id: req.user.id,
request_date: new Date().toISOString().substring(0, 10),
notes
notes,
photo_path
});
res.status(201).json({ success: true, data: request, message: '구매신청이 등록되었습니다.' });
} catch (err) {

View File

@@ -129,6 +129,27 @@ const PurchaseModel = {
return rows;
},
// 미등록 품목 → 소모품 마스터 등록
async registerToMaster(customItemName, customCategory, maker) {
const db = await getDb();
// 중복 확인
const [existing] = await db.query(
`SELECT item_id FROM consumable_items WHERE item_name = ? AND (maker = ? OR (maker IS NULL AND ? IS NULL))`,
[customItemName, maker || null, maker || null]
);
if (existing.length > 0) {
return existing[0].item_id;
}
// 신규 등록 (photo_path = NULL)
const [result] = await db.query(
`INSERT INTO consumable_items (item_name, maker, category, is_active) VALUES (?, ?, ?, 1)`,
[customItemName, maker || null, customCategory || 'consumable']
);
return result.insertId;
},
// 가격 변동 이력
async getPriceHistory(itemId) {
const db = await getDb();

View File

@@ -2,14 +2,16 @@
const { getDb } = require('../dbPool');
const PurchaseRequestModel = {
// 구매신청 목록 (소모품 정보 JOIN)
// 구매신청 목록 (소모품 정보 LEFT JOIN — item_id NULL 허용)
async getAll(filters = {}) {
const db = await getDb();
let sql = `
SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, ci.photo_path,
SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit,
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
pr.custom_item_name, pr.custom_category,
su.name AS requester_name
FROM purchase_requests pr
JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
WHERE 1=1
`;
@@ -17,7 +19,10 @@ const PurchaseRequestModel = {
if (filters.status) { sql += ' AND pr.status = ?'; params.push(filters.status); }
if (filters.requester_id) { sql += ' AND pr.requester_id = ?'; params.push(filters.requester_id); }
if (filters.category) { sql += ' AND ci.category = ?'; params.push(filters.category); }
if (filters.category) {
sql += ' AND (ci.category = ? OR pr.custom_category = ?)';
params.push(filters.category, filters.category);
}
if (filters.from_date) { sql += ' AND pr.request_date >= ?'; params.push(filters.from_date); }
if (filters.to_date) { sql += ' AND pr.request_date <= ?'; params.push(filters.to_date); }
@@ -30,10 +35,12 @@ const PurchaseRequestModel = {
async getById(requestId) {
const db = await getDb();
const [rows] = await db.query(`
SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit, ci.photo_path,
SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit,
ci.photo_path AS ci_photo_path, pr.photo_path AS pr_photo_path,
pr.custom_item_name, pr.custom_category,
su.name AS requester_name
FROM purchase_requests pr
JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN consumable_items ci ON pr.item_id = ci.item_id
LEFT JOIN sso_users su ON pr.requester_id = su.user_id
WHERE pr.request_id = ?
`, [requestId]);
@@ -44,9 +51,10 @@ const PurchaseRequestModel = {
async create(data) {
const db = await getDb();
const [result] = await db.query(
`INSERT INTO purchase_requests (item_id, quantity, requester_id, request_date, notes)
VALUES (?, ?, ?, ?, ?)`,
[data.item_id, data.quantity || 1, data.requester_id, data.request_date, data.notes || null]
`INSERT INTO purchase_requests (item_id, custom_item_name, custom_category, quantity, requester_id, request_date, notes, photo_path)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[data.item_id || null, data.custom_item_name || null, data.custom_category || null,
data.quantity || 1, data.requester_id, data.request_date, data.notes || null, data.photo_path || null]
);
return this.getById(result.insertId);
},
@@ -80,6 +88,15 @@ const PurchaseRequestModel = {
return this.getById(requestId);
},
// item_id 업데이트 (마스터 등록 후)
async updateItemId(requestId, itemId) {
const db = await getDb();
await db.query(
`UPDATE purchase_requests SET item_id = ? WHERE request_id = ?`,
[itemId, requestId]
);
},
// 삭제 (admin only, pending 상태만)
async delete(requestId) {
const db = await getDb();

View File

@@ -22,7 +22,8 @@ try {
// 업로드 디렉토리 설정 (Docker 볼륨 마운트: /usr/src/app/uploads)
const UPLOAD_DIRS = {
issues: path.join(__dirname, '../uploads/issues'),
equipments: path.join(__dirname, '../uploads/equipments')
equipments: path.join(__dirname, '../uploads/equipments'),
purchase_requests: path.join(__dirname, '../uploads/purchase_requests')
};
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 };

View File

@@ -7,6 +7,18 @@
<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">
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
<style>
.item-dropdown { position: absolute; top: 100%; left: 0; right: 0; max-height: 280px; overflow-y: auto; background: white; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 20; display: none; }
.item-dropdown.open { display: block; }
.item-dropdown-item { padding: 8px 12px; cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.item-dropdown-item:hover, .item-dropdown-item.active { background: #fff7ed; }
.item-dropdown-item .cat-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; }
.item-dropdown-custom { padding: 10px 12px; cursor: pointer; font-size: 14px; color: #ea580c; border-top: 1px solid #f3f4f6; display: flex; align-items: center; gap: 6px; }
.item-dropdown-custom:hover { background: #fff7ed; }
.photo-preview-container { position: relative; display: inline-block; }
.photo-preview-container .remove-btn { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: white; border: none; cursor: pointer; font-size: 11px; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
@@ -41,12 +53,13 @@
<!-- 구매신청 폼 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-plus-circle text-orange-500 mr-2"></i>신규 구매신청</h2>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
<div class="sm:col-span-2">
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 items-start">
<div class="sm:col-span-2 relative">
<label class="block text-xs font-medium text-gray-600 mb-1">소모품 <span class="text-red-400">*</span></label>
<select id="prItemSelect" class="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-orange-300" onchange="onItemSelect()">
<option value="">소모품 선택</option>
</select>
<input type="text" id="prItemSearch" class="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-orange-300" placeholder="소모품 검색 또는 직접 입력" autocomplete="off">
<input type="hidden" id="prItemId" value="">
<input type="hidden" id="prCustomItemName" value="">
<div id="prItemDropdown" class="item-dropdown"></div>
<div id="prItemPreview" class="mt-2 hidden flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<img id="prItemPhoto" class="w-12 h-12 rounded object-cover hidden">
<div>
@@ -54,6 +67,16 @@
<div id="prItemPrice" class="text-xs text-gray-500"></div>
</div>
</div>
<!-- 직접 입력 시 분류 선택 -->
<div id="prCustomCategoryWrap" class="mt-2 hidden">
<label class="block text-xs font-medium text-gray-600 mb-1">분류 <span class="text-red-400">*</span></label>
<select id="prCustomCategory" class="w-full px-3 py-2 border rounded-lg text-sm">
<option value="consumable">소모품</option>
<option value="safety">안전용품</option>
<option value="repair">수선비</option>
<option value="equipment">설비</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">수량 <span class="text-red-400">*</span></label>
@@ -64,6 +87,21 @@
<input type="text" id="prNotes" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="선택 사항">
</div>
</div>
<!-- 사진 첨부 -->
<div class="mt-3">
<label class="block text-xs font-medium text-gray-600 mb-1">사진 첨부 (선택)</label>
<div class="flex items-center gap-3">
<label class="px-3 py-2 border rounded-lg text-sm text-gray-600 cursor-pointer hover:bg-gray-50 inline-flex items-center gap-1">
<i class="fas fa-camera text-orange-400"></i> 사진 선택
<input type="file" id="prPhotoInput" accept="image/*,.heic,.heif" class="hidden" onchange="onPhotoSelected(this)">
</label>
<div id="prPhotoPreview" class="hidden photo-preview-container">
<img id="prPhotoPreviewImg" class="w-16 h-16 rounded object-cover">
<button class="remove-btn" onclick="removePhoto()" title="삭제">&times;</button>
</div>
<span id="prPhotoStatus" class="text-xs text-gray-400"></span>
</div>
</div>
<div class="mt-4 flex justify-end">
<button onclick="submitPurchaseRequest()" class="px-5 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-paper-plane mr-1"></i>구매신청
@@ -140,7 +178,15 @@
<label class="block text-xs font-medium text-gray-600 mb-1">수량</label>
<input type="number" id="pmQuantity" class="w-full px-3 py-2 border rounded-lg text-sm" min="1" value="1">
</div>
<div class="col-span-2" id="pmPriceDiffArea" class="hidden">
<div class="col-span-2" id="pmPriceDiffArea">
</div>
<!-- 마스터 등록 체크박스 (미등록 품목일 때만 표시) -->
<div class="col-span-2 hidden" id="pmMasterRegisterWrap">
<label class="flex items-center gap-2 cursor-pointer p-2 bg-orange-50 rounded-lg">
<input type="checkbox" id="pmRegisterToMaster" checked class="h-4 w-4 rounded text-orange-600">
<span class="text-sm text-gray-700">소모품 마스터에 등록</span>
<span class="text-xs text-gray-400">(구매 처리 시 자동 등록)</span>
</label>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
@@ -175,7 +221,7 @@
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=20260313"></script>
<script src="/static/js/purchase-request.js?v=20260313"></script>
<script src="/static/js/tkfb-core.js?v=20260313b"></script>
<script src="/static/js/purchase-request.js?v=20260313b"></script>
</body>
</html>

View File

@@ -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();
})();