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 = '
검색 결과 없음
'; + } else { + list.innerHTML = filtered.map((c: any) => `
${c.component_name}
`).join(''); + } + list.classList.remove('hidden'); + }; + + input.addEventListener('focus', () => { + showList(input.value); + }); + + input.addEventListener('input', () => { + showList(input.value); + }); + + list.addEventListener('mousedown', (e) => { + const item = (e.target as HTMLElement).closest('.autocomplete-item'); + if (item && item.getAttribute('data-val')) { + input.value = item.getAttribute('data-val') || ''; + list.classList.add('hidden'); + this.updateMinScore(); + } + }); + + document.addEventListener('mousedown', (e) => { + if (e.target !== input && !list.contains(e.target as Node)) { + list.classList.add('hidden'); + } + }); + } + + private updateMinScore(): void { + const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || ''; + const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || ''; + const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || ''; + + const score = calculatePcScoreDeductive(cpu, ram, gpu, ''); + + const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement; + if (minScoreEl) { + minScoreEl.value = score.toString(); + } + } + + 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() : '100'); + 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'; + } + + this.updateMinScore(); + } +} + +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, @@ -196,3 +200,37 @@ 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/core/utils.ts b/src/core/utils.ts index 8993e3c..27dcab0 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -288,11 +288,12 @@ export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, /** * 성능 점수 기준 등급 뱃지 메타 정보 가져오기 */ -export function getPcGrade(score: number): { name: string; class: string; color: string } { +export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } { if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' }; if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' }; if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' }; - return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; + if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; + return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' }; } /** diff --git a/src/main.ts b/src/main.ts index c10ab10..9ff54ac 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'; @@ -72,6 +74,7 @@ function initApp() { }, closeAllModals); initDomainModal(() => refreshAllData(), closeAllModals); initPartsMasterModal(() => refreshAllData(), closeAllModals); + initJobSpecModal(() => refreshAllData(), closeAllModals); initUserModal(() => refreshAllData(), closeAllModals); initDashboardDetailModal(); @@ -101,7 +104,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/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 6269e95..4be4807 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -18,12 +18,23 @@ export function renderHwDashboard(container: HTMLElement) { // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = ` +<<<<<<< HEAD

개인 PC 자산 대시보드

+======= +
+ + +
+
+

+ 개인 PC 자산 대시보드 +

+>>>>>>> origin/main
@@ -42,9 +53,10 @@ export function renderHwDashboard(container: HTMLElement) {
-
+
+<<<<<<< HEAD
@@ -54,50 +66,101 @@ export function renderHwDashboard(container: HTMLElement) {
보유 자산 수량 +======= +
+ + +
+ + +
+
+ 보유 자산 수량 +>>>>>>> origin/main
+<<<<<<< HEAD
0대
전사 보유 개인용 PC +======= +
0대
+ 전사 보유 개인용 PC +>>>>>>> origin/main
+<<<<<<< HEAD
사양 부족 검토 +======= + +
+
+ 사양 부족 +>>>>>>> origin/main
+<<<<<<< HEAD
0명
사양 교체 권고 자산 +======= +
0대
+ 사양 교체 권고 자산 +>>>>>>> origin/main
+<<<<<<< HEAD
오버스펙 검토 +======= + +
+
+ 오버 스펙 +>>>>>>> origin/main
+<<<<<<< HEAD
0명
사양 회수 권고 자산 +======= +
0대
+ 사양 회수 권고 자산 +>>>>>>> origin/main
+<<<<<<< HEAD
윈도우 11 불가 PC +======= +
+
+ 윈도우 11 불가 PC +>>>>>>> origin/main
+<<<<<<< HEAD
0대
업데이트 미지원 하드웨어 +======= +
0대
+ 업데이트 미지원 하드웨어 +>>>>>>> origin/main
@@ -105,6 +168,7 @@ export function renderHwDashboard(container: HTMLElement) {
+<<<<<<< HEAD
@@ -161,12 +225,95 @@ export function renderHwDashboard(container: HTMLElement) {
보급
-
+======= + +
+ +
+ +
+ 등급별 자산 종합 현황 +
+ + +
+ + + + + + + + + + + + + +
구분 (등급)보유량운영중재고구매 필요
+ +
+ +
+ + +
+
+ 직무별 사양 적정성 분석 +
+
+ +
+
+ + +
+ + +
+ +
+ 등급별 보유 비율 +
+ + +
+
+ +
+ +
+
+ + 최상급 +
+
+ + 상급 +
+
+ + 중급 +
+
+ + 보급 +
+
+ + 교체 대상 +
+>>>>>>> origin/main +
+
+
+ +<<<<<<< HEAD
@@ -194,11 +341,32 @@ export function renderHwDashboard(container: HTMLElement) {
0대
보급 재고
+======= + +
+
+ 연도별 PC 노후도 및 예측 +
+
+ + + + + + + + + + + +
구분 (연한)보유권장 조치
+>>>>>>> origin/main
+<<<<<<< HEAD
@@ -233,6 +401,8 @@ export function renderHwDashboard(container: HTMLElement) {
+======= +>>>>>>> origin/main
@@ -288,7 +458,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) { p._pc_score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); }); - // 3. 전사 직무군별 평균 점수 산출 + // 3. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용) + const jobSpecsMap: Record = {}; + if (state.masterData.jobSpecs) { + state.masterData.jobSpecs.forEach((s: any) => { + jobSpecsMap[s.job_name] = s.min_score; + }); + } + const jobScores: Record = {}; pcs.forEach((p: any) => { const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); @@ -301,18 +478,19 @@ function updateDashboardData(pcs: any[], selectedDept: string) { jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; }); - // 4. 등급 집계 (보유량 vs 유효 재고량) + // 4. 등급 집계 (보유량 vs 실제 할당량 vs 유효 재고량 vs 사양 부족량) const isStock = (p: any) => { return p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim(); }; - const gradeCounts = { - premium: { total: 0, stock: 0 }, - high: { total: 0, stock: 0 }, - normal: { total: 0, stock: 0 }, - entry: { total: 0, stock: 0 } + const matrix = { + premium: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + high: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + normal: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + replace: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] } }; let scoreSum = 0; @@ -325,36 +503,81 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const score = p._pc_score; scoreSum += score; const stockYn = isStock(p); + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + // 1. 현재 물리적 자산 등급 판정 + let currentGradeKey: keyof typeof matrix; if (score >= 85) { - gradeCounts.premium.total++; - if (stockYn) gradeCounts.premium.stock++; + currentGradeKey = 'premium'; } else if (score >= 70) { - gradeCounts.high.total++; - if (stockYn) gradeCounts.high.stock++; + currentGradeKey = 'high'; } else if (score >= 40) { - gradeCounts.normal.total++; - if (stockYn) gradeCounts.normal.stock++; + currentGradeKey = 'normal'; + } else if (score >= 20 && !win11Incompatible) { + currentGradeKey = 'entry'; } else { - gradeCounts.entry.total++; - if (stockYn) gradeCounts.entry.stock++; + currentGradeKey = 'replace'; } - // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) - const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const avg = jobScores[job]?.avg || 0; + const currentTarget = matrix[currentGradeKey]; + currentTarget.pcs.push(p); + currentTarget.total++; - if (avg > 0 && job !== '재고PC' && !stockYn) { - if (score < avg * 0.6) { - p._spec_status = '사양 부족'; + if (stockYn) { + currentTarget.stock++; + currentTarget.stockPcs.push(p); + } else { + currentTarget.active++; + currentTarget.activePcs.push(p); + + // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) + const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0); + + let isUnder = false; + + if (standardScore > 0 && job !== '재고PC') { + if (score < standardScore * 0.6) { + isUnder = true; + p._spec_status = '사양 부족'; + } else if (score > standardScore * 1.5 && !win11Incompatible) { + p._spec_status = '오버스펙'; + criticalList.push(p); + overSpecCount++; + } else if (win11Incompatible) { + isUnder = true; + p._spec_status = '사양 부족'; + } else { + p._spec_status = '적정'; + } + } else { + if (win11Incompatible) { + isUnder = true; + p._spec_status = '사양 부족'; + } else { + p._spec_status = '적정'; + } + } + + if (isUnder) { criticalList.push(p); underSpecCount++; - } else if (score > avg * 1.5) { - p._spec_status = '오버스펙'; - criticalList.push(p); - overSpecCount++; - } else { - p._spec_status = '적정'; + + // 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정 + let targetGradeKey: keyof typeof matrix; + if (standardScore >= 85) { + targetGradeKey = 'premium'; + } else if (standardScore >= 70) { + targetGradeKey = 'high'; + } else if (standardScore >= 40) { + targetGradeKey = 'normal'; + } else { + targetGradeKey = 'entry'; // 교체 대상은 최소 보급형 사양으로 교체 + } + + const targetGrade = matrix[targetGradeKey]; + targetGrade.under++; + targetGrade.underPcs.push(p); } } @@ -364,39 +587,120 @@ function updateDashboardData(pcs: any[], selectedDept: string) { } }); - // 5. 핵심 텍스트형 지표 갱신 + // 5. 핵심 텍스트형 요약 지표 갱신 document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}대`; - document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}명`; - document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}명`; + document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}대`; + document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}대`; document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}대`; - - // 6. 등급별 리스트 데이터 바 업데이트 - const total = filtered.length || 1; + // 6. 종합 매트릭스 테이블 렌더링 및 바인딩 + const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; - const updateCard = (id: string, counts: { total: number; stock: number }) => { - const card = document.getElementById(id)!; - const rate = Math.round((counts.total / total) * 100); + const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => { + const data = matrix[gradeKey]; + const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0; - card.querySelector('.grade-count')!.textContent = `${counts.total}대`; - card.querySelector('.grade-rate')!.textContent = `(${rate}%)`; + const cellStyle = `padding: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; + const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; + + return ` + + ${label} + ${data.total}대 (${totalRate}%) + ${data.active}대 + ${data.stock}대 + ${shortage}대 + + `; }; - updateCard('grade-premium', gradeCounts.premium); - updateCard('grade-high', gradeCounts.high); - updateCard('grade-normal', gradeCounts.normal); - updateCard('grade-entry', gradeCounts.entry); + const totalPcs = filtered.length; + const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active + matrix.replace.active; + const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock + matrix.replace.stock; + + const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); + const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); + const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); + + // 보급 PC 구매 필요 = 보급 under - 보급 stock + const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); + + // 교체 대상 PC 자체는 새로 구매하는 기종이 아니므로 구매 필요 0대 + const replaceShortage = 0; + + const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage; - // 6.2 Inventory Summary 수치 업데이트 (골드/민트 텍스트 영역) - const container = document.getElementById('view-body')?.parentElement || document.body; - const setStockVal = (cls: string, val: number) => { - const el = container.querySelector(`.${cls}`); - if (el) el.textContent = `${val}대`; - }; - setStockVal('summary-grade-stock-premium', gradeCounts.premium.stock); - setStockVal('summary-grade-stock-high', gradeCounts.high.stock); - setStockVal('summary-grade-stock-normal', gradeCounts.normal.stock); - setStockVal('summary-grade-stock-entry', gradeCounts.entry.stock); + const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`; + const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; + + matrixTbody.innerHTML = ` + ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B', premiumShortage)} + ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)} + ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)} + ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)} + ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)} + + 합계 (Total) + ${totalPcs}대 (100%) + ${totalActive}대 + ${totalStock}대 + ${totalShortage}대 + + `; + + // 셀별 동적 클릭 리스너 바인딩 + matrixTbody.querySelectorAll('.matrix-cell').forEach(cell => { + cell.addEventListener('click', () => { + const grade = cell.getAttribute('data-grade')!; + const type = cell.getAttribute('data-type')!; + + let targetList: any[] = []; + let title = ''; + + const getGradeLabel = (g: string) => { + if (g === 'premium') return '최상급 PC'; + if (g === 'high') return '상급 PC'; + if (g === 'normal') return '중급 PC'; + if (g === 'entry') return '보급 PC'; + if (g === 'replace') return '교체 대상 PC'; + return '전체 PC'; + }; + + const getTypeLabel = (t: string) => { + if (t === 'total') return '보유'; + if (t === 'active') return '운영중'; + if (t === 'stock') return '재고'; + if (t === 'under') return '구매 필요'; + return ''; + }; + + if (grade === 'all') { + if (type === 'total') { + targetList = filtered; + } else if (type === 'active') { + targetList = filtered.filter(p => !isStock(p)); + } else if (type === 'stock') { + targetList = filtered.filter(p => isStock(p)); + } else if (type === 'under') { + targetList = criticalList.filter(p => p._spec_status === '사양 부족'); + } + } else { + const data = matrix[grade as keyof typeof matrix]; + if (type === 'total') { + targetList = data.pcs; + } else if (type === 'active') { + targetList = data.activePcs; + } else if (type === 'stock') { + targetList = data.stockPcs; + } else if (type === 'under') { + targetList = data.underPcs; + } + } + + title = `${getGradeLabel(grade)} - ${getTypeLabel(type)} 자산 목록`; + showMiniListModal(title, targetList); + }); + }); // 7. 연도별 PC 노후도 집계 및 렌더링 const agingCounts = { @@ -424,9 +728,9 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { return ` - ${label} - ${list.length}대 - + ${label} + ${list.length}대 + ${badgeText} @@ -454,7 +758,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }); }); - // 8. 각 등급 행 클릭 리스너 설정 + // 8. 요약 지표 카드 클릭 리스너 설정 const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => { const card = document.getElementById(id)!; if (!card) return; @@ -470,23 +774,11 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; }; - bindCardClick('grade-premium', '최상급 PC', p => p._pc_score >= 85); - bindCardClick('grade-high', '상급 PC', p => p._pc_score >= 70 && p._pc_score < 85); - bindCardClick('grade-normal', '중급 PC', p => p._pc_score >= 40 && p._pc_score < 70); - bindCardClick('grade-entry', '보급 PC', p => p._pc_score < 40); - - // 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정 - bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족'); - bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙'); + // 사양 부족 / 오버 스펙 / 윈도우 11 불가 클릭 리스너 설정 + bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족'); + bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙'); bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram)); - - // 8.2 유효 재고 현황 클릭 리스너 설정 - bindCardClick('stock-premium-card', '최상급 유효 재고', p => p._pc_score >= 85 && isStock(p)); - bindCardClick('stock-high-card', '상급 유효 재고', p => p._pc_score >= 70 && p._pc_score < 85 && isStock(p)); - bindCardClick('stock-normal-card', '중급 유효 재고', p => p._pc_score >= 40 && p._pc_score < 70 && isStock(p)); - bindCardClick('stock-entry-card', '보급 유효 재고', p => p._pc_score < 40 && isStock(p)); - // 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화) const activeJobs = Array.from( new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC')) @@ -527,7 +819,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 10. 차트들 렌더링 호출 renderChart(activeJobs, underData, normalData, overData, filtered); - renderDonutChart(gradeCounts.premium.total, gradeCounts.high.total, gradeCounts.normal.total, gradeCounts.entry.total); + renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); // 전역 상태 등록 state.activeCharts = [jobChartInstance, donutChartInstance]; @@ -559,7 +851,7 @@ function showMiniListModal(title: string, list: any[]) { `; modal.innerHTML = ` -
+

@@ -574,23 +866,31 @@ function showMiniListModal(title: string, list: any[]) { - - - + + + + ${list.length === 0 - ? `` + ? `` : list.map(pc => { const spec = `${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}`; const user = pc.user_current || '(재고)'; + const score = pc._pc_score !== undefined ? pc._pc_score : calculatePcScoreDeductive(pc.cpu, pc.ram, pc.gpu, pc.purchase_date); + const win11Incompatible = isWindows11Incompatible(pc.cpu, pc.ram); + const grade = getPcGrade(score, win11Incompatible); + const badgeHTML = `${grade.name}`; + const scoreHTML = `${score}점`; + return ` + `; @@ -677,7 +977,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] categoryPercentage: 0.8 }, { - label: '오버스펙', + label: '오버 스펙', data: overData, backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange borderColor: 'rgb(217, 119, 6)', @@ -721,7 +1021,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] return specStatus === clickedStatus; }); - showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : clickedStatus} 자산`, matchedPcs); + showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : (clickedStatus === '오버스펙' ? '오버 스펙' : clickedStatus)} 자산`, matchedPcs); } }, plugins: { @@ -729,10 +1029,10 @@ function renderChart(labels: string[], underData: number[], normalData: number[] position: 'top', align: 'end', labels: { - font: { family: 'Pretendard', size: 11, weight: '700' }, + font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569', - boxWidth: 8, - boxHeight: 8, + boxWidth: 12, + boxHeight: 12, usePointStyle: true } }, @@ -755,7 +1055,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] stacked: true, ticks: { callback: (val: any) => `${val}대`, - font: { family: 'Pretendard', size: 10, weight: '600' }, + font: { family: 'Pretendard', size: 14, weight: '600' }, color: '#64748B' }, grid: { color: '#EEF2F6' } @@ -763,7 +1063,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] y: { stacked: true, ticks: { - font: { family: 'Pretendard', size: 11, weight: '700' }, + font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569' }, grid: { display: false } @@ -776,7 +1076,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] /** * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) */ -function renderDonutChart(premium: number, high: number, normal: number, entry: number) { +function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: number) { const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement; if (!ctx || typeof Chart === 'undefined') return; @@ -785,19 +1085,20 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: donutChartInstance = null; } - const total = premium + high + normal + entry; + const total = premium + high + normal + entry + replace; donutChartInstance = new Chart(ctx, { type: 'doughnut', data: { - labels: ['최상급', '상급', '중급', '보급'], + labels: ['최상급', '상급', '중급', '보급', '교체 대상'], datasets: [{ - data: [premium, high, normal, entry], + data: [premium, high, normal, entry, replace], backgroundColor: [ '#11302B', // premium (Hanmac Dark Green) '#1E8E7C', // high (Hanmac Teal) '#10B981', // normal (Hanmac Mint) - '#94A3B8' // entry (Slate Gray) + '#F59E0B', // entry (Yellow-Orange) + '#EF4444' // replace (Red) ], borderColor: '#ffffff', borderWidth: 2 @@ -831,7 +1132,7 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: top: 50%; left: 50%; transform: translate(-50%, -46%); - font-size: 1.56rem; + font-size: 1.65rem; font-weight: 900; color: #1E5149; font-family: 'Pretendard', sans-serif; diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index 450d3b6..864a43d 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -1,5 +1,5 @@ import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; -import { dynamicSort, renderPageHeader, calculateAssetAge } from '../../core/utils'; +import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline, isWindows11Incompatible } from '../../core/utils'; import { setupTableSorting, SortState } from '../../core/tableHandler'; import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler'; import { state } from '../../core/state'; @@ -176,10 +176,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { } let currentFilters: any = (state as any).listFilters[filterKey]; + // 서버 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드 (PC 탭은 자산 현황 숨김) const isServer = config.title === '서버'; - - // 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드 - if (!(state as any).currentViewMode || (state as any).currentViewMode === 'system') { + if (!isServer) { (state as any).currentViewMode = 'asset'; } @@ -189,7 +188,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { // 2. 필터 바 생성 (자산 목록에서만 사용) const filterBar = document.createElement('div'); - filterBar.className = 'filter-bar'; + filterBar.className = 'search-bar'; // 자산 추가 버튼 및 목록 보기 체크박스 추가 로직 const showPcFlowBtn = config.title === 'PC'; @@ -413,6 +412,146 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { `; + const updateFlowLogsSection = () => { + if (!isPcView) return; + + // 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩 + const specMismatchTbody = document.getElementById('spec-mismatch-tbody'); + if (specMismatchTbody) { + // fullList 중 개인 PC 관련 장비 필터링 + const pcs = fullList.filter((a: any) => { + const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || ''; + const job = a[ASSET_SCHEMA.USER_POSITION.key] || ''; + const status = a[ASSET_SCHEMA.HW_STATUS.key] || ''; + const user = a[ASSET_SCHEMA.CURRENT_USER.key] || ''; + + // 운영 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상 + return job !== '재고PC' && status === '운영' && user.trim() !== ''; + }); + + // 직무별 평균 점수 산출 + const jobScores: Record = {}; + pcs.forEach((pc: any) => { + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; + const ram = pc[ASSET_SCHEMA.RAM.key] || ''; + const gpu = pc[ASSET_SCHEMA.GPU.key] || ''; + const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || ''; + const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate); + pc['_pc_score'] = score; + + if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 }; + jobScores[job].totalScore += score; + jobScores[job].count += 1; + }); + + Object.keys(jobScores).forEach(job => { + jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; + }); + + // DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용) + const jobSpecsMap: Record = {}; + if (state.masterData.jobSpecs) { + state.masterData.jobSpecs.forEach((s: any) => { + jobSpecsMap[s.job_name] = s.min_score; + }); + } + + // 기준 대비 사양 부족/오버스펙 분류 + const criticalPcList: any[] = []; + pcs.forEach((pc: any) => { + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const score = pc['_pc_score']; + const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0); + + const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; + const ram = pc[ASSET_SCHEMA.RAM.key] || ''; + const win11Incompatible = isWindows11Incompatible(cpu, ram); + + let isUnder = false; + if (standardScore > 0) { + if (score < standardScore * 0.6) { + isUnder = true; + pc['_spec_status'] = '사양 부족'; + } else if (score > standardScore * 1.5 && !win11Incompatible) { + pc['_spec_status'] = '오버스펙'; + criticalPcList.push(pc); + } else if (win11Incompatible) { + isUnder = true; + pc['_spec_status'] = '사양 부족'; + } else { + pc['_spec_status'] = '적정'; + } + } else { + if (win11Incompatible) { + isUnder = true; + pc['_spec_status'] = '사양 부족'; + } else { + pc['_spec_status'] = '적정'; + } + } + + if (isUnder) { + criticalPcList.push(pc); + } + }); + + // 정렬: 기준 점수 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬 + criticalPcList.sort((a: any, b: any) => { + const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const stdA = jobSpecsMap[jobA] !== undefined ? jobSpecsMap[jobA] : (jobScores[jobA]?.avg || 0); + const stdB = jobSpecsMap[jobB] !== undefined ? jobSpecsMap[jobB] : (jobScores[jobB]?.avg || 0); + + const ratioA = stdA > 0 ? a['_pc_score'] / stdA : 1; + const ratioB = stdB > 0 ? b['_pc_score'] / stdB : 1; + return ratioA - ratioB; + }); + + if (criticalPcList.length === 0) { + specMismatchTbody.innerHTML = ''; + } else { + specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => { + const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-'; + const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-'; + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-'; + const status = pc['_spec_status']; + const assetCode = pc.asset_code || '-'; + + const badgeColor = status === '사양 부족' + ? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;' + : 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;'; + + return ` + + + + + + + `; + }).join(''); + + // 클릭 시 해당 자산 상세 페이지로 전환 + specMismatchTbody.querySelectorAll('.spec-row').forEach(row => { + row.addEventListener('click', () => { + specMismatchTbody.querySelectorAll('.spec-row').forEach(r => { + (r as HTMLElement).style.backgroundColor = 'transparent'; + }); + (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 + const assetId = row.getAttribute('data-id'); + const found = fullList.find(a => String(a.id) === String(assetId)); + if (found) { + updateDetailPanel(found); + } + }); + }); + } + } + }; + const updateDetailPanel = (asset: any) => { const emptyState = document.getElementById('detail-empty-state'); const content = document.getElementById('detail-content'); @@ -590,6 +729,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); }); updateTableOnly(); + updateFlowLogsSection(); }, 50); }; @@ -632,7 +772,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { ...config.filterOptions, initialFilters: currentFilters, extraHTML: isServer ? ` -
+
사용자조직 (직무)주요 사양사용자조직 (직무)주요 사양등급 (점수) 자산코드
해당 등급의 자산이 없습니다.
해당 등급의 자산이 없습니다.
${user} ${pc.current_dept || '-'} (${pc.user_position || '-'}) ${spec}${badgeHTML}${scoreHTML} ${pc.asset_code || '-'}
사양 주의 자산이 없습니다.
${user}${dept} (${job}) + ${status === '오버스펙' ? '오버 스펙' : status} + ${assetCode}