diff --git a/system1-factory/api/config/routes.js b/system1-factory/api/config/routes.js index 5a96659..710c8a1 100644 --- a/system1-factory/api/config/routes.js +++ b/system1-factory/api/config/routes.js @@ -55,6 +55,7 @@ function setupRoutes(app) { const settlementRoutes = require('../routes/settlementRoutes'); const itemAliasRoutes = require('../routes/itemAliasRoutes'); const purchaseBatchRoutes = require('../routes/purchaseBatchRoutes'); + const consumableCategoryRoutes = require('../routes/consumableCategoryRoutes'); const scheduleRoutes = require('../routes/scheduleRoutes'); const meetingRoutes = require('../routes/meetingRoutes'); const proxyInputRoutes = require('../routes/proxyInputRoutes'); @@ -170,6 +171,7 @@ function setupRoutes(app) { app.use('/api/settlements', settlementRoutes); // 월간 정산 app.use('/api/item-aliases', itemAliasRoutes); // 품목 별칭 app.use('/api/purchase-batches', purchaseBatchRoutes); // 구매 그룹 + app.use('/api/consumable-categories', consumableCategoryRoutes); // 소모품 카테고리 app.use('/api/schedule', scheduleRoutes); // 공정표 app.use('/api/meetings', meetingRoutes); // 생산회의록 app.use('/api/proxy-input', proxyInputRoutes); // 대리입력 + 일별현황 diff --git a/system1-factory/api/controllers/consumableCategoryController.js b/system1-factory/api/controllers/consumableCategoryController.js new file mode 100644 index 0000000..62254f7 --- /dev/null +++ b/system1-factory/api/controllers/consumableCategoryController.js @@ -0,0 +1,62 @@ +const ConsumableCategoryModel = require('../models/consumableCategoryModel'); +const logger = require('../utils/logger'); + +const ConsumableCategoryController = { + getAll: async (req, res) => { + try { + const activeOnly = req.query.all !== '1'; + const rows = await ConsumableCategoryModel.getAll(activeOnly); + res.json({ success: true, data: rows }); + } catch (err) { + logger.error('ConsumableCategory getAll error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + create: async (req, res) => { + try { + const { category_code, category_name, icon, color_bg, color_fg, sort_order } = req.body; + if (!category_code || !category_name) { + return res.status(400).json({ success: false, message: '코드와 이름을 입력해주세요.' }); + } + const cat = await ConsumableCategoryModel.create({ + categoryCode: category_code, categoryName: category_name, + icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order + }); + res.status(201).json({ success: true, data: cat, message: '카테고리가 추가되었습니다.' }); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ success: false, message: '이미 존재하는 코드입니다.' }); + } + logger.error('ConsumableCategory create error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + update: async (req, res) => { + try { + const { category_name, icon, color_bg, color_fg, sort_order } = req.body; + const cat = await ConsumableCategoryModel.update(req.params.id, { + categoryName: category_name, icon, colorBg: color_bg, colorFg: color_fg, sortOrder: sort_order + }); + if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' }); + res.json({ success: true, data: cat, message: '카테고리가 수정되었습니다.' }); + } catch (err) { + logger.error('ConsumableCategory update error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + }, + + deactivate: async (req, res) => { + try { + const cat = await ConsumableCategoryModel.deactivate(req.params.id); + if (!cat) return res.status(404).json({ success: false, message: '카테고리를 찾을 수 없습니다.' }); + res.json({ success: true, data: cat, message: '카테고리가 비활성화되었습니다.' }); + } catch (err) { + logger.error('ConsumableCategory deactivate error:', err); + res.status(500).json({ success: false, message: '서버 오류가 발생했습니다.' }); + } + } +}; + +module.exports = ConsumableCategoryController; diff --git a/system1-factory/api/db/migrations/20260401_category_table.sql b/system1-factory/api/db/migrations/20260401_category_table.sql new file mode 100644 index 0000000..ab903c1 --- /dev/null +++ b/system1-factory/api/db/migrations/20260401_category_table.sql @@ -0,0 +1,26 @@ +-- 소모품 카테고리 테이블 분리 (ENUM → 마스터 테이블) + +-- 1단계: 카테고리 마스터 테이블 생성 +CREATE TABLE IF NOT EXISTS consumable_categories ( + category_id INT AUTO_INCREMENT PRIMARY KEY, + category_code VARCHAR(30) NOT NULL UNIQUE COMMENT '코드 (consumable, safety 등)', + category_name VARCHAR(50) NOT NULL COMMENT '표시명', + icon VARCHAR(30) DEFAULT 'fa-box' COMMENT 'Font Awesome 아이콘', + color_bg VARCHAR(30) DEFAULT '#dbeafe' COMMENT '배경색', + color_fg VARCHAR(30) DEFAULT '#1e40af' COMMENT '글자색', + sort_order INT DEFAULT 0, + is_active TINYINT(1) DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2단계: 기존 4개 시드 +INSERT IGNORE INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order) VALUES +('consumable', '소모품', 'fa-box', '#dbeafe', '#1e40af', 1), +('safety', '안전용품', 'fa-hard-hat', '#dcfce7', '#166534', 2), +('repair', '수선비', 'fa-wrench', '#fef3c7', '#92400e', 3), +('equipment', '설비', 'fa-cogs', '#f3e8ff', '#7e22ce', 4); + +-- 3단계: ENUM → VARCHAR 변환 +ALTER TABLE consumable_items MODIFY COLUMN category VARCHAR(30) DEFAULT 'consumable'; +ALTER TABLE purchase_requests MODIFY COLUMN custom_category VARCHAR(30) NULL; +ALTER TABLE purchase_batches MODIFY COLUMN category VARCHAR(30) NULL; diff --git a/system1-factory/api/db/migrations/20260401_seed_aliases.sql b/system1-factory/api/db/migrations/20260401_seed_aliases.sql new file mode 100644 index 0000000..b4323a8 --- /dev/null +++ b/system1-factory/api/db/migrations/20260401_seed_aliases.sql @@ -0,0 +1,27 @@ +-- 소모품 별칭 시드 데이터 (item_name LIKE 매칭, 데이터 없으면 무시) + +-- 장갑류 +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%'; +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '목장갑' FROM consumable_items WHERE item_name LIKE '%면장갑%'; + +-- 테이프류 +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%'; +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '전기테이프' FROM consumable_items WHERE item_name LIKE '%절연테이프%'; + +-- 연마류 +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '사포' FROM consumable_items WHERE item_name LIKE '%연마지%' OR item_name LIKE '%연마석%'; + +-- 마스크 +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '마스크' FROM consumable_items WHERE item_name LIKE '%방진마스크%' OR item_name LIKE '%방독마스크%'; + +-- 안전화 +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '작업화' FROM consumable_items WHERE item_name LIKE '%안전화%'; +INSERT IGNORE INTO item_aliases (item_id, alias_name) +SELECT item_id, '신발' FROM consumable_items WHERE item_name LIKE '%안전화%'; diff --git a/system1-factory/api/models/consumableCategoryModel.js b/system1-factory/api/models/consumableCategoryModel.js new file mode 100644 index 0000000..7491402 --- /dev/null +++ b/system1-factory/api/models/consumableCategoryModel.js @@ -0,0 +1,47 @@ +// models/consumableCategoryModel.js +const { getDb } = require('../dbPool'); + +const ConsumableCategoryModel = { + async getAll(activeOnly = true) { + const db = await getDb(); + let sql = 'SELECT * FROM consumable_categories'; + if (activeOnly) sql += ' WHERE is_active = 1'; + sql += ' ORDER BY sort_order, category_name'; + const [rows] = await db.query(sql); + return rows; + }, + + async getById(id) { + const db = await getDb(); + const [rows] = await db.query('SELECT * FROM consumable_categories WHERE category_id = ?', [id]); + return rows[0] || null; + }, + + async create({ categoryCode, categoryName, icon, colorBg, colorFg, sortOrder }) { + const db = await getDb(); + const [result] = await db.query( + `INSERT INTO consumable_categories (category_code, category_name, icon, color_bg, color_fg, sort_order) + VALUES (?, ?, ?, ?, ?, ?)`, + [categoryCode, categoryName, icon || 'fa-box', colorBg || '#dbeafe', colorFg || '#1e40af', sortOrder || 0] + ); + return this.getById(result.insertId); + }, + + async update(id, { categoryName, icon, colorBg, colorFg, sortOrder }) { + const db = await getDb(); + await db.query( + `UPDATE consumable_categories SET category_name = ?, icon = ?, color_bg = ?, color_fg = ?, sort_order = ? + WHERE category_id = ?`, + [categoryName, icon, colorBg, colorFg, sortOrder, id] + ); + return this.getById(id); + }, + + async deactivate(id) { + const db = await getDb(); + await db.query('UPDATE consumable_categories SET is_active = 0 WHERE category_id = ?', [id]); + return this.getById(id); + } +}; + +module.exports = ConsumableCategoryModel; diff --git a/system1-factory/api/routes/consumableCategoryRoutes.js b/system1-factory/api/routes/consumableCategoryRoutes.js new file mode 100644 index 0000000..08cd55e --- /dev/null +++ b/system1-factory/api/routes/consumableCategoryRoutes.js @@ -0,0 +1,13 @@ +const express = require('express'); +const router = express.Router(); +const ctrl = require('../controllers/consumableCategoryController'); +const { createRequirePage } = require('../../../shared/middleware/pagePermission'); +const { getDb } = require('../dbPool'); +const requirePage = createRequirePage(getDb); + +router.get('/', ctrl.getAll); +router.post('/', requirePage('factory_purchases'), ctrl.create); +router.put('/:id', requirePage('factory_purchases'), ctrl.update); +router.put('/:id/deactivate', requirePage('factory_purchases'), ctrl.deactivate); + +module.exports = router; diff --git a/system1-factory/web/static/js/purchase-analysis.js b/system1-factory/web/static/js/purchase-analysis.js index 36bdb92..0b6710b 100644 --- a/system1-factory/web/static/js/purchase-analysis.js +++ b/system1-factory/web/static/js/purchase-analysis.js @@ -1,7 +1,23 @@ /* ===== 구매 분석 페이지 ===== */ -const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' }; -const CAT_ICONS = { consumable: 'fa-box', safety: 'fa-hard-hat', repair: 'fa-wrench', equipment: 'fa-cogs' }; -const CAT_BG = { consumable: 'bg-blue-50 text-blue-700', safety: 'bg-green-50 text-green-700', repair: 'bg-amber-50 text-amber-700', equipment: 'bg-purple-50 text-purple-700' }; +// 카테고리 — API 동적 로드 +let _categories = null; +async function loadCategories() { + if (_categories) return _categories; + try { const r = await api('/consumable-categories'); _categories = r.data || []; } catch(e) { _categories = []; } + return _categories; +} +function getCatLabel(code) { return (_categories || []).find(c => c.category_code === code)?.category_name || code || '-'; } +function getCatIcon(code) { return (_categories || []).find(c => c.category_code === code)?.icon || 'fa-box'; } +function getCatBgClass(code) { + const c = (_categories || []).find(x => x.category_code === code); + if (!c) return 'bg-gray-50 text-gray-700'; + // Tailwind class 생성 (인라인 스타일 대신) + return ''; +} +function getCatColors(code) { + const c = (_categories || []).find(x => x.category_code === code); + return c ? { bg: c.color_bg, fg: c.color_fg } : { bg: '#f3f4f6', fg: '#374151' }; +} const STATUS_LABELS = { received: '입고완료', returned: '반품' }; const STATUS_COLORS = { received: 'badge-teal', returned: 'badge-red' }; @@ -69,20 +85,17 @@ async function loadReceivedBasis() { /* ===== 렌더링 함수들 ===== */ function renderCategorySummary(data) { const el = document.getElementById('paCategorySummary'); - const allCategories = ['consumable', 'safety', 'repair', 'equipment']; + const cats = _categories || []; const dataMap = {}; data.forEach(d => { dataMap[d.category] = d; }); const totalAmount = data.reduce((sum, d) => sum + Number(d.total_amount || 0), 0); - el.innerHTML = allCategories.map(cat => { - const d = dataMap[cat] || { count: 0, total_amount: 0 }; - const label = CAT_LABELS[cat]; - const icon = CAT_ICONS[cat]; - const bg = CAT_BG[cat]; + el.innerHTML = cats.map(cat => { + const d = dataMap[cat.category_code] || { count: 0, total_amount: 0 }; return `
-
- ${label} +
+ ${escapeHtml(cat.category_name)}
${Number(d.total_amount || 0).toLocaleString()}
${d.count || 0}건
@@ -127,8 +140,8 @@ function renderPurchaseList(data) { return; } tbody.innerHTML = data.map(p => { - const catLabel = CAT_LABELS[p.category] || p.category; - const catColor = CAT_BG[p.category] || ''; + const catLabel = getCatLabel(p.category); + const catColor = getCatBgClass(p.category); const subtotal = (p.quantity || 0) * (p.unit_price || 0); const basePrice = Number(p.base_price || 0); const unitPrice = Number(p.unit_price || 0); @@ -190,8 +203,8 @@ function renderReceivedList(data) { return; } tbody.innerHTML = data.map(r => { - const catLabel = CAT_LABELS[r.category] || r.category || '-'; - const catColor = CAT_BG[r.category] || ''; + const catLabel = getCatLabel(r.category); + const catColor = getCatBgClass(r.category); const statusLabel = STATUS_LABELS[r.status] || r.status; const statusColor = STATUS_COLORS[r.status] || 'badge-gray'; return ` @@ -236,6 +249,7 @@ async function cancelSettlement(vendorId) { /* ===== Init ===== */ (async function() { if (!await initAuth()) return; + await loadCategories(); const now = new Date(); document.getElementById('paMonth').value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; })(); diff --git a/system1-factory/web/static/js/purchase-request-mobile.js b/system1-factory/web/static/js/purchase-request-mobile.js index 7cbfab3..0cd2d27 100644 --- a/system1-factory/web/static/js/purchase-request-mobile.js +++ b/system1-factory/web/static/js/purchase-request-mobile.js @@ -3,7 +3,14 @@ const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net') ? 'https://tkuser.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30180'; -const CAT_LABELS = { consumable: '소모품', safety: '안전용품', repair: '수선비', equipment: '설비' }; +// 카테고리 — API 동적 로드 +let _categories = null; +async function loadCategories() { + if (_categories) return _categories; + try { const r = await api('/consumable-categories'); _categories = r.data || []; } catch(e) { _categories = []; } + return _categories; +} +function getCatLabel(code) { return (_categories || []).find(c => c.category_code === code)?.category_name || code || ''; } const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' }; const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' }; const MATCH_LABELS = { exact: '정확', name: '이름', alias: '별칭', spec: '규격', chosung: '초성', chosung_alias: '초성' }; @@ -55,7 +62,7 @@ function renderCards() { container.innerHTML = requestsList.map(r => { const itemName = r.item_name || r.custom_item_name || '-'; const category = r.category || r.custom_category; - const catLabel = CAT_LABELS[category] || category || ''; + const catLabel = getCatLabel(category); const statusLabel = STATUS_LABELS[r.status] || r.status; const statusColor = STATUS_COLORS[r.status] || 'badge-gray'; const isCustom = !r.item_id && r.custom_item_name; @@ -96,7 +103,7 @@ function openDetail(requestId) { if (!r) return; const itemName = r.item_name || r.custom_item_name || '-'; const category = r.category || r.custom_category; - const catLabel = CAT_LABELS[category] || category || '-'; + const catLabel = getCatLabel(category); const statusLabel = STATUS_LABELS[r.status] || r.status; const statusColor = STATUS_COLORS[r.status] || 'badge-gray'; @@ -182,7 +189,7 @@ function renderSearchResults(items, query) { const container = document.getElementById('searchResults'); let html = ''; items.forEach(item => { - const catLabel = CAT_LABELS[item.category] || ''; + const catLabel = getCatLabel(item.category); const matchLabel = MATCH_LABELS[item._matchType] || ''; const spec = item.spec ? ' [' + escapeHtml(item.spec) + ']' : ''; const maker = item.maker ? ' (' + escapeHtml(item.maker) + ')' : ''; @@ -288,7 +295,7 @@ function renderCart() { list.innerHTML = cartItems.map((c, idx) => { const spec = c.spec ? ' [' + escapeHtml(c.spec) + ']' : ''; const maker = c.maker ? ' (' + escapeHtml(c.maker) + ')' : ''; - const catLabel = CAT_LABELS[c.category] || ''; + const catLabel = getCatLabel(c.category); // 사진 썸네일 let thumbHtml = ''; @@ -478,6 +485,7 @@ function checkViewParam() { /* ===== Init ===== */ (async function() { if (!await initAuth()) return; + await loadCategories(); await loadRequests(); checkViewParam(); })(); diff --git a/system1-factory/web/static/js/purchase-request.js b/system1-factory/web/static/js/purchase-request.js index 520d263..bd8ff66 100644 --- a/system1-factory/web/static/js/purchase-request.js +++ b/system1-factory/web/static/js/purchase-request.js @@ -3,10 +3,16 @@ const TKUSER_BASE_URL = location.hostname.includes('technicalkorea.net') ? 'https://tkuser.technicalkorea.net' : location.protocol + '//' + location.hostname + ':30180'; -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' }; +// 카테고리 — API에서 동적 로드 +let _categories = null; +async function loadCategories() { + if (_categories) return _categories; + try { const r = await api('/consumable-categories'); _categories = r.data || []; } catch(e) { _categories = []; } + return _categories; +} +function getCatLabel(code) { return (_categories || []).find(c => c.category_code === code)?.category_name || code || '-'; } +function getCatBg(code) { return (_categories || []).find(c => c.category_code === code)?.color_bg || '#f3f4f6'; } +function getCatFg(code) { return (_categories || []).find(c => c.category_code === code)?.color_fg || '#374151'; } const STATUS_LABELS = { pending: '대기', grouped: '구매진행중', purchased: '구매완료', received: '입고완료', cancelled: '취소', returned: '반품', hold: '보류' }; const STATUS_COLORS = { pending: 'badge-amber', grouped: 'badge-blue', purchased: 'badge-green', received: 'badge-teal', cancelled: 'badge-red', returned: 'badge-red', hold: 'badge-gray' }; @@ -125,9 +131,9 @@ function showDropdown(items, query) { 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 catLabel = getCatLabel(item.category); + const bg = getCatBg(item.category); + const fg = getCatFg(item.category); const spec = _fmtSpec(item.spec ? escapeHtml(item.spec) : ''); const maker = item.maker ? ` (${escapeHtml(item.maker)})` : ''; const photoSrc = item.photo_path ? (item.photo_path.startsWith('http') ? item.photo_path : TKUSER_BASE_URL + item.photo_path) : ''; @@ -339,8 +345,8 @@ function renderRequests() { // 등록 품목이면 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 catLabel = getCatLabel(category); + const catColor = 'badge-gray'; const statusLabel = STATUS_LABELS[r.status] || r.status; const statusColor = STATUS_COLORS[r.status] || 'badge-gray'; const isCustom = !r.item_id && r.custom_item_name; @@ -415,7 +421,7 @@ function openPurchaseModal(requestId) { document.getElementById('purchaseModalInfo').innerHTML = `
${escapeHtml(itemName)}${_fmtSpec(r.spec ? escapeHtml(r.spec) : '')} ${r.maker ? '(' + escapeHtml(r.maker) + ')' : ''}${isCustom ? ' (직접입력)' : ''}
-
분류: ${CAT_LABELS[category] || category || '-'} | 기준가: ${basePrice} | 신청수량: ${r.quantity}
+
분류: ${getCatLabel(category)} | 기준가: ${basePrice} | 신청수량: ${r.quantity}
${r.pr_photo_path ? `` : ''} `; document.getElementById('pmUnitPrice').value = r.base_price || ''; @@ -773,6 +779,7 @@ async function submitReceive() { (async function() { if (!await initAuth()) return; isAdmin = currentUser && ['admin', 'system', 'system admin'].includes(currentUser.role); + await loadCategories(); initItemSearch(); await loadInitialData(); await loadRequests(); diff --git a/user-management/web/index.html b/user-management/web/index.html index 3c70664..109d8e5 100644 --- a/user-management/web/index.html +++ b/user-management/web/index.html @@ -1774,10 +1774,6 @@