feat(purchase): 카테고리 테이블 분리 + 동적 로드 + tkuser 관리

- DB: consumable_categories 테이블 생성, ENUM→VARCHAR 변환, 시드 4개
- API: GET/POST/PUT/DEACTIVATE /api/consumable-categories
- 프론트: 3개 JS 하드코딩 CAT_LABELS 제거 → API loadCategories() 동적 로드
- tkuser: 카테고리 관리 섹션 추가, select 옵션 동적 생성
- 별칭 시드 SQL (INSERT IGNORE 기반)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-01 14:07:14 +09:00
parent 118dc29c95
commit 4063eba5bb
11 changed files with 345 additions and 49 deletions

View File

@@ -1774,10 +1774,6 @@
<input type="text" id="consumableSearchTkuser" class="input-field flex-1 min-w-[160px] px-3 py-1.5 rounded-lg text-sm" placeholder="품명/메이커 검색">
<select id="consumableFilterCategoryTkuser" class="input-field px-2 py-1.5 rounded-lg text-sm">
<option value="">전체 분류</option>
<option value="consumable">소모품</option>
<option value="safety">안전용품</option>
<option value="repair">수선비</option>
<option value="equipment">설비</option>
</select>
<select id="consumableFilterActiveTkuser" class="input-field px-2 py-1.5 rounded-lg text-sm">
<option value="true">활성</option>
@@ -1789,6 +1785,14 @@
<p class="text-gray-400 text-center py-4 text-sm">탭을 선택하면 데이터를 불러옵니다.</p>
</div>
</div>
<!-- 카테고리 관리 (admin) -->
<div id="categoryManageSection" class="hidden bg-white rounded-xl shadow-sm p-5 mt-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-tags text-teal-500 mr-1"></i>카테고리 관리</h3>
<button onclick="addCategory()" class="px-2 py-1 bg-teal-600 text-white rounded text-xs hover:bg-teal-700"><i class="fas fa-plus mr-1"></i>추가</button>
</div>
<div id="categoryList" class="space-y-2"></div>
</div>
</div>
<!-- ============ 알림 수신자 탭 ============ -->

View File

@@ -2,26 +2,71 @@
let consumablesLoaded = false;
let consumablesList = [];
const CONSUMABLE_CATEGORIES = {
consumable: '소모품',
safety: '안전용품',
repair: '수선비',
equipment: '설비'
};
const CONSUMABLE_CAT_COLORS = {
consumable: 'bg-blue-50 text-blue-600',
safety: 'bg-green-50 text-green-600',
repair: 'bg-amber-50 text-amber-600',
equipment: 'bg-purple-50 text-purple-600'
};
// 카테고리 — system1-factory API에서 동적 로드 (fallback: 기본 4개)
const TKFB_API = location.hostname.includes('technicalkorea.net')
? 'https://tkfb.technicalkorea.net/api'
: location.protocol + '//' + location.hostname + ':30005/api';
let _consumableCategories = null;
const DEFAULT_CATEGORIES = [
{ category_code: 'consumable', category_name: '소모품', icon: 'fa-box', color_bg: '#dbeafe', color_fg: '#1e40af' },
{ category_code: 'safety', category_name: '안전용품', icon: 'fa-hard-hat', color_bg: '#dcfce7', color_fg: '#166534' },
{ category_code: 'repair', category_name: '수선비', icon: 'fa-wrench', color_bg: '#fef3c7', color_fg: '#92400e' },
{ category_code: 'equipment', category_name: '설비', icon: 'fa-cogs', color_bg: '#f3e8ff', color_fg: '#7e22ce' }
];
async function loadConsumableCategories() {
if (_consumableCategories) return _consumableCategories;
try {
const token = getToken();
const res = await fetch(TKFB_API + '/consumable-categories', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (res.ok) {
const data = await res.json();
_consumableCategories = data.data || DEFAULT_CATEGORIES;
} else {
_consumableCategories = DEFAULT_CATEGORIES;
}
} catch (e) {
_consumableCategories = DEFAULT_CATEGORIES;
}
return _consumableCategories;
}
function getCatLabel(code) { return (_consumableCategories || DEFAULT_CATEGORIES).find(c => c.category_code === code)?.category_name || code; }
function getCatColorClass(code) {
const c = (_consumableCategories || DEFAULT_CATEGORIES).find(x => x.category_code === code);
if (!c) return 'bg-gray-50 text-gray-600';
// 인라인 스타일 반환
return '';
}
function getCatStyle(code) {
const c = (_consumableCategories || DEFAULT_CATEGORIES).find(x => x.category_code === code);
return c ? `background:${c.color_bg};color:${c.color_fg}` : 'background:#f3f4f6;color:#6b7280';
}
async function loadConsumablesTab() {
if (consumablesLoaded) return;
consumablesLoaded = true;
await loadConsumableCategories();
if (currentUser && ['admin', 'system'].includes(currentUser.role)) {
document.getElementById('btnAddConsumableTkuser')?.classList.remove('hidden');
document.getElementById('categoryManageSection')?.classList.remove('hidden');
}
populateCategorySelects();
await loadConsumablesList();
loadCategoryManagement();
}
// 카테고리 select 옵션 동적 생성
function populateCategorySelects() {
const cats = _consumableCategories || DEFAULT_CATEGORIES;
const optionsHtml = cats.map(c => `<option value="${c.category_code}">${escHtml(c.category_name)}</option>`).join('');
['consumableFilterCategoryTkuser', 'newConsumableCategoryTkuser', 'editConsumableCategoryTkuser'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
const firstOpt = el.querySelector('option:first-child');
el.innerHTML = (firstOpt ? firstOpt.outerHTML : '<option value="">전체</option>') + optionsHtml;
});
}
async function loadConsumablesList() {
@@ -50,8 +95,8 @@ function renderConsumablesListTkuser() {
const isAdmin = currentUser && ['admin', 'system'].includes(currentUser.role);
c.innerHTML = `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">` +
consumablesList.map(item => {
const catLabel = CONSUMABLE_CATEGORIES[item.category] || item.category;
const catColor = CONSUMABLE_CAT_COLORS[item.category] || 'bg-gray-50 text-gray-600';
const catLabel = getCatLabel(item.category);
const catStyle = getCatStyle(item.category);
const price = item.base_price ? Number(item.base_price).toLocaleString() + '원' : '-';
return `<div class="bg-white border rounded-lg p-3 hover:shadow-md transition-shadow">
<div class="flex gap-3">
@@ -62,7 +107,7 @@ function renderConsumablesListTkuser() {
<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>
<span class="px-1.5 py-0.5 rounded text-xs" style="${catStyle}">${catLabel}</span>
<span class="text-xs text-gray-600 font-medium">${price}</span>
<span class="text-xs text-gray-400">${escHtml(item.unit) || 'EA'}</span>
</div>
@@ -190,6 +235,47 @@ async function deactivateConsumableTkuser(id, name) {
} catch (e) { showToast(e.message, 'error'); }
}
/* ===== 카테고리 관리 ===== */
async function loadCategoryManagement() {
const container = document.getElementById('categoryList');
if (!container) return;
const cats = _consumableCategories || DEFAULT_CATEGORIES;
container.innerHTML = cats.map(c => `
<div class="flex items-center gap-3 p-2 border rounded-lg">
<div class="w-8 h-8 rounded flex items-center justify-center" style="background:${c.color_bg};color:${c.color_fg}">
<i class="fas ${c.icon} text-sm"></i>
</div>
<div class="flex-1">
<span class="text-sm font-medium">${escHtml(c.category_name)}</span>
<span class="text-xs text-gray-400 ml-1">(${escHtml(c.category_code)})</span>
</div>
${c.category_id ? `<button onclick="editCategory(${c.category_id})" class="text-xs text-slate-500 hover:text-slate-700"><i class="fas fa-pen"></i></button>` : ''}
</div>
`).join('');
}
async function addCategory() {
const code = prompt('카테고리 코드 (영문):');
if (!code) return;
const name = prompt('표시 이름:');
if (!name) return;
try {
const token = getToken();
const res = await fetch(TKFB_API + '/consumable-categories', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ category_code: code, category_name: name })
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '추가 실패');
showToast('카테고리가 추가되었습니다');
_consumableCategories = null;
await loadConsumableCategories();
populateCategorySelects();
loadCategoryManagement();
} catch (e) { showToast(e.message, 'error'); }
}
// 검색/필터 이벤트 + 모달 폼 이벤트
document.addEventListener('DOMContentLoaded', () => {
let searchTimeout;