feat: 모든 카테고리(HW, SW, SW 사용자) DB 일괄 덮어쓰기 저장 기능 구현

This commit is contained in:
2026-04-17 15:07:54 +09:00
parent a805d9ce06
commit c5c6acea6a
27 changed files with 2863 additions and 996 deletions

107
db_init.js Normal file
View File

@@ -0,0 +1,107 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function initDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 DB 초기화 시작...');
// 1. 데이터베이스 생성
await connection.query(`CREATE DATABASE IF NOT EXISTS ${DB_NAME};`);
await connection.query(`USE ${DB_NAME};`);
console.log(`✅ 데이터베이스 생성 완료: ${DB_NAME}`);
// 2. 하드웨어 자산 테이블
const createHwTable = `
CREATE TABLE IF NOT EXISTS hw_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) NOT NULL COMMENT '개인PC, 서버, 스토리지, 전산비품',
corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호/코드',
asset_name VARCHAR(255) COMMENT '명칭/용도',
location VARCHAR(255) COMMENT '설치위치',
current_org VARCHAR(255) COMMENT '현 사용조직',
prev_org VARCHAR(255) COMMENT '이전 사용조직',
manager_main VARCHAR(100) COMMENT '담당자(정)',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
ip_address VARCHAR(100) COMMENT 'IP 주소 1',
ip_address2 VARCHAR(100) COMMENT 'IP 주소 2',
mac_address VARCHAR(100) COMMENT 'MAC 주소',
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
model_name VARCHAR(255),
purchase_date VARCHAR(50),
price VARCHAR(100),
vendor VARCHAR(255) COMMENT '납품업체',
doc_name VARCHAR(255) COMMENT '품의서명',
remote_tool VARCHAR(100) COMMENT '원격도구',
server_id VARCHAR(100),
server_pw VARCHAR(100),
monitoring VARCHAR(100),
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
// 3. 소프트웨어 자산 테이블
const createSwTable = `
CREATE TABLE IF NOT EXISTS sw_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) NOT NULL COMMENT '구독SW, 영구SW',
category VARCHAR(100) COMMENT '분야',
corp VARCHAR(100) COMMENT '구매법인',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) NOT NULL,
purchase_date VARCHAR(50),
subscription_date VARCHAR(50),
maintenance_status TINYINT(1) DEFAULT 0,
price VARCHAR(100),
quantity INT DEFAULT 1,
account_id VARCHAR(255) COMMENT '계정명',
vendor VARCHAR(255),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
// 4. 소프트웨어 사용자 매핑 테이블
const createSwUsersTable = `
CREATE TABLE IF NOT EXISTS sw_users (
id VARCHAR(50) PRIMARY KEY,
sw_id VARCHAR(50),
corp VARCHAR(100),
dept VARCHAR(100),
team VARCHAR(100),
position VARCHAR(50),
name VARCHAR(100),
usage_period VARCHAR(100),
doc_name VARCHAR(255),
FOREIGN KEY (sw_id) REFERENCES sw_assets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
await connection.query(createHwTable);
await connection.query(createSwTable);
await connection.query(createSwUsersTable);
console.log('✅ 테이블 생성 완료!');
await connection.end();
console.log('🏁 DB 초기화 프로세스 종료.');
}
initDB().catch(err => {
console.error('❌ DB 초기화 실패:', err);
process.exit(1);
});

View File

@@ -8,68 +8,50 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" />
<link rel="stylesheet" href="/src/styles/table.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="sidebar-header">
<h1>HM <span>ITAM</span></h1>
</div>
<div class="nav-section">
<h3><i data-lucide="cpu"></i> 하드웨어</h3>
<ul id="nav-hw" class="nav-list">
<li class="active" data-category="hw" data-tab="대시보드"><i data-lucide="layout-dashboard"></i> 대시보드</li>
<li data-category="hw" data-tab="개인PC"><i data-lucide="monitor"></i> 개인PC</li>
<li data-category="hw" data-tab="서버"><i data-lucide="server"></i> 서버</li>
<li data-category="hw" data-tab="스토리지"><i data-lucide="database"></i> 스토리지</li>
<li data-category="hw" data-tab="전산비품"><i data-lucide="laptop"></i> 전산비품</li>
</ul>
</div>
<div class="nav-section">
<h3><i data-lucide="layers"></i> 소프트웨어</h3>
<ul id="nav-sw" class="nav-list">
<li data-category="sw" data-tab="대시보드"><i data-lucide="layout-dashboard"></i> 대시보드</li>
<li data-category="sw" data-tab="구독SW"><i data-lucide="calendar-clock"></i> 구독 소프트웨어</li>
<li data-category="sw" data-tab="영구SW"><i data-lucide="key"></i> 영구 소프트웨어</li>
</ul>
</div>
</aside>
<!-- Main Content Area -->
<div class="main-wrapper">
<header class="top-header">
<div class="header-title">
<h2 id="current-tab-title">하드웨어 / 대시보드</h2>
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<h1>HM <span>ITAM</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<div class="header-actions">
<button id="btn-download-template" class="btn btn-outline" title="통합 양식 다운로드">
<i data-lucide="download"></i> 통합 양식 다운로드
<i data-lucide="download"></i> 양식
</button>
<label for="excel-upload" class="btn btn-outline" title="엑셀 파일 업로드">
<i data-lucide="upload"></i> 엑셀 업로드
<i data-lucide="upload"></i> 업로드
</label>
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
<button id="btn-export-excel" class="btn btn-primary" title="일괄 엑셀 저장">
<i data-lucide="file-spreadsheet"></i> 일괄 엑셀 저장
<i data-lucide="file-spreadsheet"></i> 엑셀저장
</button>
<button id="btn-add-asset" class="btn btn-primary hidden">
<i data-lucide="plus"></i> 자산 추가
<i data-lucide="plus"></i> 자산추가
</button>
</div>
</header>
</div>
</header>
<main class="content-area" id="main-content">
<!-- 컴포넌트에 의해 동적으로 채워짐 -->
</main>
</div>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
</div>
<!-- 모든 모달은 각 TS 컴포넌트 내부에서 동적으로 주입됨 -->
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

973
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,14 +6,20 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"server": "node server.js",
"db-init": "node db_init.js"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"
}
}

224
server.js Normal file
View File

@@ -0,0 +1,224 @@
import express from 'express';
import mysql from 'mysql2/promise';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
// DB 연결 풀 생성
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// --- API Routes ---
// 1. 하드웨어 자산 조회
app.get('/api/hw', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM hw_assets');
// DB 컬럼명을 프론트엔드 인터페이스(한글)에 맞게 매핑
const mapped = rows.map(r => ({
id: r.id,
type: r.type,
법인: r.corp,
자산코드: r.asset_code,
명칭: r.asset_name,
위치: r.location,
현사용조직: r.current_org,
이전사용조직: r.prev_org,
담당자_정: r.manager_main,
관리자: r.manager_main,
담당자_부: r.manager_sub,
IP주소: r.ip_address,
IP2: r.ip_address2,
MACaddress: r.mac_address,
OS: r.os,
CPU: r.cpu,
RAM: r.ram,
SSD1: r.storage1,
SSD2: r.storage2,
모델명: r.model_name,
구매일: r.purchase_date,
금액: r.price,
납품업체: r.vendor,
품의서명: r.doc_name,
용도: r.asset_name, // 서버의 경우 명칭을 용도로 사용
상세: r.remarks,
원격접속: r.remote_tool,
서버ID: r.server_id,
서버PW: r.server_pw,
모니터링: r.monitoring,
비고: r.remarks
}));
res.json(mapped);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 2. 하드웨어 자산 일괄 저장 (항상 덮어쓰기)
app.post('/api/hw/batch', async (req, res) => {
const assets = req.body;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM hw_assets');
if (assets.length > 0) {
const sql = `
INSERT INTO hw_assets (
id, type, corp, asset_code, asset_name, location, current_org, prev_org,
manager_main, manager_sub, ip_address, ip_address2, mac_address, os,
cpu, ram, storage1, storage2, model_name, purchase_date, price,
vendor, doc_name, remote_tool, server_id, server_pw, monitoring, remarks
) VALUES ?
`;
const values = assets.map(a => [
a.id, a.type, a.법인, a.자산코드, a.명칭 || a.용도, a.위치, a.현사용조직, a.이전사용조직,
a.담당자_정 || a.관리자, a.담당자_부, a.IP주소, a.IP2, a.MACaddress, a.OS,
a.CPU, a.RAM, a.SSD1, a.SSD2, a.모델명, a.구매일, a.금액,
a.납품업체, a.품의서명, a.원격접속, a.서버ID, a.서버PW, a.모니터링, a.비고 || a.상세
]);
await connection.query(sql, [values]);
}
await connection.commit();
res.json({ success: true, count: assets.length, mode: 'overwrite' });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally {
connection.release();
}
});
// 3. 소프트웨어 자산 조회
app.get('/api/sw', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_assets');
const mapped = rows.map(r => ({
id: r.id,
type: r.type,
분야: r.category,
법인: r.corp,
부서: r.dept,
제품명: r.product_name,
구매일: r.purchase_date,
구독일: r.subscription_date,
유지보수여부: !!r.maintenance_status,
금액: r.price,
수량: r.quantity,
계정명: r.account_id,
납품업체: r.vendor,
비고: r.remarks
}));
res.json(mapped);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 4. 소프트웨어 자산 일괄 저장 (항상 덮어쓰기)
app.post('/api/sw/batch', async (req, res) => {
const assets = req.body;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM sw_assets');
if (assets.length > 0) {
const sql = `
INSERT INTO sw_assets (
id, type, category, corp, dept, product_name, purchase_date,
subscription_date, maintenance_status, price, quantity,
account_id, vendor, remarks
) VALUES ?
`;
const values = assets.map(a => [
a.id, a.type, a.분야, a.법인, a.부서, a.제품명, a.구매일,
a.구독일, a.유지보수여부 ? 1 : 0, a.금액, a.수량,
a.계정명, a.납품업체, a.비고
]);
await connection.query(sql, [values]);
}
await connection.commit();
res.json({ success: true, count: assets.length, mode: 'overwrite' });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally {
connection.release();
}
});
// 5. SW 사용자 매핑 조회
app.get('/api/sw-users', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_users');
const mapped = rows.map(r => ({
id: r.id,
swId: r.sw_id,
법인: r.corp,
부서: r.dept,
: r.team,
직위: r.position,
이름: r.name,
사용기간: r.usage_period,
신청서명: r.doc_name
}));
res.json(mapped);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 6. SW 사용자 일괄 저장 (항상 덮어쓰기)
app.post('/api/sw-users/batch', async (req, res) => {
const users = req.body;
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM sw_users');
if (users.length > 0) {
const sql = `
INSERT INTO sw_users (
id, sw_id, corp, dept, team, position, name, usage_period, doc_name
) VALUES ?
`;
const values = users.map(u => [
u.id, u.swId, u.법인, u.부서, u., u.직위, u.이름, u.사용기간, u.신청서명
]);
await connection.query(sql, [values]);
}
await connection.commit();
res.json({ success: true, count: users.length, mode: 'overwrite' });
} catch (err) {
await connection.rollback();
res.status(500).json({ error: err.message });
} finally {
connection.release();
}
});
app.listen(PORT, () => {
console.log(`📡 ITAM API Server running on http://localhost:${PORT}`);
});

View File

@@ -230,7 +230,7 @@ function fillHwFormData(asset: HardwareAsset) {
}
}
export function initHwModal() {
export function initHwModal(onSave: () => void, closeModals: () => void) {
// HTML 주입
if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
@@ -245,7 +245,7 @@ export function initHwModal() {
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const closeModal = () => {
modal.classList.add('hidden');
closeModals();
isEditMode = false;
};
@@ -319,7 +319,7 @@ export function initHwModal() {
const idx = state.masterData.hw.findIndex(a => a.id === assetId);
if (idx > -1) {
state.masterData.hw[idx] = updated;
renderTable(document.getElementById('main-content')!);
onSave();
switchToViewMode();
}
});
@@ -328,7 +328,7 @@ export function initHwModal() {
if (!currentAsset) return;
if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== currentAsset!.id);
renderTable(document.getElementById('main-content')!);
onSave();
closeModal();
}
});

View File

@@ -109,8 +109,9 @@ const PC_MODAL_HTML = `
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-pc-modal" class="btn btn-outline">취소</button>
<button id="btn-save-pc-asset" class="btn btn-primary">저장</button>
<button id="btn-revert-pc-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-pc-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-pc-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
@@ -123,15 +124,66 @@ export function initPcModal(renderContent: () => void, closeModals: () => void)
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-pc-edit') as HTMLButtonElement;
const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement;
const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement;
const btnCancelPc = document.getElementById('btn-cancel-pc-modal') as HTMLButtonElement;
const btnClosePc = document.getElementById('btn-close-pc-modal') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-pc-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-pc-footer') as HTMLButtonElement;
btnCancelPc?.addEventListener('click', closeModals);
btnClosePc?.addEventListener('click', closeModals);
let isEditMode = false;
let currentAsset: HardwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
pcForm.classList.add('is-edit-mode');
pcForm.classList.remove('is-view-mode');
btnSavePc.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
pcForm.classList.add('is-view-mode');
pcForm.classList.remove('is-edit-mode');
btnSavePc.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(asset: HardwareAsset) {
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.;
(document.getElementById('pc-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('pc-사용자') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-CPU') as HTMLInputElement).value = asset.CPU || '';
(document.getElementById('pc-GPU') as HTMLInputElement).value = asset.GPU || '';
(document.getElementById('pc-RAM') as HTMLInputElement).value = asset.RAM || '';
(document.getElementById('pc-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
(document.getElementById('pc-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset. ? `첨부: ${asset.}` : '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSavePc?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }
// ... (저장 로직 유지)
e.preventDefault();
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }

View File

@@ -77,7 +77,8 @@ const SW_MODAL_HTML = `
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-sw-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
@@ -91,16 +92,61 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
}
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
const btnCancelSw = document.getElementById('btn-cancel-sw-modal') as HTMLButtonElement;
const btnCloseSw = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-sw-footer') as HTMLButtonElement;
btnCancelSw?.addEventListener('click', closeModals);
btnCloseSw?.addEventListener('click', closeModals);
let isEditMode = false;
let currentAsset: SoftwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
swForm.classList.add('is-edit-mode');
swForm.classList.remove('is-view-mode');
btnSaveSw.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
swForm.classList.add('is-view-mode');
swForm.classList.remove('is-edit-mode');
btnSaveSw.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(asset: SoftwareAsset) {
(document.getElementById('sw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('sw-분야') as HTMLSelectElement).value = asset. || '업무공통';
(document.getElementById('sw-법인') as HTMLSelectElement).value = asset.;
(document.getElementById('sw-부서') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-제품명') as HTMLInputElement).value = asset.;
(document.getElementById('sw-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-구독일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-수량') as HTMLInputElement).value = String(asset.);
(document.getElementById('sw-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-비고') as HTMLInputElement).value = asset. || '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSaveSw?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
@@ -144,6 +190,7 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
}
export function openSwModal(asset?: SoftwareAsset) {
currentAsset = asset || null;
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
@@ -163,28 +210,13 @@ export function openSwModal(asset?: SoftwareAsset) {
if (asset) {
document.getElementById('sw-modal-title')!.textContent = `${state.activeSubTab} 상세 정보 수정`;
deleteBtn.style.display = 'block';
(document.getElementById('sw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('sw-분야') as HTMLSelectElement).value = asset. || '업무공통';
(document.getElementById('sw-법인') as HTMLSelectElement).value = asset.;
(document.getElementById('sw-부서') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-제품명') as HTMLInputElement).value = asset.;
(document.getElementById('sw-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-구독일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-수량') as HTMLInputElement).value = String(asset.);
(document.getElementById('sw-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-비고') as HTMLInputElement).value = asset. || '';
fillFormData(asset);
setEditMode(false);
} else {
document.getElementById('sw-modal-title')!.textContent = `신규 ${state.activeSubTab} 자산 추가`;
deleteBtn.style.display = 'none';
(document.getElementById('sw-asset-id') as HTMLInputElement).value = '';
(document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab;
(document.getElementById('sw-분야') as HTMLSelectElement).value = '업무공통';
(document.getElementById('sw-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('sw-부서') as HTMLInputElement).value = '';
setEditMode(true);
}
}

View File

@@ -29,7 +29,8 @@ const STORAGE_MODAL_HTML = `
<div class="modal-footer">
<button id="btn-delete-storage-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-storage-modal" class="btn btn-outline">닫기</button>
<button id="btn-revert-storage-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-close-storage-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-storage-asset" class="btn btn-primary">수정</button>
</div>
</div>
@@ -43,20 +44,62 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
}
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-storage-edit') as HTMLButtonElement;
const btnSaveStorage = document.getElementById('btn-save-storage-asset') as HTMLButtonElement;
const btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
const btnCancelStorage = document.getElementById('btn-cancel-storage-modal') as HTMLButtonElement;
const btnCloseStorage = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-storage-footer') as HTMLButtonElement;
btnCancelStorage?.addEventListener('click', closeModals);
btnCloseStorage?.addEventListener('click', closeModals);
let isEditMode = false;
let currentAsset: HardwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
storageForm.classList.add('is-edit-mode');
storageForm.classList.remove('is-view-mode');
btnSaveStorage.textContent = '저장';
btnRevertEdit.classList.remove('hidden');
btnCloseFooter.classList.add('hidden');
} else {
storageForm.classList.add('is-view-mode');
storageForm.classList.remove('is-edit-mode');
btnSaveStorage.textContent = '수정';
btnRevertEdit.classList.add('hidden');
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
function fillFormData(asset: HardwareAsset) {
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('storage-법인') as HTMLInputElement).value = asset.;
(document.getElementById('storage-유형') as HTMLInputElement).value = asset.storage유형 || 'NAS';
(document.getElementById('storage-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('storage-명칭') as HTMLInputElement).value = asset.;
(document.getElementById('storage-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-용량') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-담당자_정') as HTMLInputElement).value = asset._정 || '';
(document.getElementById('storage-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('storage-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-금액') as HTMLInputElement).value = asset. || '';
}
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
btnSaveStorage?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
setEditMode(true);
return;
}
if (!storageForm.checkValidity()) { storageForm.reportValidity(); return; }
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
const newAsset: HardwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: '스토리지',
@@ -97,6 +140,7 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
}
export function openStorageModal(asset?: HardwareAsset) {
currentAsset = asset || null;
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-storage-asset')!;
@@ -106,22 +150,12 @@ export function openStorageModal(asset?: HardwareAsset) {
if (asset) {
document.getElementById('storage-modal-title')!.textContent = '스토리지 상세 정보 수정';
deleteBtn.style.display = 'block';
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('storage-법인') as HTMLInputElement).value = asset.;
(document.getElementById('storage-유형') as HTMLInputElement).value = asset.storage유형 || 'NAS';
(document.getElementById('storage-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('storage-명칭') as HTMLInputElement).value = asset.;
(document.getElementById('storage-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-용량') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-담당자_정') as HTMLInputElement).value = asset._정 || '';
(document.getElementById('storage-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('storage-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-금액') as HTMLInputElement).value = asset. || '';
fillFormData(asset);
setEditMode(false);
} else {
document.getElementById('storage-modal-title')!.textContent = '신규 스토리지 자산 추가';
deleteBtn.style.display = 'none';
(document.getElementById('storage-asset-id') as HTMLInputElement).value = '';
setEditMode(true);
}
}

View File

@@ -0,0 +1,80 @@
import { state } from '../core/state';
const MENU_CONFIG = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품']
},
sw: {
label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW']
},
ops: {
label: '운영 서비스',
tabs: ['대시보드', '서비스현황', '백업관리', '보안점검']
}
};
export function renderNavigation(onTabChange: (tab: string) => void) {
const navContainer = document.getElementById('main-nav')!;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
const render = () => {
navContainer.innerHTML = '';
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const isActive = state.activeCategory === catKey;
const group = document.createElement('div');
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
// 메인 카테고리 트리거
const trigger = document.createElement('div');
trigger.className = 'gnb-trigger';
trigger.textContent = config.label;
trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) {
state.activeCategory = catKey;
state.activeSubTab = '대시보드';
if (btnAddAsset) btnAddAsset.classList.add('hidden');
render();
onTabChange('대시보드');
}
});
group.appendChild(trigger);
// 하위 탭 선반 (Shelf)
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
config.tabs.forEach(tab => {
const item = document.createElement('div');
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
item.textContent = tab;
item.addEventListener('click', (e) => {
e.stopPropagation();
state.activeCategory = catKey;
state.activeSubTab = tab;
if (btnAddAsset) {
if (tab === '대시보드') btnAddAsset.classList.add('hidden');
else btnAddAsset.classList.remove('hidden');
}
render();
onTabChange(tab);
});
shelf.appendChild(item);
});
group.appendChild(shelf);
// 마우스 오버 시 다른 그룹의 선반은 가리고 내 것만 보여주는 스타일은 CSS에서 처리함
navContainer.appendChild(group);
});
};
render();
}

View File

@@ -1,37 +0,0 @@
import { state } from '../core/state';
export function renderSidebar(onTabChange: (tab: string) => void) {
const navItems = document.querySelectorAll('.nav-list li');
const titleElement = document.getElementById('current-tab-title') as HTMLHeadingElement;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
navItems.forEach(item => {
item.addEventListener('click', () => {
// 탭 UI 업데이트
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
// 상태 업데이트
state.activeCategory = item.getAttribute('data-category') as 'hw' | 'sw';
state.activeSubTab = item.getAttribute('data-tab') || '대시보드';
// 타이틀 업데이트
const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어';
if (titleElement) {
titleElement.textContent = `${catName} / ${state.activeSubTab}`;
}
// 추가 버튼 노출 여부
if (btnAddAsset) {
if (state.activeSubTab === '대시보드') {
btnAddAsset.classList.add('hidden');
} else {
btnAddAsset.classList.remove('hidden');
}
}
// 탭 변경 콜백 실행
onTabChange(state.activeSubTab);
});
});
}

View File

@@ -5,7 +5,7 @@ import { realServerData } from './realServerData';
// --- State Definitions ---
export interface AppState {
masterData: MasterAssetData;
activeCategory: 'hw' | 'sw';
activeCategory: 'hw' | 'sw' | 'ops';
activeSubTab: string;
activeCharts: any[];
}
@@ -54,14 +54,48 @@ const mergedHw: HardwareAsset[] = [
export const state: AppState = {
masterData: {
...dummy,
hw: mergedHw,
logs: [] // MasterAssetData 인터페이스에 맞게 추가
hw: mergedHw, // 기본적으로 하드코딩된 데이터를 가지고 시작
logs: []
},
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: []
};
/**
* DB에서 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
const [hwRes, swRes, swUserRes] = await Promise.all([
fetch('http://localhost:3000/api/hw'),
fetch('http://localhost:3000/api/sw'),
fetch('http://localhost:3000/api/sw-users')
]);
if (hwRes.ok) {
const hwData = await hwRes.json();
if (hwData && hwData.length > 0) state.masterData.hw = hwData;
}
if (swRes.ok) {
const swData = await swRes.json();
if (swData && swData.length > 0) state.masterData.sw = swData;
}
if (swUserRes.ok) {
const swUserData = await swUserRes.json();
if (swUserData && swUserData.length > 0) state.masterData.swUsers = swUserData;
}
console.log('✅ DB 데이터 로드 완료');
return true;
} catch (err) {
console.warn('⚠️ 백엔드 서버 연결 실패. 로컬 데이터를 유지합니다.');
}
return false;
}
// --- State Helpers ---
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);

56
src/core/utils.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* ITAM 공통 유틸리티 함수
*/
/**
* 숫자에 천 단위 콤마 추가 (금액 표시용)
*/
export function formatPrice(value: string | number): string {
if (value === undefined || value === null) return '';
const num = String(value).replace(/[^0-9]/g, '');
if (!num) return '';
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/
export function createBadge(text: string, bgColor: string): string {
return `<span style="background:${bgColor}; color:white; font-size:10px; padding:1px 4px; border-radius:3px; font-weight:700; margin-right:4px; display:inline-block; line-height:1.2;">${text}</span>`;
}
/**
* 텍스트 내 줄바꿈을 구분자(/)로 변경하여 한 줄로 표시
*/
export function formatInline(value: any): string {
return String(value || '').replace(/\n/g, ' / ').trim();
}
/**
* 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD)
*/
export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim();
}
/**
* 고유 ID 생성 (7자리 랜덤 문자열)
*/
export function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
/**
* 두 자산 객체 간의 변경 사항 감지
*/
export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: string, label: string}[]): string {
const changes: string[] = [];
fields.forEach(field => {
const oldVal = String(oldAsset[field.key] || '').trim();
const newVal = String(newAsset[field.key] || '').trim();
if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
}
});
return changes.join('\n');
}

View File

@@ -1,8 +1,9 @@
import { state } from './core/state';
import { renderSidebar } from './components/Sidebar';
import { state, loadMasterDataFromDB } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderTable } from './views/AssetTableView';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset } from './core/excelHandler';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAsset, SWUser } from './core/excelHandler';
import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initStorageModal } from './components/Modal/StorageModal';
@@ -11,40 +12,109 @@ import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw } from 'lucide';
// --- DB 저장을 위한 헬퍼 함수 ---
async function saveAllHwToDB(assets: HardwareAsset[]) {
try {
const response = await fetch('http://localhost:3000/api/hw/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assets)
});
if (!response.ok) throw new Error('HW DB 저장 실패');
console.log('✅ HW DB 저장 완료');
} catch (err) {
console.error('❌ HW DB 저장 실패:', err);
}
}
async function saveAllSwToDB(assets: SoftwareAsset[]) {
try {
const response = await fetch('http://localhost:3000/api/sw/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assets)
});
if (!response.ok) throw new Error('SW DB 저장 실패');
console.log('✅ SW DB 저장 완료');
} catch (err) {
console.error('❌ SW DB 저장 실패:', err);
}
}
async function saveAllSwUsersToDB(users: SWUser[]) {
try {
const response = await fetch('http://localhost:3000/api/sw-users/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(users)
});
if (!response.ok) throw new Error('SW User DB 저장 실패');
console.log('✅ SW User DB 저장 완료');
} catch (err) {
console.error('❌ SW User DB 저장 실패:', err);
}
}
// --- App Initialization ---
function initApp() {
console.log('🚀 ITAM System Initializing...');
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
// 1. 초기 뷰 렌더링 (대시보드)
// 1. 전역 모달 및 내비게이션 초기화
const { closeAllModals } = initBaseModal();
try {
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
} else {
renderTable(mainContent);
}
});
initPcModal(() => {
saveAllHwToDB(state.masterData.hw);
renderTable(mainContent);
}, closeAllModals);
initHwModal(() => {
saveAllHwToDB(state.masterData.hw);
renderTable(mainContent);
}, closeAllModals);
initStorageModal(() => {
saveAllHwToDB(state.masterData.hw);
renderTable(mainContent);
}, closeAllModals);
initSwModal(() => {
saveAllSwToDB(state.masterData.sw);
renderTable(mainContent);
}, closeAllModals);
initSwUserModal(() => {
saveAllSwUsersToDB(state.masterData.swUsers);
renderTable(mainContent);
}, closeAllModals);
initDashboardDetailModal();
} catch (e) {
console.error('❌ Initialization failed:', e);
}
// 2. 초기 렌더링
renderDashboard(mainContent);
// 2. 사이드바 초기화
renderSidebar((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
document.getElementById('btn-add-asset')?.classList.add('hidden');
} else {
renderTable(mainContent);
document.getElementById('btn-add-asset')?.classList.remove('hidden');
}
// 상단 타이틀 업데이트
const titleEl = document.getElementById('current-tab-title')!;
if (titleEl) {
const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어';
titleEl.textContent = `${catName} / ${state.activeSubTab}`;
// 3. 비동기 데이터 로드
loadMasterDataFromDB().then((success) => {
if (success) {
if (state.activeSubTab === '대시보드') renderDashboard(mainContent);
else renderTable(mainContent);
}
});
// 3. 모달 초기화 (HTML 주입 및 이벤트 바인딩)
initPcModal(() => renderTable(mainContent), () => {});
initHwModal();
initStorageModal(() => renderTable(mainContent), () => {});
initSwModal(() => renderTable(mainContent), () => {});
initSwUserModal(() => renderTable(mainContent), () => {});
initDashboardDetailModal();
// 4. 전역 버튼 이벤트 바인딩
// 4. 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
@@ -54,36 +124,29 @@ function initApp() {
if (file) {
const data = await parseExcel(file);
state.masterData = data;
// 엑셀 업로드 시 모든 카테고리 일괄 덮어쓰기 저장
await Promise.all([
saveAllHwToDB(data.hw),
saveAllSwToDB(data.sw),
saveAllSwUsersToDB(data.swUsers)
]);
renderTable(mainContent);
}
});
document.getElementById('btn-add-asset')?.addEventListener('click', () => {
if (state.activeSubTab === '서버' || state.activeSubTab === '전산비품' || state.activeSubTab === '스토리지') {
const newAsset: HardwareAsset = {
openHwModal({
id: Math.random().toString(36).substring(2, 9),
type: state.activeSubTab,
: '한맥',
: '',
: '',
: '',
: '',
IP주소: '',
MACaddress: '',
HW사양: '',
OS: '',
: '',
: ''
};
openHwModal(newAsset);
: '한맥', : '', : '', : '', : '', IP주소: '', MACaddress: '', HW사양: '', OS: '', : '', : ''
} as any);
}
});
// 전역 아이콘 초기화
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw }
});
}
// Start the app
document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -6,40 +6,14 @@
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: #dc2626;
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
}
.shadow-sm {
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
}
.rounded-lg {
border-radius: 8px;
}
.dashboard-card {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: none;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.dashboard-layout-2col {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 768px) {
.dashboard-layout-2col {
grid-template-columns: 1fr;
}
--header-height: 52px;
}
* {
@@ -55,336 +29,141 @@ body {
line-height: 1.5;
letter-spacing: -0.02em;
font-size: 14px;
}
/* App Layout - Sidebar & Main Content */
.app-layout {
display: flex;
min-height: 100vh;
width: 100%;
}
.sidebar {
width: 260px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-main);
}
.sidebar-header h1 span {
color: var(--primary-color);
}
.nav-section {
padding: 1.5rem 0 0.5rem;
}
.nav-section h3 {
padding: 0 1.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-section h3 i {
width: 14px;
height: 14px;
}
.nav-list {
list-style: none;
}
.nav-list li {
padding: 0.75rem 1.5rem;
margin: 0.25rem 0.75rem;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-main);
font-weight: 500;
transition: all 0.2s;
}
.nav-list li i {
width: 18px;
height: 18px;
color: var(--text-muted);
}
.nav-list li:hover {
background-color: var(--bg-color);
}
.nav-list li.active {
background-color: var(--primary-light);
color: var(--primary-color);
}
.nav-list li.active i {
color: var(--primary-color);
}
/* Main Content Wrapper */
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.top-header {
.app-layout {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
flex-direction: column;
height: 100vh;
width: 100%;
}
/* --- Main Header & GNB/LNB --- */
.main-header {
background-color: var(--white);
border-bottom: 1px solid var(--border-color);
z-index: 100;
height: var(--header-height);
}
.header-title h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
}
.header-actions {
.header-container {
height: 100%;
display: flex;
align-items: center;
padding: 0 1.5rem;
gap: 1.5rem;
}
.brand h1 {
font-size: 1.2rem;
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
margin-right: 1rem;
}
.brand h1 span { color: var(--primary-color); }
.integrated-nav {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
.content-area {
padding: 2rem;
flex: 1;
overflow-y: auto;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
.nav-group {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.stat-card .title {
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 500;
}
.stat-card .value {
font-size: 2rem;
.gnb-trigger {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
margin-top: 0.5rem;
padding: 0 1rem;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
/* Buttons */
.lnb-shelf {
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
height: 60%;
border-left: 1px solid var(--border-color);
margin-left: 0.25rem;
animation: fadeIn 0.2s ease-out;
}
.nav-group:hover .lnb-shelf,
.nav-group.is-showing-shelf .lnb-shelf {
display: flex;
}
.lnb-item {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
}
.lnb-item:hover { color: var(--primary-color); background-color: var(--bg-color); }
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
font-weight: 700;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-5px); }
to { opacity: 1; transform: translateX(0); }
}
/* --- Global Actions & Buttons --- */
.header-actions { display: flex; gap: 0.3rem; align-items: center; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem; /* 표준 사이즈로 통일 */
font-size: 0.875rem;
font-weight: 500;
gap: 0.35rem;
padding: 0 0.8rem;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
height: 28px;
line-height: 1;
min-width: 80px; /* 버튼의 최소 너비 확보 */
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: auto;
.btn i, .btn svg { width: 12px !important; height: 12px !important; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: 1px solid var(--primary-color); }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
/* --- Layout Frame --- */
.content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.btn i { width: 16px; height: 16px; }
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-outline {
background-color: transparent;
color: var(--primary-color);
border: 1px solid var(--primary-color);
white-space: nowrap;
}
.btn-outline:hover {
background-color: var(--primary-light);
border-color: var(--primary-color);
}
.text-nowrap {
white-space: nowrap;
}
.btn-danger {
color: var(--danger);
border-color: #fca5a5;
}
.btn-danger:hover {
background-color: #fef2f2;
}
.btn-icon {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0.25rem;
}
/* Table */
.table-container {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: auto; /* 가로/세로 스크롤 허용 */
max-height: calc(100vh - 180px); /* 화면 높이에 맞춰 제한 (가로 스크롤바 노출용) */
position: relative;
}
table {
.view-container {
width: 100%;
border-collapse: separate; /* sticky border 유지를 위해 separate 설정 */
border-spacing: 0;
text-align: left;
}
th, td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--text-muted);
font-size: 0.875rem;
white-space: nowrap;
background-color: #FAFAFA;
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color); /* sticky 시 경계선 유지 */
}
td {
font-size: 0.875rem;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background-color: var(--bg-color); }
.empty-row td { text-align: center; padding: 3rem; color: var(--text-muted); }
.sw-table td {
text-align: center;
}
/* Search Filter Bar */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
background-color: var(--white);
padding: 1.25rem;
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 2rem;
align-items: flex-end;
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 180px;
gap: 1.5rem;
}
.search-item.flex-1 {
flex: 1;
min-width: 250px;
}
.search-item label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.search-item input,
.search-item select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.875rem;
outline: none;
transition: all 0.2s;
background-color: var(--white);
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
}
.btn-reset {
height: 36px;
padding: 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.hidden {
display: none !important;
}
.hidden { display: none !important; }
.text-nowrap { white-space: nowrap; }

59
src/styles/dashboard.css Normal file
View File

@@ -0,0 +1,59 @@
/* --- Dashboard View Specific Styles --- */
.dashboard-section-title {
padding: 0 0 1rem 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--text-main);
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
}
.stat-card .title {
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 600;
}
.stat-card .value {
font-size: 2.2rem;
font-weight: 800;
color: var(--primary-color);
margin-top: 0.5rem;
}
.dashboard-layout-2col {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.dashboard-card {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
display: flex;
flex-direction: column;
min-height: 360px;
}
.dashboard-card canvas {
flex: 1;
width: 100% !important;
max-height: 280px;
}

View File

@@ -47,13 +47,21 @@
}
.modal-header .btn-icon {
color: var(--white);
opacity: 0.8;
transition: opacity 0.2s;
color: #FFFFFF !important;
cursor: pointer;
background: none !important;
border: none !important;
}
.modal-header .btn-icon i,
.modal-header .btn-icon svg {
width: 20px !important; /* Original natural size */
height: 20px !important;
stroke: #FFFFFF !important;
}
.modal-header .btn-icon:hover {
opacity: 1;
background: none !important;
}
.modal-body {

104
src/styles/table.css Normal file
View File

@@ -0,0 +1,104 @@
/* --- Table View & Filter Styles --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
align-items: flex-end;
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.search-item.flex-1 {
flex: 1;
}
.search-item label {
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
}
.search-item input,
.search-item select {
height: 38px;
padding: 0 1rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
outline: none;
}
.search-item select {
padding-right: 2.5rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.btn-reset {
height: 38px !important;
padding: 0 0.8rem !important;
font-size: 12px !important;
display: inline-flex !important;
align-items: center !important;
gap: 0.35rem !important;
border-radius: 4px !important;
}
.table-container {
background-color: var(--white);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-left: none;
border-right: none;
overflow: auto;
max-height: calc(100vh - 240px);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
white-space: nowrap;
}
th {
background-color: #FAFAFA;
font-weight: 700;
color: var(--text-muted);
font-size: 12px;
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color);
text-transform: uppercase;
}
td {
font-size: 14px;
}
tbody tr:hover {
background-color: #F9FAFB;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 11px;
height: 24px;
}

View File

@@ -1,176 +1,49 @@
import { state } from '../core/state';
import { renderPcList } from './List/PcListView';
import { renderServerList } from './List/ServerListView';
import { renderStorageList } from './List/StorageListView';
import { renderEquipmentList } from './List/EquipmentListView';
import { renderSwList } from './List/SwListView';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
import { openPcModal } from '../components/Modal/PCModal';
import { openHwModal } from '../components/Modal/HWModal';
import { openStorageModal } from '../components/Modal/StorageModal';
import { openSwModal } from '../components/Modal/SWModal';
import { openSwUserModal } from '../components/Modal/SWUserModal';
/**
* 자산 목록 테이블 렌더링 메인 함수
* 자산 목록 테이블 렌더링 통합 허브
*/
export function renderTable(mainContent: HTMLElement) {
if (!mainContent) return;
console.log(`📂 Rendering Table for: ${state.activeCategory} / ${state.activeSubTab}`);
mainContent.innerHTML = '';
const container = document.createElement('div');
container.className = 'view-container';
const table = document.createElement('table');
if (state.activeCategory === 'hw') {
renderHwTable(table, container, mainContent);
} else {
renderSwTable(table, container, mainContent);
}
try {
const tab = state.activeSubTab;
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 }
});
}
function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const list = state.masterData.hw.filter(a => a.type === state.activeSubTab);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
if (state.activeSubTab === '개인PC') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>GPU</th><th>RAM</th><th>SSD1</th><th>SSD2</th><th>HDD1</th><th>HDD2</th><th>구매일</th><th>금액</th><th>납품업체</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="17">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.CPU||''}</td><td>${asset.GPU||''}</td><td>${asset.RAM||''}</td><td>${asset.SSD1||'-'}</td><td>${asset.SSD2||'-'}</td><td>${asset.HDD1||'-'}</td><td>${asset.HDD2||'-'}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset); });
tbody.appendChild(tr);
});
} else if (state.activeSubTab === '스토리지') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>모델명</th><th>용량</th><th>담당자(정)</th><th>IP주소</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="13">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.storage유형||''}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset._정||''}</td><td>${asset.IP주소||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openStorageModal(asset); });
tbody.appendChild(tr);
});
} else {
// 서버 또는 전산비품
if (state.activeSubTab === '서버') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산번호</th><th>유형</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>IP 주소</th><th>원격접속</th><th>모델명</th><th>OS</th><th>CPU</th><th>RAM</th><th>Storage</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
} else {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th>${state.activeSubTab === '전산비품' ? '<th>유형</th>' : ''}<th>자산코드</th><th>명칭</th><th>위치</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
}
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
const colCount = state.activeSubTab === '서버' ? 15 : (state.activeSubTab === '전산비품' ? 11 : 10);
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="${colCount}">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const formatInline = (v: any) => String(v || '').replace(/\n/g, ' / ').trim();
const getBadge = (text: string, bgColor: string) => `<span style="background:${bgColor}; color:white; font-size:10px; padding:1px 4px; border-radius:3px; font-weight:700; margin-right:4px; display:inline-block; line-height:1.2;">${text}</span>`;
if (state.activeSubTab === '서버') {
const mainManager = asset._정 || '';
const subManager = asset._부 || '';
const managerHtml = [mainManager ? `${getBadge('정', '#1E5149')} ${mainManager}` : '', subManager ? `${getBadge('부', '#9CA3AF')} ${subManager}` : ''].filter(v => v !== '').join(' / ');
const tools = (asset. || '').split('\n');
const ids = (asset.ID || '').split('\n');
const pws = (asset.PW || '').split('\n');
const maxLen = Math.max(tools.length, ids.length, pws.length);
let remoteItems = [];
for(let i=0; i<maxLen; i++) {
let toolName = tools[i] || '접속';
let badgeColor = '#3B82F6';
if (toolName.toLowerCase().includes('any')) badgeColor = '#EF4444';
if (toolName.toLowerCase().includes('chrome')) badgeColor = '#F59E0B';
let item = `${getBadge(toolName, badgeColor)}`;
if (ids[i] || pws[i]) item += ` (${ids[i] || '-'}/${pws[i] || '-'})`;
remoteItems.push(item);
}
const remoteHtml = remoteItems.join(' / ');
const ipInfo = [asset.IP주소, asset.IP2].filter(v => v && v !== '').join(' / ');
const storageInfo = [asset.SSD1, asset.SSD2].filter(v => v && v !== '').join(' / ');
tr.innerHTML = `<td>${idx+1}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.storage유형)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${managerHtml}</td><td class="text-nowrap">${formatInline(ipInfo)}</td><td class="text-nowrap">${remoteHtml}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.OS)}</td><td class="text-nowrap">${formatInline(asset.CPU)}</td><td class="text-nowrap">${formatInline(asset.RAM)}</td><td class="text-nowrap">${formatInline(storageInfo)}</td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
} else {
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td>${state.activeSubTab === '전산비품' ? `<td>${asset.||'-'}</td>` : ''}<td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
if (state.activeCategory === 'hw') {
if (tab === '개인PC') renderPcList(container);
else if (tab === '서버') renderServerList(container);
else if (tab === '스토리지') renderStorageList(container);
else if (tab === '전산비품') renderEquipmentList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
tbody.appendChild(tr);
} else if (state.activeCategory === 'sw') {
if (tab === '구독SW' || tab === '영구SW') {
renderSwList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
}
mainContent.appendChild(container);
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw }
});
} catch (err) {
console.error('❌ Error rendering table view:', err);
mainContent.innerHTML = `<div style="padding:2rem; color:var(--danger);">목록을 불러오는 중 오류가 발생했습니다: ${err.message}</div>`;
}
}
function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const fullList = state.masterData.sw.filter(a => a.type === state.activeSubTab);
const isSub = state.activeSubTab === '구독SW';
container.innerHTML = '';
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `<div class="search-item flex-1"><label>통합 검색 (제품명/부서)</label><input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off"></div><div class="search-item"><label>분야</label><select id="filter-field"><option value="">전체 분야</option><option value="업무공통">업무공통</option><option value="개발S/W">개발S/W</option><option value="디자인">디자인</option><option value="설계S/W">설계S/W</option></select></div><div class="search-item"><label>법인</label><select id="filter-corp"><option value="">전체 법인</option><option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option></select></div><button id="btn-reset-filters" class="btn btn-outline btn-reset" title="검색 조건 초기화"><i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i> 필터 초기화</button>`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
table.classList.add('sw-table');
table.innerHTML = `<thead><tr><th style="text-align:center;">No.</th><th style="text-align:center;">분야</th><th style="text-align:center;">법인</th><th style="text-align:center;">부서</th><th style="text-align:center;">제품명</th><th style="text-align:center;">구매일</th>${isSub ? '<th style="text-align:center;">구독일</th>' : ''}<th style="text-align:center;">금액</th><th style="text-align:center;">수량</th><th style="text-align:center;">사용가능</th><th style="text-align:center;">관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!;
const updateTable = () => {
const keyword = (document.getElementById('filter-keyword') as HTMLInputElement).value.toLowerCase().trim();
const field = (document.getElementById('filter-field') as HTMLSelectElement).value;
const corp = (document.getElementById('filter-corp') as HTMLSelectElement).value;
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchField = !field || asset. === field;
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchField && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.||''}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.}</td><td>${asset.||''}</td>${isSub ? `<td>${asset.||''}</td>` : ''}<td>${asset.||'0'}</td><td>${qty}</td><td><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td><td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;"><button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2" style="width:18px; height:18px;"></i></button><button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users" style="width:18px; height:18px;"></i></button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); });
tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset));
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset));
tbody.appendChild(tr);
});
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const fieldSelect = document.getElementById('filter-field') as HTMLSelectElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const resetBtn = document.getElementById('btn-reset-filters') as HTMLButtonElement;
keywordInput.addEventListener('input', updateTable);
fieldSelect.addEventListener('change', updateTable);
corpSelect.addEventListener('change', updateTable);
resetBtn.addEventListener('click', () => {
keywordInput.value = ''; fieldSelect.value = ''; corpSelect.value = '';
updateTable();
});
updateTable();
}

View File

@@ -0,0 +1,104 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
declare var Chart: any;
export function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품'];
const units = ['대', '대', '대', '개'];
const groups: any = {};
types.forEach(t => { groups[t] = { idle: [], active: [] }; });
state.masterData.hw.forEach(a => {
if (!groups[a.type]) return;
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a);
});
let usageCards = '';
types.forEach((t, i) => {
const total = groups[t].idle.length + groups[t].active.length;
const used = groups[t].active.length;
const per = total > 0 ? Math.round((used / total) * 100) : 0;
const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)';
usageCards += `
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">
${total}${units[i]}${used}${units[i]} 사용 중
</div>
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div>
<div style="width:100%; height:4px; background-color:var(--border-color); border-radius:2px; overflow:hidden; margin-top:0.75rem;">
<div style="width:${per}%; height:100%; background-color:${barColor};"></div>
</div>
</div>`;
});
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">자산 사용현황 요약</h3>
<div class="dashboard-grid">${usageCards}</div>
<h3 class="dashboard-section-title">하드웨어 보유 통계</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4>
<canvas id="chart-hw-types"></canvas>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">법인별 자산 분포</h4>
<canvas id="chart-hw-corps"></canvas>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d');
if (ctxType) {
const chart = new Chart(ctxType, {
type: 'doughnut',
data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }
});
state.activeCharts.push(chart);
}
if (ctxCorp) {
const corps = ['한맥', '삼안', '바론'];
const chart = new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ label: '보유 수량', data: corps.map(c => state.masterData.hw.filter(a => a. === c).length), backgroundColor: 'rgba(30, 81, 73, 0.7)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelectorAll('[data-action="idle"]').forEach(card => {
card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!;
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
});
});
}
function isHwIdle(a: HardwareAsset) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-';
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-';
return !a. || a..trim() === '' || a..trim() === '-';
}
function getHwAgeYears(a: HardwareAsset) {
if (!a.) return 0;
try {
const buyDate = new Date(normalizeDate(a.));
if (isNaN(buyDate.getTime())) return 0;
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
} catch { return 0; }
}

View File

@@ -0,0 +1,150 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openSwDashboardDetail, openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
declare var Chart: any;
export function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
const currentYear = new Date().getFullYear().toString();
const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
const costByCorp: Record<string, number> = { '한맥': 0, '삼안': 0, '바론': 0 };
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
state.masterData.sw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0';
const price = parseInt(priceStr, 10) || 0;
if (sw.type === '구독SW') {
subQty += qty; subUsed += assigned; subTotal++;
if (isSWExpiring(sw)) subExp++;
} else {
permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++;
}
if (sw. && sw..startsWith(currentYear)) {
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
}
});
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0;
container.innerHTML = `
<div class="view-container">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${subQty}카피 중 ${subUsed}개 할당</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${subPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${subPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div>
</div>
<div class="dashboard-card" data-action="perm-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">영구 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${permQty}카피 중 ${permUsed}개 할당</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${permPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${permPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정 (30일 이내)</span>
<div style="font-size: 1.5rem; font-weight:700; color:${subExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${subExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="perm-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정 (30일 이내)</span>
<div style="font-size: 1.5rem; font-weight:700; color:${permExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${permExp}개 제품</div>
</div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 48px; height: 48px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.875rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">${currentYear}년 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">법인별 도입 금액 (원)</h4>
<canvas id="chart-sw-corp"></canvas>
</div>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">분야별 도입 금액 (원)</h4>
<canvas id="chart-sw-cat"></canvas>
</div>
</div>
</div>
`;
setTimeout(() => {
if (typeof Chart === 'undefined') return;
const ctxCorp = (document.getElementById('chart-sw-corp') as HTMLCanvasElement)?.getContext('2d');
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp) {
const chart = new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ data: corps.map(c => costByCorp[c]), backgroundColor: 'rgba(30, 81, 73, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
if (ctxCat) {
const chart = new Chart(ctxCat, {
type: 'bar',
data: { labels: categories, datasets: [{ data: categories.map(c => costByCat[c]), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW')));
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '영구SW')));
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '구독SW' && isSWExpiring(sw))));
container.querySelector('[data-action="perm-exp"]')?.addEventListener('click', () => openSwDashboardDetail('유지보수 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '영구SW' && isSWExpiring(sw))));
}
function isSWExpiring(sw: SoftwareAsset) {
if (sw.type === '구독SW' && sw.) {
const parts = sw..split('~');
if (parts.length > 1) {
const endMs = new Date(normalizeDate(parts[1])).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}
} else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
try {
const endMs = new Date(normalizeDate(sw..split('~')[1])).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
} catch { return false; }
}
return false;
}

View File

@@ -1,11 +1,9 @@
import { state } from '../core/state';
import { HardwareAsset, SoftwareAsset } from '../core/excelHandler';
import { openDashboardDetail, openSwDashboardDetail, openSwUsageDetail } from '../components/Modal/DashboardDetailModal';
declare var Chart: any;
import { renderHwDashboard } from './Dashboard/HwDashboard';
import { renderSwDashboard } from './Dashboard/SwDashboard';
/**
* 대시보드 렌더링 메인 함수
* 대시보드 렌더링 통합 허브
*/
export function renderDashboard(mainContent: HTMLElement) {
if (!mainContent) return;
@@ -21,327 +19,9 @@ export function renderDashboard(mainContent: HTMLElement) {
if (state.activeCategory === 'hw') {
renderHwDashboard(mainContent);
} else {
} else if (state.activeCategory === 'sw') {
renderSwDashboard(mainContent);
} else {
mainContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">운영 서비스 대시보드는 준비 중입니다.</div>`;
}
}
// --- 하드웨어 대시보드 ---
function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품'];
const units = ['대', '대', '대', '개'];
const groups: any = {};
types.forEach(t => { groups[t] = { idle: [], active: [], aged: [], normal: [] }; });
state.masterData.hw.forEach(a => {
if (!groups[a.type]) return;
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a);
const ageY = getHwAgeYears(a);
const isAged = a.type === '전산비품' ? ageY >= 3 : ageY >= 5;
if (isAged) groups[a.type].aged.push(a);
else groups[a.type].normal.push(a);
});
let usageCards = '';
types.forEach((t, i) => {
const total = groups[t].idle.length + groups[t].active.length;
const used = groups[t].active.length;
const per = total > 0 ? Math.round((used / total) * 100) : 0;
const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)';
usageCards += `
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 0.5rem;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">
${total}${units[i]}${used}${units[i]} 사용 중 · 유휴 ${groups[t].idle.length}${units[i]}
</div>
<div style="display:flex; justify-content:space-between; align-items:flex-end; margin-bottom: 0.5rem;">
<div style="font-size: 2rem; font-weight:700; color:${barColor}; line-height:1;">${per}%</div>
<div style="font-size:0.75rem; color:${per >= 50 ? '#4a8220' : 'var(--dash-danger)'}; background:${per >= 50 ? 'var(--dash-light)' : '#fef2f2'}; padding:4px 8px; border-radius:4px; font-weight:500;">
${per >= 50 ? '잘 사용하고 있어요' : '점검이 필요합니다'}
</div>
</div>
<div style="width:100%; height:4px; background-color:var(--border-color); border-radius:2px; overflow:hidden; margin-top:0.25rem;">
<div style="width:${per}%; height:100%; background-color:${barColor};"></div>
</div>
</div>`;
});
let agedCards = '';
types.forEach((t, i) => {
const total = groups[t].aged.length + groups[t].normal.length;
const agedCount = groups[t].aged.length;
const agedPer = total > 0 ? Math.round((agedCount / total) * 100) : 0;
const threshold = t === '전산비품' ? '3년' : '5년';
agedCards += `
<div class="dashboard-card" data-action="aged" data-type="${t}" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 노후화 현황</span>
<span style="font-size:0.75rem; color:#bfbfbf; background:#f9f9f9; padding:2px 6px; border-radius:4px;">${threshold} 초과</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1.25rem;">
전체 ${total}${units[i]}${agedCount}${units[i]} 노후 장비
</div>
<div style="font-size: 1.5rem; font-weight:700; color:${agedCount > 0 ? 'var(--dash-danger)' : 'var(--text-main)'};">${agedCount}${units[i]}</div>
</div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${agedPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 64px; height: 64px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 1rem; color:var(--text-muted); font-weight:600;">${agedPer}%</span>
</div>
</div>
</div>`;
});
container.innerHTML = `
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem; color: var(--text-main);">사용현황</h3>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">${usageCards}</div>
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem; color: var(--text-main);">노후화 자산 비율</h3>
<div class="dashboard-layout-2col">${agedCards}</div>
`;
container.querySelectorAll('[data-action="idle"]').forEach(card => {
card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!;
openDashboardDetail(`[${t}] 유휴 자산 목록`, groups[t].idle);
});
});
container.querySelectorAll('[data-action="aged"]').forEach(card => {
card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!;
openDashboardDetail(`[${t}] 노후 장비 목록`, groups[t].aged);
});
});
}
// --- 소프트웨어 대시보드 ---
function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
const currentYear = new Date().getFullYear().toString();
const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
const costByCorp: Record<string, number> = { '한맥': 0, '삼안': 0, '바론': 0 };
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
state.masterData.sw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0';
const price = parseInt(priceStr, 10) || 0;
if (sw.type === '구독SW') {
subQty += qty; subUsed += assigned; subTotal++;
if (isSWExpiring(sw)) subExp++;
} else {
permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++;
}
if (sw. && sw..startsWith(currentYear)) {
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
}
});
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0;
container.innerHTML = `
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-usage" style="padding: 1.25rem 1.5rem; cursor:pointer;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 소프트웨어 사용정보</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${subQty}개의 제품 중 ${subUsed}개 사용 중</div>
<div style="display:flex; justify-content:space-between; align-items:flex-end;">
<div style="font-size: 2rem; font-weight:700; color:${subPer >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'};">${subPer}%</div>
</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${subPer}%; height: 100%; background-color: ${subPer >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'};"></div>
</div>
</div>
<div class="dashboard-card" data-action="perm-usage" style="padding: 1.25rem 1.5rem; cursor:pointer;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">영구 소프트웨어 사용정보</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${permQty}개의 제품 중 ${permUsed}개 사용 중</div>
<div style="display:flex; justify-content:space-between; align-items:flex-end;">
<div style="font-size: 2rem; font-weight:700; color:${permPer >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'};">${permPer}%</div>
</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${permPer}%; height: 100%; background-color: ${permPer >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'};"></div>
</div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="sub-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정</span>
<span style="font-size:0.75rem; color:#bfbfbf; background:#f9f9f9; padding:2px 6px; border-radius:4px;">30일 이내</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1.25rem;">
전체 ${subTotal}개 제품 중 ${subExp}개 만료 예정
</div>
<div style="font-size: 1.5rem; font-weight:700; color:${subExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'};">${subExp}개</div>
</div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 64px; height: 64px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 1rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="perm-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
<div style="flex:1;">
<div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정</span>
<span style="font-size:0.75rem; color:#bfbfbf; background:#f9f9f9; padding:2px 6px; border-radius:4px;">30일 이내</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1.25rem;">
전체 ${permTotal}개 제품 중 ${permExp}개 만료 예정
</div>
<div style="font-size: 1.5rem; font-weight:700; color:${permExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'};">${permExp}개</div>
</div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 64px; height: 64px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 1rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem; color: var(--text-main);">${currentYear}년 소프트웨어 도입 비용</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card" style="padding: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">법인별 도입 금액 (원)</h4>
<canvas id="chart-cost-corp" style="max-height: 250px;"></canvas>
</div>
<div class="dashboard-card" style="padding: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">분야별 도입 금액 (원)</h4>
<canvas id="chart-cost-cat" style="max-height: 250px;"></canvas>
</div>
</div>
`;
setTimeout(() => {
const ctxCorp = (document.getElementById('chart-cost-corp') as HTMLCanvasElement)?.getContext('2d');
const ctxCat = (document.getElementById('chart-cost-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp && typeof Chart !== 'undefined') {
const chartCorp = new Chart(ctxCorp, {
type: 'bar',
data: {
labels: corps,
datasets: [{
label: '도입 금액',
data: corps.map(c => costByCorp[c]),
backgroundColor: '#3b82f6',
borderRadius: 4,
barThickness: 20
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: (v: any) => v.toLocaleString() },
grid: { display: false }
},
x: { grid: { display: false } }
}
}
});
state.activeCharts.push(chartCorp);
}
if (ctxCat && typeof Chart !== 'undefined') {
const chartCat = new Chart(ctxCat, {
type: 'bar',
data: {
labels: categories,
datasets: [{
label: '도입 금액',
data: categories.map(c => costByCat[c]),
backgroundColor: '#10b981',
borderRadius: 4,
barThickness: 20
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: (v: any) => v.toLocaleString() },
grid: { display: false }
},
x: { grid: { display: false } }
}
}
});
state.activeCharts.push(chartCat);
}
}, 0);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => {
openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW'));
});
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => {
openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '영구SW'));
});
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => {
openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '구독SW' && isSWExpiring(sw)));
});
container.querySelector('[data-action="perm-exp"]')?.addEventListener('click', () => {
openSwDashboardDetail('유지보수 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '영구SW' && isSWExpiring(sw)));
});
}
function isHwIdle(a: HardwareAsset) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-';
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-';
return !a. || a..trim() === '' || a..trim() === '-';
}
function getHwAgeYears(a: HardwareAsset) {
if (!a.) return 0;
try {
const buyDate = new Date(a..replace(/\./g, '-'));
if (isNaN(buyDate.getTime())) return 0;
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
} catch { return 0; }
}
function isSWExpiring(sw: SoftwareAsset) {
if (sw.type === '구독SW' && sw.) {
const parts = sw..split('~');
if (parts.length > 1) {
const endStr = parts[1].trim();
const endMs = new Date(endStr.replace(/\./g, '-')).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}
} else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
try {
const endStr = sw..split('~')[1].trim();
const endMs = new Date(endStr.replace(/\./g, '-')).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
} catch { return false; }
}
return false;
}

View File

@@ -0,0 +1,85 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline } from '../../core/utils';
import { createIcons, RefreshCcw } from 'lucide';
export function renderEquipmentList(container: HTMLElement) {
const fullList = state.masterData.hw.filter(a => a.type === '전산비품');
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/명칭)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||'-'}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
tbody.appendChild(tr);
});
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-corp')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
(document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}

View File

@@ -0,0 +1,94 @@
import { state } from '../../core/state';
import { openPcModal } from '../../components/Modal/PCModal';
import { formatInline } from '../../core/utils';
import { createIcons, Paperclip, RefreshCcw } from 'lucide';
export function renderPcList(container: HTMLElement) {
const fullList = state.masterData.hw.filter(a => a.type === '개인PC');
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/사용자)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp">
<option value="">전체 법인</option>
${corps.map(c => `<option value="${c}">${c}</option>`).join('')}
</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>RAM</th><th>Storage</th><th>구매일</th><th>금액</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const storage = [asset.SSD1, asset.SSD2, asset.HDD1].filter(v => v).join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td>${asset.CPU||''}</td>
<td>${asset.RAM||''}</td>
<td>${formatInline(storage)}</td>
<td>${asset.||''}</td>
<td>${asset.||''}</td>
<td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset); });
tbody.appendChild(tr);
});
createIcons({ icons: { Paperclip } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-corp')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
(document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}

View File

@@ -0,0 +1,108 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge } from '../../core/utils';
import { createIcons, RefreshCcw } from 'lucide';
export function renderServerList(container: HTMLElement) {
const fullList = state.masterData.hw.filter(a => a.type === '서버');
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
const orgUnits = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산번호/조직/모델명)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<div class="search-item">
<label>현 사용조직</label>
<select id="filter-org-unit"><option value="">전체 조직</option>${orgUnits.map(o => `<option value="${o}">${o}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>현 사용조직</th><th>자산번호</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>IP주소</th><th>모델명</th><th>OS</th><th>CPU/RAM</th><th>Storage</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const orgSelect = document.getElementById('filter-org-unit') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
const matchOrg = !orgUnit || asset. === orgUnit;
return matchKeyword && matchCorp && matchOrg;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
const mainManager = asset._정 || '';
const subManager = asset._부 || '';
const managerHtml = [mainManager ? `${createBadge('정', '#1E5149')} ${mainManager}` : '', subManager ? `${createBadge('부', '#9CA3AF')} ${subManager}` : ''].filter(v => v !== '').join(' / ');
const ipInfo = [asset.IP주소, asset.IP2].filter(v => v).join(' / ');
const cpuRam = [asset.CPU, asset.RAM].filter(v => v).join(' / ');
const storage = [asset.SSD1, asset.SSD2].filter(v => v).join(' / ');
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${managerHtml}</td>
<td>${formatInline(ipInfo)}</td>
<td>${asset.||''}</td>
<td>${asset.OS||''}</td>
<td>${formatInline(cpuRam)}</td>
<td>${formatInline(storage)}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
tbody.appendChild(tr);
});
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-corp')?.addEventListener('change', updateTable);
document.getElementById('filter-org-unit')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
(document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = '';
(document.getElementById('filter-org-unit') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}

View File

@@ -0,0 +1,86 @@
import { state } from '../../core/state';
import { openStorageModal } from '../../components/Modal/StorageModal';
import { formatInline } from '../../core/utils';
import { createIcons, RefreshCcw } from 'lucide';
export function renderStorageList(container: HTMLElement) {
const fullList = state.masterData.hw.filter(a => a.type === '스토리지');
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
const corps = Array.from(new Set(fullList.map(a => a.))).filter(Boolean).sort();
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (자산코드/명칭/모델명)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp"><option value="">전체 법인</option>${corps.map(c => `<option value="${c}">${c}</option>`).join('')}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>모델명</th><th>용량</th><th>IP주소</th><th>구매일</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword) || String(asset.||'').toLowerCase().includes(keyword);
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${idx+1}</td>
<td>${asset.}</td>
<td>${asset.storage유형||''}</td>
<td>${asset.}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td>
<td>${asset.||''}</td>
<td>${asset.IP주소||''}</td>
<td>${asset.||''}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openStorageModal(asset); });
tbody.appendChild(tr);
});
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-corp')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
(document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}

View File

@@ -0,0 +1,131 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { openSwUserModal } from '../../components/Modal/SWUserModal';
import { createIcons, Edit2, Users, RefreshCcw } from 'lucide';
export function renderSwList(container: HTMLElement) {
const fullList = state.masterData.sw.filter(a => a.type === state.activeSubTab);
const isSub = state.activeSubTab === '구독SW';
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (제품명/부서)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
<label>분야</label>
<select id="filter-field">
<option value="">전체 분야</option>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="search-item">
<label>법인</label>
<select id="filter-corp">
<option value="">전체 법인</option>
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">분야</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">부서</th>
<th style="text-align:center;">제품명</th>
<th style="text-align:center;">구매일</th>
${isSub ? '<th style="text-align:center;">구독일</th>' : ''}
<th style="text-align:center;">금액</th>
<th style="text-align:center;">수량</th>
<th style="text-align:center;">사용가능</th>
<th style="text-align:center;">관리</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const fieldSelect = document.getElementById('filter-field') as HTMLSelectElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const field = fieldSelect ? fieldSelect.value : '';
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchField = !field || asset. === field;
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchField && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td style="text-align:center;">${asset.||''}</td>
${isSub ? `<td style="text-align:center;">${asset.||''}</td>` : ''}
<td style="text-align:right;">${asset.||'0'}</td>
<td style="text-align:center;">${qty}</td>
<td style="text-align:center;"><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td>
<td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;">
<button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2"></i></button>
<button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users"></i></button>
</td>
`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); });
tr.querySelector('.btn-edit')?.addEventListener('click', (e) => { e.stopPropagation(); openSwModal(asset); });
tr.querySelector('.btn-users')?.addEventListener('click', (e) => { e.stopPropagation(); openSwUserModal(asset); });
tbody.appendChild(tr);
});
createIcons({ icons: { Edit2, Users } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);
document.getElementById('filter-field')?.addEventListener('change', updateTable);
document.getElementById('filter-corp')?.addEventListener('change', updateTable);
document.getElementById('btn-reset-filters')?.addEventListener('click', () => {
(document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-field') as HTMLSelectElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = '';
updateTable();
});
updateTable();
}