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..336b384 --- /dev/null +++ b/src/components/Modal/JobSpecModal.ts @@ -0,0 +1,284 @@ +import { state, saveJobSpec, deleteJobSpec } from '../../core/state'; +import { BaseModal } from './BaseModal'; +import { setFieldValue } from './ModalUtils'; +import { UI_TEXT } from '../../core/schema'; +import { calculatePcScoreDeductive } from '../../core/utils'; + +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(); + } + }); + + // 자동완성 바인딩 + this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU'); + this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM'); + this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU'); + + // 실시간 점수 계산 이벤트 바인딩 + const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard']; + inputs.forEach(id => { + const el = document.getElementById(id); + el?.addEventListener('input', () => this.updateMinScore()); + el?.addEventListener('change', () => this.updateMinScore()); + }); + } + + private bindAutocomplete(inputId: string, autocompleteId: string, category: string) { + const input = document.getElementById(inputId) as HTMLInputElement; + const list = document.getElementById(autocompleteId) as HTMLDivElement; + if (!input || !list) return; + + const showList = (filterText: string = '') => { + if (!this.isEditMode) return; + const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category); + const filtered = filterText + ? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase())) + : items; + + if (filtered.length === 0) { + list.innerHTML = '