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:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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="삭제">×</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>
|
||||
|
||||
@@ -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