[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;
|
) 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -262,11 +262,6 @@ class HwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 === '부품 마스터';
|
|
||||||
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.textContent = tab;
|
||||||
item.style.fontSize = 'var(--fs-sm)';
|
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 = '실사 승인';
|
|
||||||
} else {
|
|
||||||
state.activeSubTab = tab;
|
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 = '서버';
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
31
src/main.ts
31
src/main.ts
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? `
|
||||||
|
${state.currentUserRole === 'admin' ? `
|
||||||
<button id="btn-goto-parts-master" class="btn btn-outline">
|
<button id="btn-goto-parts-master" class="btn btn-outline">
|
||||||
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
||||||
</button>
|
</button>
|
||||||
|
` : ''}
|
||||||
<button id="btn-pc-flow" class="btn btn-outline">
|
<button id="btn-pc-flow" class="btn btn-outline">
|
||||||
PC 이동/반납
|
PC 이동/반납
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
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