4 Commits

Author SHA1 Message Date
5ff991693a feat: 하드웨어 대시보드 노후도 중심 개편 및 자산 연령 계산 유틸리티 추가 2026-04-21 16:59:21 +09:00
a576d54a2d backup: stable baseline before hardware dashboard revamp 2026-04-21 16:35:29 +09:00
d983ad469f feat: SW 통합 모달 구현 및 대시보드 자산 추가 기능 고도화
- SW 모달(구독, 영구, 클라우드) 통합 및 레이아웃 최적화
- 모든 자산 상세 모달에 '조회/수정 모드' 전환 로직(Edit Lock) 적용
- 하드웨어/소프트웨어 대시보드에서 '자산 추가' 버튼 연동 및 기본값 설정
- 클라우드 자산 리스트의 데이터 소스를 DB 직결(cloud_assets) 방식으로 변경
- 클라우드 자산 저장 API 연동 및 불필요한 구형 모달(CloudModal) 제거
- 리스트 뷰에서 상세 보기 시 '조회 모드'로 열리도록 호출 로직 수정
2026-04-21 11:37:13 +09:00
153e422180 feat: 자산 관리 시스템 고도화 및 데이터 구조 최적화
- 모바일 자산(Mobile) 카테고리 추가 및 엑셀 업로드/다운로드 지원
- 클라우드 자산(Cloud) 및 변경 이력(Logs) 테이블 및 API 구현
- 데이터베이스 초기화 로직 개선 및 테이블 자동 생성 기능 추가
- 하드웨어 저장 로직 통합 및 카테고리 판별 자동화
- SW 대시보드 사용량 산출 방식 개선 (sw_id 기반 맵핑)
- 수동 모달(Storage)을 통합 하드웨어 모달(HWModal)로 통합 및 정리
2026-04-21 10:30:05 +09:00
12 changed files with 802 additions and 520 deletions

View File

@@ -18,7 +18,10 @@ async function initDB() {
console.log('🔄 DB 초기화 시작 (표준화 스키마 적용)...'); console.log('🔄 DB 초기화 시작 (표준화 스키마 적용)...');
// 기존 테이블 삭제 // 기존 테이블 삭제
const tablesToDrop = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets', 'sw_sub_assets', 'sw_perm_assets', 'sw_users']; const tablesToDrop = [
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
];
for (const table of tablesToDrop) { for (const table of tablesToDrop) {
await connection.query(`DROP TABLE IF EXISTS ${table}`); await connection.query(`DROP TABLE IF EXISTS ${table}`);
} }
@@ -65,7 +68,7 @@ async function initDB() {
await connection.query(createHardwareTable('mobile_assets', '모바일기기 자산')); await connection.query(createHardwareTable('mobile_assets', '모바일기기 자산'));
// 소프트웨어 구독 테이블 // 소프트웨어 구독 테이블
const createSubSwTable = ` await connection.query(`
CREATE TABLE sw_sub_assets ( CREATE TABLE sw_sub_assets (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인', corp VARCHAR(100) COMMENT '구매법인',
@@ -80,10 +83,10 @@ async function initDB() {
remarks TEXT COMMENT '비고', remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `);
// 소프트웨어 영구 테이블 // 소프트웨어 영구 테이블
const createPermSwTable = ` await connection.query(`
CREATE TABLE sw_perm_assets ( CREATE TABLE sw_perm_assets (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인', corp VARCHAR(100) COMMENT '구매법인',
@@ -97,10 +100,28 @@ async function initDB() {
remarks TEXT COMMENT '비고', remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `);
// 클라우드 자산 테이블
await connection.query(`
CREATE TABLE cloud_assets (
id VARCHAR(50) PRIMARY KEY,
platform_name VARCHAR(100),
corp VARCHAR(100),
dept VARCHAR(100),
product_name VARCHAR(255),
account_name VARCHAR(255),
pay_method VARCHAR(100),
pay_day VARCHAR(50),
card_num VARCHAR(100),
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
// 소프트웨어 사용자 매핑 테이블 // 소프트웨어 사용자 매핑 테이블
const createSwUsersTable = ` await connection.query(`
CREATE TABLE sw_users ( CREATE TABLE sw_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
sw_id VARCHAR(50) COMMENT 'SW 자산 ID', sw_id VARCHAR(50) COMMENT 'SW 자산 ID',
@@ -112,11 +133,19 @@ async function initDB() {
doc_name VARCHAR(255) COMMENT '신청서명', doc_name VARCHAR(255) COMMENT '신청서명',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `);
await connection.query(createSubSwTable); // 변경 이력 테이블
await connection.query(createPermSwTable); await connection.query(`
await connection.query(createSwUsersTable); CREATE TABLE asset_logs (
id VARCHAR(50) PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`);
console.log('✅ 모든 테이블이 표준화된 스키마로 재생성되었습니다.'); console.log('✅ 모든 테이블이 표준화된 스키마로 재생성되었습니다.');
await connection.end(); await connection.end();

249
server.js
View File

@@ -22,6 +22,42 @@ const pool = mysql.createPool({
queueLimit: 0 queueLimit: 0
}); });
// 테이블 존재 여부 확인 및 자동 생성
async function ensureTables() {
const connection = await pool.getConnection();
try {
await connection.query(`
CREATE TABLE IF NOT EXISTS cloud_assets (
id VARCHAR(50) PRIMARY KEY,
platform_name VARCHAR(100),
corp VARCHAR(100),
dept VARCHAR(100),
product_name VARCHAR(255),
account_name VARCHAR(255),
pay_method VARCHAR(100),
pay_day VARCHAR(50),
card_num VARCHAR(100),
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS asset_logs (
id VARCHAR(50) PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ Cloud & Logs tables ensured.');
} finally {
connection.release();
}
}
// 공통 배치 저장 로직 // 공통 배치 저장 로직
async function batchSave(tableName, assets, getQuery) { async function batchSave(tableName, assets, getQuery) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -42,81 +78,45 @@ async function batchSave(tableName, assets, getQuery) {
} }
} }
// 공통 하드웨어 매핑 함수 // 하드웨어 쿼리 헬퍼
const mapHardware = (r, defaultType) => ({ const hardwareInsertSQL = (table) => `
id: r.id, INSERT INTO ${table} (
법인: r.corp, id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
자산코드: r.asset_code, current_org, prev_org, location, manager_main, manager_sub, ip_address,
구매일: r.purchase_date, remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu,
purchase_date: r.purchase_date, storage1, storage2, storage3, monitoring, price, remarks
type: r.type || defaultType, ) VALUES ?
상세용도: r.detail_purpose, `;
detail_purpose: r.detail_purpose,
용도: r.purpose,
purpose: r.purpose,
상세: r.details,
details: r.details,
현사용조직: r.current_org,
current_org: r.current_org,
이전사용조직: r.prev_org,
prev_org: r.prev_org,
위치: r.location,
location: r.location,
담당자_정: r.manager_main,
manager_main: r.manager_main,
담당자_부: r.manager_sub,
manager_sub: r.manager_sub,
IP주소: r.ip_address,
ip_address: r.ip_address,
원격접속: r.remote_tool,
remote_tool: r.remote_tool,
서버ID: r.server_id,
server_id: r.server_id,
서버PW: r.server_pw,
server_pw: r.server_pw,
모델명: r.model_name,
model_name: r.model_name,
OS: r.os,
os: r.os,
CPU: r.cpu,
cpu: r.cpu,
RAM: r.ram,
ram: r.ram,
GPU: r.gpu,
gpu: r.gpu,
SSD1: r.storage1,
storage1: r.storage1,
SSD2: r.storage2,
storage2: r.storage2,
HDD1: r.storage3,
storage3: r.storage3,
모니터링: r.monitoring,
monitoring: r.monitoring,
금액: r.price,
price: r.price,
비고: r.remarks,
remarks: r.remarks
});
// 공통 하드웨어 저장 값 생성 함수
const getHardwareValues = (a) => [ const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'', a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.담당자_정||'', a.담당자_부||'', a.IP주소||'', a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'' a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||''
]; ];
const hardwareInsertSQL = (table) => ` const mapHardware = (r, defaultType) => ({
INSERT INTO ${table} id: r.id, 법인: r.corp, 자산코드: r.asset_code, 구매일: r.purchase_date, type: r.type || defaultType,
(id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, current_org, prev_org, location, manager_main, manager_sub, ip_address, remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, remarks) 상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org,
VALUES ? 이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub,
`; IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw,
모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1,
SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks
});
// --- 1. 개인PC API --- // --- API 라우트 정의 ---
// PC API
app.get('/api/pc', async (req, res) => { app.get('/api/pc', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM pc_assets'); const [rows] = await pool.query('SELECT * FROM pc_assets');
res.json(rows.map(r => mapHardware(r, 'PC'))); console.log('🔍 DB Raw Rows (PC):', rows.length, 'items found.');
} catch (err) { res.status(500).json({ error: err.message }); } if (rows.length > 0) console.log('🔍 First row sample:', rows[0]);
res.json(rows.map(r => mapHardware(r, '개인PC')));
} catch (err) {
console.error('❌ DB Query Error (PC):', err.message);
res.status(500).json({ error: err.message });
}
}); });
app.post('/api/pc/batch', async (req, res) => { app.post('/api/pc/batch', async (req, res) => {
@@ -129,7 +129,7 @@ app.post('/api/pc/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 2. 서버 API --- // 서버 API
app.get('/api/server', async (req, res) => { app.get('/api/server', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM server_assets'); const [rows] = await pool.query('SELECT * FROM server_assets');
@@ -147,7 +147,7 @@ app.post('/api/server/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 3. 스토리지 API --- // 스토리지 API
app.get('/api/storage', async (req, res) => { app.get('/api/storage', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM storage_assets'); const [rows] = await pool.query('SELECT * FROM storage_assets');
@@ -165,7 +165,7 @@ app.post('/api/storage/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 4. 전산비품 API --- // 전산비품 API
app.get('/api/equip', async (req, res) => { app.get('/api/equip', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM equip_assets'); const [rows] = await pool.query('SELECT * FROM equip_assets');
@@ -183,7 +183,7 @@ app.post('/api/equip/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 5. 모바일기기 API --- // 모바일 API
app.get('/api/mobile', async (req, res) => { app.get('/api/mobile', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM mobile_assets'); const [rows] = await pool.query('SELECT * FROM mobile_assets');
@@ -201,16 +201,15 @@ app.post('/api/mobile/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 6. 소프트웨어 구독 API --- // 구독 SW API
app.get('/api/sw/sub', async (req, res) => { app.get('/api/sw/sub', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM sw_sub_assets'); const [rows] = await pool.query('SELECT * FROM sw_sub_assets');
const mapped = rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name,
라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date,
만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks 만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks
})); })));
res.json(mapped);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -224,16 +223,15 @@ app.post('/api/sw/sub/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 7. 소프트웨어 영구 API --- // 영구 SW API
app.get('/api/sw/perm', async (req, res) => { app.get('/api/sw/perm', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM sw_perm_assets'); const [rows] = await pool.query('SELECT * FROM sw_perm_assets');
const mapped = rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date,
납품업체: r.vendor, 비고: r.remarks 납품업체: r.vendor, 비고: r.remarks
})); })));
res.json(mapped);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -247,15 +245,58 @@ app.post('/api/sw/perm/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// --- 8. 소프트웨어 사용자 관리 API --- // 클라우드 API
app.get('/api/cloud', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM cloud_assets');
res.json(rows.map(r => ({
id: r.id, type: '클라우드', 플랫폼명: r.platform_name, 법인: r.corp, 부서: r.dept,
제품명: r.product_name, 계정명: r.account_name, 결제수단: r.pay_method,
결제일: r.pay_day, 연결카드번호: r.card_num, 당월청구액: r.monthly_fee, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/cloud/batch', async (req, res) => {
try {
const result = await batchSave('cloud_assets', req.body, (assets) => ({
sql: `INSERT INTO cloud_assets (id, platform_name, corp, dept, product_name, account_name, pay_method, pay_day, card_num, monthly_fee, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.플랫폼명||'', a.법인||'', a.부서||'', a.제품명||'', a.계정명||'', a.결제수단||'', a.결제일||'', a.연결카드번호||'', a.당월청구액||'', a.비고||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 로그 API
app.get('/api/logs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC');
res.json(rows.map(r => ({
id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/logs/batch', async (req, res) => {
try {
const result = await batchSave('asset_logs', req.body, (assets) => ({
sql: `INSERT INTO asset_logs (id, asset_id, log_date, log_user, details) VALUES ?`,
values: assets.map(a => [a.id, a.assetId||'', a.date||'', a.user||'', a.details||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// SW 사용자 API
app.get('/api/sw-users', async (req, res) => { app.get('/api/sw-users', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM sw_users'); const [rows] = await pool.query('SELECT * FROM sw_users');
const result = rows.map(u => ({ const grouped = rows.reduce((acc, u) => {
sw_id: u.sw_id, if (!acc[u.sw_id]) acc[u.sw_id] = [];
userData: [u.corp||'', u.dept||'', u.position||'', u.user_name||'', u.usage_period||'', u.doc_name||''] acc[u.sw_id].push([u.corp, u.dept, u.position, u.user_name, u.usage_period, u.doc_name]);
})); return acc;
res.json(result); }, {});
res.json(Object.keys(grouped).map(sw_id => ({ sw_id, userData: grouped[sw_id] })));
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -267,7 +308,7 @@ app.post('/api/sw-users/batch', async (req, res) => {
const allUsers = req.body; const allUsers = req.body;
if (allUsers.length > 0) { if (allUsers.length > 0) {
const values = allUsers.flatMap(item => const values = allUsers.flatMap(item =>
item.userDataList.map(u => [item.sw_id, u.구매법인||u.법인||'', u.부서||'', u.직위||'', u.이름||'', u.사용기간||'', u.신청서명||'']) (item.userData || []).map(u => [item.sw_id, u[0], u[1], u[2], u[3], u[4], u[5]])
); );
if (values.length > 0) { if (values.length > 0) {
await connection.query('INSERT INTO sw_users (sw_id, corp, dept, position, user_name, usage_period, doc_name) VALUES ?', [values]); await connection.query('INSERT INTO sw_users (sw_id, corp, dept, position, user_name, usage_period, doc_name) VALUES ?', [values]);
@@ -279,35 +320,11 @@ app.post('/api/sw-users/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 자산번호 자동 생성 API // 초기화 및 서버 기동
app.get('/api/generate-asset-code', async (req, res) => { ensureTables().then(() => {
const { prefix } = req.query; app.listen(PORT, () => {
if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`);
});
try { }).catch(err => {
const tables = ['server_assets', 'pc_assets', 'storage_assets', 'equip_assets', 'mobile_assets']; console.error('❌ Failed to start server:', err);
let maxNum = 0;
for (const table of tables) {
const [rows] = await pool.query(
`SELECT asset_code as 자산코드 FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`,
[`${prefix}%`]
);
if (rows.length > 0) {
const lastCode = rows[0].자산코드;
const lastNum = parseInt(lastCode.split('-').pop() || '0');
if (lastNum > maxNum) maxNum = lastNum;
}
}
const nextNum = String(maxNum + 1).padStart(3, '0');
res.json({ nextCode: `${prefix}${nextNum}` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.listen(PORT, () => {
console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`);
}); });

View File

@@ -2,9 +2,14 @@ import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler'; import { SoftwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal'; import { openModal, closeModals } from './BaseModal';
import { openSwUserModal } from './SWUserModal'; import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X } from 'lucide'; import { createIcons, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils'; import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
setEditLock
} from './ModalUtils';
let currentSwAsset: SoftwareAsset | null = null; let currentSwAsset: SoftwareAsset | null = null;
let isEditMode = false; let isEditMode = false;
@@ -14,7 +19,7 @@ const SW_MODAL_HTML = `
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2> <h2 id="sw-modal-title">소프트웨어 상세 정보</h2>
<button id="btn-close-sw-modal" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-body-split"> <div class="modal-body-split">
@@ -23,48 +28,85 @@ const SW_MODAL_HTML = `
<input type="hidden" id="sw-asset-id" /> <input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" /> <input type="hidden" id="sw-asset-type" />
<div class="form-section-title">기본 정보 (Basic Info)</div> <!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group"> <div class="form-group">
<label for="sw-법인">구매법인</label> <label for="sw-법인">구매법인</label>
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select> <select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group sw-standard-field">
<label for="sw-자산번호">자산번호</label> <label for="sw-자산번호">자산번호</label>
<input type="text" id="sw-자산번호" readonly placeholder="자동 생성" /> <input type="text" id="sw-자산번호" readonly placeholder="자동 생성" />
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="sw-제품명">제품명</label> <label for="sw-제품명">제품명 / 서비스명</label>
<input type="text" id="sw-제품명" required /> <input type="text" id="sw-제품명" required />
</div> </div>
<div class="form-group cloud-only">
<label for="sw-플랫폼명">플랫폼명</label>
<input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" />
</div>
<div class="form-group cloud-only">
<label for="sw-부서">담당부서</label>
<input type="text" id="sw-부서" />
</div>
<div class="form-section-title">라이선스 정보 (License)</div> <!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-group" id="sw-license-type-group"> <div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field" id="sw-license-type-group">
<label for="sw-라이선스유형">라이선스 유형</label> <label for="sw-라이선스유형">라이선스 유형</label>
<input type="text" id="sw-라이선스유형" /> <input type="text" id="sw-라이선스유형" />
</div> </div>
<div class="form-group" id="sw-license-key-group"> <div class="form-group sw-standard-field" id="sw-license-key-group">
<label for="sw-라이선스키">라이선스 키</label> <label for="sw-라이선스키">라이선스 키</label>
<input type="text" id="sw-라이선스키" /> <input type="text" id="sw-라이선스키" />
</div> </div>
<div class="form-group"> <div class="form-group sw-standard-field">
<label for="sw-수량">보유 수량</label> <label for="sw-수량">보유 수량</label>
<input type="number" id="sw-수량" min="0" /> <input type="number" id="sw-수량" min="0" />
</div> </div>
<div class="form-group"> <div class="form-group sw-standard-field">
<label for="sw-금액">도입 금액</label> <label for="sw-금액">도입 금액</label>
<input type="text" id="sw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" /> <input type="text" id="sw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div> </div>
<div class="form-section-title">구매 및 계약 (Purchase)</div> <!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group"> <div class="form-group cloud-only">
<label for="sw-계정명">계정명 (이메일)</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제수단">결제수단</label>
<select id="sw-결제수단">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group cloud-only">
<label for="sw-연결카드번호">연결카드번호(뒷4자리)</label>
<input type="text" id="sw-연결카드번호" maxlength="4" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제일">결제일 (기준일)</label>
<input type="number" id="sw-결제일" min="1" max="31" />
</div>
<div class="form-group cloud-only">
<label for="sw-당월청구액">당월 청구액(원)</label>
<input type="text" id="sw-당월청구액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label for="sw-구매일">구매일</label> <label for="sw-구매일">구매일</label>
<input type="text" id="sw-구매일" /> <input type="text" id="sw-구매일" />
</div> </div>
<div class="form-group" id="sw-expiry-group"> <div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">만료일 (구독)</label> <label for="sw-만료일">만료일 (구독)</label>
<input type="text" id="sw-만료일" /> <input type="text" id="sw-만료일" />
</div> </div>
<div class="form-group"> <div class="form-group sw-standard-field">
<label for="sw-납품업체">납품업체</label> <label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" /> <input type="text" id="sw-납품업체" />
</div> </div>
@@ -74,22 +116,23 @@ const SW_MODAL_HTML = `
</div> </div>
</form> </form>
<div class="user-management-section" style="margin-top: 2rem;"> <div id="sw-user-section" class="user-management-section" style="margin-top: 2rem;">
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;"> <div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3> <h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm"> <button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
할당 관리 <i data-lucide="plus" style="width:14px; height:14px;"></i> 할당 관리 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button> </button>
</div> </div>
<div id="sw-assigned-users-summary" class="user-summary-grid"> <div id="sw-assigned-users-summary" class="user-summary-grid"></div>
<!-- User summary list -->
</div>
</div> </div>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header"> <div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3> <h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<button type="button" id="btn-add-sw-log" class="btn btn-outline btn-sm cloud-only">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div> </div>
<div id="sw-history-list" class="history-timeline"></div> <div id="sw-history-list" class="history-timeline"></div>
</div> </div>
@@ -105,39 +148,95 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
</div> </div>
<!-- 클라우드 이력 추가를 위한 간이 모달 -->
<div id="sw-log-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>업데이트 내역 추가</h2>
<button id="btn-close-sw-log" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>날짜</label>
<input type="date" id="new-log-date" />
</div>
<div class="form-group">
<label>상세 내용</label>
<textarea id="new-log-details" rows="3" placeholder="예: 결제 금액 변동, 담당자 변경 등"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-log" class="btn btn-outline">취소</button>
<button id="btn-confirm-sw-log" class="btn btn-primary">추가</button>
</div>
</div>
</div>
</div>
`; `;
function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const keyGroup = document.getElementById('sw-license-key-group');
const typeGroup = document.getElementById('sw-license-type-group');
const expiryGroup = document.getElementById('sw-expiry-group');
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '구독SW') {
if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex';
if (expiryGroup) expiryGroup.style.display = 'flex';
} else {
if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'none';
}
}
}
function fillSwFormData(asset: SoftwareAsset) { function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-asset-id', asset.id); setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.type); setFieldValue('sw-asset-type', asset.type);
setFieldValue('sw-법인', asset.); setFieldValue('sw-법인', asset.);
setFieldValue('sw-자산번호', asset.); setFieldValue('sw-자산번호', asset. || '');
setFieldValue('sw-제품명', asset.); setFieldValue('sw-제품명', asset.);
setFieldValue('sw-수량', asset.); setFieldValue('sw-수량', asset.);
setFieldValue('sw-금액', asset.); setFieldValue('sw-금액', asset.);
setFieldValue('sw-구매일', asset.); setFieldValue('sw-구매일', asset. || '');
setFieldValue('sw-납품업체', asset.); setFieldValue('sw-납품업체', asset. || '');
setFieldValue('sw-비고', asset.); setFieldValue('sw-비고', asset. || '');
const type = asset.type; if (asset.type === '클라우드') {
const keyGroup = document.getElementById('sw-license-key-group'); setFieldValue('sw-플랫폼명', (asset as any). || '');
const typeGroup = document.getElementById('sw-license-type-group'); setFieldValue('sw-부서', (asset as any). || '');
const expiryGroup = document.getElementById('sw-expiry-group'); setFieldValue('sw-계정명', (asset as any). || '');
setFieldValue('sw-결제수단', (asset as any). || '');
if (type === '구독SW') { setFieldValue('sw-연결카드번호', (asset as any). || '');
if (keyGroup) keyGroup.style.display = 'none'; setFieldValue('sw-결제일', (asset as any). || '');
if (typeGroup) typeGroup.style.display = 'flex'; setFieldValue('sw-당월청구액', (asset as any). || '');
if (expiryGroup) expiryGroup.style.display = 'flex'; } else if (asset.type === '구독SW') {
setFieldValue('sw-라이선스유형', (asset as any). || ''); setFieldValue('sw-라이선스유형', (asset as any). || '');
setFieldValue('sw-만료일', (asset as any). || ''); setFieldValue('sw-만료일', (asset as any). || '');
} else { } else {
if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'none';
setFieldValue('sw-라이선스키', (asset as any). || ''); setFieldValue('sw-라이선스키', (asset as any). || '');
} }
renderUserSummary(asset.id); renderUserSummary(asset.id);
renderSwHistory(asset.id);
} }
function renderUserSummary(swId: string) { function renderUserSummary(swId: string) {
@@ -161,7 +260,7 @@ function renderSwHistory(swId: string) {
if (!container) return; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId); const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) { if (logs.length === 0) {
container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>';
return; return;
} }
container.innerHTML = logs.map(l => ` container.innerHTML = logs.map(l => `
@@ -173,32 +272,21 @@ function renderSwHistory(swId: string) {
`).join(''); `).join('');
} }
export function openSwModal(asset: SoftwareAsset) { export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' = 'view') {
currentSwAsset = asset; currentSwAsset = asset;
const modal = document.getElementById('sw-asset-modal')!; const modal = document.getElementById('sw-asset-modal')!;
const form = document.getElementById('sw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
form.reset(); // 수정 잠금 상태 제어
const isNew = !asset.; setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
if (isNew) { revertBtnId: 'btn-revert-sw-edit'
isEditMode = true; });
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.add('hidden');
} else {
isEditMode = false;
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
}
isEditMode = (mode === 'add');
fillSwFormData(asset); fillSwFormData(asset);
renderSwHistory(asset.id); applySwTypeUI(asset.type);
modal.classList.remove('hidden'); modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } }); createIcons({ icons: { X, History, Plus } });
} }
@@ -213,26 +301,29 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
const revertBtn = document.getElementById('btn-revert-sw-edit')!; const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const userUpdateBtn = document.getElementById('btn-open-sw-update')!; const userUpdateBtn = document.getElementById('btn-open-sw-update')!;
const logAddBtn = document.getElementById('btn-add-sw-log')!;
const closeModalAction = () => { closeModals(); isEditMode = false; }; const closeModalAction = () => { closeModals(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false; isEditMode = false;
form.classList.replace('is-edit-mode', 'is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentSwAsset) fillSwFormData(currentSwAsset); if (currentSwAsset) fillSwFormData(currentSwAsset);
}); });
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
if (!currentSwAsset) return; if (!currentSwAsset) return;
if (!isEditMode) { if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = true; isEditMode = true;
form.classList.replace('is-view-mode', 'is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.remove('hidden');
return; return;
} }
@@ -250,23 +341,37 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
type: type type: type
}; };
if (type === '구독SW') { if (type === '클라우드') {
updated. = getFieldValue('sw-플랫폼명');
updated. = getFieldValue('sw-부서');
updated. = getFieldValue('sw-계정명');
updated. = getFieldValue('sw-결제수단');
updated. = getFieldValue('sw-연결카드번호');
updated. = getFieldValue('sw-결제일');
updated. = getFieldValue('sw-당월청구액');
} else if (type === '구독SW') {
updated. = getFieldValue('sw-라이선스유형'); updated. = getFieldValue('sw-라이선스유형');
updated. = getFieldValue('sw-만료일'); updated. = getFieldValue('sw-만료일');
} else { } else {
updated. = getFieldValue('sw-라이선스키'); updated. = getFieldValue('sw-라이선스키');
} }
const targetList = type === '구독SW' ? state.masterData.subSw : state.masterData.permSw; // 데이터 저장 로직 (state 업데이트)
let targetList: SoftwareAsset[] = [];
if (type === '구독SW') targetList = state.masterData.subSw;
else if (type === '영구SW') targetList = state.masterData.permSw;
else if (type === '클라우드') targetList = state.masterData.cloud;
const idx = targetList.findIndex(a => a.id === updated.id); const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated; if (idx > -1) targetList[idx] = updated;
else targetList.push(updated); else targetList.push(updated);
onSave(); onSave();
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false; isEditMode = false;
form.classList.replace('is-edit-mode', 'is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
}); });
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
@@ -274,7 +379,8 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
if (confirm('삭제하시겠습니까?')) { if (confirm('삭제하시겠습니까?')) {
const type = currentSwAsset.type; const type = currentSwAsset.type;
if (type === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== currentSwAsset!.id); if (type === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== currentSwAsset!.id);
else state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== currentSwAsset!.id); else if (type === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== currentSwAsset!.id);
else if (type === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== currentSwAsset!.id);
onSave(); onSave();
closeModalAction(); closeModalAction();
} }
@@ -283,4 +389,36 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
userUpdateBtn.addEventListener('click', () => { userUpdateBtn.addEventListener('click', () => {
if (currentSwAsset) openSwUserModal(currentSwAsset); if (currentSwAsset) openSwUserModal(currentSwAsset);
}); });
// 이력 추가 모달 로직
const logModal = document.getElementById('sw-log-modal')!;
logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden');
(document.getElementById('new-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-log-details') as HTMLTextAreaElement).value = '';
});
document.getElementById('btn-close-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-sw-log')?.addEventListener('click', () => {
if (!currentSwAsset) return;
const date = (document.getElementById('new-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-log-details') as HTMLTextAreaElement).value;
if (!date || !details) { alert('날짜와 내용을 입력해주세요.'); return; }
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: currentSwAsset.id,
date,
user: '관리자',
details
});
logModal.classList.add('hidden');
renderSwHistory(currentSwAsset.id);
});
} }

View File

@@ -20,15 +20,20 @@ function randUser() { // 25% 확률로 유휴자산 할당
} }
export function generateDummyData(): MasterAssetData { export function generateDummyData(): MasterAssetData {
const hw: HardwareAsset[] = []; const pc: HardwareAsset[] = [];
const sw: SoftwareAsset[] = []; const server: HardwareAsset[] = [];
const swUsers: SWUser[] = []; const storage: HardwareAsset[] = [];
const equip: HardwareAsset[] = [];
const mobile: HardwareAsset[] = [];
const subSw: SoftwareAsset[] = [];
const permSw: SoftwareAsset[] = [];
const swUsers: any[] = [];
const logs: any[] = []; const logs: any[] = [];
// 1. 개인PC 50개 // 1. 개인PC 50개
for (let i = 1; i <= 50; i++) { for (let i = 1; i <= 50; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026 const purchaseYear = Math.floor(Math.random() * 10) + 2017;
hw.push({ pc.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '개인PC', type: '개인PC',
법인: rand(corps), 법인: rand(corps),
@@ -53,8 +58,8 @@ export function generateDummyData(): MasterAssetData {
// 2. 서버 20개 // 2. 서버 20개
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026 const purchaseYear = Math.floor(Math.random() * 10) + 2017;
hw.push({ server.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '서버', type: '서버',
법인: rand(corps), 법인: rand(corps),
@@ -87,10 +92,10 @@ export function generateDummyData(): MasterAssetData {
}); });
} }
// 3. 스토리지 20개 // 3. 스토리지 10개
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 10; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026 const purchaseYear = Math.floor(Math.random() * 10) + 2017;
hw.push({ storage.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '스토리지', type: '스토리지',
법인: rand(corps), 법인: rand(corps),
@@ -112,168 +117,84 @@ export function generateDummyData(): MasterAssetData {
}); });
} }
// 4. 전산비품 (노트북, 태블릿, 휴대폰 각각 5개씩) // 4. 전산비품 15개
const equips = [ for (let i = 1; i <= 15; i++) {
{ type: '노트북', code: 'NB', name: 'LG 그램 16인치', price: '1,800,000' }, const purchaseYear = Math.floor(Math.random() * 8) + 2019;
{ type: '태블릿', code: 'TB', name: '아이패드 프로 12.9', price: '1,500,000' }, equip.push({
{ type: '휴대폰', code: 'PH', name: '갤럭시 S24', price: '1,200,000' } id: Math.random().toString(36).substring(2, 9),
]; type: '전산비품',
equips.forEach((eq) => { 법인: rand(corps),
for (let i = 1; i <= 5; i++) { 비품유형: rand(['프린터', '모니터', 'UPS']),
const purchaseYear = Math.floor(Math.random() * 8) + 2019; // 2019~2026 : `HM-EQ-${purchaseYear}-${String(i).padStart(3, '0')}`,
hw.push({ : `비품 #${i}`,
id: Math.random().toString(36).substring(2, 9), 위치: rand(['본사', '지사']),
type: '전산비품', 관리자: randUser(),
법인: rand(corps), 구매일: randDate(purchaseYear, purchaseYear),
비품유형: eq.type, : '300,000',
: `HM-${eq.code}-${purchaseYear}-${String(i).padStart(3, '0')}`, : '오피스공구',
명칭: eq.name, : '',
위치: rand(['본사', '지사']), IP주소: '', MACaddress: '', OS: '', HW사양: ''
관리자: randUser(), });
구매일: randDate(purchaseYear, purchaseYear), }
금액: eq.price,
: '브랜드 총판',
: '',
IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
});
// 5. 구독형 S/W 40개 // 5. 모바일기기 10개
for (let i = 1; i <= 40; i++) { for (let i = 1; i <= 10; i++) {
const purchaseYear = Math.floor(Math.random() * 5) + 2022;
mobile.push({
id: Math.random().toString(36).substring(2, 9),
type: '모바일기기',
법인: rand(corps),
: `HM-MO-${purchaseYear}-${String(i).padStart(3, '0')}`,
명칭: rand(['아이폰 15', '갤럭시 S24', '아이패드 에어']),
: '개인 지급',
관리자: randUser(),
OS: rand(['iOS', 'Android', 'iPadOS']),
구매일: randDate(purchaseYear, purchaseYear),
: '1,200,000',
: '통신사',
: '',
IP주소: '', MACaddress: '', HW사양: '', : ''
});
}
// 6. 구독 SW 20개
for (let i = 1; i <= 20; i++) {
const swId = Math.random().toString(36).substring(2, 9); const swId = Math.random().toString(36).substring(2, 9);
const purchaseYear = Math.random() < 0.3 ? 2026 : 2024; subSw.push({
let isExpiring = Math.random() < 0.25;
let endDt = new Date();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
sw.push({
id: swId, id: swId,
type: '구독SW', type: '구독SW',
분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']), 분야: rand(['업무공통', '개발S/W']),
법인: rand(corps), 법인: rand(corps),
부서: rand(depts), 제품명: rand(['Adobe CC', 'M365']),
제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']), : '2024-01-01',
: `${purchaseYear}-01-01`, : '2025-01-01',
: `${purchaseYear}.01.01 ~ ${endStr}`, : '100,000',
금액: String(Math.floor(Math.random() * 100 + 10) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','), 수량: 5,
수량: Math.floor(Math.random() * 5) + 3, // 3~7 : `admin${i}@hm.com`,
: `user${i}@hm.com`,
: '총판', : '총판',
: '연간구독' : ''
}); });
swUsers.push({ sw_id: swId, userData: [[rand(corps), rand(depts), '사원', rand(users), '2024.01~12', '신청완료']] });
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀', '기획팀']),
직위: rand(['사원', '대리', '과장']),
이름: rand(users),
사용기간: '2024.01~12',
신청서명: ''
});
}
} }
// 6. 영구 S/W 40개 // 7. 영구 SW 20개
for (let i = 1; i <= 40; i++) { for (let i = 1; i <= 20; i++) {
const swId = Math.random().toString(36).substring(2, 9); const swId = Math.random().toString(36).substring(2, 9);
permSw.push({
let isExpiring = Math.random() < 0.25;
let endDt = new Date();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
sw.push({
id: swId, id: swId,
type: '영구SW', type: '영구SW',
분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']), 분야: rand(['설계S/W']),
법인: rand(corps), 법인: rand(corps),
부서: rand(depts), 제품명: rand(['AutoCAD', '한컴오피스']),
제품명: rand(['AutoCAD 2024', 'Windows 10 Pro', '한컴오피스 2022', 'Visual Studio 2022']), : '2023-01-01',
구매일: '2020-05-15', : `KEY-${swId}`,
유지보수여부: true, : '500,000',
비고: `유지보수: ~ ${endStr}`, 수량: 2,
금액: '1,500,000', : `license${i}`,
수량: Math.floor(Math.random() * 3) + 2, // 2~4 : '총판',
계정명: `sn-2020-${i}`, : ''
납품업체: '오토데스크 / MS'
}); });
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀']),
직위: rand(['과장', '차장', '부장']),
이름: rand(users),
사용기간: '영구',
신청서명: ''
});
}
} }
// 7. 클라우드 서비스 15개 return { pc, server, storage, equip, mobile, subSw, permSw, swUsers, logs };
for (let i = 1; i <= 15; i++) {
const swId = Math.random().toString(36).substring(2, 9);
const platforms = ['AWS', 'Microsoft Azure', 'Google Cloud', 'Naver Cloud', 'Cafe24'];
const pfmt = rand(platforms);
const billing = (Math.floor(Math.random() * 500) + 10) * 10000;
const paymentDay = String(Math.floor(Math.random() * 28) + 1);
sw.push({
id: swId,
type: '클라우드',
플랫폼명: pfmt,
법인: rand(corps),
부서: rand(depts),
제품명: rand(['본사 홈페이지 운영', 'AI 분석 프로젝트', '인트라넷 백업용', '현장 모니터링 시스템']),
계정명: `admin_${i}@hm.com`,
결제수단: Math.random() > 0.5 ? '법인카드' : '인보이스',
결제일: paymentDay,
연결카드번호: String(Math.floor(Math.random() * 8999) + 1000), // 1000~9999
당월청구액: String(billing),
비고: Math.random() > 0.8 ? '비용 한도 초과 경고' : '',
// 더미 필수값
: '',
: '',
수량: 1,
: ''
});
// 4개월치 모의 결제 이력 생성
for (let m = 0; m < 4; m++) {
const logDate = new Date();
logDate.setMonth(logDate.getMonth() - m);
logDate.setDate(parseInt(paymentDay, 10));
const historyBilling = Math.floor(billing * (1 + (Math.random() * 0.2 - 0.1)));
logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: swId,
date: `${logDate.getFullYear()}-${String(logDate.getMonth()+1).padStart(2,'0')}-${String(logDate.getDate()).padStart(2,'0')}`,
user: `admin_${i}@hm.com`,
details: `정기 결제 완료 (비용: ₩ ${historyBilling.toLocaleString()})`
});
}
}
return { hw, sw, swUsers, logs };
} }

View File

@@ -2,7 +2,7 @@ import * as XLSX from 'xlsx';
export interface HardwareAsset { export interface HardwareAsset {
id: string; id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품' type: string; // '개인PC', '서버', '스토리지', '전산비품', '모바일기기'
법인: string; 법인: string;
자산코드: string; 자산코드: string;
명칭: string; 명칭: string;
@@ -51,6 +51,9 @@ export interface SoftwareAsset {
제품명: string; 제품명: string;
구매일: string; 구매일: string;
구독일?: string; 구독일?: string;
만료일?: string;
라이선스유형?: string;
라이선스키?: string;
유지보수여부?: boolean; 유지보수여부?: boolean;
금액: string; 금액: string;
수량: number; 수량: number;
@@ -66,7 +69,7 @@ export interface SoftwareAsset {
export interface SWUser { export interface SWUser {
id: string; id: string;
swId: string; sw_id: string;
법인: string; 법인: string;
부서: string; 부서: string;
: string; : string;
@@ -74,7 +77,7 @@ export interface SWUser {
이름: string; 이름: string;
사용기간: string; 사용기간: string;
신청서명: string; 신청서명: string;
userData?: any[]; // 동료 작업 호환용 userData?: any[];
} }
export interface HardwareLog { export interface HardwareLog {
@@ -93,22 +96,22 @@ export interface MasterAssetData {
mobile: HardwareAsset[]; mobile: HardwareAsset[];
subSw: SoftwareAsset[]; subSw: SoftwareAsset[];
permSw: SoftwareAsset[]; permSw: SoftwareAsset[];
swUsers: SWUser[]; swUsers: any[]; // { sw_id, userData: [] } 형태로 처리
logs: HardwareLog[]; logs: HardwareLog[];
} }
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품']; const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
const SW_TABS = ['구독SW', '영구SW', '클라우드']; const SW_TABS = ['구독SW', '영구SW', '클라우드'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고']; const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고']; const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고'];
const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고']; const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고'];
const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고']; const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고']; const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고']; const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고'];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고']; const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
export function downloadTemplate() { export function downloadTemplate() {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
@@ -116,7 +119,8 @@ export function downloadTemplate() {
{ name: '개인PC', headers: PC_HEADERS }, { name: '개인PC', headers: PC_HEADERS },
{ name: '서버', headers: SERVER_HEADERS }, { name: '서버', headers: SERVER_HEADERS },
{ name: '스토리지', headers: STORAGE_HEADERS }, { name: '스토리지', headers: STORAGE_HEADERS },
{ name: '전산비품', headers: EQUIP_HEADERS } { name: '전산비품', headers: EQUIP_HEADERS },
{ name: '모바일기기', headers: MOBILE_HEADERS }
]; ];
tabConfigs.forEach(config => { tabConfigs.forEach(config => {
@@ -132,8 +136,6 @@ export function downloadTemplate() {
XLSX.utils.book_append_sheet(wb, ws, tab); XLSX.utils.book_append_sheet(wb, ws, tab);
}); });
const swUserWs = XLSX.utils.aoa_to_sheet([SW_USER_HEADERS]);
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
XLSX.writeFile(wb, 'itam_assets_template_full.xlsx'); XLSX.writeFile(wb, 'itam_assets_template_full.xlsx');
} }
@@ -144,8 +146,9 @@ export function exportToExcel(masterData: MasterAssetData) {
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] }, { tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] },
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] }, { tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] }, { tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] }, { tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a., a., a., a., a.] },
{ tab: '구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a. ? 'Y' : 'N', a., a., a., a., a.] } { tab: '구SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] }
]; ];
exportMap.forEach(m => { exportMap.forEach(m => {
@@ -167,17 +170,17 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
if (sheetName === '개인PC') { if (sheetName === '개인PC') {
rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' })); rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
} else if (sheetName === '서버') { } else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' })); rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' }));
} else if (sheetName === '스토리지') { } else if (sheetName === '스토리지') {
rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' })); rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' }));
} else if (sheetName === '전산비품') { } else if (sheetName === '전산비품') {
rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' })); rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }));
} else if (sheetName === '모바일기기') {
rows.forEach(r => data.mobile.push({ id: Math.random().toString(36).substring(2, 9), type: '모바일기기', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' }));
} else if (sheetName === '구독SW') { } else if (sheetName === '구독SW') {
rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 구독: r['구독일']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 만료: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} else if (sheetName === '영구SW') { } else if (sheetName === '영구SW') {
rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 유지보수여부: r['유지보수여부']==='Y', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} else if (sheetName === 'SW_사용자') {
rows.forEach(r => data.swUsers.push({ id: r['id']||Math.random().toString(36).substring(2, 9), swId: r['swId']||'', 법인: r['법인']||'', 부서: r['부서']||'', : r['팀']||'', 직위: r['직위']||'', 이름: r['이름']||'', 사용기간: r['사용기간']||'', 신청서명: r['신청서명']||'' }));
} }
}); });
resolve(data); resolve(data);

View File

@@ -9,13 +9,18 @@ export interface MasterAssetData {
mobile: HardwareAsset[]; mobile: HardwareAsset[];
subSw: SoftwareAsset[]; subSw: SoftwareAsset[];
permSw: SoftwareAsset[]; permSw: SoftwareAsset[];
cloud: SoftwareAsset[]; // 클라우드 배열 추가
swUsers: SWUser[]; swUsers: SWUser[];
logs: HardwareLog[]; logs: HardwareLog[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[];
sw: SoftwareAsset[];
} }
export interface AppState { export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw'; activeCategory: 'dashboard' | 'hw' | 'sw';
activeSubTab: string; // '대시보드', '개인PC', '서버', '스토리지', '전산비품', '구독SW', '영구SW' activeSubTab: string; // '대시보드', '개인PC', '서버', '스토리지', '전산비품', '구독SW', '영구SW', '클라우드'
masterData: MasterAssetData; masterData: MasterAssetData;
} }
@@ -31,6 +36,9 @@ export const state: AppState = {
mobile: [], mobile: [],
subSw: [], subSw: [],
permSw: [], permSw: [],
cloud: [],
hw: [], // 호환용
sw: [], // 호환용
swUsers: [], swUsers: [],
logs: [] logs: []
} }
@@ -49,19 +57,51 @@ export async function loadMasterDataFromDB() {
{ key: 'mobile', url: 'http://localhost:3000/api/mobile' }, { key: 'mobile', url: 'http://localhost:3000/api/mobile' },
{ key: 'subSw', url: 'http://localhost:3000/api/sw/sub' }, { key: 'subSw', url: 'http://localhost:3000/api/sw/sub' },
{ key: 'permSw', url: 'http://localhost:3000/api/sw/perm' }, { key: 'permSw', url: 'http://localhost:3000/api/sw/perm' },
{ key: 'swUsers', url: 'http://localhost:3000/api/sw-users' } { key: 'cloud', url: 'http://localhost:3000/api/cloud' },
{ key: 'swUsers', url: 'http://localhost:3000/api/sw-users' },
{ key: 'logs', url: 'http://localhost:3000/api/logs' }
]; ];
const results = await Promise.all(endpoints.map(e => fetch(e.url))); const results = await Promise.all(endpoints.map(e => fetch(e.url)));
// 기존 데이터 초기화 (재분류 전)
state.masterData.pc = [];
state.masterData.server = [];
state.masterData.storage = [];
state.masterData.equip = [];
state.masterData.mobile = [];
for (let i = 0; i < endpoints.length; i++) { for (let i = 0; i < endpoints.length; i++) {
if (results[i].ok) { if (results[i].ok) {
const data = await results[i].json(); const data = await results[i].json();
(state.masterData as any)[endpoints[i].key] = data || []; const key = endpoints[i].key;
if (['pc', 'server', 'storage', 'equip', 'mobile'].includes(key)) {
// 하드웨어 데이터는 자동 재분류 로직 통과
(data as HardwareAsset[]).forEach(asset => saveHardwareAsset(asset));
} else {
(state.masterData as any)[key] = data || [];
}
} }
} }
console.log('✅ 6개 테이블 데이터 로드 완료'); // 동료 코드 호환을 위한 통합 sw 배열 생성
state.masterData.sw = [
...state.masterData.subSw,
...state.masterData.permSw,
...state.masterData.cloud
];
// 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
console.log('✅ 모든 DB 데이터 로드 및 통합 완료');
return true; return true;
} catch (err) { } catch (err) {
console.warn('⚠️ 백엔드 서버 연결 실패. 로컬 데이터를 유지합니다.'); console.warn('⚠️ 백엔드 서버 연결 실패. 로컬 데이터를 유지합니다.');
@@ -78,18 +118,25 @@ export function updateState(newState: Partial<AppState>) {
* 하드웨어 자산 통합 저장 (자동 카테고리 분류) * 하드웨어 자산 통합 저장 (자동 카테고리 분류)
*/ */
export function saveHardwareAsset(updatedAsset: HardwareAsset) { export function saveHardwareAsset(updatedAsset: HardwareAsset) {
const { type } = updatedAsset; const type = updatedAsset.type || '';
const detailPurpose = (updatedAsset as any). || ''; const detailPurpose = (updatedAsset as any). || updatedAsset.detail_purpose || '';
// 1. 타겟 카테고리 결정 // 1. 타겟 카테고리 결정 (유연한 검색)
let targetKey: keyof MasterAssetData = 'equip'; let targetKey: keyof MasterAssetData = 'equip';
if (type === '서버' || (type === 'PC' && detailPurpose === '서버')) targetKey = 'server';
else if (['NAS', 'DAS', '스토리지'].includes(type)) targetKey = 'storage'; if (type.includes('서버') || detailPurpose.includes('서버')) {
else if (['CPU', 'GPU', 'RAM', 'HDD'].includes(type)) targetKey = 'equip'; targetKey = 'server';
else if (['모바일', '태블릿', '노트북'].includes(type)) targetKey = 'mobile'; } else if (['NAS', 'DAS', '스토리지'].some(t => type.includes(t))) {
else if (type === 'PC' && detailPurpose === '개인PC') targetKey = 'pc'; targetKey = 'storage';
} else if (['모바일', '태블릿', '휴대폰', '핸드폰', '노트북'].some(t => type.includes(t))) {
targetKey = 'mobile';
} else if (type === 'PC' || type === '개인PC' || detailPurpose === '개인PC') {
targetKey = 'pc';
} else if (['CPU', 'GPU', 'RAM', 'HDD'].some(t => type.toUpperCase().includes(t))) {
targetKey = 'equip';
}
// 2. 모든 카테고리에서 기존 ID 자산 삭제 (이동 가능성 대비) // 2. 모든 카테고리에서 기존 ID 자산 삭제 (중복 방지)
const hwKeys: (keyof MasterAssetData)[] = ['pc', 'server', 'storage', 'equip', 'mobile']; const hwKeys: (keyof MasterAssetData)[] = ['pc', 'server', 'storage', 'equip', 'mobile'];
hwKeys.forEach(key => { hwKeys.forEach(key => {
const arr = state.masterData[key] as HardwareAsset[]; const arr = state.masterData[key] as HardwareAsset[];
@@ -101,6 +148,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
// 3. 새로운 타겟 카테고리에 추가 // 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset); (state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset);
// 4. 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
} }
/** /**
@@ -115,4 +171,13 @@ export function deleteHardwareAsset(assetId: string) {
if (idx > -1) arr.splice(idx, 1); if (idx > -1) arr.splice(idx, 1);
} }
}); });
// 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
} }

View File

@@ -33,6 +33,21 @@ export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim(); return (dateStr || '').replace(/\./g, '-').trim();
} }
/**
* 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
*/
export function calculateAssetAge(purchaseDate: string): number {
const normalized = normalizeDate(purchaseDate);
if (!normalized) return 0;
const purchase = new Date(normalized);
if (isNaN(purchase.getTime())) return 0;
const diffMs = Date.now() - purchase.getTime();
const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
return Math.max(0, parseFloat(age.toFixed(1)));
}
/** /**
* 고유 ID 생성 (7자리 랜덤 문자열) * 고유 ID 생성 (7자리 랜덤 문자열)
*/ */

View File

@@ -6,9 +6,7 @@ import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAss
import { initBaseModal } from './components/Modal/BaseModal'; import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal'; import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initStorageModal } from './components/Modal/StorageModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initCloudModal, openCloudModal } from './components/Modal/CloudModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; 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'; import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw } from 'lucide';
@@ -35,6 +33,7 @@ const saveEquipToDB = () => apiBatchSave('http://localhost:3000/api/equip/batch'
const saveMobileToDB = () => apiBatchSave('http://localhost:3000/api/mobile/batch', state.masterData.mobile, '모바일기기'); const saveMobileToDB = () => apiBatchSave('http://localhost:3000/api/mobile/batch', state.masterData.mobile, '모바일기기');
const saveSubSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/sub/batch', state.masterData.subSw, '구독SW'); const saveSubSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/sub/batch', state.masterData.subSw, '구독SW');
const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/batch', state.masterData.permSw, '영구SW'); const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/batch', state.masterData.permSw, '영구SW');
const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자'); const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자');
// 모든 하드웨어 DB 동기화 // 모든 하드웨어 DB 동기화
@@ -48,6 +47,16 @@ async function saveAllHardwareToDB() {
]); ]);
} }
// 모든 소프트웨어 DB 동기화
async function saveAllSoftwareToDB() {
await Promise.all([
saveSubSwToDB(),
savePermSwToDB(),
saveCloudToDB(),
saveSwUsersToDB()
]);
}
// --- App Initialization --- // --- App Initialization ---
function initApp() { function initApp() {
console.log('🚀 ITAM Dedicated System Initializing...'); console.log('🚀 ITAM Dedicated System Initializing...');
@@ -65,29 +74,27 @@ function initApp() {
} }
}); });
// 동료의 새로운 UI 방식(renderSWTable)과 우리의 통합 저장 로직 결합 // 모달 초기화
initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initStorageModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initSwModal(() => { initSwModal(() => {
if (state.activeSubTab === '구독SW') saveSubSwToDB(); saveAllSoftwareToDB();
else savePermSwToDB();
renderSWTable(mainContent); renderSWTable(mainContent);
}, closeAllModals); }, closeAllModals);
initCloudModal(() => { initSwUserModal(() => {
// 클라우드 저장 로직 추가 필요시 여기에 구현 saveSwUsersToDB();
renderSWTable(mainContent); renderSWTable(mainContent);
}, closeAllModals); }, closeAllModals);
initSwUserModal(() => { saveSwUsersToDB(); renderSWTable(mainContent); }, closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
} catch (e) { console.error('❌ Initialization failed:', e); } } catch (e) { console.error('❌ Initialization failed:', e); }
// 초기 로드 시 대시보드 렌더링
renderDashboard(mainContent); renderDashboard(mainContent);
// DB에서 데이터 로드 후 화면 갱신
loadMasterDataFromDB().then((success) => { loadMasterDataFromDB().then((success) => {
if (success) { if (success) {
if (state.activeSubTab === '대시보드') renderDashboard(mainContent); if (state.activeSubTab === '대시보드') renderDashboard(mainContent);
@@ -95,6 +102,7 @@ function initApp() {
} }
}); });
// 버튼 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate()); document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData)); document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
@@ -106,23 +114,33 @@ function initApp() {
state.masterData = data; state.masterData = data;
await Promise.all([ await Promise.all([
saveAllHardwareToDB(), saveAllHardwareToDB(),
saveSubSwToDB(), savePermSwToDB(), saveSwUsersToDB() saveAllSoftwareToDB()
]); ]);
renderSWTable(mainContent); renderSWTable(mainContent);
} }
}); });
document.getElementById('btn-add-asset')?.addEventListener('click', () => { document.getElementById('btn-add-asset')?.addEventListener('click', () => {
if (['개인PC', '서버', '전산비품', '스토리지'].includes(state.activeSubTab)) { const tab = state.activeSubTab;
const cat = state.activeCategory;
if (cat === 'hw') {
// 하드웨어 대시보드 또는 개별 탭에서 추가
const defaultType = (tab === '대시보드') ? '' : tab;
openHwModal({ openHwModal({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: state.activeSubTab, type: defaultType,
: '한맥', : '', : '', : '', : '', IP주소: '', MACaddress: '', HW사양: '', OS: '', : '', : '' : '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : ''
} as any, 'add');
} else if (cat === 'sw') {
// 소프트웨어 대시보드 또는 개별 탭에서 추가
let defaultType = tab;
if (tab === '대시보드') defaultType = '구독SW'; // SW는 기본 레이아웃을 위해 하나 지정하되 필드는 빈값
openSwModal({
id: Math.random().toString(36).substring(2, 9),
type: defaultType, : '', : '', 수량: 1, : '', : '', : '', : '한맥'
} as any, 'add'); } as any, 'add');
} else if (state.activeSubTab === '클라우드') {
openCloudModal({ type: '클라우드', : '', : '', 수량: 1, : '', : '', : '', : '한맥', : '' } as any);
} else if (state.activeSubTab === '구독SW' || state.activeSubTab === '영구SW') {
openSwModal({ type: state.activeSubTab, : '', : '', 수량: 1, : '', : '', : '', : '한맥' } as any);
} }
}); });

View File

@@ -1,104 +1,191 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { openDashboardDetail } from '../../components/Modal/DashboardDetailModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { normalizeDate } from '../../core/utils'; import { calculateAssetAge, normalizeDate } from '../../core/utils';
declare var Chart: any; declare var Chart: any;
export function renderHwDashboard(container: HTMLElement) { export function renderHwDashboard(container: HTMLElement) {
const types = ['개인PC', '서버', '스토리지', '전산비품']; const allHw = state.masterData.hw || [];
const units = ['대', '대', '대', '개'];
const groups: any = {};
types.forEach(t => { groups[t] = { idle: [], active: [] }; }); // 1. 데이터 가공
let totalAge = 0;
let countWithDate = 0;
let over5YearsCount = 0;
let latestAsset: HardwareAsset | null = null;
let latestYear = 0;
state.masterData.hw.forEach(a => { const ageGroups = { stable: 0, warning: 0, critical: 0 };
if (!groups[a.type]) return; const yearlyCount: Record<string, number> = {};
if (isHwIdle(a)) groups[a.type].idle.push(a);
else groups[a.type].active.push(a); allHw.forEach(a => {
const pDate = a. || (a as any).purchase_date;
if (!pDate) return;
const age = calculateAssetAge(pDate);
totalAge += age;
countWithDate++;
// 노후도 분류
if (age >= 5) {
over5YearsCount++;
ageGroups.critical++;
} else if (age >= 3) {
ageGroups.warning++;
} else {
ageGroups.stable++;
}
// 연도별 도입 현황 추출
const year = normalizeDate(pDate).split('-')[0];
if (year && year.length === 4) {
yearlyCount[year] = (yearlyCount[year] || 0) + 1;
const yNum = parseInt(year);
if (yNum > latestYear) {
latestYear = yNum;
latestAsset = a;
}
}
}); });
let usageCards = ''; const avgAge = countWithDate > 0 ? (totalAge / countWithDate).toFixed(1) : '0';
types.forEach((t, i) => { const over5Rate = allHw.length > 0 ? Math.round((over5YearsCount / allHw.length) * 100) : 0;
const total = groups[t].idle.length + groups[t].active.length;
const used = groups[t].active.length; // 교체 시급 대상 TOP 10 (오래된 순)
const per = total > 0 ? Math.round((used / total) * 100) : 0; const criticalList = [...allHw]
const barColor = per >= 50 ? 'var(--dash-primary)' : 'var(--dash-danger)'; .filter(a => (a. || (a as any).purchase_date))
.sort((a, b) => {
usageCards += ` const dateA = new Date(normalizeDate(a. || (a as any).purchase_date)).getTime();
<div class="dashboard-card" data-action="idle" data-type="${t}" style="padding: 1.25rem 1.5rem; cursor:pointer; min-height:auto;"> const dateB = new Date(normalizeDate(b. || (b as any).purchase_date)).getTime();
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">${t} 사용현황</span> return dateA - dateB;
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;"> })
${total}${units[i]}${used}${units[i]} 사용 중 .slice(0, 10);
</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>`;
});
// 2. UI 렌더링
container.innerHTML = ` container.innerHTML = `
<div class="view-container"> <div class="view-container">
<h3 class="dashboard-section-title">자산 사용현황 요약</h3> <div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-grid">${usageCards}</div> <div class="dashboard-card stat-card">
<div class="stat-label">전체 평균 사용 연수</div>
<h3 class="dashboard-section-title">하드웨어 보유 통계</h3> <div class="stat-value">${avgAge}<span class="unit">년</span></div>
<div class="dashboard-layout-2col"> <div class="stat-footer">권장 교체 주기: 4.5년</div>
</div>
<div class="dashboard-card stat-card ${over5Rate >= 20 ? 'critical' : ''}">
<div class="stat-label">5년 이상 노후 자산 비율</div>
<div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div>
<div class="stat-footer">${over5YearsCount}대의 자산이 교체 대상을 초과함</div>
</div>
<div class="dashboard-card stat-card">
<div class="stat-label">최신 도입 모델 (${latestYear}년)</div>
<div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${latestAsset?. || '정보 없음'}">
${latestAsset?. || '정보 없음'}
</div>
<div class="stat-footer">가장 최근 자산번호: ${latestAsset?. || '-'}</div>
</div>
</div>
<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">
<div class="dashboard-card"> <div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">자산 유형별 보유 현황</h4> <h4 class="card-title">자산 노후도 분포</h4>
<canvas id="chart-hw-types"></canvas> <div style="height: 250px;"><canvas id="chart-aging-dist"></canvas></div>
</div> </div>
<div class="dashboard-card"> <div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 자산 분포</h4> <h4 class="card-title">연도별 자산 도입 추이</h4>
<canvas id="chart-hw-corps"></canvas> <div style="height: 250px;"><canvas id="chart-purchase-trend"></canvas></div>
</div> </div>
</div> </div>
<h3 class="dashboard-section-title">⚠️ 교체 검토 대상 (가장 오래된 자산 TOP 10)</h3>
<div class="table-container" style="background: white; border-radius: 8px; border: 1px solid var(--border-color);">
<table>
<thead>
<tr>
<th>순위</th>
<th>자산번호</th>
<th>유형</th>
<th>모델명</th>
<th>사용자/담당자</th>
<th>구매일</th>
<th>연령</th>
</tr>
</thead>
<tbody>
${criticalList.map((a, i) => `
<tr class="clickable-row" data-id="${a.id}">
<td style="text-align:center; font-weight:600; color:var(--text-muted)">${i + 1}</td>
<td>${a. || '-'}</td>
<td><span class="badge-type">${a.type}</span></td>
<td>${a. || a. || '-'}</td>
<td>${a. || a._정 || '-'}</td>
<td style="text-align:center;">${a. || (a as any).purchase_date || '-'}</td>
<td style="text-align:center;"><strong style="color:${calculateAssetAge(a. || (a as any).purchase_date) >= 5 ? 'var(--danger)' : 'inherit'}">${calculateAssetAge(a. || (a as any).purchase_date)}년</strong></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div> </div>
`; `;
// 3. 차트 초기화
setTimeout(() => { setTimeout(() => {
if (typeof Chart === 'undefined') return; initAgingCharts(ageGroups, yearlyCount);
const ctxType = (document.getElementById('chart-hw-types') as HTMLCanvasElement)?.getContext('2d');
const ctxCorp = (document.getElementById('chart-hw-corps') as HTMLCanvasElement)?.getContext('2d'); // 행 클릭 이벤트 바인딩
if (ctxType) { container.querySelectorAll('.clickable-row').forEach(row => {
const chart = new Chart(ctxType, { row.addEventListener('click', () => {
type: 'doughnut', const id = row.getAttribute('data-id');
data: { labels: types, datasets: [{ data: types.map(t => state.masterData.hw.filter(a => a.type === t).length), backgroundColor: ['#1E5149', '#3b82f6', '#10b981', '#f59e0b'] }] }, const asset = allHw.find(h => h.id === id);
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } } if (asset) openHwModal(asset, 'view');
}); });
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);
}); });
}); }, 100);
} }
function isHwIdle(a: HardwareAsset) { function initAgingCharts(ageGroups: any, yearlyCount: Record<string, number>) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-'; const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement;
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-'; if (agingCtx) {
return !a. || a..trim() === '' || a..trim() === '-'; new Chart(agingCtx, {
} type: 'doughnut',
data: {
labels: ['안정 (3년 미만)', '주의 (3~5년)', '위험 (5년 이상)'],
datasets: [{
data: [ageGroups.stable, ageGroups.warning, ageGroups.critical],
backgroundColor: ['#1E5149', '#9CA3AF', '#E11D48'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'right' } },
cutout: '70%'
}
});
}
function getHwAgeYears(a: HardwareAsset) { const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement;
if (!a.) return 0; if (trendCtx) {
try { const years = Object.keys(yearlyCount).sort();
const buyDate = new Date(normalizeDate(a.)); new Chart(trendCtx, {
if (isNaN(buyDate.getTime())) return 0; type: 'bar',
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); data: {
} catch { return 0; } labels: years,
datasets: [{
label: '도입 수량',
data: years.map(y => yearlyCount[y]),
backgroundColor: '#1E5149',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1 } },
x: { grid: { display: false } }
}
}
});
}
} }

View File

@@ -9,12 +9,7 @@ export function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0; let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0; let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
let thisMonthCloudCost = 0;
let lastMonthCloudCost = 0;
let cloudExp = 0;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const today = new Date();
const corps = ['한맥', '삼안', '바론']; const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W']; const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
@@ -23,11 +18,12 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {}; const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0); categories.forEach(c => costByCat[c] = 0);
// 통합 SW 데이터 (호환용) // 통합 SW 데이터
const allSw = [...state.masterData.subSw, ...state.masterData.permSw]; const allSw = [...state.masterData.subSw, ...state.masterData.permSw];
allSw.forEach(sw => { allSw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length; const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id);
const assigned = userMapping ? (userMapping.userData ? userMapping.userData.length : 0) : 0;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10); const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0'; const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0';
const price = parseInt(priceStr, 10) || 0; const price = parseInt(priceStr, 10) || 0;
@@ -46,22 +42,11 @@ export function renderSwDashboard(container: HTMLElement) {
} }
}); });
// 클라우드 데이터 처리 (필요시 추가)
// ...
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0; const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0; const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0; const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0; const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0;
const cloudCostTrend = [0, 0, 0, 0];
const trendLabels: string[] = [];
for(let i=3; i>=0; i--) {
const d = new Date();
d.setMonth(d.getMonth() - i);
trendLabels.push(`${d.getMonth()+1}`);
}
container.innerHTML = ` container.innerHTML = `
<div class="view-container"> <div class="view-container">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3> <h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
@@ -153,21 +138,17 @@ export function renderSwDashboard(container: HTMLElement) {
} }
function isSWExpiring(sw: SoftwareAsset) { function isSWExpiring(sw: SoftwareAsset) {
if (sw.type === '구독SW' && sw.) { if (sw.type === '구독SW' && sw.) {
const parts = sw..split('~'); const endMs = new Date(normalizeDate(sw.)).getTime();
if (parts.length > 1) { const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
const endMs = new Date(normalizeDate(parts[1])).getTime(); return diffDays >= 0 && diffDays <= 30;
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24); } else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
return diffDays >= 0 && diffDays <= 30;
}
} else if (sw.type === '영구SW' && sw. && sw..includes('~')) {
// 임시 로직: 비고란에 날짜가 포함된 경우
try { try {
const dateMatch = sw..match(/\\d{4}-\\d{2}-\\d{2}/); const parts = sw..split('~');
if (dateMatch) { if (parts.length > 1) {
const endMs = new Date(normalizeDate(dateMatch[0])).getTime(); const endMs = new Date(normalizeDate(parts[1].trim())).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24); const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30; return diffDays >= 0 && diffDays <= 30;
} }
} catch { return false; } } catch { return false; }
} }

View File

@@ -1,9 +1,10 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openCloudModal } from '../../components/Modal/CloudModal'; import { openSwModal } from '../../components/Modal/SWModal';
import { createIcons, Cloud, CreditCard, DollarSign } from 'lucide'; import { createIcons, Cloud, CreditCard, DollarSign } from 'lucide';
export function renderCloudList(container: HTMLElement) { export function renderCloudList(container: HTMLElement) {
const fullList = state.masterData.sw.filter(a => a.type === '클라우드'); // DB에서 직접 로드된 전용 배열을 사용하여 데이터 소스를 일원화함
const getFullList = () => state.masterData.cloud || [];
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -58,7 +59,7 @@ export function renderCloudList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const payment = paymentSelect ? paymentSelect.value : ''; const payment = paymentSelect ? paymentSelect.value : '';
const filtered = fullList.filter(asset => { const filtered = getFullList().filter(asset => {
const kwMatch = !keyword || const kwMatch = !keyword ||
(asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword) ||
@@ -96,7 +97,7 @@ export function renderCloudList(container: HTMLElement) {
<td>${asset.||''}</td> <td>${asset.||''}</td>
`; `;
tr.addEventListener('click', () => openCloudModal(asset)); tr.addEventListener('click', () => openSwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
createIcons({ icons: { Cloud, CreditCard, DollarSign } }); createIcons({ icons: { Cloud, CreditCard, DollarSign } });

View File

@@ -131,8 +131,15 @@ export function renderSwList(container: HTMLElement) {
</td> </td>
`; `;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); tr.addEventListener('click', (e) => {
tr.querySelector('.btn-edit')?.addEventListener('click', (e) => { e.stopPropagation(); openSwModal(asset); }); if (!(e.target as HTMLElement).closest('button')) {
openSwModal(asset, 'view');
}
});
tr.querySelector('.btn-edit')?.addEventListener('click', (e) => {
e.stopPropagation();
openSwModal(asset, 'edit');
});
tr.querySelector('.btn-users')?.addEventListener('click', (e) => { e.stopPropagation(); openSwUserModal(asset); }); tr.querySelector('.btn-users')?.addEventListener('click', (e) => { e.stopPropagation(); openSwUserModal(asset); });
tbody.appendChild(tr); tbody.appendChild(tr);
}); });