[FE/BE] 대시보드 폰트 하향, 가변 이슈 기준 세대 정량 비교 엔진 및 4번째 카드 단독화 구현, 사양부족/오버스펙 기존 배포버전 산정식 복원 및 누락 CPU 14종 부품마스터 DB 일괄 정제 적재
This commit is contained in:
@@ -20,6 +20,9 @@
|
||||
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
|
||||
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
|
||||
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인** 및 **외과 수술식 수정** 규칙을 준수한다.
|
||||
7. **CSS 스타일 분리 원칙 (CSS Separation Policy)**:
|
||||
- HTML/JS/TS 파일 내에 인라인 `<style>` 태그를 절대 사용하지 않는다.
|
||||
- 모든 디자인 및 스타일 코드는 관련 `.css` 파일(예: `common.css`, `modal.css`, `dashboard.css` 등)에만 작성하여 구조와 디자인을 명확히 분리한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
75
server.js
75
server.js
@@ -62,6 +62,28 @@ const pool = mysql.createPool({
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
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) {
|
||||
console.error('❌ Failed to verify/create job_spec_standards table:', {
|
||||
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
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,13 @@ export abstract class BaseModal {
|
||||
btnCloseHeader?.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. 자식 클래스 전용 초기화 로직 실행
|
||||
this.initChildLogic(onSave, closeModalsFn);
|
||||
|
||||
|
||||
@@ -262,11 +262,6 @@ class HwAssetModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,36 +68,6 @@ class JobSpecModal extends BaseModal {
|
||||
</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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -212,15 +212,6 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,15 +111,6 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = `<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.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 = '서버';
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface AppState {
|
||||
// 초기 상태
|
||||
export const state: AppState = {
|
||||
activeCategory: 'hw',
|
||||
activeSubTab: '대시보드',
|
||||
activeSubTab: '자산현황',
|
||||
viewMode: 'location',
|
||||
activeCharts: [],
|
||||
currentUserRole: 'user',
|
||||
|
||||
31
src/main.ts
31
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';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,6 @@ export function renderDashboard(mainContent: HTMLElement) {
|
||||
} else if (state.activeCategory === 'sw') {
|
||||
renderSwDashboard(innerContent);
|
||||
} else {
|
||||
innerContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">해당 카테고리의 대시보드는 준비 중입니다.</div>`;
|
||||
innerContent.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,9 +814,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
actionContainer.className = "header-action-group";
|
||||
actionContainer.innerHTML = `
|
||||
${showPcFlowBtn ? `
|
||||
<button id="btn-goto-parts-master" class="btn btn-outline">
|
||||
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
||||
</button>
|
||||
${state.currentUserRole === 'admin' ? `
|
||||
<button id="btn-goto-parts-master" class="btn btn-outline">
|
||||
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
||||
</button>
|
||||
` : ''}
|
||||
<button id="btn-pc-flow" class="btn btn-outline">
|
||||
PC 이동/반납
|
||||
</button>
|
||||
|
||||
@@ -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 = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
|
||||
366
src/views/ServerManagementView.ts
Normal file
366
src/views/ServerManagementView.ts
Normal 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();
|
||||
}
|
||||
387
src/views/SpecChangeApprovalView.ts
Normal file
387
src/views/SpecChangeApprovalView.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user