feat(consumable): 소모품 마스터에 "규격(spec)" 필드 추가
품목의 규격 정보(예: 4" 용접, M16)를 분리 저장할 수 있도록 spec 컬럼 추가. DB ALTER 필요: ALTER TABLE consumable_items ADD COLUMN spec VARCHAR(200) DEFAULT NULL AFTER item_name; Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@ const PurchaseModel = {
|
||||
async getAll(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT p.*, ci.item_name, ci.maker, ci.category, ci.unit, ci.photo_path,
|
||||
SELECT p.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.photo_path,
|
||||
v.vendor_name, su.name AS purchaser_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
@@ -122,7 +122,7 @@ const PurchaseModel = {
|
||||
// 소모품 목록 (구매신청용)
|
||||
async getConsumableItems(activeOnly = true) {
|
||||
const db = await getDb();
|
||||
let sql = 'SELECT item_id, item_name, maker, category, base_price, unit, photo_path FROM consumable_items';
|
||||
let sql = 'SELECT item_id, item_name, spec, maker, category, base_price, unit, photo_path FROM consumable_items';
|
||||
if (activeOnly) sql += ' WHERE is_active = 1';
|
||||
sql += ' ORDER BY category, item_name';
|
||||
const [rows] = await db.query(sql);
|
||||
|
||||
@@ -6,7 +6,7 @@ const PurchaseRequestModel = {
|
||||
async getAll(filters = {}) {
|
||||
const db = await getDb();
|
||||
let sql = `
|
||||
SELECT pr.*, ci.item_name, ci.maker, ci.category, ci.base_price, ci.unit,
|
||||
SELECT pr.*, ci.item_name, ci.spec, 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
|
||||
@@ -35,7 +35,7 @@ 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,
|
||||
SELECT pr.*, ci.item_name, ci.spec, 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
|
||||
|
||||
@@ -40,7 +40,7 @@ const SettlementModel = {
|
||||
async getMonthlyPurchases(yearMonth) {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT p.*, ci.item_name, ci.maker, ci.category, ci.unit, ci.base_price, ci.photo_path,
|
||||
SELECT p.*, ci.item_name, ci.spec, ci.maker, ci.category, ci.unit, ci.base_price, ci.photo_path,
|
||||
v.vendor_name, su.name AS purchaser_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
@@ -88,7 +88,7 @@ const SettlementModel = {
|
||||
const db = await getDb();
|
||||
const [rows] = await db.query(`
|
||||
SELECT p.purchase_id, p.purchase_date, p.unit_price, p.quantity,
|
||||
ci.item_id, ci.item_name, ci.maker, ci.category, ci.base_price,
|
||||
ci.item_id, ci.item_name, ci.spec, ci.maker, ci.category, ci.base_price,
|
||||
v.vendor_name
|
||||
FROM purchases p
|
||||
JOIN consumable_items ci ON p.item_id = ci.item_id
|
||||
|
||||
@@ -108,6 +108,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
||||
<script src="/static/js/purchase-analysis.js?v=2026031601"></script>
|
||||
<script src="/static/js/purchase-analysis.js?v=2026031602"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -222,6 +222,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/tkfb-core.js?v=2026031601"></script>
|
||||
<script src="/static/js/purchase-request.js?v=2026031601"></script>
|
||||
<script src="/static/js/purchase-request.js?v=2026031602"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -102,7 +102,7 @@ function renderPurchaseList(data) {
|
||||
|
||||
return `<tr class="hover:bg-gray-50 ${hasPriceDiff ? 'bg-yellow-50' : ''}">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-800">${escapeHtml(p.item_name)}</div>
|
||||
<div class="font-medium text-gray-800">${escapeHtml(p.item_name)}${p.spec ? ' <span class="text-gray-400">[' + escapeHtml(p.spec) + ']</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(p.maker || '')}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="px-1.5 py-0.5 rounded text-xs ${catColor}">${catLabel}</span></td>
|
||||
@@ -138,7 +138,7 @@ function renderPriceChanges(data) {
|
||||
const arrow = diff > 0 ? '▲' : '▼';
|
||||
const color = diff > 0 ? 'text-red-600' : 'text-blue-600';
|
||||
return `<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3">${escapeHtml(p.item_name)} ${p.maker ? '(' + escapeHtml(p.maker) + ')' : ''}</td>
|
||||
<td class="px-4 py-3">${escapeHtml(p.item_name)}${p.spec ? ' [' + escapeHtml(p.spec) + ']' : ''} ${p.maker ? '(' + escapeHtml(p.maker) + ')' : ''}</td>
|
||||
<td class="px-4 py-3 text-right">${Number(p.base_price).toLocaleString()}원</td>
|
||||
<td class="px-4 py-3 text-right font-medium ${color}">${Number(p.unit_price).toLocaleString()}원</td>
|
||||
<td class="px-4 py-3 text-right ${color}">${arrow} ${Math.abs(diff).toLocaleString()}원</td>
|
||||
|
||||
@@ -10,6 +10,8 @@ const CAT_FG = { consumable: '#1e40af', safety: '#166534', repair: '#92400e', eq
|
||||
const STATUS_LABELS = { pending: '대기', purchased: '구매완료', hold: '보류' };
|
||||
const STATUS_COLORS = { pending: 'badge-amber', purchased: 'badge-green', hold: 'badge-gray' };
|
||||
|
||||
function _fmtSpec(spec) { return spec ? ' [' + spec + ']' : ''; }
|
||||
|
||||
let consumableItems = [];
|
||||
let vendorsList = [];
|
||||
let requestsList = [];
|
||||
@@ -56,7 +58,8 @@ function initItemSearch() {
|
||||
const lower = query.toLowerCase();
|
||||
const filtered = consumableItems.filter(item =>
|
||||
item.item_name.toLowerCase().includes(lower) ||
|
||||
(item.maker && item.maker.toLowerCase().includes(lower))
|
||||
(item.maker && item.maker.toLowerCase().includes(lower)) ||
|
||||
(item.spec && item.spec.toLowerCase().includes(lower))
|
||||
);
|
||||
showDropdown(filtered, query);
|
||||
}
|
||||
@@ -71,7 +74,8 @@ function initItemSearch() {
|
||||
const lower = query.toLowerCase();
|
||||
const filtered = consumableItems.filter(item =>
|
||||
item.item_name.toLowerCase().includes(lower) ||
|
||||
(item.maker && item.maker.toLowerCase().includes(lower))
|
||||
(item.maker && item.maker.toLowerCase().includes(lower)) ||
|
||||
(item.spec && item.spec.toLowerCase().includes(lower))
|
||||
);
|
||||
showDropdown(filtered, query);
|
||||
}
|
||||
@@ -115,10 +119,11 @@ function showDropdown(items, query) {
|
||||
const catLabel = CAT_LABELS[item.category] || item.category;
|
||||
const bg = CAT_BG[item.category] || '#f3f4f6';
|
||||
const fg = CAT_FG[item.category] || '#374151';
|
||||
const spec = _fmtSpec(item.spec ? escapeHtml(item.spec) : '');
|
||||
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>
|
||||
<span>${escapeHtml(item.item_name)}${spec}${maker}</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
@@ -156,7 +161,7 @@ function selectItem(itemId) {
|
||||
if (!item) return;
|
||||
|
||||
const input = document.getElementById('prItemSearch');
|
||||
input.value = item.item_name + (item.maker ? ' (' + item.maker + ')' : '');
|
||||
input.value = item.item_name + _fmtSpec(item.spec) + (item.maker ? ' (' + item.maker + ')' : '');
|
||||
document.getElementById('prItemId').value = item.item_id;
|
||||
document.getElementById('prCustomItemName').value = '';
|
||||
closeDropdown();
|
||||
@@ -172,7 +177,7 @@ function selectItem(itemId) {
|
||||
} else {
|
||||
photoEl.classList.add('hidden');
|
||||
}
|
||||
document.getElementById('prItemInfo').textContent = `${item.item_name} ${item.maker ? '(' + item.maker + ')' : ''}`;
|
||||
document.getElementById('prItemInfo').textContent = `${item.item_name}${_fmtSpec(item.spec)} ${item.maker ? '(' + item.maker + ')' : ''}`;
|
||||
const price = item.base_price ? Number(item.base_price).toLocaleString() + '원/' + (item.unit || 'EA') : '기준가 미설정';
|
||||
document.getElementById('prItemPrice').textContent = price;
|
||||
|
||||
@@ -343,7 +348,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(itemName)}${isCustom ? ' <span class="text-xs text-orange-500">(직접입력)</span>' : ''}</div>
|
||||
<div class="font-medium text-gray-800">${escapeHtml(itemName)}${r.spec ? ' <span class="text-gray-400">[' + escapeHtml(r.spec) + ']</span>' : ''}${isCustom ? ' <span class="text-xs text-orange-500">(직접입력)</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500">${escapeHtml(r.maker || '')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,7 +378,7 @@ function openPurchaseModal(requestId) {
|
||||
const basePrice = r.base_price ? Number(r.base_price).toLocaleString() + '원' : '-';
|
||||
|
||||
document.getElementById('purchaseModalInfo').innerHTML = `
|
||||
<div class="font-medium">${escapeHtml(itemName)} ${r.maker ? '(' + escapeHtml(r.maker) + ')' : ''}${isCustom ? ' <span class="text-orange-500 text-xs">(직접입력)</span>' : ''}</div>
|
||||
<div class="font-medium">${escapeHtml(itemName)}${_fmtSpec(r.spec ? escapeHtml(r.spec) : '')} ${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'">` : ''}
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,7 @@ async function findAll({ category, search, is_active } = {}) {
|
||||
const params = [];
|
||||
if (is_active !== undefined) { sql += ' AND is_active = ?'; params.push(is_active); }
|
||||
if (category) { sql += ' AND category = ?'; params.push(category); }
|
||||
if (search) { sql += ' AND (item_name LIKE ? OR maker LIKE ?)'; params.push(`%${search}%`, `%${search}%`); }
|
||||
if (search) { sql += ' AND (item_name LIKE ? OR maker LIKE ? OR spec LIKE ?)'; params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
|
||||
sql += ' ORDER BY category, item_name';
|
||||
const [rows] = await db.query(sql, params);
|
||||
return rows;
|
||||
@@ -23,9 +23,9 @@ async function findById(id) {
|
||||
async function create(data) {
|
||||
const db = getPool();
|
||||
const [result] = await db.query(
|
||||
`INSERT INTO consumable_items (item_name, maker, category, base_price, unit, photo_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[data.item_name, data.maker || null, data.category,
|
||||
`INSERT INTO consumable_items (item_name, spec, maker, category, base_price, unit, photo_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[data.item_name, data.spec || null, data.maker || null, data.category,
|
||||
data.base_price || 0, data.unit || 'EA', data.photo_path || null]
|
||||
);
|
||||
return findById(result.insertId);
|
||||
@@ -36,6 +36,7 @@ async function update(id, data) {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
if (data.item_name !== undefined) { fields.push('item_name = ?'); values.push(data.item_name); }
|
||||
if (data.spec !== undefined) { fields.push('spec = ?'); values.push(data.spec || null); }
|
||||
if (data.maker !== undefined) { fields.push('maker = ?'); values.push(data.maker || null); }
|
||||
if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category); }
|
||||
if (data.base_price !== undefined) { fields.push('base_price = ?'); values.push(data.base_price); }
|
||||
|
||||
@@ -1909,6 +1909,10 @@
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">품명 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="newConsumableNameTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">규격</label>
|
||||
<input type="text" id="newConsumableSpecTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder='예: 4" 용접, M16, 면(500G)-10게이지'>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">메이커</label>
|
||||
<input type="text" id="newConsumableMakerTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
@@ -1959,6 +1963,10 @@
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">품명 <span class="text-red-400">*</span></label>
|
||||
<input type="text" id="editConsumableNameTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">규격</label>
|
||||
<input type="text" id="editConsumableSpecTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm" placeholder='예: 4" 용접, M16, 면(500G)-10게이지'>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">메이커</label>
|
||||
<input type="text" id="editConsumableMakerTkuser" class="input-field w-full px-3 py-2 rounded-lg text-sm">
|
||||
@@ -2014,7 +2022,7 @@
|
||||
<script src="/static/js/tkuser-layout-map.js?v=2026031401"></script>
|
||||
<script src="/static/js/tkuser-partners.js?v=2026031601"></script>
|
||||
<script src="/static/js/tkuser-vendors.js?v=2026031401"></script>
|
||||
<script src="/static/js/tkuser-consumables.js?v=2026031401"></script>
|
||||
<script src="/static/js/tkuser-consumables.js?v=2026031602"></script>
|
||||
<script src="/static/js/tkuser-notificationRecipients.js?v=2026031401"></script>
|
||||
<!-- Boot -->
|
||||
<script>init();</script>
|
||||
|
||||
@@ -59,7 +59,7 @@ function renderConsumablesListTkuser() {
|
||||
? `<img src="${item.photo_path}" class="w-16 h-16 rounded object-cover flex-shrink-0 cursor-pointer" onclick="document.getElementById('photoViewImage').src=this.src; document.getElementById('photoViewModal').classList.remove('hidden');" onerror="this.style.display='none'">`
|
||||
: `<div class="w-16 h-16 rounded bg-gray-100 flex items-center justify-center flex-shrink-0"><i class="fas fa-box text-gray-300 text-xl"></i></div>`}
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-gray-800 truncate">${escHtml(item.item_name)}</div>
|
||||
<div class="text-sm font-medium text-gray-800 truncate">${escHtml(item.item_name)}${item.spec ? ' <span class="text-gray-400">[' + escHtml(item.spec) + ']</span>' : ''}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">${escHtml(item.maker) || '-'}</div>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs ${catColor}">${catLabel}</span>
|
||||
@@ -102,6 +102,7 @@ async function submitAddConsumableTkuser(e) {
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('item_name', itemName);
|
||||
fd.append('spec', document.getElementById('newConsumableSpecTkuser').value.trim());
|
||||
fd.append('maker', document.getElementById('newConsumableMakerTkuser').value.trim());
|
||||
fd.append('category', category);
|
||||
fd.append('base_price', document.getElementById('newConsumablePriceTkuser').value || '0');
|
||||
@@ -130,6 +131,7 @@ function openEditConsumableTkuser(id) {
|
||||
if (!item) return;
|
||||
document.getElementById('editConsumableIdTkuser').value = item.item_id;
|
||||
document.getElementById('editConsumableNameTkuser').value = item.item_name;
|
||||
document.getElementById('editConsumableSpecTkuser').value = item.spec || '';
|
||||
document.getElementById('editConsumableMakerTkuser').value = item.maker || '';
|
||||
document.getElementById('editConsumableCategoryTkuser').value = item.category;
|
||||
document.getElementById('editConsumablePriceTkuser').value = item.base_price || '';
|
||||
@@ -155,6 +157,7 @@ async function submitEditConsumableTkuser(e) {
|
||||
const id = document.getElementById('editConsumableIdTkuser').value;
|
||||
const fd = new FormData();
|
||||
fd.append('item_name', document.getElementById('editConsumableNameTkuser').value.trim());
|
||||
fd.append('spec', document.getElementById('editConsumableSpecTkuser').value.trim());
|
||||
fd.append('maker', document.getElementById('editConsumableMakerTkuser').value.trim());
|
||||
fd.append('category', document.getElementById('editConsumableCategoryTkuser').value);
|
||||
fd.append('base_price', document.getElementById('editConsumablePriceTkuser').value || '0');
|
||||
|
||||
Reference in New Issue
Block a user