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);
+ }
});
}
+