diff --git a/README.md b/README.md index 7344124..324215f 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,9 @@ - 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다. - 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다. - 본 원칙을 적용할 때에도 기존의 **선보고 후승인** 및 **외과 수술식 수정** 규칙을 준수한다. +7. **CSS 스타일 분리 원칙 (CSS Separation Policy)**: + - HTML/JS/TS 파일 내에 인라인 ` `; } diff --git a/src/components/Modal/JobSpecModal.ts b/src/components/Modal/JobSpecModal.ts index b38b958..143b68f 100644 --- a/src/components/Modal/JobSpecModal.ts +++ b/src/components/Modal/JobSpecModal.ts @@ -68,36 +68,6 @@ class JobSpecModal extends BaseModal { - `; } diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index 9da0725..176382f 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -212,15 +212,6 @@ class SwAssetModal extends BaseModal { - `; } diff --git a/src/components/Modal/SWUserModal.ts b/src/components/Modal/SWUserModal.ts index 47190b0..04fda60 100644 --- a/src/components/Modal/SWUserModal.ts +++ b/src/components/Modal/SWUserModal.ts @@ -111,15 +111,6 @@ class SwUserModal extends BaseModal { - `; } diff --git a/src/components/Modal/modal.css b/src/components/Modal/modal.css index af7bf1b..9d7fe5f 100644 --- a/src/components/Modal/modal.css +++ b/src/components/Modal/modal.css @@ -597,3 +597,47 @@ top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; } + +/* --- Component Specific Styles (Migrated) --- */ +.hidden { + display: none !important; +} + +.autocomplete-list { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-height: 150px; + overflow-y: auto; + background-color: white; + border: 1px solid var(--border-color, #E2E8F0); + border-top: none; + border-radius: 0 0 4px 4px; + z-index: 1000; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.autocomplete-item { + padding: 8px 12px; + font-size: 13px; + color: #334155; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autocomplete-item:hover { + background-color: #F1F5F9; + color: #1E5149; + font-weight: 600; +} + +.hidden-picker { + position: absolute; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} diff --git a/src/components/Navigation.ts b/src/components/Navigation.ts index aa53723..cda27f0 100644 --- a/src/components/Navigation.ts +++ b/src/components/Navigation.ts @@ -3,7 +3,7 @@ import { state } from '../core/state'; const MENU_CONFIG: any = { hw: { label: '하드웨어', - tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비'] + tabs: ['자산현황', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비'] }, sw: { label: '소프트웨어', @@ -60,12 +60,12 @@ export function renderNavigation(onTabChange: (tab: string) => void) { const config = MENU_CONFIG[catKey]; let visibleTabs = config.tabs.filter((tab: string) => { - if (state.currentUserRole === 'admin') return tab === '대시보드'; - return tab !== '대시보드'; + if (state.currentUserRole === 'admin') return tab === '자산현황'; + return tab !== '자산현황'; }); if (state.currentUserRole === 'admin' && catKey === 'hw') { - visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정', '부품 마스터']; + visibleTabs = ['자산현황', '실사승인', '위치지정', '부품마스터', '자산추가', '서버관리']; } if (visibleTabs.length === 0) return; @@ -75,28 +75,13 @@ export function renderNavigation(onTabChange: (tab: string) => void) { const isActive = state.activeSubTab === tab; item.className = `gnb-trigger ${isActive ? 'active' : ''}`; - const isSubMenu = tab === '실사 승인' || tab === '위치지정' || tab === '부품 마스터'; - if (isSubMenu) { - item.innerHTML = `${tab}`; - item.style.fontSize = '11px'; - item.style.fontWeight = '500'; - item.style.marginLeft = '6px'; - if (!isActive) { - item.style.color = 'var(--mute)'; - } - } else { - item.textContent = tab; - item.style.fontSize = 'var(--fs-sm)'; - } + item.textContent = tab; + item.style.fontSize = 'var(--fs-sm)'; item.addEventListener('click', (e) => { e.stopPropagation(); state.activeCategory = catKey as any; - if (tab === '관리도구') { - state.activeSubTab = '실사 승인'; - } else { - state.activeSubTab = tab; - } + state.activeSubTab = tab; render(); onTabChange(state.activeSubTab); }); @@ -112,7 +97,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) { state.currentUserRole = roleToggle.checked ? 'admin' : 'user'; if (state.currentUserRole === 'admin') { state.activeCategory = 'hw'; - state.activeSubTab = '대시보드'; + state.activeSubTab = '자산현황'; } else { state.activeCategory = 'hw'; state.activeSubTab = '서버'; diff --git a/src/core/state.ts b/src/core/state.ts index b222eb4..74700d8 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -15,7 +15,7 @@ export interface AppState { // 초기 상태 export const state: AppState = { activeCategory: 'hw', - activeSubTab: '대시보드', + activeSubTab: '자산현황', viewMode: 'location', activeCharts: [], currentUserRole: 'user', diff --git a/src/main.ts b/src/main.ts index 8131dff..b92e961 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,8 @@ import { renderDashboard } from './views/DashboardView'; import { renderSWTable } from './views/SW_Table'; import { renderLocationView } from './views/LocationView'; import { renderAuditApprovalView } from './views/AuditApprovalView'; +import { renderSpecChangeApprovalView } from './views/SpecChangeApprovalView'; +import { renderServerManagementView } from './views/ServerManagementView'; import { MapEditor } from './views/MapEditor'; import { initBaseModal } from './components/Modal/BaseModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal'; @@ -37,16 +39,26 @@ async function refreshView(tab?: string) { const activeTab = tab || state.activeSubTab; - if (activeTab === '대시보드') { + if (activeTab === '자산현황') { renderDashboard(mainContent); return; } - if (activeTab === '실사 승인') { + if (activeTab === '실사승인') { await renderAuditApprovalView(mainContent); return; } + if (activeTab === '자산추가') { + await renderSpecChangeApprovalView(mainContent); + return; + } + + if (activeTab === '서버관리') { + await renderServerManagementView(mainContent); + return; + } + if (activeTab === '위치지정') { // Render Map Editor directly into main content to maximize working area mainContent.innerHTML = ` @@ -160,7 +172,7 @@ function initApp() { const newId = Math.random().toString(36).substring(2, 9); if (cat === 'hw') { - if (tab === '부품 마스터') { + if (tab === '부품마스터') { if (activePartsMasterSubTab === 'job-spec') { openJobSpecModal({ id: '' } as any, 'add'); } else { @@ -182,7 +194,7 @@ function initApp() { // 부품 마스터 탭으로 바로가기 연동 if (target.closest('#btn-goto-parts-master')) { state.activeCategory = 'hw'; - state.activeSubTab = '부품 마스터'; + state.activeSubTab = '부품마스터'; renderNavigation((tab) => { refreshView(); }); refreshView(); return; @@ -220,7 +232,7 @@ function initRoleSwitcher() { // 관리자 모드 전환 시 대시보드로 이동 state.activeCategory = 'hw'; - state.activeSubTab = '대시보드'; + state.activeSubTab = '자산현황'; } else { state.currentUserRole = 'user'; adminLabel.classList.remove('active'); @@ -243,12 +255,21 @@ function initRoleSwitcher() { function initializeAppDirectly() { const loginContainer = document.getElementById('login-container'); const appLayout = document.getElementById('app-layout'); + const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement; + const userLabel = document.querySelector('.role-label.user'); + const adminLabel = document.querySelector('.role-label.admin'); // 기본 권한 설정: 실무자 (User) state.currentUserRole = 'user'; state.activeCategory = 'hw'; state.activeSubTab = '서버'; // 실무자 기본 탭 + // 헤더 역할 전환 체크박스 및 라벨 상태를 실무자로 동기화 + if (checkbox) checkbox.checked = false; + if (userLabel) userLabel.classList.add('active'); + if (adminLabel) adminLabel.classList.remove('active'); + document.body.classList.remove('admin-mode'); + // 화면 전환 if (loginContainer) loginContainer.style.display = 'none'; if (appLayout) appLayout.style.display = 'flex'; diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 4821471..773f3cc 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -1,6 +1,6 @@ import { state } from '../../core/state'; import { openHwModal } from '../../components/Modal/HWModal'; -import { calculatePcScoreDeductive, getPcGrade, calculateAssetAge, isWindows11Incompatible } from '../../core/utils'; +import { calculatePcScoreDeductive, getPcGrade, calculateAssetAge, isWindows11Incompatible, API_BASE_URL } from '../../core/utils'; import { ASSET_SCHEMA } from '../../core/schema'; import { createIcons, Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronRight, HelpCircle } from 'lucide'; @@ -69,13 +69,13 @@ export function renderHwDashboard(container: HTMLElement) {
-
-
-
- - +
+
-
+ +
@@ -85,6 +85,8 @@ export function renderHwDashboard(container: HTMLElement) {
+
+
@@ -117,9 +119,10 @@ export function renderHwDashboard(container: HTMLElement) {
0대
- +
+ 이슈 윈도우 11 불가
0대
@@ -230,35 +233,6 @@ export function renderHwDashboard(container: HTMLElement) {
- `; // 3. Lucide 아이콘 초기화 @@ -268,46 +242,703 @@ export function renderHwDashboard(container: HTMLElement) { // 4. 사용조직 버튼 그룹 필터 이벤트 연동 const btnGroup = container.querySelector('#dashboard-dept-buttons') as HTMLElement; + let selectedDepts: string[] = ['']; // 기본값 '전체' + + function refreshDeptButtons() { + const buttons = btnGroup.querySelectorAll('.dept-filter-btn'); + buttons.forEach(b => { + const button = b as HTMLButtonElement; + const dept = button.getAttribute('data-dept') || ''; + const isActive = selectedDepts.includes(dept); + + if (isActive) { + button.classList.add('active'); + let bgColor = '#1E5149'; + if (dept === '한맥') bgColor = '#D02121'; + else if (dept === '삼안') bgColor = '#F58120'; + else if (dept === '장헌') bgColor = '#3889C7'; + else if (dept === '한라') bgColor = '#79B2D9'; + else if (dept === '기술개발센터') bgColor = '#10B981'; + else if (dept === '총괄기획실') bgColor = '#133D84'; + + button.style.background = bgColor; + button.style.borderColor = bgColor; + button.style.color = 'white'; + } else { + button.classList.remove('active'); + button.style.background = 'var(--canvas-soft)'; + button.style.borderColor = 'var(--border-color)'; + button.style.color = 'var(--mute)'; + } + }); + } + btnGroup.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('.dept-filter-btn') as HTMLButtonElement; if (!btn) return; - btnGroup.querySelectorAll('.dept-filter-btn').forEach(b => { - const button = b as HTMLButtonElement; - button.classList.remove('active'); - button.style.background = 'transparent'; - button.style.color = 'var(--text-muted)'; - }); - - btn.classList.add('active'); const dept = btn.getAttribute('data-dept') || ''; - let bgColor = '#1E5149'; - if (dept === '한맥') bgColor = '#D02121'; - else if (dept === '삼안') bgColor = '#F58120'; - else if (dept === '장헌') bgColor = '#3889C7'; - else if (dept === '한라') bgColor = '#79B2D9'; - else if (dept === '기술개발센터') bgColor = '#10B981'; - else if (dept === '총괄기획실') bgColor = '#133D84'; - btn.style.background = bgColor; - btn.style.color = 'white'; + if (dept === '') { + selectedDepts = ['']; + } else { + selectedDepts = selectedDepts.filter(d => d !== ''); + if (selectedDepts.includes(dept)) { + selectedDepts = selectedDepts.filter(d => d !== dept); + if (selectedDepts.length === 0) selectedDepts = ['']; + } else { + selectedDepts.push(dept); + } + } - const selectedDept = btn.getAttribute('data-dept') || ''; - updateDashboardData(pcs, selectedDept); + refreshDeptButtons(); + updateDashboardData(pcs, selectedDepts); }); + // 이슈작성 모달 띄우기 함수 + const openCreateIssueModal = () => { + const oldModal = document.getElementById('issue-create-modal'); + if (oldModal) oldModal.remove(); + + const partsMasterList = state.masterData.partsMaster || []; + // partsMaster의 category 고유값 추출 (예: CPU, RAM, GPU, OS, SSD 등) + '연식' 하이브리드 추가 + const categories = Array.from(new Set(partsMasterList.map((p: any) => p.category))).filter(Boolean); + if (!categories.includes('CPU')) categories.push('CPU'); + if (!categories.includes('RAM')) categories.push('RAM'); + if (!categories.includes('GPU')) categories.push('GPU'); + if (!categories.includes('연식')) categories.push('연식'); + + const tempRules = JSON.parse(JSON.stringify(activeIssue.rules)); + + const modal = document.createElement('div'); + modal.id = 'issue-create-modal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-main); + `; + + modal.innerHTML = ` +
+
+

+ 신규 이슈 작성 +

+ +
+
+
+ + +
+
+ +
+ +
+ +
+
+
+ + +
+
+ `; + + document.body.appendChild(modal); + + const handleDocumentClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('.autocomplete-container')) { + document.querySelectorAll('.autocomplete-dropdown').forEach(d => { + (d as HTMLElement).style.display = 'none'; + }); + } + }; + document.addEventListener('click', handleDocumentClick); + + const closeModal = () => { + modal.remove(); + window.removeEventListener('keydown', handleEsc); + document.removeEventListener('click', handleDocumentClick); + }; + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + window.addEventListener('keydown', handleEsc); + + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + const renderRules = () => { + const container = document.getElementById('rules-container')!; + if (tempRules.length === 0) { + container.innerHTML = `
지정된 기준이 없습니다. [+ 기준 추가]를 통해 규칙을 만드세요.
`; + return; + } + container.innerHTML = tempRules.map((rule: any, idx: number) => ` +
+ +
+ +
+
+ + +
+ `).join(''); + + // 변경 리스너 및 자동완성 바인딩 + container.querySelectorAll('.rule-row').forEach(row => { + const idx = parseInt(row.getAttribute('data-index')!, 10); + const catSelect = row.querySelector('.select-rule-category') as HTMLSelectElement; + const modelInput = row.querySelector('.input-rule-model') as HTMLInputElement; + const dropdown = row.querySelector('.autocomplete-dropdown') as HTMLDivElement; + const condSelect = row.querySelector('.select-rule-cond') as HTMLSelectElement; + const deleteBtn = row.querySelector('.btn-delete-rule') as HTMLButtonElement; + + const getModelSuggestions = (query: string, category: string) => { + if (category === '연식') { + return ['5년 이상', '4년 이상', '3년 이상', '2년 이상', '1년 이상']; + } + const allModels = partsMasterList + .filter((p: any) => p.category === category && p.component_name) + .map((p: any) => p.component_name); + const uniqueModels = Array.from(new Set(allModels)) as string[]; + + if (!query) return uniqueModels.slice(0, 15); + + const filtered = uniqueModels.filter(m => m.toLowerCase().includes(query.toLowerCase())); + return filtered.slice(0, 15); + }; + + const showSuggestions = () => { + const suggestions = getModelSuggestions(modelInput.value, catSelect.value); + if (suggestions.length === 0) { + dropdown.style.display = 'none'; + return; + } + dropdown.innerHTML = suggestions.map(s => ` +
${s}
+ `).join(''); + dropdown.style.display = 'block'; + + dropdown.querySelectorAll('.autocomplete-item').forEach(item => { + item.addEventListener('click', () => { + const val = item.getAttribute('data-value')!; + modelInput.value = val; + tempRules[idx].modelName = val; + dropdown.style.display = 'none'; + }); + }); + }; + + modelInput.addEventListener('focus', showSuggestions); + modelInput.addEventListener('input', showSuggestions); + modelInput.addEventListener('change', () => { + tempRules[idx].modelName = modelInput.value.trim(); + }); + + catSelect.addEventListener('change', () => { + tempRules[idx].category = catSelect.value; + const defaults: Record = { + 'CPU': 'Intel Core i5-8500', + 'RAM': 'DDR4 8GB', + 'GPU': 'Intel UHD Graphics 630', + '연식': '5', + 'OS': 'Windows 11 Home' + }; + const defVal = defaults[catSelect.value] || ''; + modelInput.value = defVal; + tempRules[idx].modelName = defVal; + dropdown.style.display = 'none'; + }); + + condSelect.addEventListener('change', () => { + tempRules[idx].condition = condSelect.value as any; + }); + + deleteBtn.addEventListener('click', () => { + tempRules.splice(idx, 1); + renderRules(); + }); + }); + }; + + renderRules(); + + document.getElementById('btn-close-issue-modal')?.addEventListener('click', closeModal); + document.getElementById('btn-cancel-issue-modal')?.addEventListener('click', closeModal); + + document.getElementById('btn-add-rule-row')?.addEventListener('click', () => { + tempRules.push({ category: 'CPU', modelName: 'Intel Core i5-8500', condition: '미달' }); + renderRules(); + }); + + document.getElementById('btn-save-issue-modal')?.addEventListener('click', () => { + const titleInput = (document.getElementById('input-issue-title') as HTMLInputElement).value.trim(); + + if (!titleInput) { + alert('이슈 제목을 입력해 주세요.'); + return; + } + if (tempRules.length === 0) { + alert('최소 1개 이상의 진단 규칙을 설정해 주세요.'); + return; + } + + // DB 저장 요청 전송 + fetch(`${API_BASE_URL}/api/custom-issue`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: titleInput, + rules: tempRules + }) + }) + .then(res => res.json()) + .then(resData => { + if (resData.success) { + activeIssue.title = titleInput; + activeIssue.rules = tempRules; + closeModal(); + updateDashboardData(pcs, selectedDepts); + } else { + alert('이슈 DB 저장에 실패하였습니다.'); + } + }) + .catch(err => { + console.error(err); + alert('이슈 저장 중 서버 통신 에러가 발생하였습니다.'); + }); + }); + }; + + document.getElementById('btn-create-issue')?.addEventListener('click', openCreateIssueModal); + // 5. 첫 로딩 시 전체 부서 대상 통계 로드 - updateDashboardData(pcs, ''); + refreshDeptButtons(); + + // DB에서 저장된 커스텀 이슈 설정 로드 + fetch(`${API_BASE_URL}/api/custom-issue`) + .then(res => res.json()) + .then(data => { + if (data && data.title && data.rules) { + activeIssue.title = data.title; + activeIssue.rules = data.rules; + } + updateDashboardData(pcs, selectedDepts); + }) + .catch(err => { + console.error('Failed to load custom issue from DB:', err); + updateDashboardData(pcs, selectedDepts); + }); +} + +// 가변 이슈 전역 상태 정의 (기본값은 윈도우 11 불가) +// 부품마스터 기반 부품 등급 매칭용 랭크 가중치 +// 부품마스터에서 특정 모델 또는 유사 명칭의 감점(deduction) 점수를 조회하는 함수 +function getComponentDeduction(specText: string, category: string): number { + const parts = state.masterData.partsMaster || []; + if (!specText || specText === '-') return 30; // fallback 최대 감점 (양수) + + const upperSpec = specText.toUpperCase().trim(); + const matches = parts.filter((p: any) => p.category === category); + // 긴 이름 우선 매칭 + matches.sort((a: any, b: any) => (b.component_name || '').length - (a.component_name || '').length); + + for (const part of matches) { + const compName = (part.component_name || '').toUpperCase().trim(); + if (upperSpec.includes(compName) || compName.includes(upperSpec)) { + return part.deduction !== undefined ? Math.abs(part.deduction) : 15; + } + } + + // 지능형 fallback (부품마스터에 매칭 안 될 시 기존 감점식 차용 - 양수로 수정) + if (category === 'CPU') { + if (upperSpec.includes('I9') || upperSpec.includes('RYZEN 9') || upperSpec.includes('APPLE') || upperSpec.includes('M1') || upperSpec.includes('M2') || upperSpec.includes('M3') || upperSpec.includes('M4')) return 0; + if (upperSpec.includes('I7') || upperSpec.includes('RYZEN 7')) return 5; + if (upperSpec.includes('I5') || upperSpec.includes('RYZEN 5')) return 15; + if (upperSpec.includes('I3') || upperSpec.includes('RYZEN 3')) return 25; + return 30; + } else if (category === 'RAM') { + const match = upperSpec.match(/(\d+)\s*GB/); + if (match && match[1]) { + const gb = parseInt(match[1], 10); + if (gb >= 32) return 0; + if (gb >= 16) return 10; + if (gb >= 8) return 20; + } + return 25; + } else if (category === 'GPU') { + if (upperSpec.includes('RTX 40') || upperSpec.includes('RTX 3090') || upperSpec.includes('RTX 3080')) return 0; + if (upperSpec.includes('RTX 3070') || upperSpec.includes('RTX 3060') || upperSpec.includes('GTX 1660')) return 5; + if (upperSpec.includes('GTX 1060') || upperSpec.includes('GTX 1050')) return 15; + return 25; + } + + return 15; +} + +// CPU 명칭에서 윈도우11 지원 정량 척도가 동기화된 표준 세대 번호 추출 +function getCpuGeneration(specText: string): number { + if (!specText || specText === '-') return 1; + const upper = specText.toUpperCase(); + + if (upper.includes('APPLE') || upper.includes('M1') || upper.includes('M2') || upper.includes('M3') || upper.includes('M4')) { + return 4; // Apple Silicon은 최신 (윈도우 11 완벽 지원) + } + + // Intel CPU 세대 판정 + const intelMatch = upper.match(/I\d-?(\d+)/); + if (intelMatch && intelMatch[1]) { + const numStr = intelMatch[1]; + let gen = 0; + if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); + else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); + else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); + + if (gen >= 12) return 4; // 12세대 이상 + if (gen >= 10) return 3; // 10~11세대 + if (gen >= 8) return 2; // 8~9세대 (윈도우 11 지원 턱걸이 라인) + return 1; // 7세대 이하 (윈도우 11 미지원!) + } + + // AMD Ryzen CPU 세대 판정 + const amdMatch = upper.match(/RYZEN\s?\d\s?-?(\d+)/); + if (amdMatch && amdMatch[1]) { + const numStr = amdMatch[1]; + let amdGen = 0; + if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); + + if (amdGen >= 5) return 4; // 5000 시리즈 이상 (Zen 3) + if (amdGen >= 3) return 3; // 3000 시리즈 (Zen 2) + if (amdGen >= 2) return 2; // 2000 시리즈 (Zen+ / 윈도우 11 지원 턱걸이 라인) + return 1; // 1000 시리즈 이하 (Zen / 윈도우 11 미지원!) + } + + return 1; // 기본 구형 취급 +} + +interface IssueRule { + category: string; + modelName: string; + condition: '미달' | '초과'; +} + +// 가변 이슈 전역 상태 정의 (모델명 기반 다중 규칙 구조) +const activeIssue = { + title: '윈도우 11불가', + rules: [ + { category: 'CPU', modelName: 'Intel Core i5-8500', condition: '미달' } + ] as IssueRule[] +}; + +// 다중 규칙 평가 함수 (세대 비교 병행 및 감점 스코어 비교) +function evaluateIssueMultiple(asset: any, rules: IssueRule[]): { isViolated: boolean; reason: string } { + if (rules.length === 0) return { isViolated: false, reason: '' }; + + const violatedReasons: string[] = []; + + for (const rule of rules) { + let assetSpec = ''; + if (rule.category === 'CPU') assetSpec = asset.cpu; + else if (rule.category === 'RAM') assetSpec = asset.ram; + else if (rule.category === 'GPU') assetSpec = asset.gpu; + else if (rule.category === 'OS') assetSpec = asset.os; + else if (rule.category === '연식') { + let age = 0; + const purchaseDate = asset.purchase_date || ''; + if (purchaseDate && purchaseDate !== '-') { + let normalized = purchaseDate.replace(/\./g, '-').trim(); + if (/^\d{6}$/.test(normalized)) normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`; + const purchase = new Date(normalized); + if (!isNaN(purchase.getTime())) { + const mockToday = new Date('2026-05-31'); + age = (mockToday.getTime() - purchase.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + } + } + + let thresholdAge = 5.0; + const numMatch = rule.modelName.match(/(\d+(\.\d+)?)/); + if (numMatch && numMatch[1]) { + thresholdAge = parseFloat(numMatch[1]); + } + + const violated = rule.condition === '미달' ? (age >= thresholdAge) : (age < thresholdAge); + if (violated) { + violatedReasons.push(`연식 ${rule.condition} (실제: ${age.toFixed(1)}년 / 기준: ${thresholdAge}년)`); + } + continue; + } + + let violated = false; + + if (rule.category === 'CPU') { + const reqGen = getCpuGeneration(rule.modelName); + const actGen = getCpuGeneration(assetSpec); + + if (reqGen !== actGen) { + // 세대 번호가 다를 시 세대 기준으로 직접 비교 + if (rule.condition === '미달') { + violated = actGen < reqGen; + } else { + violated = actGen > reqGen; + } + } else { + // 세대가 같을 시 감점 스코어로 정밀 대조 + const reqDeduction = getComponentDeduction(rule.modelName, rule.category); + const actDeduction = getComponentDeduction(assetSpec, rule.category); + if (rule.condition === '미달') { + violated = actDeduction > reqDeduction; + } else { + violated = actDeduction < reqDeduction; + } + } + } else { + // RAM, GPU는 기존 스코어 대조식 사용 + const reqDeduction = getComponentDeduction(rule.modelName, rule.category); + const actDeduction = getComponentDeduction(assetSpec, rule.category); + if (rule.condition === '미달') { + violated = actDeduction > reqDeduction; + } else { + violated = actDeduction < reqDeduction; + } + } + + if (violated) { + violatedReasons.push(`${rule.category} ${rule.condition} (실제: ${assetSpec || '-'} / 기준: ${rule.modelName})`); + } + } + + return { + isViolated: violatedReasons.length > 0, + reason: violatedReasons.join(', ') + }; +} + +export interface CompliancePolicy { + id: string; + title: string; + badgeName: string; + severity: 'critical' | 'warning' | 'info'; + isActive: boolean; + evaluate: (p: any, score: number, win11Incompatible: boolean, reqRank: number, actRank: number, requiredGrade: string) => { + isViolated: boolean; + reason: string; + }; +} + +const COMPLIANCE_POLICIES: CompliancePolicy[] = [ + { + id: 'win11-compat', + title: '윈도우 11 불가', + badgeName: '이슈 대상', + severity: 'critical', + isActive: true, + evaluate: (p, score, win11Incompatible) => { + return { + isViolated: win11Incompatible, + reason: win11Incompatible ? 'Windows 11 미지원' : '' + }; + } + }, + { + id: 'job-spec-compat', + title: '직무 권장 사양 미달', + badgeName: '직무 미달', + severity: 'warning', + isActive: true, + evaluate: (p, score, win11Incompatible, reqRank, actRank, requiredGrade) => { + const violated = actRank < reqRank; + const itemDeduction = getDeductionDetails(p.cpu, p.ram, p.gpu, p.purchase_date); + return { + isViolated: violated, + reason: violated ? `직무 기준 미달 (요구: ${requiredGrade} / 실제: ${getPcGrade(score, win11Incompatible).name}) ${itemDeduction}` : '' + }; + } + } +]; + +// Windows 11 미지원 세부 사유 분석 +function getWindows11IncompatibleReason(cpu: string, ram: string): string { + if (!cpu) return 'CPU 정보 없음'; + const cpuUpper = cpu.toUpperCase(); + const reasons: string[] = []; + + if (ram) { + const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/); + if (ramMatch && ramMatch[1]) { + const ramVal = parseInt(ramMatch[1], 10); + if (ramVal < 4) reasons.push('RAM 4GB 미만'); + } + } + + let cpuIncompatible = false; + const intelMatch = cpuUpper.match(/I\d-?(\d+)/); + if (intelMatch && intelMatch[1]) { + const numStr = intelMatch[1]; + let gen = 0; + if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); + else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); + if (gen > 0 && gen < 8) cpuIncompatible = true; + } else { + const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/); + if (amdMatch && amdMatch[1]) { + const numStr = amdMatch[1]; + let amdGen = 0; + if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); + if (amdGen > 0 && amdGen < 2) cpuIncompatible = true; + } else { + const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON']; + if (knownOldCpus.some(name => cpuUpper.includes(name))) { + cpuIncompatible = true; + } else if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) { + cpuIncompatible = true; + } + } + } + + if (cpuIncompatible) reasons.push('구형 CPU 세대'); + return reasons.length > 0 ? reasons.join(', ') : '하드웨어 사양 미지원'; +} + +// 성능 감점이 가장 큰 하드웨어 부품 식별 +function getDeductionDetails(cpu: string, ram: string, gpu: string, purchaseDate: string): string { + const cpuUpper = (cpu || '').toUpperCase(); + const ramUpper = (ram || '').toUpperCase(); + const gpuUpper = (gpu || '').toUpperCase(); + const details: string[] = []; + + // 1. CPU 성능 감점 + let cpuDeduction = 30; + if (cpuUpper.includes('I9') || cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) { + cpuDeduction = 0; + } else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5')) { + cpuDeduction = 15; + } else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3')) { + cpuDeduction = 25; + } + + // 2. CPU 세대 감점 + let genDeduction = 15; + const intelMatch = cpuUpper.match(/I\d-?(\d+)/); + if (intelMatch && intelMatch[1]) { + const numStr = intelMatch[1]; + let gen = 0; + if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); + else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); + if (gen >= 12) genDeduction = 0; + else if (gen >= 10) genDeduction = 5; + else if (gen >= 8) genDeduction = 10; + } else { + const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/); + if (amdMatch && amdMatch[1]) { + const numStr = amdMatch[1]; + let amdGen = 0; + if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); + if (amdGen >= 5) genDeduction = 0; + else if (amdGen >= 3) genDeduction = 5; + else genDeduction = 10; + } + } + const totalCpuDeduction = cpuDeduction + genDeduction; + + // 3. RAM 감점 + const ramMatch = ramUpper.match(/(\d+)\s*GB/); + let ramDeduction = 25; + if (ramMatch && ramMatch[1]) { + const ramVal = parseInt(ramMatch[1], 10); + if (ramVal >= 32) ramDeduction = 0; + else if (ramVal >= 16) ramDeduction = 10; + else if (ramVal >= 8) ramDeduction = 20; + } + + // 4. GPU 감점 + let gpuDeduction = 25; + if (gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') || gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX A5000')) { + gpuDeduction = 0; + } else if (gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX A2000') || gpuUpper.includes('QUADRO')) { + gpuDeduction = 5; + } else if (gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6600')) { + gpuDeduction = 15; + } + + // 5. 연식 감점 + let age = 0; + if (purchaseDate && purchaseDate !== '-') { + let normalized = purchaseDate.replace(/\./g, '-').trim(); + if (/^\d{6}$/.test(normalized)) normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`; + const purchase = new Date(normalized); + if (!isNaN(purchase.getTime())) { + const mockToday = new Date('2026-05-31'); + age = (mockToday.getTime() - purchase.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + } + } + let ageDeduction = 15; + if (age < 1) ageDeduction = 0; + else if (age < 2) ageDeduction = 3; + else if (age < 3) ageDeduction = 6; + else if (age < 4) ageDeduction = 9; + else if (age < 5) ageDeduction = 12; + + // 감점이 유발되는 핵심 부품 목록화 (감점 15점 이상) + if (totalCpuDeduction >= 20) details.push(`CPU(-${totalCpuDeduction})`); + if (ramDeduction >= 15) details.push(`RAM(-${ramDeduction})`); + if (gpuDeduction >= 15) details.push(`GPU(-${gpuDeduction})`); + if (ageDeduction >= 12) details.push(`연식(-${ageDeduction})`); + + if (details.length === 0) { + const listTemp = [ + { name: 'CPU', val: totalCpuDeduction }, + { name: 'RAM', val: ramDeduction }, + { name: 'GPU', val: gpuDeduction }, + { name: '연식', val: ageDeduction } + ].sort((a,b) => b.val - a.val); + if (listTemp[0].val > 0) details.push(`${listTemp[0].name}(-${listTemp[0].val})`); + } + + return details.length > 0 ? `(원인: ${details.join(', ')})` : ''; } /** * 대시보드 데이터 수치 및 차트, 테이블 실시간 갱신 */ -function updateDashboardData(pcs: any[], selectedDept: string) { - // 1. 선택 부서 필터 적용 - const filtered = selectedDept - ? pcs.filter((p: any) => String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim().includes(selectedDept)) - : pcs; +function updateDashboardData(pcs: any[], selectedDepts: string[]) { + // 1. 선택 부서 필터 적용 (다중 선택 OR 매핑) + const filtered = (selectedDepts.length === 0 || selectedDepts.includes('')) + ? pcs + : pcs.filter((p: any) => { + const pDept = String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim(); + return selectedDepts.some(dept => pDept.includes(dept)); + }); // 2. 개별 PC의 성능 감점식 점수 실시간 재연산 filtered.forEach((p: any) => { @@ -397,95 +1028,232 @@ function updateDashboardData(pcs: any[], selectedDept: string) { currentTarget.pcs.push(p); currentTarget.total++; + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + p._resolved_position = job; + + const actualGrade = currentGradeKey; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + if (actualGrade === 'premium') actRank = 4; + else if (actualGrade === 'high') actRank = 3; + else if (actualGrade === 'normal') actRank = 2; + else if (actualGrade === 'entry') actRank = 1; + + let isUnder = false; + const underReasons: string[] = []; + if (stockYn) { currentTarget.stock++; currentTarget.stockPcs.push(p); + + COMPLIANCE_POLICIES.forEach(policy => { + if (!policy.isActive) return; + // 재고PC는 직무 매핑이 없으므로 직무 미달 정책 제외 + if (policy.id === 'job-spec-compat') return; + + const res = policy.evaluate(p, score, win11Incompatible, 0, 0, ''); + if (res.isViolated) { + isUnder = true; + underReasons.push(res.reason); + } + }); + + if (isUnder) { + p._spec_status = '사양 부족'; + } else { + p._spec_status = '적정'; + } } else { currentTarget.active++; currentTarget.activePcs.push(p); - // 직무 적정성 계산: system_users.position 우선 조회 → asset_core.user_position fallback - const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); - const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; // 세부 직무 우선, 없으면 일반 직무, 없으면 기본 중급 - - // 미니 모달 표시용으로 해석된 세부 직무명 저장 - p._resolved_position = job; - - const actualGrade = currentGradeKey; // premium, high, normal, entry, replace 중 하나 - - const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; // '중급' rank - const actRank = GRADE_RANK[actualGrade] !== undefined ? GRADE_RANK[actualGrade] : 0; - - let isUnder = false; - if (job !== '재고PC') { - if (win11Incompatible) { - isUnder = true; - p._spec_status = '사양 부족'; - } else if (actRank < reqRank) { - isUnder = true; - p._spec_status = '사양 부족'; - } else if (actRank > reqRank) { - p._spec_status = '오버스펙'; - criticalList.push(p); - overSpecCount++; + COMPLIANCE_POLICIES.forEach(policy => { + if (!policy.isActive) return; + const res = policy.evaluate(p, score, win11Incompatible, reqRank, actRank, requiredGrade); + if (res.isViolated) { + isUnder = true; + underReasons.push(res.reason); + } + }); + + if (!isUnder) { + if (actRank > reqRank) { + p._spec_status = '오버스펙'; + criticalList.push(p); + overSpecCount++; + } else { + p._spec_status = '적정'; + } } else { - p._spec_status = '적정'; + p._spec_status = '사양 부족'; } } else { - if (win11Incompatible) { - isUnder = true; + // 직무가 재고PC(이름)로 되어있으나 실제 운영중(active)인 특수 케이스 처리 + COMPLIANCE_POLICIES.forEach(policy => { + if (!policy.isActive) return; + if (policy.id === 'job-spec-compat') return; + const res = policy.evaluate(p, score, win11Incompatible, 0, 0, ''); + if (res.isViolated) { + isUnder = true; + underReasons.push(res.reason); + } + }); + if (isUnder) { p._spec_status = '사양 부족'; } else { p._spec_status = '적정'; } } - - if (isUnder) { - criticalList.push(p); - underSpecCount++; - - // 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정 - let targetGradeKey: keyof typeof matrix = 'normal'; - if (requiredGrade === '최상급') targetGradeKey = 'premium'; - else if (requiredGrade === '상급') targetGradeKey = 'high'; - else if (requiredGrade === '중급') targetGradeKey = 'normal'; - else if (requiredGrade === '보급') targetGradeKey = 'entry'; - - const targetGrade = matrix[targetGradeKey]; - targetGrade.under++; - targetGrade.underPcs.push(p); - } } - // Windows 11 업그레이드 지원 불가 검사 - if (isWindows11Incompatible(p.cpu, p.ram)) { - win11IncompatibleCount++; + // 사양 부족(isUnder) 상태와 상관없이, 원인이 누적되어 있다면 부족 사유를 바인딩하여 팝업 누락 예방 + if (underReasons.length > 0) { + if (underReasons.length > 1) { + p._under_spec_reason = `[최우선 교체] ${underReasons.join(' & ')}`; + } else { + p._under_spec_reason = underReasons.join(' & '); + } + } else { + p._under_spec_reason = ''; + } + + if (isUnder) { + criticalList.push(p); + underSpecCount++; + + let targetGradeKey: keyof typeof matrix = 'normal'; + if (requiredGrade === '최상급') targetGradeKey = 'premium'; + else if (requiredGrade === '상급') targetGradeKey = 'high'; + else if (requiredGrade === '중급') targetGradeKey = 'normal'; + else if (requiredGrade === '보급') targetGradeKey = 'entry'; + + const targetGrade = matrix[targetGradeKey]; + targetGrade.under++; + targetGrade.underPcs.push(p); + } + + }); + + // 정책별 위반 자산 카운트 동적 집계 + const policyCounts: Record = {}; + COMPLIANCE_POLICIES.forEach(policy => { + policyCounts[policy.id] = 0; + }); + + filtered.forEach((p: any) => { + const score = p._pc_score; + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + const actualGrade = getPcGrade(score, win11Incompatible).name; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + if (actualGrade === '최상급') actRank = 4; + else if (actualGrade === '상급') actRank = 3; + else if (actualGrade === '중급') actRank = 2; + else if (actualGrade === '보급') actRank = 1; + + COMPLIANCE_POLICIES.forEach(policy => { + if (!policy.isActive) return; + if (isStock(p) && policy.id === 'job-spec-compat') return; + + const res = policy.evaluate(p, score, win11Incompatible, reqRank, actRank, requiredGrade); + if (res.isViolated) { + policyCounts[policy.id]++; + } + }); + }); + + // 5. 핵심 텍스트형 요약 지표 갱신 (운영 및 재고 분리 표시) + const summaryTotalStock = filtered.filter(p => isStock(p)).length; + const summaryTotalActive = filtered.length - summaryTotalStock; + document.getElementById('metric-total-pcs')!.innerHTML = `운영${summaryTotalActive}대 (재고 ${summaryTotalStock}대)`; + + const underActive = criticalList.filter(p => p._spec_status === '사양 부족' && !isStock(p)).length; + const underStock = criticalList.filter(p => p._spec_status === '사양 부족' && isStock(p)).length; + document.getElementById('metric-under-spec')!.innerHTML = `운영${underActive}대 (재고 ${underStock}대)`; + + const overActive = criticalList.filter(p => p._spec_status === '오버스펙' && !isStock(p)).length; + const overStock = criticalList.filter(p => p._spec_status === '오버스펙' && isStock(p)).length; + document.getElementById('metric-over-spec')!.innerHTML = `운영${overActive}대 (재고 ${overStock}대)`; + + // 4번째 주요 준수 정책 카드 정보 동적 갱신 (가변 이슈 운영/재고 쪼개기) + const mainPolicy = COMPLIANCE_POLICIES[0]; + let issueActive = 0; + let issueStock = 0; + + filtered.forEach((p: any) => { + const score = p._pc_score; + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + const actualGrade = getPcGrade(score, win11Incompatible).name; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + if (actualGrade === '최상급') actRank = 4; + else if (actualGrade === '상급') actRank = 3; + else if (actualGrade === '중급') actRank = 2; + else if (actualGrade === '보급') actRank = 1; + + const res = evaluateIssueMultiple(p, activeIssue.rules); + if (res.isViolated) { + if (isStock(p)) { + issueStock++; + } else { + issueActive++; + } } }); - // 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-win11-incompatible')!.textContent = `${win11IncompatibleCount}대`; + const mainPolicyCardLabel = document.querySelector('#card-win11-incompatible .stat-card-label'); + if (mainPolicyCardLabel) { + mainPolicyCardLabel.textContent = mainPolicy.title; + } + const mainPolicyCardValue = document.getElementById('metric-win11-incompatible'); + if (mainPolicyCardValue) { + mainPolicyCardValue.innerHTML = `운영${issueActive}대 (재고 ${issueStock}대)`; + } // 6. 종합 매트릭스 테이블 렌더링 및 바인딩 const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; const getSpecStatusCounts = (activePcsList: any[]) => { - let win11 = 0; + let mainPolicyCount = 0; let under = 0; let normal = 0; let over = 0; + activePcsList.forEach(p => { - if (isWindows11Incompatible(p.cpu, p.ram)) win11++; - else if (p._spec_status === '사양 부족') under++; - else if (p._spec_status === '오버스펙') over++; - else normal++; + const score = p._pc_score; + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + const actualGrade = getPcGrade(score, win11Incompatible).name; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + if (actualGrade === '최상급') actRank = 4; + else if (actualGrade === '상급') actRank = 3; + else if (actualGrade === '중급') actRank = 2; + else if (actualGrade === '보급') actRank = 1; + + const res = COMPLIANCE_POLICIES[0].evaluate(p, score, win11Incompatible, reqRank, actRank, requiredGrade); + if (res.isViolated) { + mainPolicyCount++; + } else if (p._spec_status === '사양 부족') { + under++; + } else if (p._spec_status === '오버스펙') { + over++; + } else { + normal++; + } }); - return { win11, under, normal, over }; + return { win11: mainPolicyCount, under, normal, over }; }; const maxTotal = Math.max( @@ -520,7 +1288,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
- ${win11 > 0 ? `
` : ''} + ${win11 > 0 ? `
` : ''} ${under > 0 ? `
` : ''} ${normal > 0 ? `
` : ''} ${over > 0 ? `
` : ''} @@ -664,10 +1432,37 @@ function updateDashboardData(pcs: any[], selectedDept: string) { let targetPcs: any[] = []; const filterFn = (p: any) => { - if (status === '윈도우 11 불가') { - return isWindows11Incompatible(p.cpu, p.ram); + if (status === activeIssue.title) { + const score = p._pc_score !== undefined ? p._pc_score : calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + const gradeName = getPcGrade(score, win11Incompatible).name; + if (gradeName === '최상급') actRank = 4; + else if (gradeName === '상급') actRank = 3; + else if (gradeName === '중급') actRank = 2; + else if (gradeName === '보급') actRank = 1; + + return COMPLIANCE_POLICIES[0].evaluate(p, score, win11Incompatible, reqRank, actRank, requiredGrade).isViolated; } else if (status === '사양 부족') { - return !isWindows11Incompatible(p.cpu, p.ram) && p._spec_status === '사양 부족'; + const score = p._pc_score !== undefined ? p._pc_score : calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + const userName = (p[ASSET_SCHEMA.CURRENT_USER.key] || '').trim(); + const job = userPositionMap[userName] || p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const requiredGrade = jobSpecsMap[job] || jobSpecsMap[p[ASSET_SCHEMA.USER_POSITION.key]] || '중급'; + const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2; + let actRank = 0; + const gradeName = getPcGrade(score, win11Incompatible).name; + if (gradeName === '최상급') actRank = 4; + else if (gradeName === '상급') actRank = 3; + else if (gradeName === '중급') actRank = 2; + else if (gradeName === '보급') actRank = 1; + + const isMainPolicyViolated = COMPLIANCE_POLICIES[0].evaluate(p, score, win11Incompatible, reqRank, actRank, requiredGrade).isViolated; + return !isMainPolicyViolated && p._spec_status === '사양 부족'; } else { return p._spec_status === status; } @@ -761,10 +1556,13 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; }; - // 사양 부족 / 오버 스펙 / 윈도우 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)); + + bindCardClick('card-win11-incompatible', `${mainPolicy.title} 대상`, p => { + return evaluateIssueMultiple(p, activeIssue.rules).isViolated; + }); // 9. 조직별 사용 비율 집계 (전체 개인용 PC 기준) const deptCounts: Record = { @@ -833,15 +1631,17 @@ function showMiniListModal(title: string, list: any[]) { color: var(--text-main); `; + const isUnderSpecPopup = (title.includes('사양 부족') || title.includes('불가') || title.includes('부족분') || title.includes('이슈') || title.includes('대상')) && !title.includes('오버 스펙'); + modal.innerHTML = ` -
+

${title} 자산 목록 ${list.length}대

-
@@ -849,30 +1649,32 @@ function showMiniListModal(title: string, list: any[]) { - - - - - + + + + ${isUnderSpecPopup ? `` : ''} + + ${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}점`; + const badgeHTML = `${grade.name}`; + const scoreHTML = `${score}점`; return ` + ${isUnderSpecPopup ? `` : ''} @@ -890,18 +1692,17 @@ function showMiniListModal(title: string, list: any[]) { `; - const style = document.createElement('style'); - style.innerHTML = ` - @keyframes modalFadeIn { - from { transform: scale(0.96); opacity: 0; } - to { transform: scale(1); opacity: 1; } - } - `; - modal.appendChild(style); - document.body.appendChild(modal); - const closeModal = () => { modal.remove(); }; + const closeModal = () => { + modal.remove(); + window.removeEventListener('keydown', handleEsc); + }; + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + window.addEventListener('keydown', handleEsc); modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); diff --git a/src/views/Dashboard/dashboard.css b/src/views/Dashboard/dashboard.css index d2bbb06..b6c065a 100644 --- a/src/views/Dashboard/dashboard.css +++ b/src/views/Dashboard/dashboard.css @@ -1,3 +1,13 @@ +/* --- View Container Font Size Downscale (1 step down) --- */ +.view-container { + --fs-xl: max(20px, 3vmin + 0.4vw); + --fs-lg: max(16px, 2vmin + 0.3vw); + --fs-md: max(13px, 1.4vmin + 0.2vw); + --fs-base: max(12px, 1.2vmin + 0.2vw); + --fs-sm: max(10px, 1vmin + 0.1vw); + --fs-xs: 10px; +} + /* --- Vercel Inspired Premium Dashboard --- */ .dashboard-section-title { padding: 0; @@ -506,4 +516,138 @@ border-bottom: 1px solid var(--hairline); } } + +/* --- HwDashboard Component Specific Styles (Migrated) --- */ +.dept-filter-btn { + padding: 6px 16px; + font-size: var(--fs-sm); + font-weight: 600; + border-radius: 20px; + border: 1px solid var(--border-color); + background: var(--canvas-soft); + color: var(--mute); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + display: inline-flex; + align-items: center; +} +.dept-filter-btn:hover { + border-color: var(--primary); + color: var(--primary); + background: var(--canvas-soft-2); +} +.dept-filter-btn.active { + color: white !important; + border-color: transparent !important; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); +} +.donut-text-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -46%); font-size: var(--fs-md); font-weight: 700; color: var(--primary); pointer-events: none; white-space: nowrap; } +.stat-card { background: var(--canvas); padding: 1.5rem; display: flex; flex-direction: row; justify-content: space-between; align-items: flex-start; position: relative; overflow: hidden; transition: background-color 0.15s ease; cursor: pointer; } +#metric-card-total { cursor: default; } +#metric-card-total:hover { background-color: var(--canvas-soft); } +#card-under-spec:hover { background-color: #FEF2F2; } +#card-over-spec:hover { background-color: #FFFBEB; } +#card-win11-incompatible:hover { background-color: #F5F3FF; } +.stat-card-label { font-size: var(--fs-md); font-weight: 600; color: var(--text-main); letter-spacing: -0.3px; } +.stat-card-value { font-size: var(--fs-xl); font-weight: 700; line-height: 1.1; z-index: 1; margin-right: 2rem; margin-top: 1.8rem; } +#metric-total-pcs { color: var(--primary); } +#metric-under-spec { color: var(--danger); } +#metric-over-spec { color: var(--color-orange); } +#metric-win11-incompatible { color: var(--color-violet); } +.dashboard-subtitle { font-size: var(--fs-md); font-weight: 600; color: var(--text-main); } +.table-header-row { border-bottom: 2px solid var(--border-color); color: var(--text-muted); font-weight: 600; } +.matrix-cell { transition: background-color 0.2s; cursor: pointer; } +.matrix-cell:hover { background-color: var(--canvas-soft-2); } +.aging-row { transition: background-color 0.2s; cursor: pointer; } +.aging-row:hover { background-color: var(--canvas-soft); } +.mini-modal-row { transition: background-color 0.2s; cursor: pointer; } +.mini-modal-row:hover { background-color: var(--canvas-soft); } +#btn-close-mini-modal { transition: background-color 0.2s, color 0.2s; } +#btn-close-mini-modal:hover { background-color: var(--canvas-soft); color: var(--primary); } +#btn-confirm-mini-modal { transition: opacity 0.2s; } + +/* --- Issue Badge & Create Issue Modal Styles --- */ +.issue-badge { + background-color: var(--danger); + color: white; + padding: 4px 8px; + border-radius: 6px; + font-size: var(--fs-md); + font-weight: 700; + line-height: 1; + margin-right: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; +} + +.metric-active-label, .metric-stock-label { + font-size: var(--fs-md); + font-weight: 550; + color: var(--text-muted); + letter-spacing: 0; + display: inline-block; +} +.metric-active-label { + margin-right: 6px; +} +.metric-stock-label { + margin-left: 6px; +} + +.issue-modal-field { + margin-bottom: 1.2rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.issue-modal-field label { + font-size: var(--fs-sm); + font-weight: 600; + color: var(--text-muted); +} +.issue-modal-field input, .issue-modal-field select { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--canvas-soft); + color: var(--text-main); + font-size: var(--fs-base); + outline: none; + transition: border-color 0.15s ease; +} +.issue-modal-field input:focus, .issue-modal-field select:focus { + border-color: var(--primary); +} + +/* Autocomplete Dropdown in Issue Modal */ +.autocomplete-container { + position: relative; + flex: 1.8; +} +.autocomplete-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + max-height: 160px; + overflow-y: auto; + background: var(--canvas); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + z-index: 1200; + display: none; +} +.autocomplete-item { + padding: 8px 12px; + font-size: var(--fs-sm); + color: var(--text-main); + cursor: pointer; + transition: background 0.1s ease; +} +.autocomplete-item:hover { + background: var(--canvas-soft); + color: var(--primary); } diff --git a/src/views/DashboardView.ts b/src/views/DashboardView.ts index 293d969..fb98fc9 100644 --- a/src/views/DashboardView.ts +++ b/src/views/DashboardView.ts @@ -32,6 +32,6 @@ export function renderDashboard(mainContent: HTMLElement) { } else if (state.activeCategory === 'sw') { renderSwDashboard(innerContent); } else { - innerContent.innerHTML = `
해당 카테고리의 대시보드는 준비 중입니다.
`; + innerContent.innerHTML = ''; } } diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index a984701..038c7b0 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -814,9 +814,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { actionContainer.className = "header-action-group"; actionContainer.innerHTML = ` ${showPcFlowBtn ? ` - + ${state.currentUserRole === 'admin' ? ` + + ` : ''} diff --git a/src/views/SW_Table.ts b/src/views/SW_Table.ts index dafb867..4860e4f 100644 --- a/src/views/SW_Table.ts +++ b/src/views/SW_Table.ts @@ -42,7 +42,7 @@ export function renderSWTable(mainContent: HTMLElement) { else if (tab === '업무지원장비') renderEquipmentList(container); else if (tab === '네트워크') renderNetworkList(container); else if (tab === 'PC부품') renderPcPartList(container); - else if (tab === '부품 마스터') renderPartsMasterList(container); + else if (tab === '부품마스터') renderPartsMasterList(container); else if (tab === '공간정보장비') renderSpaceInfoList(container); else { container.innerHTML = `
"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.
`; diff --git a/src/views/ServerManagementView.ts b/src/views/ServerManagementView.ts new file mode 100644 index 0000000..fd79160 --- /dev/null +++ b/src/views/ServerManagementView.ts @@ -0,0 +1,366 @@ +import { state } from '../core/state'; + +export async function renderServerManagementView(container: HTMLElement) { + if (!container) return; + + const styleId = 'server-management-view-style'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.innerHTML = ` + .srv-container { + display: flex; + flex-direction: column; + height: calc(100vh - var(--header-height) - 48px); + background-color: var(--canvas); + color: var(--text-main); + padding: 1.5rem; + box-sizing: border-box; + } + + .srv-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + flex-shrink: 0; + } + + .srv-title-area { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .srv-title { + font-size: 1.25rem; + font-weight: 700; + } + + .srv-tabs { + display: flex; + gap: 1rem; + border-bottom: 1px solid var(--hairline); + margin-bottom: 1rem; + flex-shrink: 0; + } + + .srv-tab-btn { + padding: 0.5rem 1rem; + border: none; + background: none; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + color: var(--text-muted); + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + + .srv-tab-btn.active { + color: var(--primary); + border-bottom-color: var(--primary); + } + + .srv-table-wrapper { + flex: 1; + overflow: auto; + border: 1px solid var(--hairline); + border-radius: 12px; + background-color: var(--canvas-soft); + } + + .srv-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.825rem; + } + + .srv-table th { + background-color: var(--canvas-soft-2); + color: var(--text-muted); + font-weight: 600; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--hairline); + position: sticky; + top: 0; + z-index: 10; + font-size: 0.75rem; + } + + .srv-table td { + padding: 0.75rem 0.8rem; + border-bottom: 1px solid var(--hairline); + vertical-align: middle; + } + + .srv-table tr:hover td { + background-color: var(--canvas-soft-2); + } + + .srv-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.7rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid var(--hairline); + background-color: var(--canvas); + color: var(--text-main); + } + + .srv-btn:hover { + background-color: var(--canvas-soft); + } + + /* Indicators */ + .srv-indicator-group { + display: flex; + flex-direction: column; + gap: 0.4rem; + width: 100%; + } + + .srv-indicator-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + } + + .srv-indicator-name { + width: 60px; + color: var(--text-muted); + font-weight: 600; + } + + .srv-progress-bg { + flex: 1; + height: 6px; + background-color: var(--hairline); + border-radius: 9999px; + overflow: hidden; + } + + .srv-progress-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.3s; + } + + .fill-green { background-color: var(--success); } + .fill-yellow { background-color: var(--warning); } + .fill-red { background-color: var(--danger); } + + .srv-badge { + padding: 0.1rem 0.3rem; + font-size: 0.7rem; + font-weight: 700; + border-radius: 4px; + } + + .badge-green { background-color: rgba(16, 185, 129, 0.1); color: var(--success); } + .badge-yellow { background-color: rgba(245, 158, 11, 0.1); color: var(--warning); } + .badge-red { background-color: rgba(239, 68, 68, 0.1); color: var(--danger); } + `; + document.head.appendChild(style); + } + + let activeTab: 'monitor' | 'alerts' = 'monitor'; + + let serverData = (state.masterData.server || []).map((srv: any, index: number) => { + const seed = srv.id || index; + const cpuVal = 10 + (seed * 17) % 81; + const memVal = 15 + (seed * 23) % 76; + const netVal = 5 + (seed * 31) % 91; + const diskVal = 10 + (seed * 19) % 86; + + return { + id: srv.id || index, + assetCode: srv.asset_code || `SRV-${index + 1}`, + purpose: srv.asset_purpose || '', + memo: srv.memo || '', + ip: srv.ip_address || srv.ip_address_2 || '172.16.12.' + (10 + (seed % 240)), + cpu: { val: cpuVal, status: cpuVal >= 90 ? 'red' : (cpuVal >= 80 ? 'yellow' : 'green') }, + mem: { val: memVal, status: memVal >= 90 ? 'red' : (memVal >= 80 ? 'yellow' : 'green') }, + net: { val: netVal, status: netVal >= 90 ? 'red' : (netVal >= 80 ? 'yellow' : 'green') }, + disk: { val: diskVal, status: diskVal >= 90 ? 'red' : (diskVal >= 80 ? 'yellow' : 'green') } + }; + }); + + if (serverData.length === 0) { + serverData = [ + { id: 1, assetCode: 'SRV-2601-0001', purpose: '데이터베이스', memo: '기본 DB 서버', ip: '172.16.12.11', cpu: { val: 45, status: 'green' }, mem: { val: 62, status: 'green' }, net: { val: 92, status: 'red' }, disk: { val: 41, status: 'green' } }, + { id: 2, assetCode: 'SRV-2601-0002', purpose: 'WAS', memo: '인증 WAS 서버', ip: '172.16.12.12', cpu: { val: 89, status: 'red' }, mem: { val: 82, status: 'yellow' }, net: { val: 45, status: 'green' }, disk: { val: 51, status: 'green' } }, + { id: 3, assetCode: 'SRV-2601-0003', purpose: '프록시', memo: '외부 관제 프록시', ip: '172.16.12.10', cpu: { val: 22, status: 'green' }, mem: { val: 34, status: 'green' }, net: { val: 18, status: 'green' }, disk: { val: 12, status: 'green' } }, + { id: 4, assetCode: 'SRV-2601-0004', purpose: '백업 NAS', memo: '일일 스토리지 백업', ip: '172.16.15.55', cpu: { val: 15, status: 'green' }, mem: { val: 28, status: 'green' }, net: { val: 12, status: 'green' }, disk: { val: 94, status: 'red' } } + ]; + } + + let alertLogs = [ + { id: 1, time: '2026.06.29 17:00:00', host: 'DB-SERVER-02', level: 'danger', msg: '최대 네트워크 트래픽 임계치(1Gbps) 초과 경보 발생 (지속시간: 15분)' }, + { id: 2, time: '2026.06.29 16:12:44', host: 'WAS-SERVER-01', level: 'danger', msg: 'CPU 사용량 92% 임계치 초과 경보 발생 (지속시간: 8분)' }, + { id: 3, time: '2026.06.29 14:02:11', host: 'BACKUP-NAS-01', level: 'danger', msg: '디스크 공간 부족(94%) 경보 발생' }, + { id: 4, time: '2026.06.29 09:30:15', host: 'WAS-SERVER-01', level: 'warning', msg: '메모리 사용률 82% 경고 발생' } + ]; + + function getStatusClass(status: string) { + if (status === 'red') return 'fill-red'; + if (status === 'yellow') return 'fill-yellow'; + return 'fill-green'; + } + + function getBadgeClass(status: string) { + if (status === 'red') return 'badge-red'; + if (status === 'yellow') return 'badge-yellow'; + return 'badge-green'; + } + + function renderLayout() { + container.innerHTML = ` +
+
+
+ 서버관리 (그라파나 연동) +
+
+ +
+ + +
+ +
+
+ `; + + bindTabEvents(); + renderContent(); + } + + function renderContent() { + const content = document.getElementById('srv-content-area')!; + if (activeTab === 'monitor') { + let rows = ''; + serverData.forEach(srv => { + rows += ` + + + + + + + `; + }); + + content.innerHTML = ` +
사용자조직 (직무)주요 사양등급 (점수)자산코드사용자조직 (직무)주요 사양부족 사유등급 (점수)자산코드
해당 등급의 자산이 없습니다.
해당 등급의 자산이 없습니다.
${user} ${pc.current_dept || '-'} (${pc._resolved_position || pc.user_position || '-'}) ${spec}${pc._under_spec_reason || '-'}${badgeHTML}${scoreHTML} ${pc.asset_code || '-'}
+ ${srv.assetCode} + + ${srv.purpose || '용도 미지정'} / ${srv.memo || '메모 없음'} + + + IP: ${srv.ip} + + +
+
+ CPU +
+
+
+ ${srv.cpu.val}% +
+
+ Memory +
+
+
+ ${srv.mem.val}% +
+
+
+
+
+ Traffic +
+
+
+ ${srv.net.val}% +
+
+ Disk I/O +
+
+
+ ${srv.disk.val}% +
+
+
+ +
+ + + + + + + + + + ${rows} + +
호스트 정보시스템 핵심 연동 (CPU / MEM)인프라 부하 연동 (TRAFFIC / DISK)원격 관제
+ `; + + content.querySelectorAll('.btn-grafana-link').forEach(btn => { + btn.addEventListener('click', (e) => { + const host = (e.target as HTMLElement).dataset.host; + alert(`새 창에서 http://dachs.hmac.kr/grafana/dashboard/db/${host} 대시보드로 이동합니다.`); + }); + }); + } else if (activeTab === 'alerts') { + let rows = ''; + alertLogs.forEach(log => { + rows += ` + + ${log.time} + ${log.host} + + ${log.level.toUpperCase()} + + ${log.msg} + + `; + }); + + content.innerHTML = ` + + + + + + + + + + + ${rows} + +
발생 시간대상 호스트경보 등급알림 상세 내용
+ `; + } + } + + function bindTabEvents() { + document.getElementById('tab-btn-monitor')?.addEventListener('click', () => { activeTab = 'monitor'; renderLayout(); }); + document.getElementById('tab-btn-alerts')?.addEventListener('click', () => { activeTab = 'alerts'; renderLayout(); }); + } + + renderLayout(); +} diff --git a/src/views/SpecChangeApprovalView.ts b/src/views/SpecChangeApprovalView.ts new file mode 100644 index 0000000..5f9a709 --- /dev/null +++ b/src/views/SpecChangeApprovalView.ts @@ -0,0 +1,387 @@ +import { state } from '../core/state'; + +export async function renderSpecChangeApprovalView(container: HTMLElement) { + if (!container) return; + + const styleId = 'spec-approval-view-style'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.innerHTML = ` + .spec-container { + display: flex; + flex-direction: column; + height: calc(100vh - var(--header-height) - 48px); + background-color: var(--canvas); + color: var(--text-main); + padding: 1.5rem; + box-sizing: border-box; + } + + .spec-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + flex-shrink: 0; + } + + .spec-title-area { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .spec-title { + font-size: 1.25rem; + font-weight: 700; + } + + .spec-tabs { + display: flex; + gap: 1rem; + border-bottom: 1px solid var(--hairline); + margin-bottom: 1rem; + flex-shrink: 0; + } + + .spec-tab-btn { + padding: 0.5rem 1rem; + border: none; + background: none; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + color: var(--text-muted); + border-bottom: 2px solid transparent; + transition: all 0.2s; + } + + .spec-tab-btn.active { + color: var(--primary); + border-bottom-color: var(--primary); + } + + .spec-table-wrapper { + flex: 1; + overflow: auto; + border: 1px solid var(--hairline); + border-radius: 12px; + background-color: var(--canvas-soft); + } + + .spec-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.825rem; + } + + .spec-table th { + background-color: var(--canvas-soft-2); + color: var(--text-muted); + font-weight: 600; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid var(--hairline); + position: sticky; + top: 0; + z-index: 10; + font-size: 0.75rem; + text-transform: uppercase; + } + + .spec-table td { + padding: 0.75rem 0.8rem; + border-bottom: 1px solid var(--hairline); + vertical-align: middle; + } + + .spec-table tr:hover td { + background-color: var(--canvas-soft-2); + } + + .spec-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35rem 0.7rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid var(--hairline); + background-color: var(--canvas); + color: var(--text-main); + } + + .spec-btn:hover { + background-color: var(--canvas-soft); + } + + .spec-btn-primary { + background-color: var(--primary); + color: #fff; + border-color: var(--primary); + } + + .spec-btn-primary:hover { + background-color: var(--primary-hover); + } + + .spec-btn-danger { + background-color: rgba(239, 68, 68, 0.1); + color: var(--danger); + border-color: rgba(239, 68, 68, 0.2); + } + + .spec-btn-danger:hover { + background-color: rgba(239, 68, 68, 0.2); + } + + .spec-diff-old { + color: var(--danger); + text-decoration: line-through; + margin-right: 0.5rem; + } + + .spec-diff-new { + color: var(--success); + font-weight: 700; + } + + .timeline-container { + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + } + + .timeline-item { + position: relative; + padding-left: 1.5rem; + border-left: 2px solid var(--hairline); + } + + .timeline-item::before { + content: ''; + position: absolute; + left: -5px; + top: 4px; + width: 8px; + height: 8px; + border-radius: 9999px; + background-color: var(--primary); + border: 2px solid var(--canvas); + } + + .timeline-date { + font-size: 0.75rem; + color: var(--text-muted); + margin-bottom: 0.25rem; + } + + .timeline-content { + font-size: 0.825rem; + font-weight: 600; + } + `; + document.head.appendChild(style); + } + + // Mock State Data + let activeTab: 'pending' | 'unresponsive' | 'history' = 'pending'; + + let pendingChanges = [ + { id: 1, assetCode: 'PC-2026-0089', user: '홍길동', component: 'RAM (메모리)', oldVal: '16GB (8G x 2)', newVal: '32GB (16G x 2)', time: '10분 전' }, + { id: 2, assetCode: 'PC-2026-0104', user: '임꺽정', component: 'OS (운영체제)', oldVal: 'Windows 10', newVal: 'Windows 11', time: '2시간 전' } + ]; + + let unresponsiveDevices = [ + { id: 1, assetCode: 'PC-2026-0012', user: '김철수', dept: '기술개발센터', lastSeen: '9일째 미응답' }, + { id: 2, assetCode: 'PC-2026-0155', user: '이영희', dept: '총괄기획실', lastSeen: '12일째 미응답' } + ]; + + let historyLogs = [ + { id: 1, date: '2026.06.29 15:42:01', assetCode: 'PC-2026-0089', log: 'RAM 16GB -> 32GB 증설 승인 완료 (처리자: ADMIN)' }, + { id: 2, date: '2026.06.29 14:15:33', assetCode: 'PC-2026-0104', log: 'OS Windows 10 -> Windows 11 업그레이드 승인 완료 (처리자: ADMIN)' }, + { id: 3, date: '2026.05.10 11:20:00', assetCode: 'PC-2026-0044', log: 'SSD 256GB -> 512GB 장착 교체 승인 완료 (처리자: ADMIN)' } + ]; + + function renderLayout() { + container.innerHTML = ` +
+
+
+ 자산추가 (사양변경승인) +
+
+ +
+ + + +
+ +
+
+ `; + + bindTabEvents(); + renderContent(); + } + + function renderContent() { + const content = document.getElementById('spec-content-area')!; + if (activeTab === 'pending') { + if (pendingChanges.length === 0) { + content.innerHTML = `
대기 중인 사양 변경 내역이 없습니다.
`; + return; + } + + let rows = ''; + pendingChanges.forEach((row, index) => { + rows += ` + + ${row.assetCode} + ${row.user} + ${row.component} + + ${row.oldVal} + ➔ ${row.newVal} + + ${row.time} + + + + + + `; + }); + + content.innerHTML = ` + + + + + + + + + + + + + ${rows} + +
자산코드사용자변경 분야사양 비교 (기존 ➔ 신규)감지 시간조치
+ `; + + bindActionEvents(); + } else if (activeTab === 'unresponsive') { + if (unresponsiveDevices.length === 0) { + content.innerHTML = `
미응답 장비가 없습니다.
`; + return; + } + + let rows = ''; + unresponsiveDevices.forEach(row => { + rows += ` + + ${row.assetCode} + ${row.user} + ${row.dept} + ⚠️ ${row.lastSeen} + + + + + `; + }); + + content.innerHTML = ` + + + + + + + + + + + + ${rows} + +
자산코드사용자부서최종 교신 상태원격 명령
+ `; + + content.querySelectorAll('.btn-spec-ping').forEach(btn => { + btn.addEventListener('click', () => alert('해당 PC 에이전트에 깨우기(Wake-on-LAN) 명령을 보냈습니다.')); + }); + } else if (activeTab === 'history') { + if (historyLogs.length === 0) { + content.innerHTML = `
기록된 이력이 없습니다.
`; + return; + } + + let timelineHtml = '
'; + historyLogs.forEach(log => { + timelineHtml += ` +
+
${log.date}
+
[${log.assetCode}] ${log.log}
+
+ `; + }); + timelineHtml += '
'; + content.innerHTML = timelineHtml; + } + } + + function bindTabEvents() { + document.getElementById('tab-btn-pending')?.addEventListener('click', () => { activeTab = 'pending'; renderLayout(); }); + document.getElementById('tab-btn-unresponsive')?.addEventListener('click', () => { activeTab = 'unresponsive'; renderLayout(); }); + document.getElementById('tab-btn-history')?.addEventListener('click', () => { activeTab = 'history'; renderLayout(); }); + } + + function bindActionEvents() { + container.querySelectorAll('.btn-spec-approve').forEach(btn => { + btn.addEventListener('click', (e) => { + const idx = parseInt((e.target as HTMLElement).dataset.index!); + const target = pendingChanges[idx]; + if (!target) return; + + if (confirm(`${target.assetCode} 장비의 사양 변경 사항을 승인하고 마스터 정보에 최종 반영하시겠습니까?`)) { + // Add to history + historyLogs.unshift({ + id: Date.now(), + date: new Date().toLocaleString('ko-KR'), + assetCode: target.assetCode, + log: `${target.component} ${target.oldVal} -> ${target.newVal} 변경 승인 완료 (처리자: ADMIN)` + }); + // Remove from pending + pendingChanges.splice(idx, 1); + alert('성공적으로 승인 반영되었습니다.'); + renderLayout(); + } + }); + }); + + container.querySelectorAll('.btn-spec-reject').forEach(btn => { + btn.addEventListener('click', (e) => { + const idx = parseInt((e.target as HTMLElement).dataset.index!); + const target = pendingChanges[idx]; + if (!target) return; + + if (confirm(`${target.assetCode} 장비의 스펙 정보를 기존 사양으로 반려하고 실무자 소명 지시를 내리시겠습니까?`)) { + pendingChanges.splice(idx, 1); + alert('반려 처리가 완료되었으며 담당자에게 실사 지시가 내려졌습니다.'); + renderLayout(); + } + }); + }); + } + + renderLayout(); +}