[FE/BE] 대시보드 폰트 하향, 가변 이슈 기준 세대 정량 비교 엔진 및 4번째 카드 단독화 구현, 사양부족/오버스펙 기존 배포버전 산정식 복원 및 누락 CPU 14종 부품마스터 DB 일괄 정제 적재

This commit is contained in:
이태훈
2026-06-30 20:27:05 +09:00
parent b5d18b6839
commit d8259bfa75
18 changed files with 2015 additions and 233 deletions

View File

@@ -20,6 +20,9 @@
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다. - 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다. - 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다. - 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다.
7. **CSS 스타일 분리 원칙 (CSS Separation Policy)**:
- HTML/JS/TS 파일 내에 인라인 `<style>` 태그를 절대 사용하지 않는다.
- 모든 디자인 및 스타일 코드는 관련 `.css` 파일(예: `common.css`, `modal.css`, `dashboard.css` 등)에만 작성하여 구조와 디자인을 명확히 분리한다.
--- ---

View File

@@ -62,6 +62,28 @@ const pool = mysql.createPool({
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
console.log('✅ job_spec_standards table verification completed.'); console.log('✅ job_spec_standards table verification completed.');
await connection.query(`
CREATE TABLE IF NOT EXISTS asset_issues (
id INT PRIMARY KEY DEFAULT 1,
title VARCHAR(255) NOT NULL,
rules JSON NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ asset_issues table verification completed.');
const [issueCountRows] = await connection.query('SELECT COUNT(*) AS cnt FROM asset_issues');
if (issueCountRows[0] && issueCountRows[0].cnt === 0) {
const defaultRules = JSON.stringify([
{ category: 'CPU', modelName: 'Intel Core i5-8500', condition: '미달' }
]);
await connection.query(
'INSERT INTO asset_issues (id, title, rules) VALUES (1, ?, ?)',
['윈도우 11불가', defaultRules]
);
console.log('✅ Default custom issue seeded into asset_issues.');
}
} catch (err) { } catch (err) {
console.error('❌ Failed to verify/create job_spec_standards table:', { console.error('❌ Failed to verify/create job_spec_standards table:', {
db: getDbConnectionSummary(), db: getDbConnectionSummary(),
@@ -1180,6 +1202,59 @@ app.post('/api/upload', (req, res) => {
} }
}); });
// GET /api/custom-issue (커스텀 이슈 조회)
app.get('/api/custom-issue', async (req, res) => {
let connection;
try {
connection = await pool.getConnection();
const [rows] = await connection.query('SELECT * FROM asset_issues WHERE id = 1');
if (rows.length > 0) {
const row = rows[0];
const rules = typeof row.rules === 'string' ? JSON.parse(row.rules) : row.rules;
res.json({
id: row.id,
title: row.title,
rules: rules
});
} else {
res.json({
title: '윈도우 11불가',
rules: [
{ category: 'CPU', requiredTier: '중급', condition: '미달' }
]
});
}
} catch (err) {
console.error('❌ Failed to fetch custom issue:', err);
res.status(500).json({ error: 'Failed to fetch custom issue' });
} finally {
if (connection) connection.release();
}
});
// POST /api/custom-issue (커스텀 이슈 저장/업데이트)
app.post('/api/custom-issue', async (req, res) => {
const { title, rules } = req.body;
if (!title || !rules) {
return res.status(400).json({ error: 'Missing title or rules' });
}
let connection;
try {
connection = await pool.getConnection();
const rulesStr = JSON.stringify(rules);
await connection.query(
'INSERT INTO asset_issues (id, title, rules) VALUES (1, ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), rules = VALUES(rules)',
[title, rulesStr]
);
res.json({ success: true, title, rules });
} catch (err) {
console.error('❌ Failed to save custom issue:', err);
res.status(500).json({ error: 'Failed to save custom issue' });
} finally {
if (connection) connection.release();
}
});
// Health check endpoint for container orchestration // Health check endpoint for container orchestration
app.get('/health', async (req, res) => { app.get('/health', async (req, res) => {
try { try {

View File

@@ -43,6 +43,13 @@ export abstract class BaseModal {
btnCloseHeader?.addEventListener('click', closeAction); btnCloseHeader?.addEventListener('click', closeAction);
btnCancelFooter?.addEventListener('click', closeAction); btnCancelFooter?.addEventListener('click', closeAction);
// ESC 키로 이 모달을 안전하게 닫기 (상태 초기화 포함)
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modalEl && !this.modalEl.classList.contains('hidden')) {
closeAction();
}
});
// 3. 자식 클래스 전용 초기화 로직 실행 // 3. 자식 클래스 전용 초기화 로직 실행
this.initChildLogic(onSave, closeModalsFn); this.initChildLogic(onSave, closeModalsFn);

View File

@@ -262,11 +262,6 @@ class HwAssetModal extends BaseModal {
</div> </div>
</div> </div>
</div> </div>
<style>
.hidden {
display: none !important;
}
</style>
`; `;
} }

View File

@@ -68,36 +68,6 @@ class JobSpecModal extends BaseModal {
</div> </div>
</div> </div>
</div> </div>
<style>
.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;
}
</style>
`; `;
} }

View File

@@ -212,15 +212,6 @@ class SwAssetModal extends BaseModal {
</div> </div>
</div> </div>
</div> </div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`; `;
} }

View File

@@ -111,15 +111,6 @@ class SwUserModal extends BaseModal {
</div> </div>
</div> </div>
</div> </div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`; `;
} }

View File

@@ -597,3 +597,47 @@
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none; 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;
}

View File

@@ -3,7 +3,7 @@ import { state } from '../core/state';
const MENU_CONFIG: any = { const MENU_CONFIG: any = {
hw: { hw: {
label: '하드웨어', label: '하드웨어',
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비'] tabs: ['자산현황', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
}, },
sw: { sw: {
label: '소프트웨어', label: '소프트웨어',
@@ -60,12 +60,12 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const config = MENU_CONFIG[catKey]; const config = MENU_CONFIG[catKey];
let visibleTabs = config.tabs.filter((tab: string) => { let visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') return tab === '대시보드'; if (state.currentUserRole === 'admin') return tab === '자산현황';
return tab !== '대시보드'; return tab !== '자산현황';
}); });
if (state.currentUserRole === 'admin' && catKey === 'hw') { if (state.currentUserRole === 'admin' && catKey === 'hw') {
visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정', '부품 마스터']; visibleTabs = ['자산현황', '실사승인', '위치지정', '부품마스터', '자산추가', '서버관리'];
} }
if (visibleTabs.length === 0) return; if (visibleTabs.length === 0) return;
@@ -75,28 +75,13 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const isActive = state.activeSubTab === tab; const isActive = state.activeSubTab === tab;
item.className = `gnb-trigger ${isActive ? 'active' : ''}`; item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
const isSubMenu = tab === '실사 승인' || tab === '위치지정' || tab === '부품 마스터'; item.textContent = tab;
if (isSubMenu) { item.style.fontSize = 'var(--fs-sm)';
item.innerHTML = `<span style="opacity: 0.5; margin-right: 3px; font-family: sans-serif;">↳</span>${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.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
state.activeCategory = catKey as any; state.activeCategory = catKey as any;
if (tab === '관리도구') { state.activeSubTab = tab;
state.activeSubTab = '실사 승인';
} else {
state.activeSubTab = tab;
}
render(); render();
onTabChange(state.activeSubTab); onTabChange(state.activeSubTab);
}); });
@@ -112,7 +97,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
state.currentUserRole = roleToggle.checked ? 'admin' : 'user'; state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
if (state.currentUserRole === 'admin') { if (state.currentUserRole === 'admin') {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; state.activeSubTab = '자산현황';
} else { } else {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; state.activeSubTab = '서버';

View File

@@ -15,7 +15,7 @@ export interface AppState {
// 초기 상태 // 초기 상태
export const state: AppState = { export const state: AppState = {
activeCategory: 'hw', activeCategory: 'hw',
activeSubTab: '대시보드', activeSubTab: '자산현황',
viewMode: 'location', viewMode: 'location',
activeCharts: [], activeCharts: [],
currentUserRole: 'user', currentUserRole: 'user',

View File

@@ -6,6 +6,8 @@ import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table'; import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView'; import { renderLocationView } from './views/LocationView';
import { renderAuditApprovalView } from './views/AuditApprovalView'; import { renderAuditApprovalView } from './views/AuditApprovalView';
import { renderSpecChangeApprovalView } from './views/SpecChangeApprovalView';
import { renderServerManagementView } from './views/ServerManagementView';
import { MapEditor } from './views/MapEditor'; import { MapEditor } from './views/MapEditor';
import { initBaseModal } from './components/Modal/BaseModal'; import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal';
@@ -37,16 +39,26 @@ async function refreshView(tab?: string) {
const activeTab = tab || state.activeSubTab; const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') { if (activeTab === '자산현황') {
renderDashboard(mainContent); renderDashboard(mainContent);
return; return;
} }
if (activeTab === '실사 승인') { if (activeTab === '실사승인') {
await renderAuditApprovalView(mainContent); await renderAuditApprovalView(mainContent);
return; return;
} }
if (activeTab === '자산추가') {
await renderSpecChangeApprovalView(mainContent);
return;
}
if (activeTab === '서버관리') {
await renderServerManagementView(mainContent);
return;
}
if (activeTab === '위치지정') { if (activeTab === '위치지정') {
// Render Map Editor directly into main content to maximize working area // Render Map Editor directly into main content to maximize working area
mainContent.innerHTML = ` mainContent.innerHTML = `
@@ -160,7 +172,7 @@ function initApp() {
const newId = Math.random().toString(36).substring(2, 9); const newId = Math.random().toString(36).substring(2, 9);
if (cat === 'hw') { if (cat === 'hw') {
if (tab === '부품 마스터') { if (tab === '부품마스터') {
if (activePartsMasterSubTab === 'job-spec') { if (activePartsMasterSubTab === 'job-spec') {
openJobSpecModal({ id: '' } as any, 'add'); openJobSpecModal({ id: '' } as any, 'add');
} else { } else {
@@ -182,7 +194,7 @@ function initApp() {
// 부품 마스터 탭으로 바로가기 연동 // 부품 마스터 탭으로 바로가기 연동
if (target.closest('#btn-goto-parts-master')) { if (target.closest('#btn-goto-parts-master')) {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '부품 마스터'; state.activeSubTab = '부품마스터';
renderNavigation((tab) => { refreshView(); }); renderNavigation((tab) => { refreshView(); });
refreshView(); refreshView();
return; return;
@@ -220,7 +232,7 @@ function initRoleSwitcher() {
// 관리자 모드 전환 시 대시보드로 이동 // 관리자 모드 전환 시 대시보드로 이동
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; state.activeSubTab = '자산현황';
} else { } else {
state.currentUserRole = 'user'; state.currentUserRole = 'user';
adminLabel.classList.remove('active'); adminLabel.classList.remove('active');
@@ -243,12 +255,21 @@ function initRoleSwitcher() {
function initializeAppDirectly() { function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container'); const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout'); 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) // 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user'; state.currentUserRole = 'user';
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭 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 (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex'; if (appLayout) appLayout.style.display = 'flex';

File diff suppressed because it is too large Load Diff

View File

@@ -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 --- */ /* --- Vercel Inspired Premium Dashboard --- */
.dashboard-section-title { .dashboard-section-title {
padding: 0; padding: 0;
@@ -506,4 +516,138 @@
border-bottom: 1px solid var(--hairline); 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);
} }

View File

@@ -32,6 +32,6 @@ export function renderDashboard(mainContent: HTMLElement) {
} else if (state.activeCategory === 'sw') { } else if (state.activeCategory === 'sw') {
renderSwDashboard(innerContent); renderSwDashboard(innerContent);
} else { } else {
innerContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">해당 카테고리의 대시보드는 준비 중입니다.</div>`; innerContent.innerHTML = '';
} }
} }

View File

@@ -814,9 +814,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
actionContainer.className = "header-action-group"; actionContainer.className = "header-action-group";
actionContainer.innerHTML = ` actionContainer.innerHTML = `
${showPcFlowBtn ? ` ${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline"> ${state.currentUserRole === 'admin' ? `
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터 <button id="btn-goto-parts-master" class="btn btn-outline">
</button> <i data-lucide="settings" class="icon-sm"></i> 부품 마스터
</button>
` : ''}
<button id="btn-pc-flow" class="btn btn-outline"> <button id="btn-pc-flow" class="btn btn-outline">
PC 이동/반납 PC 이동/반납
</button> </button>

View File

@@ -42,7 +42,7 @@ export function renderSWTable(mainContent: HTMLElement) {
else if (tab === '업무지원장비') renderEquipmentList(container); else if (tab === '업무지원장비') renderEquipmentList(container);
else if (tab === '네트워크') renderNetworkList(container); else if (tab === '네트워크') renderNetworkList(container);
else if (tab === 'PC부품') renderPcPartList(container); else if (tab === 'PC부품') renderPcPartList(container);
else if (tab === '부품 마스터') renderPartsMasterList(container); else if (tab === '부품마스터') renderPartsMasterList(container);
else if (tab === '공간정보장비') renderSpaceInfoList(container); else if (tab === '공간정보장비') renderSpaceInfoList(container);
else { else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`; container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;

View File

@@ -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 = `
<div class="srv-container">
<div class="srv-header">
<div class="srv-title-area">
<span class="srv-title">서버관리 (그라파나 연동)</span>
</div>
</div>
<div class="srv-tabs">
<button id="tab-btn-monitor" class="srv-tab-btn ${activeTab === 'monitor' ? 'active' : ''}">실시간 서버 자원 모니터</button>
<button id="tab-btn-alerts" class="srv-tab-btn ${activeTab === 'alerts' ? 'active' : ''}">그라파나 임계치 경보 로그 (${alertLogs.length})</button>
</div>
<div id="srv-content-area" class="srv-table-wrapper"></div>
</div>
`;
bindTabEvents();
renderContent();
}
function renderContent() {
const content = document.getElementById('srv-content-area')!;
if (activeTab === 'monitor') {
let rows = '';
serverData.forEach(srv => {
rows += `
<tr>
<td>
<strong>${srv.assetCode}</strong>
<small style="color:var(--text-muted); font-size:11px; display:block; margin-top:2px;">
${srv.purpose || '용도 미지정'} / ${srv.memo || '메모 없음'}
</small>
<small style="color:var(--primary); font-size:11px; display:block; margin-top:2px;">
IP: ${srv.ip}
</small>
</td>
<td>
<div class="srv-indicator-group">
<div class="srv-indicator-item">
<span class="srv-indicator-name">CPU</span>
<div class="srv-progress-bg">
<div class="srv-progress-fill ${getStatusClass(srv.cpu.status)}" style="width: ${srv.cpu.val}%;"></div>
</div>
<span class="srv-badge ${getBadgeClass(srv.cpu.status)}">${srv.cpu.val}%</span>
</div>
<div class="srv-indicator-item">
<span class="srv-indicator-name">Memory</span>
<div class="srv-progress-bg">
<div class="srv-progress-fill ${getStatusClass(srv.mem.status)}" style="width: ${srv.mem.val}%;"></div>
</div>
<span class="srv-badge ${getBadgeClass(srv.mem.status)}">${srv.mem.val}%</span>
</div>
</div>
</td>
<td>
<div class="srv-indicator-group">
<div class="srv-indicator-item">
<span class="srv-indicator-name">Traffic</span>
<div class="srv-progress-bg">
<div class="srv-progress-fill ${getStatusClass(srv.net.status)}" style="width: ${srv.net.val}%;"></div>
</div>
<span class="srv-badge ${getBadgeClass(srv.net.status)}">${srv.net.val}%</span>
</div>
<div class="srv-indicator-item">
<span class="srv-indicator-name">Disk I/O</span>
<div class="srv-progress-bg">
<div class="srv-progress-fill ${getStatusClass(srv.disk.status)}" style="width: ${srv.disk.val}%;"></div>
</div>
<span class="srv-badge ${getBadgeClass(srv.disk.status)}">${srv.disk.val}%</span>
</div>
</div>
</td>
<td>
<button class="srv-btn btn-grafana-link" data-host="${srv.assetCode}">그라파나 대시보드</button>
</td>
</tr>
`;
});
content.innerHTML = `
<table class="srv-table">
<thead>
<tr>
<th style="width: 20%;">호스트 정보</th>
<th style="width: 35%;">시스템 핵심 연동 (CPU / MEM)</th>
<th style="width: 35%;">인프라 부하 연동 (TRAFFIC / DISK)</th>
<th style="width: 10%;">원격 관제</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
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 += `
<tr>
<td style="color:var(--text-muted);">${log.time}</td>
<td><strong style="color:var(--danger);">${log.host}</strong></td>
<td>
<span class="srv-badge ${log.level === 'danger' ? 'badge-red' : 'badge-yellow'}">${log.level.toUpperCase()}</span>
</td>
<td>${log.msg}</td>
</tr>
`;
});
content.innerHTML = `
<table class="srv-table">
<thead>
<tr>
<th style="width: 20%;">발생 시간</th>
<th style="width: 15%;">대상 호스트</th>
<th style="width: 10%;">경보 등급</th>
<th style="width: 55%;">알림 상세 내용</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
}
}
function bindTabEvents() {
document.getElementById('tab-btn-monitor')?.addEventListener('click', () => { activeTab = 'monitor'; renderLayout(); });
document.getElementById('tab-btn-alerts')?.addEventListener('click', () => { activeTab = 'alerts'; renderLayout(); });
}
renderLayout();
}

View File

@@ -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 = `
<div class="spec-container">
<div class="spec-header">
<div class="spec-title-area">
<span class="spec-title">자산추가 (사양변경승인)</span>
</div>
</div>
<div class="spec-tabs">
<button id="tab-btn-pending" class="spec-tab-btn ${activeTab === 'pending' ? 'active' : ''}">스펙 변경 승인 대기 (${pendingChanges.length})</button>
<button id="tab-btn-unresponsive" class="spec-tab-btn ${activeTab === 'unresponsive' ? 'active' : ''}">에이전트 미응답 장비 (${unresponsiveDevices.length})</button>
<button id="tab-btn-history" class="spec-tab-btn ${activeTab === 'history' ? 'active' : ''}">변경 이력 타임라인</button>
</div>
<div id="spec-content-area" class="spec-table-wrapper"></div>
</div>
`;
bindTabEvents();
renderContent();
}
function renderContent() {
const content = document.getElementById('spec-content-area')!;
if (activeTab === 'pending') {
if (pendingChanges.length === 0) {
content.innerHTML = `<div style="padding:3rem; text-align:center; color:var(--text-muted);">대기 중인 사양 변경 내역이 없습니다.</div>`;
return;
}
let rows = '';
pendingChanges.forEach((row, index) => {
rows += `
<tr data-id="${row.id}">
<td><strong>${row.assetCode}</strong></td>
<td>${row.user}</td>
<td><span class="badge">${row.component}</span></td>
<td>
<span class="spec-diff-old">${row.oldVal}</span>
<span class="spec-diff-new">➔ ${row.newVal}</span>
</td>
<td style="color:var(--text-muted);">${row.time}</td>
<td>
<button class="spec-btn spec-btn-primary btn-spec-approve" data-index="${index}" style="margin-right:0.25rem;">승인</button>
<button class="spec-btn spec-btn-danger btn-spec-reject" data-index="${index}">반려</button>
</td>
</tr>
`;
});
content.innerHTML = `
<table class="spec-table">
<thead>
<tr>
<th>자산코드</th>
<th>사용자</th>
<th>변경 분야</th>
<th>사양 비교 (기존 ➔ 신규)</th>
<th>감지 시간</th>
<th>조치</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
bindActionEvents();
} else if (activeTab === 'unresponsive') {
if (unresponsiveDevices.length === 0) {
content.innerHTML = `<div style="padding:3rem; text-align:center; color:var(--text-muted);">미응답 장비가 없습니다.</div>`;
return;
}
let rows = '';
unresponsiveDevices.forEach(row => {
rows += `
<tr>
<td><strong>${row.assetCode}</strong></td>
<td>${row.user}</td>
<td>${row.dept}</td>
<td><span style="color:var(--danger); font-weight:700;">⚠️ ${row.lastSeen}</span></td>
<td>
<button class="spec-btn btn-spec-ping">핑 전송</button>
</td>
</tr>
`;
});
content.innerHTML = `
<table class="spec-table">
<thead>
<tr>
<th>자산코드</th>
<th>사용자</th>
<th>부서</th>
<th>최종 교신 상태</th>
<th>원격 명령</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;
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 = `<div style="padding:3rem; text-align:center; color:var(--text-muted);">기록된 이력이 없습니다.</div>`;
return;
}
let timelineHtml = '<div class="timeline-container">';
historyLogs.forEach(log => {
timelineHtml += `
<div class="timeline-item">
<div class="timeline-date">${log.date}</div>
<div class="timeline-content"><span style="color:var(--primary); font-weight:700;">[${log.assetCode}]</span> ${log.log}</div>
</div>
`;
});
timelineHtml += '</div>';
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();
}