diff --git a/server.js b/server.js index 04336bf..18a5d95 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,32 @@ const pool = mysql.createPool({ queueLimit: 0 }); +// Database startup check (ensure job_spec_standards table exists) +(async () => { + let connection; + try { + connection = await pool.getConnection(); + await connection.query(` + CREATE TABLE IF NOT EXISTS job_spec_standards ( + id INT AUTO_INCREMENT PRIMARY KEY, + job_name VARCHAR(100) UNIQUE NOT NULL, + cpu_standard VARCHAR(255), + ram_standard VARCHAR(100), + gpu_standard VARCHAR(100), + min_score INT DEFAULT 0, + remarks TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + `); + console.log('✅ job_spec_standards table verification completed.'); + } catch (err) { + console.error('❌ Failed to verify/create job_spec_standards table:', err); + } finally { + if (connection) connection.release(); + } +})(); + // Error Handler const handleError = (res, err, label) => { console.error(`❌ [${label}] Error:`, err); @@ -151,6 +177,7 @@ app.get('/api/assets/master', async (req, res) => { const [users] = await connection.query('SELECT * FROM system_users'); const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC'); const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name'); + const [jobSpecs] = await connection.query('SELECT * FROM job_spec_standards ORDER BY job_name'); masterData.swInternal = swInternal; masterData.swExternal = swExternal; @@ -158,6 +185,7 @@ app.get('/api/assets/master', async (req, res) => { masterData.users = users; masterData.logs = logs; masterData.partsMaster = partsMaster; + masterData.jobSpecs = jobSpecs; res.json(masterData); } catch (err) { @@ -546,6 +574,56 @@ app.delete('/api/hardware-components/:id', async (req, res) => { } }); +// 6.7.1. Get Job Spec Standards +app.get('/api/job-specs', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM job_spec_standards ORDER BY job_name'); + res.json(rows); + } catch (err) { + handleError(res, err, 'GET JOB SPECS'); + } +}); + +// 6.7.2. Save Job Spec Standard (Add or Update) +app.post('/api/job-specs/save', async (req, res) => { + const { id, job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks } = req.body; + let connection; + try { + connection = await pool.getConnection(); + if (id) { + await connection.query( + 'UPDATE job_spec_standards SET job_name = ?, cpu_standard = ?, ram_standard = ?, gpu_standard = ?, min_score = ?, remarks = ? WHERE id = ?', + [job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks, id] + ); + } else { + await connection.query( + 'INSERT INTO job_spec_standards (job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks) VALUES (?, ?, ?, ?, ?, ?)', + [job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks] + ); + } + res.json({ success: true }); + } catch (err) { + handleError(res, err, 'SAVE JOB SPEC'); + } finally { + if (connection) connection.release(); + } +}); + +// 6.7.3. Delete Job Spec Standard +app.delete('/api/job-specs/:id', async (req, res) => { + const { id } = req.params; + let connection; + try { + connection = await pool.getConnection(); + await connection.query('DELETE FROM job_spec_standards WHERE id = ?', [id]); + res.json({ success: true }); + } catch (err) { + handleError(res, err, 'DELETE JOB SPEC'); + } finally { + if (connection) connection.release(); + } +}); + // 6.8. Get System Users List app.get('/api/system-users', async (req, res) => { try { diff --git a/src/components/Modal/JobSpecModal.ts b/src/components/Modal/JobSpecModal.ts new file mode 100644 index 0000000..2152177 --- /dev/null +++ b/src/components/Modal/JobSpecModal.ts @@ -0,0 +1,176 @@ +import { state, saveJobSpec, deleteJobSpec } from '../../core/state'; +import { BaseModal } from './BaseModal'; +import { setFieldValue } from './ModalUtils'; +import { UI_TEXT } from '../../core/schema'; + +class JobSpecModal extends BaseModal { + constructor() { + super('job-spec', '직무별 기준 사양'); + } + + protected renderFrameHTML(): string { + const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;'; + const inputStyle = sharedStyle; + + return ` + + `; + } + + protected initChildLogic(onSave: () => void, closeModals: () => void): void { + const saveBtn = document.getElementById('btn-save-job-spec-asset')!; + const revertBtn = document.getElementById('btn-revert-job-spec-edit')!; + const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!; + + saveBtn.addEventListener('click', async () => { + if (!this.currentAsset) return; + if (!this.isEditMode) { + this.setEditLockMode('edit'); + this.isEditMode = true; + return; + } + + const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim(); + const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim(); + const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim(); + const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim(); + const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value; + const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim(); + + if (!jobName) { + alert('직무명을 입력해 주세요.'); + return; + } + + const updated = { + id: this.currentAsset.id || null, + job_name: jobName, + cpu_standard: cpuStd, + ram_standard: ramStd, + gpu_standard: gpuStd, + min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0, + remarks: remarks + }; + + if (await saveJobSpec(updated)) { + alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); + onSave(); this.close(); closeModals(); + } + }); + + revertBtn.addEventListener('click', () => { + this.setEditLockMode('view'); + if (this.currentAsset) this.fillFormData(this.currentAsset); + }); + + deleteBtn.addEventListener('click', async () => { + if (!this.currentAsset || !this.currentAsset.id) return; + if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return; + + if (await deleteJobSpec(this.currentAsset.id)) { + alert('성공적으로 삭제되었습니다.'); + onSave(); this.close(); closeModals(); + } + }); + } + + protected fillFormData(asset: any): void { + setFieldValue('job-spec-id', asset.id || ''); + setFieldValue('job-spec-job-name', asset.job_name || ''); + setFieldValue('job-spec-cpu-standard', asset.cpu_standard || ''); + setFieldValue('job-spec-ram-standard', asset.ram_standard || ''); + setFieldValue('job-spec-gpu-standard', asset.gpu_standard || ''); + setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '0'); + setFieldValue('job-spec-remarks', asset.remarks || ''); + } + + protected onAfterOpen(asset: any, mode: string): void { + const titleEl = document.getElementById('job-spec-modal-title'); + + if (titleEl) { + if (mode === 'add') { + titleEl.textContent = '신규 직무별 기준 사양 등록'; + } else { + titleEl.textContent = '직무별 기준 사양 상세 편집'; + } + } + + const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!; + const saveBtn = document.getElementById('btn-save-job-spec-asset')!; + + // 추가 모드일 때는 삭제 버튼 숨김 + deleteBtn.style.display = (mode === 'add') ? 'none' : 'block'; + + if (mode === 'add') { + this.setEditLockMode('edit'); + this.isEditMode = true; + saveBtn.textContent = '등록'; + saveBtn.style.display = 'block'; + } else { + this.setEditLockMode('view'); + this.isEditMode = false; + saveBtn.textContent = '수정'; + saveBtn.style.display = 'block'; + } + } +} + +export const jobSpecModal = new JobSpecModal(); + +export function initJobSpecModal(onSave: () => void, closeModals: () => void) { + jobSpecModal.init(onSave, closeModals); +} + +export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { + jobSpecModal.open(asset, mode); +} diff --git a/src/core/schema.ts b/src/core/schema.ts index d5405d8..288e56e 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -160,6 +160,16 @@ export const PAGE_DESCRIPTIONS: Record ({ ...l, assetId: l.asset_id || l.assetId, @@ -229,3 +232,38 @@ export async function deleteSystemUser(id: string) { } return false; } + +export async function saveJobSpec(spec: any) { + try { + const url = `${API_BASE_URL}/api/job-specs/save`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spec) + }); + + if (response.ok) { + await loadMasterDataFromDB(); // 전역 상태 갱신 + return true; + } + } catch (err) { + console.error('직무별 기준 사양 저장 실패:', err); + } + return false; +} + +export async function deleteJobSpec(id: number) { + try { + const url = `${API_BASE_URL}/api/job-specs/${id}`; + const response = await fetch(url, { method: 'DELETE' }); + + if (response.ok) { + await loadMasterDataFromDB(); // 전역 상태 갱신 + return true; + } + } catch (err) { + console.error('직무별 기준 사양 삭제 실패:', err); + } + return false; +} + diff --git a/src/main.ts b/src/main.ts index f413815..ee7ecc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,9 @@ import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwUserModal } from './components/Modal/SWUserModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal'; +import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal'; import { initUserModal, openUserModal } from './components/Modal/UserModal'; +import { activePartsMasterSubTab } from './views/List/PartsMasterListView'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initGuide } from './components/Guide'; import { pcFlowModal } from './components/Modal/PCFlowModal'; @@ -85,6 +87,7 @@ function initApp() { }, closeAllModals); initDomainModal(() => refreshAllData(), closeAllModals); initPartsMasterModal(() => refreshAllData(), closeAllModals); + initJobSpecModal(() => refreshAllData(), closeAllModals); initUserModal(() => refreshAllData(), closeAllModals); initDashboardDetailModal(); @@ -114,7 +117,11 @@ function initApp() { if (cat === 'hw') { if (tab === '부품 마스터') { - openPartsMasterModal({ id: '' } as any, 'add'); + if (activePartsMasterSubTab === 'job-spec') { + openJobSpecModal({ id: '' } as any, 'add'); + } else { + openPartsMasterModal({ id: '' } as any, 'add'); + } } else { openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add'); } diff --git a/src/views/List/PartsMasterListView.ts b/src/views/List/PartsMasterListView.ts index 6608e9f..552189c 100644 --- a/src/views/List/PartsMasterListView.ts +++ b/src/views/List/PartsMasterListView.ts @@ -1,66 +1,171 @@ import { state } from '../../core/state'; import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal'; +import { openJobSpecModal } from '../../components/Modal/JobSpecModal'; import { formatInline } from '../../core/utils'; import { createListView } from './ListFactory'; +export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master'; + export function renderPartsMasterList(container: HTMLElement) { - createListView(container, { - title: '부품 마스터', - dataSource: () => state.masterData.partsMaster || [], - searchKeys: ['component_name', 'category', 'score_tier'], - filterOptions: { - keywordLabel: '부품명 / 등급 검색', - showLoc: false, - showDept: false, - showType: false - }, - onRowClick: (component) => openPartsMasterModal(component, 'view'), - columns: [ - { - header: 'ID', - sortKey: 'id', - align: 'center', - width: '5%', - render: c => c.id.toString() + if (activePartsMasterSubTab === 'parts-master') { + createListView(container, { + title: '부품 마스터', + dataSource: () => state.masterData.partsMaster || [], + searchKeys: ['component_name', 'category', 'score_tier'], + filterOptions: { + keywordLabel: '부품명 / 등급 검색', + showLoc: false, + showDept: false, + showType: false }, - { - header: '분류', - sortKey: 'category', - align: 'center', - width: '15%', - render: c => { - let badgeClass = 'badge-primary'; - if (c.category === 'CPU') badgeClass = 'b-primary'; - else if (c.category === 'GPU') badgeClass = 'b-purple'; - else if (c.category === 'RAM') badgeClass = 'b-green'; - return `${c.category}`; + onRowClick: (component) => openPartsMasterModal(component, 'view'), + columns: [ + { + header: 'ID', + sortKey: 'id', + align: 'center', + width: '5%', + render: c => c.id.toString() + }, + { + header: '분류', + sortKey: 'category', + align: 'center', + width: '15%', + render: c => { + let badgeClass = 'badge-primary'; + if (c.category === 'CPU') badgeClass = 'b-primary'; + else if (c.category === 'GPU') badgeClass = 'b-purple'; + else if (c.category === 'RAM') badgeClass = 'b-green'; + return `${c.category}`; + } + }, + { + header: '부품 표준 명칭', + sortKey: 'component_name', + render: c => formatInline(c.component_name || '-') + }, + { + header: '성능 등급', + sortKey: 'score_tier', + align: 'center', + width: '15%', + render: c => c.score_tier || '-' + }, + { + header: '감점 점수', + sortKey: 'deduction', + align: 'center', + width: '15%', + render: c => { + const score = c.deduction || 0; + let color = '#3b82f6'; // blue + if (score >= 20) color = '#ef4444'; // red + else if (score >= 10) color = '#f59e0b'; // orange + return `-${score}점`; + } } + ] + }); + } else { + createListView(container, { + title: '직무별 기준 사양', + dataSource: () => state.masterData.jobSpecs || [], + searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'], + filterOptions: { + keywordLabel: '직무명 / 사양 검색', + showLoc: false, + showDept: false, + showType: false }, - { - header: '부품 표준 명칭', - sortKey: 'component_name', - render: c => formatInline(c.component_name || '-') - }, - { - header: '성능 등급', - sortKey: 'score_tier', - align: 'center', - width: '15%', - render: c => c.score_tier || '-' - }, - { - header: '감점 점수', - sortKey: 'deduction', - align: 'center', - width: '15%', - render: c => { - const score = c.deduction || 0; - let color = '#3b82f6'; // blue - if (score >= 20) color = '#ef4444'; // red - else if (score >= 10) color = '#f59e0b'; // orange - return `-${score}점`; + onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'), + columns: [ + { + header: 'ID', + sortKey: 'id', + align: 'center', + width: '5%', + render: j => j.id.toString() + }, + { + header: '직무명', + sortKey: 'job_name', + width: '15%', + render: j => `${formatInline(j.job_name || '-')}` + }, + { + header: '권장 CPU 사양', + sortKey: 'cpu_standard', + render: j => formatInline(j.cpu_standard || '-') + }, + { + header: '권장 RAM 사양', + sortKey: 'ram_standard', + width: '12%', + render: j => formatInline(j.ram_standard || '-') + }, + { + header: '권장 GPU 사양', + sortKey: 'gpu_standard', + render: j => formatInline(j.gpu_standard || '-') + }, + { + header: '기준 점수', + sortKey: 'min_score', + align: 'center', + width: '10%', + render: j => `${j.min_score || 0}점 이상` + }, + { + header: '비고', + sortKey: 'remarks', + width: '20%', + render: j => formatInline(j.remarks || '-') } - } - ] + ] + }); + } + + renderSubTabs(container); +} + +function renderSubTabs(container: HTMLElement) { + const header = container.querySelector('.page-header'); + if (!header) return; + + const tabContainer = document.createElement('div'); + tabContainer.className = 'sub-tab-container'; + tabContainer.style.cssText = 'display: flex; gap: 16px; margin-top: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 0;'; + + const tab1Active = activePartsMasterSubTab === 'parts-master'; + const tab2Active = activePartsMasterSubTab === 'job-spec'; + + tabContainer.innerHTML = ` + + + `; + + header.parentNode!.insertBefore(tabContainer, header.nextSibling); + + const tabPartsMaster = document.getElementById('tab-parts-master')!; + const tabJobSpec = document.getElementById('tab-job-spec')!; + + tabPartsMaster.addEventListener('click', () => { + if (activePartsMasterSubTab !== 'parts-master') { + activePartsMasterSubTab = 'parts-master'; + renderPartsMasterList(container); + } + }); + + tabJobSpec.addEventListener('click', () => { + if (activePartsMasterSubTab !== 'job-spec') { + activePartsMasterSubTab = 'job-spec'; + renderPartsMasterList(container); + } }); } +