7 Commits

Author SHA1 Message Date
d711af7f69 feat: 엑셀 양식 고도화 및 DB/UI 연동 최적화 (드롭다운, 상세위치, 데이터 정합성)
1. exceljs 도입: 엑셀 파일 내 실제 드롭다운(건물-상세위치 연동) 구현\n2. 업로드 프리뷰: 위치/상세위치 Select Box 전환 및 실시간 동기화\n3. DB 마이그레이션: user_name, mainboard, detail_location 컬럼 자동화\n4. 양식 보강: 시트별 커스텀 유형 적용 및 누락 필드(모델명, 담당자 부, 용도/상세 등) 추가\n5. UI 개선: 편집 폰트 색상(#FF3D00) 고정 및 지능형 레드 박스 강조 로직 적용
2026-04-24 15:35:59 +09:00
ab0d25b827 docs: Add branch difference summaries for Gitea issues 2026-04-23 20:44:47 +09:00
d7af75976e fix: Use absolute API paths in upload modal and fix cloud endpoint 2026-04-23 20:34:56 +09:00
dde3aefaac fix: Ensure upload preview modal opens and add debug logs 2026-04-23 20:28:05 +09:00
1fbd297988 feat: Add bulk asset code generation in upload review modal 2026-04-23 20:25:58 +09:00
4b5e25fd3f feat: Implement Excel bulk upload with review modal and domain support 2026-04-23 20:14:51 +09:00
367f72673d feat: 운영 서비스 도메인 관리 기능 추가 및 UI 간격 조정 2026-04-23 20:06:56 +09:00
15 changed files with 2045 additions and 333 deletions

58
branch_diff_issues.md Normal file
View File

@@ -0,0 +1,58 @@
# ITAM 프로젝트 브랜치별 변경 사항 및 이슈 정리
본 문서는 `setting` 브랜치를 기준으로 `SW_Table`, `Operation_Table`, `Upload` 브랜치의 주요 변경 사항을 정리한 내용입니다. Gitea 이슈 등록 시 참고하시기 바랍니다.
---
## 1. [SW_Table] 소프트웨어 자산 관리 고도화 및 대시보드 강화
**제목:** `[SW_Table] 소프트웨어 자산 관리 고도화 및 대시보드 강화`
### 주요 변경 사항
- **SW 상세 모달 리팩토링**
- 구독형, 영구형, 클라우드 자산 유형에 따른 동적 필드 전환 기능 구현
- 자산 유형 명칭 일원화 및 필드 매핑 로직 개선
- **데이터 표준화 및 확장**
- 시작일/만료일 'yyyy-mm-dd' 형식 통일 및 데이터 일관성 확보
- 클라우드 자산 통합 관리 및 관련 스키마 확장
- **대시보드 분석 기능 강화**
- 월별 누적 비용 분석 그래프 도입
- 카테고리별 자산 보유 현황 및 비용 통계 시각화 고도화
- **사용자 관리 개선**
- SW 사용자 할당 및 이력 관리 기능 강화 (`SWUserModal` 도입)
---
## 2. [Operation_Table] 운영 서비스 도메인 관리 모듈 및 UI 최적화
**제목:** `[Operation_Table] 운영 서비스 도메인 관리 모듈 및 UI 최적화`
### 주요 변경 사항
- **도메인 관리 모듈 신규 도입**
- `ops_domain_assets` 테이블 신규 생성 및 서버 API 연동
- 유형(호스팅/SSL/도메인/네임서버), 법인, 서비스명, 관리도메인 등 상세 필드 관리
- **도메인 전용 UI 구현**
- 도메인 전용 등록/수정 모달 및 리스트 뷰 인터페이스 구축
- **사용자 경험(UX) 및 스타일 최적화**
- 상단바와 본문 사이 간격 조정 (여백 최적화)
- 전역 스타일 가이드에 맞춘 UI 레이아웃 정밀 조정
- **기능 통합**
- SW_Table의 모든 고도화 사항을 포함하여 운영 환경에 맞춰 통합 완료
---
## 3. [Upload] 엑셀 대량 업로드 워크플로우 및 자산코드 자동 생성
**제목:** `[Upload] 엑셀 대량 업로드 워크플로우 및 자산코드 자동 생성`
### 주요 변경 사항
- **통합 엑셀 업로드 시스템**
- 9개 자산 카테고리(HW, SW, 클라우드, 도메인)를 아우르는 통합 엑셀 양식 파싱 엔진 구현
- **업로드 데이터 검토 프로세스 (`UploadPreviewModal`)**
- 업로드 전 데이터를 미리 확인하고 검증할 수 있는 중간 검토 모달 도입
- 시트별 데이터 요약 및 상세 내역 확인 기능 제공
- **자산코드 일괄 생성 기능**
- 하드웨어 자산 대상, 카테고리별 접두사 및 구매연월 기반 자동 번호 부여 시스템 구축
- 서버 API와 연동된 중복 방지 및 자동 증분 로직 적용
- **안정성 및 네트워크 최적화**
- 대량 데이터 처리를 위한 배치 저장 API(Port 3000) 연동 및 절대 경로 통신 적용

View File

@@ -37,6 +37,7 @@ async function initDB() {
details TEXT, details TEXT,
current_org VARCHAR(255), current_org VARCHAR(255),
prev_org VARCHAR(255), prev_org VARCHAR(255),
user_name VARCHAR(100) COMMENT '사용자',
location VARCHAR(255), location VARCHAR(255),
manager_main VARCHAR(100), manager_main VARCHAR(100),
manager_sub VARCHAR(100), manager_sub VARCHAR(100),
@@ -149,6 +150,23 @@ async function initDB() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(`
CREATE TABLE ops_domain_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) COMMENT '유형',
corp VARCHAR(100) COMMENT '법인',
service_name VARCHAR(255) COMMENT '서비스명',
domain_name VARCHAR(255) COMMENT '관리도메인',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
price VARCHAR(100) COMMENT '금액',
manager_main VARCHAR(100) COMMENT '담당자',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.'); console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
await connection.end(); await connection.end();
} }

962
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"exceljs": "^4.4.0",
"express": "^5.2.1", "express": "^5.2.1",
"lucide": "^0.364.0", "lucide": "^0.364.0",
"mysql2": "^3.22.1", "mysql2": "^3.22.1",

306
server.js
View File

@@ -26,71 +26,76 @@ const pool = mysql.createPool({
async function ensureTables() { async function ensureTables() {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
await connection.query(` // 1. 기본 테이블 생성
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 INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50),
log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS pc_assets ( CREATE TABLE IF NOT EXISTS pc_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), purchase_date VARCHAR(50), id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), purchase_date VARCHAR(50),
type VARCHAR(50), detail_purpose VARCHAR(100), purpose VARCHAR(255), details TEXT, type VARCHAR(50), detail_purpose VARCHAR(100), purpose VARCHAR(255), details TEXT,
current_org VARCHAR(100), prev_org VARCHAR(100), location VARCHAR(255), current_org VARCHAR(100), prev_org VARCHAR(100), user_name VARCHAR(100), location VARCHAR(255), detail_location VARCHAR(255),
manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50), manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(100),
remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100), remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100),
model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100), model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(255), ram VARCHAR(100), gpu VARCHAR(100),
storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT, storage1 VARCHAR(255), storage2 VARCHAR(255), storage3 VARCHAR(255), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT,
storage_location VARCHAR(255), status VARCHAR(50) storage_location VARCHAR(255), status VARCHAR(50)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
// 다른 하드웨어 테이블들도 동일한 스키마로 생성 (서버, 스토리지, 비품, 모바일)
// 2. 누락된 컬럼 강제 추가 (Migration)
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
for (const table of tables) {
const [userCols] = await connection.query(`SHOW COLUMNS FROM ${table} LIKE 'user_name'`);
if (userCols.length === 0) await connection.query(`ALTER TABLE ${table} ADD COLUMN user_name VARCHAR(100) AFTER prev_org`);
const [mbCols] = await connection.query(`SHOW COLUMNS FROM ${table} LIKE 'mainboard'`);
if (mbCols.length === 0) await connection.query(`ALTER TABLE ${table} ADD COLUMN mainboard VARCHAR(255) AFTER model_name`);
const [dlCols] = await connection.query(`SHOW COLUMNS FROM ${table} LIKE 'detail_location'`);
if (dlCols.length === 0) await connection.query(`ALTER TABLE ${table} ADD COLUMN detail_location VARCHAR(255) AFTER location`);
}
// 다른 하드웨어 테이블들도 동일한 스키마로 보장
for (const table of ['server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) { for (const table of ['server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) {
await connection.query(`CREATE TABLE IF NOT EXISTS ${table} LIKE pc_assets`); await connection.query(`CREATE TABLE IF NOT EXISTS ${table} LIKE pc_assets`);
} }
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS sw_sub_assets ( CREATE TABLE IF NOT EXISTS cloud_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), id VARCHAR(50) PRIMARY KEY, platform_name VARCHAR(100), corp VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
category 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),
license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS sw_perm_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100),
category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS asset_logs ( CREATE TABLE IF NOT EXISTS asset_logs (
id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50), id INT AUTO_INCREMENT PRIMARY KEY, asset_id VARCHAR(50), log_date VARCHAR(50),
log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0 log_user VARCHAR(100), details TEXT, cost DECIMAL(15,2) DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS sw_sub_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(255), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS sw_perm_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(255), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS sw_users ( CREATE TABLE IF NOT EXISTS sw_users (
id INT AUTO_INCREMENT PRIMARY KEY, sw_id VARCHAR(50), corp VARCHAR(100), dept VARCHAR(100), id INT AUTO_INCREMENT PRIMARY KEY, sw_id VARCHAR(50), corp VARCHAR(100), dept VARCHAR(100),
position VARCHAR(100), user_name VARCHAR(100), usage_period VARCHAR(255), doc_name VARCHAR(255) position VARCHAR(50), user_name VARCHAR(100), usage_period VARCHAR(100), doc_name VARCHAR(255), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS ops_domain_assets (
id VARCHAR(50) PRIMARY KEY, type VARCHAR(50), corp VARCHAR(100), service_name VARCHAR(255), domain_name VARCHAR(255),
start_date VARCHAR(50), expiry_date VARCHAR(50), price VARCHAR(100), manager_main VARCHAR(100),
manager_sub VARCHAR(100), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
@@ -100,6 +105,39 @@ async function ensureTables() {
} }
} }
// 하드웨어 쿼리 헬퍼 (detail_location 추가)
const hardwareInsertSQL = (table) => `
INSERT INTO ${table} (
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, user_name, location, detail_location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, mainboard, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks,
storage_location, status
) VALUES ?
`;
const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매연월||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.사용자||'', a.위치||'', a.상세위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'',
a.보관위치||'', a.현재상태||''
];
const mapHardware = (r, defaultType) => {
const type = r.type || defaultType;
return {
id: r.id, 법인: r.corp, 자산코드: r.asset_code, 구매연월: r.purchase_date, 구매일: r.purchase_date,
type: type, 상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
용도: r.purpose, 상세: r.details, 사용자: r.user_name, 현사용조직: r.current_org,
이전사용조직: r.prev_org, 위치: r.location, 상세위치: r.detail_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, 메인보드: r.mainboard, 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, 보관위치: r.storage_location, 현재상태: r.status
};
};
// 공통 배치 저장 로직 // 공통 배치 저장 로직
async function batchSave(tableName, assets, getQuery) { async function batchSave(tableName, assets, getQuery) {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
@@ -120,76 +158,13 @@ async function batchSave(tableName, assets, getQuery) {
} }
} }
// 하드웨어 쿼리 헬퍼 // --- API 라우트 ---
const hardwareInsertSQL = (table) => `
INSERT INTO ${table} (
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, mainboard, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks,
storage_location, status
) VALUES ?
`;
const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매연월||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'',
a.보관위치||'', a.현재상태||''
];
const mapHardware = (r, defaultType) => {
const type = r.type || defaultType;
return {
id: r.id,
법인: r.corp,
자산코드: r.asset_code,
구매연월: r.purchase_date,
구매일: r.purchase_date,
type: type,
상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
용도: r.purpose,
상세: r.details,
현사용조직: r.current_org,
이전사용조직: 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,
메인보드: r.mainboard,
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,
보관위치: r.storage_location,
현재상태: r.status
};
};
// --- 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');
console.log('🔍 DB Raw Rows (PC):', rows.length, 'items found.');
if (rows.length > 0) console.log('🔍 First row sample:', rows[0]);
res.json(rows.map(r => mapHardware(r, '개인PC'))); res.json(rows.map(r => mapHardware(r, '개인PC')));
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
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) => {
@@ -202,7 +177,6 @@ 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 }); }
}); });
// 서버 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');
@@ -220,7 +194,6 @@ 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 }); }
}); });
// 스토리지 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');
@@ -238,7 +211,6 @@ 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 }); }
}); });
// 전산비품 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');
@@ -256,7 +228,6 @@ 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 }); }
}); });
// 모바일 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');
@@ -274,17 +245,10 @@ 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 }); }
}); });
// 구독 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');
res.json(rows.map(r => ({ res.json(rows.map(r => ({ id: r.id, type: '구독SW', 법인: r.corp, 분야: r.category, 부서: r.dept, 제품명: r.product_name, 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks })));
id: r.id, type: '구독SW', 법인: r.corp,
분야: r.category, 부서: r.dept, 제품명: r.product_name,
라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date,
납품업체: r.vendor, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -292,53 +256,33 @@ app.post('/api/sw/sub/batch', async (req, res) => {
try { try {
const result = await batchSave('sw_sub_assets', req.body, (assets) => ({ const result = await batchSave('sw_sub_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_sub_assets (id, corp, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`, sql: `INSERT INTO sw_sub_assets (id, corp, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [ values: assets.map(a => [a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'', a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''])
a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''
])
})); }));
res.json(result); res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 영구 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');
res.json(rows.map(r => ({ res.json(rows.map(r => ({ id: r.id, type: '영구SW', 법인: r.corp, 분야: r.category, 부서: r.dept, 제품명: r.product_name, 라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks })));
id: r.id, type: '영구SW', 법인: r.corp,
분야: r.category, 부서: r.dept, 제품명: r.product_name,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date,
납품업체: r.vendor, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
app.post('/api/sw/perm/batch', async (req, res) => { app.post('/api/sw/perm/batch', async (req, res) => {
try { try {
console.log('📦 Permanent SW Batch Save Request:', req.body.length, 'items');
if (req.body.length > 0) console.log('Sample:', req.body[0]);
const result = await batchSave('sw_perm_assets', req.body, (assets) => ({ const result = await batchSave('sw_perm_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_perm_assets (id, corp, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`, sql: `INSERT INTO sw_perm_assets (id, corp, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [ values: assets.map(a => [a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'', a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''])
a.id, a.법인||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''
])
})); }));
res.json(result); res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 클라우드 API
app.get('/api/cloud', async (req, res) => { app.get('/api/cloud', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM cloud_assets'); const [rows] = await pool.query('SELECT * FROM cloud_assets');
res.json(rows.map(r => ({ 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 })));
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 }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -352,27 +296,13 @@ app.post('/api/cloud/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/logs', async (req, res) => { app.get('/api/logs', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC'); const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC');
res.json(rows.map(r => ({ res.json(rows.map(r => ({ id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details, cost: r.cost })));
id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details, cost: r.cost
})));
} catch (err) { res.status(500).json({ error: err.message }); } } 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, (logs) => ({
sql: `INSERT INTO asset_logs (asset_id, log_date, log_user, details, cost) VALUES ?`,
values: logs.map(l => [l.assetId, l.date, l.user, l.details, l.cost || 0])
}));
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');
@@ -392,52 +322,44 @@ app.post('/api/sw-users/batch', async (req, res) => {
await connection.query('DELETE FROM sw_users'); await connection.query('DELETE FROM sw_users');
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.userData || []).map(u => [item.sw_id, u[0], u[1], u[2], u[3], u[4], u[5]]));
(item.userData || []).map(u => [item.sw_id, u[0], u[1], u[2], u[3], u[4], u[5]]) if (values.length > 0) await connection.query('INSERT INTO sw_users (sw_id, corp, dept, position, user_name, usage_period, doc_name) VALUES ?', [values]);
);
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.commit(); connection.release(); res.json({ success: true });
await connection.commit();
connection.release();
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 자산번호 자동 생성 API app.get('/api/ops/domain', async (req, res) => {
app.get('/api/generate-asset-code', async (req, res) => { try {
const { prefix } = req.query; const [rows] = await pool.query('SELECT * FROM ops_domain_assets ORDER BY created_at DESC');
if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/ops/domain/batch', async (req, res) => {
try {
const result = await batchSave('ops_domain_assets', req.body, (assets) => ({
sql: `INSERT INTO ops_domain_assets (id, type, corp, service_name, domain_name, start_date, expiry_date, price, manager_main, manager_sub, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.type||'', a.corp||'', a.service_name||'', a.domain_name||'', a.start_date||'', a.expiry_date||'', a.price||'', a.manager_main||'', a.manager_sub||'', a.remarks||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix } = req.query; if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try { try {
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']; const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
let maxNum = 0; let maxNum = 0;
for (const table of tables) { for (const table of tables) {
const [rows] = await pool.query( const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [`${prefix}%`]);
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, rows.forEach(r => { const numPart = r.asset_code.replace(prefix, ''); const num = parseInt(numPart); if (!isNaN(num) && num > maxNum) maxNum = num; });
[`${prefix}%`]
);
rows.forEach(r => {
const numPart = r.asset_code.replace(prefix, '');
const num = parseInt(numPart);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
} }
const nextNum = (maxNum + 1).toString().padStart(4, '0'); const nextNum = (maxNum + 1).toString().padStart(4, '0');
res.json({ nextCode: `${prefix}${nextNum}` }); res.json({ nextCode: `${prefix}${nextNum}` });
} catch (err) { } catch (err) { res.status(500).json({ error: err.message }); }
res.status(500).json({ error: err.message });
}
}); });
// 초기화 및 서버 기동
ensureTables().then(() => { ensureTables().then(() => {
app.listen(PORT, () => { app.listen(PORT, () => { console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`); });
console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`); }).catch(err => { console.error('❌ Failed to start server:', err); });
});
}).catch(err => {
console.error('❌ Failed to start server:', err);
});

View File

@@ -0,0 +1,192 @@
import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
let currentItem: any = null;
const DOMAIN_MODAL_HTML = `
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">도메인 정보</h2>
<div style="display:flex; gap:0.5rem; align-items:center;">
<button id="btn-close-domain-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div>
</div>
<div class="modal-body">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<!-- Group 1: 기본 정보 (Service Identity) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem;">
<i data-lucide="database" style="width:16px; height:16px; color:var(--primary-color);"></i>
기본 정보 (Identity)
</div>
<div class="form-group">
<label class="required">유형</label>
<select id="domain-type" required>
<option value="호스팅">호스팅</option>
<option value="SSL">SSL</option>
<option value="도메인">도메인</option>
<option value="네임서버">네임서버</option>
</select>
</div>
<div class="form-group">
<label class="required">법인</label>
<select id="domain-corp" required>
${generateOptionsHTML(CORP_LIST)}
</select>
</div>
<div class="form-group">
<label class="required">서비스명</label>
<input type="text" id="domain-service-name" placeholder="예: 그룹웨어, 홈페이지" required>
</div>
<div class="form-group">
<label class="required">관리도메인</label>
<input type="text" id="domain-name" placeholder="예: hmac.kr" required>
</div>
<!-- Group 2: 계약 및 담당 정보 (Contract & Manager) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;">
<i data-lucide="calendar-clock" style="width:16px; height:16px; color:var(--primary-color);"></i>
계약 및 담당 정보
</div>
<div class="form-group">
<label>계약 시작일</label>
<input type="date" id="domain-start-date">
</div>
<div class="form-group">
<label>계약 만료일</label>
<input type="date" id="domain-expiry-date">
</div>
<div class="form-group">
<label>도입 금액</label>
<input type="text" id="domain-price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" placeholder="0">
</div>
<div class="form-group">
<label>담당자</label>
<input type="text" id="domain-manager-main">
</div>
<div class="form-group">
<label>담당자(부)</label>
<input type="text" id="domain-manager-sub">
</div>
<!-- Group 3: 기타 (Additional) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;">
<i data-lucide="edit-2" style="width:16px; height:16px; color:var(--primary-color);"></i>
기타 사항
</div>
<div class="form-group full-width">
<label>비고</label>
<textarea id="domain-remarks" rows="3" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel-domain" class="btn btn-outline">취소</button>
<button id="btn-save-domain" class="btn btn-primary"><i data-lucide="save"></i> 저장하기</button>
</div>
</div>
</div>
`;
export function initDomainModal() {
if (!document.getElementById('domain-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
}
const modal = document.getElementById('domain-asset-modal')!;
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals());
document.getElementById('btn-save-domain')?.addEventListener('click', () => saveDomain());
}
export function openDomainModal(item: any = null) {
currentItem = item;
const isEdit = !!item;
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 수정' : '신규 도메인 등록';
const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) el.value = val || '';
};
setVal('domain-type', item?.type || '호스팅');
setVal('domain-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', item?.start_date || '');
setVal('domain-expiry-date', item?.expiry_date || '');
setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || '');
openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
}
async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
const newDomain = {
id: currentItem ? currentItem.id : `DOM-${Date.now()}`,
type: getVal('domain-type'),
corp: getVal('domain-corp'),
service_name: getVal('domain-service-name'),
domain_name: getVal('domain-name'),
start_date: getVal('domain-start-date'),
expiry_date: getVal('domain-expiry-date'),
price: getVal('domain-price'),
manager_main: getVal('domain-manager-main'),
manager_sub: getVal('domain-manager-sub'),
remarks: getVal('domain-remarks')
};
if (!newDomain.service_name || !newDomain.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
}
if (currentItem) {
const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id);
if (idx > -1) state.masterData.domain[idx] = newDomain;
} else {
state.masterData.domain.push(newDomain);
}
try {
const response = await fetch(`http://${location.hostname}:3000/api/ops/domain/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state.masterData.domain)
});
if (response.ok) {
// alert('성공적으로 저장되었습니다.');
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
} else {
throw new Error('DB 저장 실패');
}
} catch (err) {
console.error(err);
alert('저장 중 오류가 발생했습니다.');
}
}

View File

@@ -0,0 +1,323 @@
import { openModal, closeModals } from './BaseModal';
import { createIcons, X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } from 'lucide';
import { state, loadMasterDataFromDB } from '../../core/state';
import { TYPE_PREFIX_MAP, LOCATION_DATA } from './SharedData';
import { ASSET_SCHEMA } from '../../core/schema';
let parsedData: any = null;
let currentTab: string = '';
let onSuccessCallback: (() => void) | null = null;
let hasAttemptedBulkGenerate: boolean = false;
const UPLOAD_PREVIEW_MODAL_HTML = `
<div id="upload-preview-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="width: 95vw; max-width: 1500px; height: 90vh; display: flex; flex-direction: column;">
<div class="modal-header">
<div style="display:flex; align-items:center; gap:0.75rem;">
<div style="background:rgba(255,255,255,0.2); padding:0.5rem; border-radius:8px;">
<i data-lucide="file-spreadsheet" style="width:20px; height:20px; color:white;"></i>
</div>
<div>
<h2 id="upload-preview-title" style="color:white;">데이터 업로드 최종 검토</h2>
<p style="font-size:12px; color:rgba(255,255,255,0.8); margin-top:2px;">수정 가능한 텍스트는 <span style="color:#ff3d00; font-weight:700;">다홍색(#FF3D00)</span>으로 표시됩니다.</p>
</div>
</div>
<button id="btn-close-upload-preview" class="btn-icon" style="color:white;"><i data-lucide="x"></i></button>
</div>
<div class="modal-body" style="display:flex; padding:0; overflow:hidden; flex: 1;">
<div id="upload-tab-sidebar" style="width:200px; border-right:1px solid var(--border-color); background:#fafafa; padding:1.5rem 1rem; overflow-y:auto; flex-shrink: 0;">
<div style="font-size:11px; font-weight:700; color:var(--text-muted); text-transform:uppercase; margin-bottom:1rem; letter-spacing:0.05em;">카테고리</div>
<div id="upload-tabs-container" style="display:flex; flex-direction:column; gap:0.4rem;"></div>
</div>
<div style="flex:1; display:flex; flex-direction:column; background:white; overflow:hidden;">
<div id="upload-preview-stats" style="padding:1rem 1.5rem; border-bottom:1px solid var(--border-color); display:flex; justify-content:space-between; align-items:center; background:white;">
<div style="display:flex; align-items:center; gap:0.5rem;">
<span id="current-tab-name" style="font-weight:700; font-size:16px;">선택된 탭 없음</span>
<span id="current-tab-count" class="badge badge-primary">0건</span>
<button id="btn-bulk-generate-codes" class="btn btn-outline btn-sm hidden" style="margin-left:1rem; height:28px; font-size:12px; padding:0 0.75rem;">
<i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 자산코드 일괄 생성
</button>
</div>
<div id="upload-warning-text" style="font-size:12px; color:#dc2626; font-weight:600; display:none;">
* 자산번호가 누락된 항목이 있습니다. 일괄 생성을 눌러주세요.
</div>
</div>
<div id="upload-preview-table-wrapper" style="flex:1; overflow:auto; padding:0;"></div>
</div>
</div>
<div class="modal-footer" style="background:#f9fafb; border-top:1px solid var(--border-color); flex-shrink: 0;">
<div style="display:flex; gap:0.75rem; width:100%; justify-content:flex-end;">
<button id="btn-cancel-upload" class="btn btn-outline" style="height:40px; padding:0 1.5rem;">취소</button>
<button id="btn-confirm-upload" class="btn btn-primary" style="height:40px; padding:0 2rem;">
<i data-lucide="save"></i> 최종 데이터 저장하기
</button>
</div>
</div>
</div>
</div>
`;
export function initUploadPreviewModal(onSuccess?: () => void) {
if (onSuccess) onSuccessCallback = onSuccess;
if (!document.getElementById('upload-preview-modal')) {
document.body.insertAdjacentHTML('beforeend', UPLOAD_PREVIEW_MODAL_HTML);
}
document.getElementById('btn-close-upload-preview')?.addEventListener('click', closeModals);
document.getElementById('btn-cancel-upload')?.addEventListener('click', closeModals);
document.getElementById('btn-confirm-upload')?.addEventListener('click', () => confirmUpload());
document.getElementById('btn-bulk-generate-codes')?.addEventListener('click', () => generateBulkCodes());
}
export function openUploadPreview(data: any) {
parsedData = data;
hasAttemptedBulkGenerate = false;
const tabNames = Object.keys(data);
if (tabNames.length === 0) { alert('업로드할 데이터가 없습니다.'); return; }
currentTab = tabNames[0];
renderTabs();
renderCurrentTable();
openModal('upload-preview-modal');
createIcons({ icons: { X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } });
}
function renderTabs() {
const container = document.getElementById('upload-tabs-container');
if (!container) return;
container.innerHTML = '';
Object.keys(parsedData).forEach(tab => {
const btn = document.createElement('div');
btn.className = `upload-tab-btn ${tab === currentTab ? 'active' : ''}`;
btn.style.cssText = `
padding: 0.6rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
display: flex; justify-content: space-between; align-items: center; transition: all 0.2s;
background: ${tab === currentTab ? 'white' : 'transparent'};
color: ${tab === currentTab ? 'var(--primary-color)' : 'var(--text-main)'};
box-shadow: ${tab === currentTab ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'};
border: 1px solid ${tab === currentTab ? 'var(--border-color)' : 'transparent'};
`;
btn.innerHTML = `<span>${tab}</span><span style="font-size:11px; opacity:0.6;">${parsedData[tab].length}</span>`;
btn.onclick = () => { syncCurrentDataFromDOM(); currentTab = tab; renderTabs(); renderCurrentTable(); };
container.appendChild(btn);
});
}
function renderCurrentTable() {
const tableWrapper = document.getElementById('upload-preview-table-wrapper');
const tabNameEl = document.getElementById('current-tab-name');
const tabCountEl = document.getElementById('current-tab-count');
const warningText = document.getElementById('upload-warning-text');
if (!tableWrapper || !tabNameEl || !tabCountEl) return;
const data = parsedData[currentTab];
tabNameEl.textContent = currentTab;
tabCountEl.textContent = `${data.length}`;
const generateBtn = document.getElementById('btn-bulk-generate-codes');
const isHwTab = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'].includes(currentTab);
if (generateBtn) { isHwTab ? generateBtn.classList.remove('hidden') : generateBtn.classList.add('hidden'); }
if (!data || data.length === 0) {
tableWrapper.innerHTML = '<div style="padding:4rem; text-align:center; color:var(--text-muted);">표시할 데이터가 없습니다.</div>';
return;
}
const rawHeaders = Object.keys(data[0]).filter(k => k !== 'id' && k !== 'type' && k !== '상세용도');
const headerMap: Record<string, string> = {};
Object.values(ASSET_SCHEMA).forEach(item => { headerMap[item.key] = item.ui; });
const centerCols = ['No.', '구매법인', '자산번호', '현 사용조직', '설치위치', '보관위치', '상세위치', '담당자', '구매연월', '현재상태'];
const locations = Object.keys(LOCATION_DATA);
let hasAnyMissingCode = false;
let tableHTML = `
<table class="preview-table" id="upload-data-table" style="width:100%; border-collapse:collapse; min-width:max-content; table-layout:auto;">
<thead style="position:sticky; top:0; z-index:10; background:#f8fafc; box-shadow:0 1px 0 var(--border-color);">
<tr>
<th style="padding:0.8rem 1rem; text-align:center; font-size:12px; border-bottom:1px solid var(--border-color); width:50px; background:#FAFAFA;">No.</th>
${rawHeaders.map(h => {
const label = headerMap[h] || h;
const isCenter = centerCols.includes(label);
return `<th data-key="${h}" style="padding:0.8rem 1rem; text-align:${isCenter ? 'center' : 'left'}; font-size:12px; border-bottom:1px solid var(--border-color); color:var(--text-muted); font-weight:600; background:#FAFAFA;">${label}</th>`;
}).join('')}
</tr>
</thead>
<tbody>
${data.map((row: any, idx: number) => {
const isCodeMissing = isHwTab && !row[ASSET_SCHEMA.ASSET_CODE.key];
if (isCodeMissing) hasAnyMissingCode = true;
const showRedBox = hasAttemptedBulkGenerate && isCodeMissing;
const trStyle = showRedBox ? 'outline: 2px solid #ff3d00; outline-offset: -2px; background: #fff5f2;' : 'border-bottom:1px solid #f1f5f9;';
return `
<tr data-id="${row.id}" style="${trStyle}">
<td style="padding:0.8rem 1rem; text-align:center; font-size:13px; color:var(--text-muted); background:#fafafa;">${idx + 1}</td>
${rawHeaders.map(h => {
const label = headerMap[h] || h;
const isCenter = centerCols.includes(label);
const isCodeCol = h === ASSET_SCHEMA.ASSET_CODE.key;
const isLocCol = h === ASSET_SCHEMA.LOCATION.key || h === ASSET_SCHEMA.STORE_LOC.key;
const isDetailLocCol = h === ASSET_SCHEMA.DETAIL_LOCATION.key;
const fontColor = isCodeCol ? '#111111' : '#ff3d00';
if (isLocCol) {
return `
<td style="padding:0; min-width:120px; border-right:1px solid #f8fafc;">
<select class="preview-select loc-select" data-key="${h}" style="width:100%; height:36px; border:none; background:transparent; padding:0 0.5rem; font-size:13px; color:#ff3d00; font-weight:600; outline:none; cursor:pointer;">
<option value="">선택</option>
${locations.map(loc => `<option value="${loc}" ${String(row[h]||'').trim() === loc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</td>
`;
} else if (isDetailLocCol) {
const parentLocKey = row[ASSET_SCHEMA.LOCATION.key] ? ASSET_SCHEMA.LOCATION.key : ASSET_SCHEMA.STORE_LOC.key;
const currentLoc = String(row[parentLocKey] || '').trim();
const subLocs = LOCATION_DATA[currentLoc] || [];
return `
<td style="padding:0; min-width:120px; border-right:1px solid #f8fafc;">
<select class="preview-select detail-select" data-key="${h}" style="width:100%; height:36px; border:none; background:transparent; padding:0 0.5rem; font-size:13px; color:#ff3d00; font-weight:600; outline:none; cursor:pointer;">
<option value=""></option>
${subLocs.map(sl => `<option value="${sl}" ${String(row[h]||'').trim() === sl ? 'selected' : ''}>${sl}</option>`).join('')}
${(row[h] && !subLocs.includes(String(row[h]).trim())) ? `<option value="${row[h]}" selected>${row[h]}</option>` : ''}
</select>
</td>
`;
} else {
return `<td contenteditable="${!isCodeCol}" data-key="${h}" style="padding:0.8rem 1rem; font-size:13px; text-align:${isCenter ? 'center' : 'left'}; outline:none; color: ${fontColor} !important; border-right:1px solid #f8fafc;">${row[h] || ''}</td>`;
}
}).join('')}
</tr>
`;
}).join('')}
</tbody>
</table>
`;
tableWrapper.innerHTML = tableHTML;
if (warningText) warningText.style.display = (hasAttemptedBulkGenerate && hasAnyMissingCode) ? 'block' : 'none';
// ─── 드롭다운 실시간 연동 이벤트 ───
tableWrapper.querySelectorAll('.loc-select').forEach(sel => {
sel.addEventListener('change', (e) => {
const target = e.target as HTMLSelectElement;
const tr = target.closest('tr')!;
const detailSel = tr.querySelector('.detail-select') as HTMLSelectElement;
if (detailSel) {
const subLocs = LOCATION_DATA[target.value] || [];
detailSel.innerHTML = '<option value="">선택</option>' + subLocs.map(sl => `<option value="${sl}">${sl}</option>`).join('');
// 데이터 객체도 즉시 동기화
syncCurrentDataFromDOM();
}
});
});
// 편집 포커스 스타일
tableWrapper.querySelectorAll('td[contenteditable="true"]').forEach(td => {
td.addEventListener('focus', () => (td as HTMLElement).style.background = '#fff8f6');
td.addEventListener('blur', () => (td as HTMLElement).style.background = 'transparent');
});
}
function syncCurrentDataFromDOM() {
const table = document.getElementById('upload-data-table') as HTMLTableElement;
if (!table) return;
const rows = table.querySelectorAll('tbody tr');
const data = parsedData[currentTab];
rows.forEach((tr) => {
const asset = data.find((a: any) => a.id === tr.getAttribute('data-id'));
if (asset) {
tr.querySelectorAll('td[contenteditable="true"]').forEach(td => {
const key = td.getAttribute('data-key')!;
asset[key] = td.textContent?.trim() || '';
});
tr.querySelectorAll('select.preview-select').forEach(sel => {
const key = sel.getAttribute('data-key')!;
asset[key] = (sel as HTMLSelectElement).value;
});
}
});
}
async function confirmUpload() {
syncCurrentDataFromDOM();
const tabNames = Object.keys(parsedData);
const hwTabs = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
for (const tab of tabNames) {
if (hwTabs.includes(tab)) {
const missingCodeRows = parsedData[tab].filter((r: any) => !r[ASSET_SCHEMA.ASSET_CODE.key]);
if (missingCodeRows.length > 0) {
alert(`[${tab}] 카테고리에 자산번호가 누락된 항목이 있습니다.\n번호를 생성하거나 수정한 후 다시 시도해주세요.`);
hasAttemptedBulkGenerate = true;
renderCurrentTable();
return;
}
}
}
const confirmBtn = document.getElementById('btn-confirm-upload') as HTMLButtonElement;
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.innerHTML = '저장 중...'; }
try {
let successCount = 0;
const API_BASE = `http://${location.hostname}:3000`;
for (const tab of tabNames) {
const data = parsedData[tab];
let endpoint = '';
if (tab === '개인PC') endpoint = `${API_BASE}/api/pc/batch`;
else if (tab === '서버') endpoint = `${API_BASE}/api/server/batch`;
else if (tab === '스토리지') endpoint = `${API_BASE}/api/storage/batch`;
else if (tab === '전산비품') endpoint = `${API_BASE}/api/equip/batch`;
else if (tab === '모바일기기') endpoint = `${API_BASE}/api/mobile/batch`;
else if (tab === '구독SW') endpoint = `${API_BASE}/api/sw/sub/batch`;
else if (tab === '영구SW') endpoint = `${API_BASE}/api/sw/perm/batch`;
else if (tab === '클라우드') endpoint = `${API_BASE}/api/cloud/batch`;
else if (tab === '도메인') endpoint = `${API_BASE}/api/ops/domain/batch`;
if (endpoint) {
const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
if (response.ok) successCount++;
}
}
if (successCount > 0) { if (onSuccessCallback) onSuccessCallback(); closeModals(); alert(`${successCount}개 카테고리의 데이터가 성공적으로 업로드되었습니다.`); }
else { alert('데이터 업로드에 실패했습니다.'); }
} catch (err) { alert('업로드 중 오류가 발생했습니다.'); }
finally { if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.innerHTML = '<i data-lucide="save"></i> 최종 데이터 저장하기'; createIcons({ icons: { Save } }); } }
}
async function generateBulkCodes() {
syncCurrentDataFromDOM();
const data = parsedData[currentTab];
if (!data) return;
hasAttemptedBulkGenerate = true;
const generateBtn = document.getElementById('btn-bulk-generate-codes') as HTMLButtonElement;
if (generateBtn) { generateBtn.disabled = true; generateBtn.innerHTML = '생성 중...'; }
try {
const rowsToProcess = data.filter((r: any) => !r[ASSET_SCHEMA.ASSET_CODE.key]);
if (rowsToProcess.length === 0) { alert('이미 모든 항목에 자산코드가 부여되어 있습니다.'); return; }
const groups: Record<string, any[]> = {};
rowsToProcess.forEach((r: any) => {
const type = r. || r. || r.type || 'ETC';
const typeCode = TYPE_PREFIX_MAP[type] || 'ETC';
const purchaseYM = String(r[ASSET_SCHEMA.PURCHASE_YM.key] || '').replace(/[^0-9]/g, '');
if (purchaseYM.length < 6) return;
const prefix = `${typeCode}-${purchaseYM.substring(0, 6)}-`;
if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(r);
});
for (const prefix in groups) {
const rows = groups[prefix];
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}`);
const result = await res.json();
if (result.nextCode) {
let baseNum = parseInt(result.nextCode.replace(prefix, ''));
rows.forEach((r, idx) => { r[ASSET_SCHEMA.ASSET_CODE.key] = `${prefix}${(baseNum + idx).toString().padStart(4, '0')}`; });
}
}
renderCurrentTable();
alert('자산코드 생성이 완료되었습니다.');
} catch (err) { alert('자산코드 생성 중 오류가 발생했습니다.'); }
finally { if (generateBtn) { generateBtn.disabled = false; generateBtn.innerHTML = '<i data-lucide="refresh-ccw"></i> 자산코드 일괄 생성'; createIcons({ icons: { RefreshCcw } }); } }
}

View File

@@ -1,76 +1,42 @@
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import ExcelJS from 'exceljs';
import { ASSET_SCHEMA } from './schema';
import { LOCATION_DATA, CORP_LIST, ORG_LIST, HW_TYPE_LIST } from '../components/Modal/SharedData';
export interface HardwareAsset { export interface HardwareAsset {
[key: string]: any; [key: string]: any;
id: string; id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품', '모바일기기' type: string;
법인: string; 법인: string;
자산코드: string; 자산코드: string;
사용자: string;
명칭: string; 명칭: string;
위치: string; 위치: string;
상세위치: string;
관리자: string; 관리자: string;
IP주소: string; IP주소: string;
IP2?: string;
MACaddress: string; MACaddress: string;
HW사양: string; HW사양: string;
OS: string; OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매연월?: string;
금액?: string; 금액?: string;
납품업체: string; 납품업체: string;
품의서명: string; 품의서명: string;
용도?: string;
상세?: string;
원격접속?: string;
서버ID?: string;
서버PW?: string;
모니터링?: string;
비고?: string; 비고?: string;
현사용조직?: string;
이전사용조직?: string;
상세용도?: string;
메인보드?: string;
보관위치?: string;
현재상태?: string;
} }
export interface SoftwareAsset { export interface SoftwareAsset {
[key: string]: any; [key: string]: any;
id: string; id: string;
type: string; // '구독SW', '영구SW', '클라우드' type: string;
분야?: string; 분야?: string;
법인: string; 법인: string;
부서?: string; 부서?: string;
제품명: string; 제품명: string;
구매연월?: string;
구독일?: string;
만료일?: string;
라이선스유형?: string;
라이선스키?: string;
유지보수여부?: boolean;
금액: string; 금액: string;
수량: number; 수량: number;
계정명: string; 계정명: string;
납품업체: string; 납품업체: string;
비고: string; 비고: string;
플랫폼명?: string;
결제수단?: string;
결제일?: string;
연결카드번호?: string;
당월청구액?: string;
} }
export interface SWUser { export interface SWUser {
@@ -104,96 +70,270 @@ export interface MasterAssetData {
subSw: SoftwareAsset[]; subSw: SoftwareAsset[];
permSw: SoftwareAsset[]; permSw: SoftwareAsset[];
cloud: SoftwareAsset[]; cloud: SoftwareAsset[];
domain?: any[];
hw: HardwareAsset[]; hw: HardwareAsset[];
sw: SoftwareAsset[]; sw: SoftwareAsset[];
swUsers: any[]; // { sw_id, userData: [] } 형태로 처리 swUsers: SWUser[];
logs: HardwareLog[]; logs: HardwareLog[];
} }
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기']; const PC_HEADERS = [
const SW_TABS = ['구독SW', '영구SW', '클라우드']; ASSET_SCHEMA.CORP.ui, ASSET_SCHEMA.ASSET_CODE.ui, ASSET_SCHEMA.USER.ui, ASSET_SCHEMA.ORG.ui,
ASSET_SCHEMA.MAINBOARD.ui, ASSET_SCHEMA.OS.ui, ASSET_SCHEMA.CPU.ui, 'GPU', ASSET_SCHEMA.RAM.ui,
ASSET_SCHEMA.STORAGE1.ui, ASSET_SCHEMA.STORAGE2.ui, ASSET_SCHEMA.STORAGE3.ui,
ASSET_SCHEMA.IP_ADDR.ui, ASSET_SCHEMA.MANAGER_MAIN.ui, ASSET_SCHEMA.MANAGER_SUB.ui,
ASSET_SCHEMA.PURCHASE_YM.ui, ASSET_SCHEMA.PRICE.ui, ASSET_SCHEMA.VENDOR.ui, ASSET_SCHEMA.DOC_NAME.ui, ASSET_SCHEMA.REMARKS.ui
];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', '모델명', '메인보드', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매연월', '금액', '납품업체', '품의서명', '비고']; const BASE_HW_HEADERS = [
const SERVER_HEADERS = ['구매법인', '자산번호', '구매연월', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고']; ASSET_SCHEMA.CORP.ui, '유형', ASSET_SCHEMA.ASSET_CODE.ui, ASSET_SCHEMA.PURCHASE_YM.ui,
const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매연월', '금액', '납품업체', '품의서명', '비고']; ASSET_SCHEMA.ORG.ui, ASSET_SCHEMA.LOCATION.ui, ASSET_SCHEMA.DETAIL_LOCATION.ui,
const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고']; ASSET_SCHEMA.PURPOSE.ui, ASSET_SCHEMA.DETAILS.ui, // 용도, 상세 추가
const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고']; ASSET_SCHEMA.MANAGER_MAIN.ui, ASSET_SCHEMA.MANAGER_SUB.ui,
ASSET_SCHEMA.MODEL.ui, ASSET_SCHEMA.OS.ui, ASSET_SCHEMA.CPU.ui, ASSET_SCHEMA.RAM.ui, 'GPU',
ASSET_SCHEMA.STORAGE1.ui, ASSET_SCHEMA.STORAGE2.ui, ASSET_SCHEMA.STORAGE3.ui,
ASSET_SCHEMA.IP_ADDR.ui, ASSET_SCHEMA.IP_ADDR2.ui, '원격도구', '서버ID', '서버PW', ASSET_SCHEMA.MONITORING.ui, ASSET_SCHEMA.REMARKS.ui
];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고']; const SERVER_HEADERS = [...BASE_HW_HEADERS];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '만료일', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고']; const STORAGE_HEADERS = [...BASE_HW_HEADERS];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
// 1. 전산비품/모바일기기: 현 사용조직 추가
const EQUIP_HEADERS = [
ASSET_SCHEMA.CORP.ui, '유형', ASSET_SCHEMA.ASSET_CODE.ui, ASSET_SCHEMA.MODEL.ui,
ASSET_SCHEMA.ORG.ui, // 현 사용조직 추가
ASSET_SCHEMA.STORE_LOC.ui, ASSET_SCHEMA.DETAIL_LOCATION.ui,
ASSET_SCHEMA.MANAGER_MAIN.ui, ASSET_SCHEMA.MANAGER_SUB.ui,
ASSET_SCHEMA.PURCHASE_YM.ui, ASSET_SCHEMA.PRICE.ui, ASSET_SCHEMA.REMARKS.ui
];
const MOBILE_HEADERS = [...EQUIP_HEADERS];
const SUB_SW_HEADERS = ['분야', ASSET_SCHEMA.CORP.ui, ASSET_SCHEMA.PRODUCT.ui, '부서', ASSET_SCHEMA.QTY.ui, ASSET_SCHEMA.PRICE.ui, ASSET_SCHEMA.PURCHASE_YM.ui, '시작일', ASSET_SCHEMA.EXPIRY.ui, ASSET_SCHEMA.VENDOR.ui, ASSET_SCHEMA.REMARKS.ui];
const PERM_SW_HEADERS = ['분야', ASSET_SCHEMA.CORP.ui, ASSET_SCHEMA.PRODUCT.ui, '부서', ASSET_SCHEMA.QTY.ui, ASSET_SCHEMA.PRICE.ui, ASSET_SCHEMA.PURCHASE_YM.ui, '시작일', ASSET_SCHEMA.LICENSE_KEY.ui, ASSET_SCHEMA.VENDOR.ui, ASSET_SCHEMA.REMARKS.ui];
const CLOUD_HEADERS = [ASSET_SCHEMA.PLATFORM.ui, ASSET_SCHEMA.CORP.ui, ASSET_SCHEMA.PRODUCT.ui, '부서', ASSET_SCHEMA.ACCOUNT.ui, ASSET_SCHEMA.PAY_METHOD.ui, ASSET_SCHEMA.PAY_DAY.ui, ASSET_SCHEMA.CARD_NUM.ui, ASSET_SCHEMA.BILLING.ui, ASSET_SCHEMA.REMARKS.ui];
const DOMAIN_HEADERS = ['유형', ASSET_SCHEMA.CORP.ui, '서비스명', '관리도메인', '시작일', '만료일', ASSET_SCHEMA.PRICE.ui, '담당자', '담당자(부)', ASSET_SCHEMA.REMARKS.ui];
/**
* 엑셀 양식 다운로드
*/
export async function downloadTemplate() {
const workbook = new ExcelJS.Workbook();
const refSheet = workbook.addWorksheet('RefData');
const buildings = Object.keys(LOCATION_DATA);
const corps = CORP_LIST;
const orgs = ORG_LIST;
const typeLists = {
server: ['서버', 'PC'],
storage: ['스토리지', 'NAS', 'DAS'],
equip: ['CPU', 'GPU', 'HDD'],
mobile: ['태블릿', '모바일', '노트북']
};
refSheet.getColumn(1).values = ['CorpList', ...corps];
refSheet.getColumn(2).values = ['OrgList', ...orgs];
refSheet.getColumn(3).values = ['LocList', ...buildings];
refSheet.getColumn(4).values = ['ServerTypes', ...typeLists.server];
refSheet.getColumn(5).values = ['StorageTypes', ...typeLists.storage];
refSheet.getColumn(6).values = ['EquipTypes', ...typeLists.equip];
refSheet.getColumn(7).values = ['MobileTypes', ...typeLists.mobile];
workbook.definedNames.add('RefData!$A$2:$A$' + (corps.length + 1), 'CorpList');
workbook.definedNames.add('RefData!$B$2:$B$' + (orgs.length + 1), 'OrgList');
workbook.definedNames.add('RefData!$C$2:$C$' + (buildings.length + 1), 'LocList');
workbook.definedNames.add('RefData!$D$2:$D$' + (typeLists.server.length + 1), 'ServerTypes');
workbook.definedNames.add('RefData!$E$2:$E$' + (typeLists.storage.length + 1), 'StorageTypes');
workbook.definedNames.add('RefData!$F$2:$F$' + (typeLists.equip.length + 1), 'EquipTypes');
workbook.definedNames.add('RefData!$G$2:$G$' + (typeLists.mobile.length + 1), 'MobileTypes');
buildings.forEach((building, idx) => {
const colIdx = 8 + idx;
const subLocs = LOCATION_DATA[building];
const column = refSheet.getColumn(colIdx);
column.values = [building, ...subLocs];
const safeName = building.replace(/\s/g, '_');
workbook.definedNames.add(`RefData!$${column.letter}$2:$${column.letter}$${subLocs.length + 1}`, safeName);
});
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
const tabConfigs = [ const tabConfigs = [
{ name: '개인PC', headers: PC_HEADERS }, { name: '개인PC', headers: PC_HEADERS, typeRef: '' },
{ name: '서버', headers: SERVER_HEADERS }, { name: '서버', headers: SERVER_HEADERS, typeRef: 'ServerTypes' },
{ name: '스토리지', headers: STORAGE_HEADERS }, { name: '스토리지', headers: STORAGE_HEADERS, typeRef: 'StorageTypes' },
{ name: '전산비품', headers: EQUIP_HEADERS }, { name: '전산비품', headers: EQUIP_HEADERS, typeRef: 'EquipTypes' },
{ name: '모바일기기', headers: MOBILE_HEADERS } { name: '모바일기기', headers: MOBILE_HEADERS, typeRef: 'MobileTypes' },
{ name: '구독SW', headers: SUB_SW_HEADERS, typeRef: '' },
{ name: '영구SW', headers: PERM_SW_HEADERS, typeRef: '' },
{ name: '클라우드', headers: CLOUD_HEADERS, typeRef: '' },
{ name: '도메인', headers: DOMAIN_HEADERS, typeRef: '' }
]; ];
tabConfigs.forEach(config => { tabConfigs.forEach(config => {
const ws = XLSX.utils.aoa_to_sheet([config.headers]); const sheet = workbook.addWorksheet(config.name);
ws['!cols'] = Array(config.headers.length).fill({ wch: 18 }); sheet.getRow(1).values = config.headers;
XLSX.utils.book_append_sheet(wb, ws, config.name); sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE9EEED' } };
const rowCount = 200;
const codeIdx = config.headers.indexOf(ASSET_SCHEMA.ASSET_CODE.ui) + 1;
if (codeIdx > 0) sheet.getColumn(codeIdx).hidden = true;
config.headers.forEach((header, hIdx) => {
const colNum = hIdx + 1;
const isFreeTextGroup = (config.name === '전산비품' || config.name === '모바일기기');
for (let r = 2; r <= rowCount; r++) {
const cell = sheet.getCell(r, colNum);
if (header === ASSET_SCHEMA.CORP.ui) {
cell.dataValidation = { type: 'list', allowBlank: true, formulae: ['CorpList'] };
} else if (header === ASSET_SCHEMA.ORG.ui) {
cell.dataValidation = { type: 'list', allowBlank: true, formulae: ['OrgList'] };
} else if ((header === ASSET_SCHEMA.LOCATION.ui || header === ASSET_SCHEMA.STORE_LOC.ui) && !isFreeTextGroup) {
cell.dataValidation = { type: 'list', allowBlank: true, formulae: ['LocList'] };
} else if (header === ASSET_SCHEMA.DETAIL_LOCATION.ui && !isFreeTextGroup) {
const parentColIdx = config.headers.indexOf(ASSET_SCHEMA.LOCATION.ui) + 1 || config.headers.indexOf(ASSET_SCHEMA.STORE_LOC.ui) + 1;
if (parentColIdx > 0) {
const parentLetter = sheet.getColumn(parentColIdx).letter;
cell.dataValidation = { type: 'list', allowBlank: true, formulae: [`=INDIRECT(SUBSTITUTE(${parentLetter}${r}," ","_"))`] };
}
} else if (header === '유형' && config.typeRef) {
cell.dataValidation = { type: 'list', allowBlank: true, formulae: [config.typeRef] };
}
}
});
sheet.columns.forEach(col => { col.width = col.hidden ? 0 : 18; });
}); });
SW_TABS.forEach(tab => { refSheet.state = 'veryHidden';
let hd = tab === '구독SW' ? SUB_SW_HEADERS : (tab === '클라우드' ? CLOUD_HEADERS : PERM_SW_HEADERS); const buffer = await workbook.xlsx.writeBuffer();
const ws = XLSX.utils.aoa_to_sheet([hd]); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
ws['!cols'] = Array(hd.length).fill({ wch: 18 }); const url = window.URL.createObjectURL(blob);
XLSX.utils.book_append_sheet(wb, ws, tab); const a = document.createElement('a');
}); a.href = url;
a.download = `ITAM_Standard_Template_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(wb, 'itam_assets_template_full.xlsx'); a.click();
window.URL.revokeObjectURL(url);
} }
export function exportToExcel(masterData: MasterAssetData) { export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
const getVal = (obj: any, schemaItem: any) => obj[schemaItem.key] || '';
const exportMap = [ const exportMap = [
{ tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a.||a., a., a., a., a.] }, {
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., 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: '개인PC', list: masterData.pc, headers: PC_HEADERS,
{ 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., a.] }, map: (a: any) => [
{ 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., a.] }, getVal(a, ASSET_SCHEMA.CORP), getVal(a, ASSET_SCHEMA.ASSET_CODE), getVal(a, ASSET_SCHEMA.USER), getVal(a, ASSET_SCHEMA.ORG),
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a.||a., a., a., a., a.] }, getVal(a, ASSET_SCHEMA.MAINBOARD), getVal(a, ASSET_SCHEMA.OS), getVal(a, ASSET_SCHEMA.CPU), a.GPU, getVal(a, ASSET_SCHEMA.RAM),
{ 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., a.] }, getVal(a, ASSET_SCHEMA.STORAGE1), getVal(a, ASSET_SCHEMA.STORAGE2), getVal(a, ASSET_SCHEMA.STORAGE3),
{ 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., a., a.] } getVal(a, ASSET_SCHEMA.IP_ADDR), getVal(a, ASSET_SCHEMA.MANAGER_MAIN), getVal(a, ASSET_SCHEMA.MANAGER_SUB),
getVal(a, ASSET_SCHEMA.PURCHASE_YM), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.VENDOR), getVal(a, ASSET_SCHEMA.DOC_NAME), getVal(a, ASSET_SCHEMA.REMARKS)
]
},
{
tab: '서버', list: masterData.server, headers: SERVER_HEADERS,
map: (a: any) => [
getVal(a, ASSET_SCHEMA.CORP), a.type, getVal(a, ASSET_SCHEMA.ASSET_CODE), getVal(a, ASSET_SCHEMA.PURCHASE_YM), getVal(a, ASSET_SCHEMA.ORG),
getVal(a, ASSET_SCHEMA.LOCATION), getVal(a, ASSET_SCHEMA.DETAIL_LOCATION), getVal(a, ASSET_SCHEMA.MANAGER_MAIN), getVal(a, ASSET_SCHEMA.MANAGER_SUB), getVal(a, ASSET_SCHEMA.MODEL),
getVal(a, ASSET_SCHEMA.OS), getVal(a, ASSET_SCHEMA.CPU), getVal(a, ASSET_SCHEMA.RAM), a.GPU, getVal(a, ASSET_SCHEMA.STORAGE1),
getVal(a, ASSET_SCHEMA.STORAGE2), getVal(a, ASSET_SCHEMA.STORAGE3), getVal(a, ASSET_SCHEMA.IP_ADDR), getVal(a, ASSET_SCHEMA.IP_ADDR2),
a., a.ID, a.PW, getVal(a, ASSET_SCHEMA.MONITORING), getVal(a, ASSET_SCHEMA.REMARKS)
]
},
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [getVal(a, ASSET_SCHEMA.CORP), a.type, getVal(a, ASSET_SCHEMA.ASSET_CODE), getVal(a, ASSET_SCHEMA.ORG), getVal(a, ASSET_SCHEMA.LOCATION), getVal(a, ASSET_SCHEMA.DETAIL_LOCATION), getVal(a, ASSET_SCHEMA.MANAGER_MAIN), getVal(a, ASSET_SCHEMA.MANAGER_SUB), getVal(a, ASSET_SCHEMA.MODEL), a., getVal(a, ASSET_SCHEMA.IP_ADDR), getVal(a, ASSET_SCHEMA.PURCHASE_YM), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [getVal(a, ASSET_SCHEMA.CORP), a.type, getVal(a, ASSET_SCHEMA.ASSET_CODE), getVal(a, ASSET_SCHEMA.MODEL), getVal(a, ASSET_SCHEMA.ORG), getVal(a, ASSET_SCHEMA.STORE_LOC), getVal(a, ASSET_SCHEMA.DETAIL_LOCATION), getVal(a, ASSET_SCHEMA.MANAGER_MAIN), getVal(a, ASSET_SCHEMA.MANAGER_SUB), getVal(a, ASSET_SCHEMA.PURCHASE_YM), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [getVal(a, ASSET_SCHEMA.CORP), a.type, getVal(a, ASSET_SCHEMA.ASSET_CODE), getVal(a, ASSET_SCHEMA.MODEL), getVal(a, ASSET_SCHEMA.ORG), getVal(a, ASSET_SCHEMA.STORE_LOC), getVal(a, ASSET_SCHEMA.DETAIL_LOCATION), getVal(a, ASSET_SCHEMA.MANAGER_MAIN), getVal(a, ASSET_SCHEMA.MANAGER_SUB), getVal(a, ASSET_SCHEMA.PURCHASE_YM), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a., getVal(a, ASSET_SCHEMA.CORP), getVal(a, ASSET_SCHEMA.PRODUCT), a., getVal(a, ASSET_SCHEMA.QTY), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.PURCHASE_YM), a., getVal(a, ASSET_SCHEMA.EXPIRY), getVal(a, ASSET_SCHEMA.VENDOR), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a., getVal(a, ASSET_SCHEMA.CORP), getVal(a, ASSET_SCHEMA.PRODUCT), a., getVal(a, ASSET_SCHEMA.QTY), getVal(a, ASSET_SCHEMA.PRICE), getVal(a, ASSET_SCHEMA.PURCHASE_YM), a., getVal(a, ASSET_SCHEMA.LICENSE_KEY), getVal(a, ASSET_SCHEMA.VENDOR), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '클라우드', list: masterData.cloud, headers: CLOUD_HEADERS, map: (a: any) => [getVal(a, ASSET_SCHEMA.PLATFORM), getVal(a, ASSET_SCHEMA.CORP), getVal(a, ASSET_SCHEMA.PRODUCT), a., getVal(a, ASSET_SCHEMA.ACCOUNT), getVal(a, ASSET_SCHEMA.PAY_METHOD), getVal(a, ASSET_SCHEMA.PAY_DAY), getVal(a, ASSET_SCHEMA.CARD_NUM), getVal(a, ASSET_SCHEMA.BILLING), getVal(a, ASSET_SCHEMA.REMARKS)] },
{ tab: '도메인', list: masterData.domain || [], headers: DOMAIN_HEADERS, map: (a: any) => [a.type, a.corp, a.service_name, a.domain_name, a.start_date, a.expiry_date, a.price, a.manager_main, a.manager_sub, a.remarks] }
]; ];
exportMap.forEach(m => { exportMap.forEach(m => {
const ws = XLSX.utils.aoa_to_sheet([m.headers, ...m.list.map(m.map)]); const ws = XLSX.utils.aoa_to_sheet([m.headers, ...m.list.map(m.map)]);
XLSX.utils.book_append_sheet(wb, ws, m.tab); XLSX.utils.book_append_sheet(wb, ws, m.tab);
}); });
XLSX.writeFile(wb, `itam_master_full_${new Date().toISOString().split('T')[0]}.xlsx`); XLSX.writeFile(wb, `itam_master_${new Date().toISOString().split('T')[0]}.xlsx`);
} }
export async function parseExcel(file: File): Promise<MasterAssetData> { export async function parseExcel(file: File): Promise<any> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const workbook = XLSX.read(e.target?.result, { type: 'binary' }); const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const data: MasterAssetData = { pc: [], server: [], storage: [], equip: [], mobile: [], subSw: [], permSw: [], cloud: [], hw: [], sw: [], swUsers: [], logs: [] }; const parsedData: any = {};
workbook.SheetNames.forEach(sheetName => { workbook.SheetNames.forEach(sheetName => {
if (sheetName === 'RefData') return;
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[]; const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
const list: any[] = [];
rows.forEach(r => {
const common = { id: Math.random().toString(36).substring(2, 9) };
const mapVal = (schemaItem: any) => r[schemaItem.ui] || '';
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['위치']||'', 모델명: 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['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' })); list.push({
} else if (sheetName === '서버') { ...common, type: 'PC', : '개인PC',
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||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사양: '', : '', : '', : '' })); [ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP),
} else if (sheetName === '스토리지') { [ASSET_SCHEMA.ASSET_CODE.key]: mapVal(ASSET_SCHEMA.ASSET_CODE),
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['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' })); [ASSET_SCHEMA.USER.key]: mapVal(ASSET_SCHEMA.USER),
} else if (sheetName === '전산비품') { [ASSET_SCHEMA.ORG.key]: mapVal(ASSET_SCHEMA.ORG),
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['품의서명']||'', 비고: r['비고']||'' })); [ASSET_SCHEMA.MAINBOARD.key]: mapVal(ASSET_SCHEMA.MAINBOARD),
} else if (sheetName === '모바일기기') { [ASSET_SCHEMA.OS.key]: mapVal(ASSET_SCHEMA.OS),
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['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' })); [ASSET_SCHEMA.CPU.key]: mapVal(ASSET_SCHEMA.CPU),
GPU: r['GPU'] || '',
[ASSET_SCHEMA.RAM.key]: mapVal(ASSET_SCHEMA.RAM),
[ASSET_SCHEMA.STORAGE1.key]: mapVal(ASSET_SCHEMA.STORAGE1),
[ASSET_SCHEMA.STORAGE2.key]: mapVal(ASSET_SCHEMA.STORAGE2),
[ASSET_SCHEMA.STORAGE3.key]: mapVal(ASSET_SCHEMA.STORAGE3),
[ASSET_SCHEMA.IP_ADDR.key]: mapVal(ASSET_SCHEMA.IP_ADDR),
[ASSET_SCHEMA.MANAGER_MAIN.key]: mapVal(ASSET_SCHEMA.MANAGER_MAIN),
[ASSET_SCHEMA.MANAGER_SUB.key]: mapVal(ASSET_SCHEMA.MANAGER_SUB),
[ASSET_SCHEMA.PURCHASE_YM.key]: mapVal(ASSET_SCHEMA.PURCHASE_YM),
[ASSET_SCHEMA.PRICE.key]: mapVal(ASSET_SCHEMA.PRICE),
[ASSET_SCHEMA.VENDOR.key]: mapVal(ASSET_SCHEMA.VENDOR),
[ASSET_SCHEMA.DOC_NAME.key]: mapVal(ASSET_SCHEMA.DOC_NAME),
[ASSET_SCHEMA.REMARKS.key]: mapVal(ASSET_SCHEMA.REMARKS)
});
} else if (['서버', '스토리지', '전산비품', '모바일기기'].includes(sheetName)) {
const typeValue = r['유형'] || sheetName;
list.push({
...common,
type: typeValue,
상세용도: sheetName === '서버' ? '서버' : typeValue,
[ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP),
[ASSET_SCHEMA.ASSET_CODE.key]: mapVal(ASSET_SCHEMA.ASSET_CODE),
[ASSET_SCHEMA.PURCHASE_YM.key]: mapVal(ASSET_SCHEMA.PURCHASE_YM),
[ASSET_SCHEMA.ORG.key]: mapVal(ASSET_SCHEMA.ORG),
[ASSET_SCHEMA.LOCATION.key]: mapVal(ASSET_SCHEMA.LOCATION) || r[ASSET_SCHEMA.STORE_LOC.ui] || '',
[ASSET_SCHEMA.DETAIL_LOCATION.key]: mapVal(ASSET_SCHEMA.DETAIL_LOCATION),
[ASSET_SCHEMA.MANAGER_MAIN.key]: mapVal(ASSET_SCHEMA.MANAGER_MAIN),
[ASSET_SCHEMA.MANAGER_SUB.key]: mapVal(ASSET_SCHEMA.MANAGER_SUB),
[ASSET_SCHEMA.IP_ADDR.key]: mapVal(ASSET_SCHEMA.IP_ADDR),
[ASSET_SCHEMA.IP_ADDR2.key]: mapVal(ASSET_SCHEMA.IP_ADDR2),
원격접속: r['원격도구'] || '', 서버ID: r['서버ID'] || '', 서버PW: r['서버PW'] || '',
[ASSET_SCHEMA.MODEL.key]: mapVal(ASSET_SCHEMA.MODEL),
[ASSET_SCHEMA.OS.key]: mapVal(ASSET_SCHEMA.OS),
[ASSET_SCHEMA.CPU.key]: mapVal(ASSET_SCHEMA.CPU),
[ASSET_SCHEMA.RAM.key]: mapVal(ASSET_SCHEMA.RAM),
GPU: r['GPU'] || '',
[ASSET_SCHEMA.STORAGE1.key]: mapVal(ASSET_SCHEMA.STORAGE1),
[ASSET_SCHEMA.STORAGE2.key]: mapVal(ASSET_SCHEMA.STORAGE2),
[ASSET_SCHEMA.STORAGE3.key]: mapVal(ASSET_SCHEMA.STORAGE3),
[ASSET_SCHEMA.MONITORING.key]: mapVal(ASSET_SCHEMA.MONITORING),
[ASSET_SCHEMA.REMARKS.key]: mapVal(ASSET_SCHEMA.REMARKS)
});
} 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['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); list.push({ ...common, type: '구독SW', 분야: r['분야'] || '', [ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP), [ASSET_SCHEMA.PRODUCT.key]: mapVal(ASSET_SCHEMA.PRODUCT), 부서: r['부서'] || '', [ASSET_SCHEMA.QTY.key]: mapVal(ASSET_SCHEMA.QTY), [ASSET_SCHEMA.PRICE.key]: mapVal(ASSET_SCHEMA.PRICE), [ASSET_SCHEMA.PURCHASE_YM.key]: mapVal(ASSET_SCHEMA.PURCHASE_YM), 시작일: r['시작일'] || '', [ASSET_SCHEMA.VENDOR.key]: mapVal(ASSET_SCHEMA.VENDOR), [ASSET_SCHEMA.EXPIRY.key]: mapVal(ASSET_SCHEMA.EXPIRY), [ASSET_SCHEMA.REMARKS.key]: mapVal(ASSET_SCHEMA.REMARKS) });
} 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['구매일']||'', 만료일: r['만료일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); list.push({ ...common, type: '영구SW', 분야: r['분야'] || '', [ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP), [ASSET_SCHEMA.PRODUCT.key]: mapVal(ASSET_SCHEMA.PRODUCT), 부서: r['부서'] || '', [ASSET_SCHEMA.QTY.key]: mapVal(ASSET_SCHEMA.QTY), [ASSET_SCHEMA.PRICE.key]: mapVal(ASSET_SCHEMA.PRICE), [ASSET_SCHEMA.PURCHASE_YM.key]: mapVal(ASSET_SCHEMA.PURCHASE_YM), 시작일: r['시작일'] || '', [ASSET_SCHEMA.VENDOR.key]: mapVal(ASSET_SCHEMA.VENDOR), [ASSET_SCHEMA.LICENSE_KEY.key]: mapVal(ASSET_SCHEMA.LICENSE_KEY), [ASSET_SCHEMA.REMARKS.key]: mapVal(ASSET_SCHEMA.REMARKS) });
} else if (sheetName === '클라우드') {
list.push({ ...common, type: '클라우드', [ASSET_SCHEMA.PLATFORM.key]: mapVal(ASSET_SCHEMA.PLATFORM), [ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP), [ASSET_SCHEMA.PRODUCT.key]: mapVal(ASSET_SCHEMA.PRODUCT), 부서: r['부서'] || '', [ASSET_SCHEMA.ACCOUNT.key]: mapVal(ASSET_SCHEMA.ACCOUNT), [ASSET_SCHEMA.PAY_METHOD.key]: mapVal(ASSET_SCHEMA.PAY_METHOD), [ASSET_SCHEMA.PAY_DAY.key]: mapVal(ASSET_SCHEMA.PAY_DAY), [ASSET_SCHEMA.CARD_NUM.key]: mapVal(ASSET_SCHEMA.CARD_NUM), [ASSET_SCHEMA.BILLING.key]: mapVal(ASSET_SCHEMA.BILLING), [ASSET_SCHEMA.REMARKS.key]: mapVal(ASSET_SCHEMA.REMARKS) });
} else if (sheetName === '도메인') {
list.push({ ...common, type: r['유형']||'도메인', corp: r['법인']||'', service_name: r['서비스명']||'', domain_name: r['관리도메인']||'', start_date: r['시작일']||'', expiry_date: r['만료일']||'', price: r['금액']||'', manager_main: r['담당자']||'', manager_sub: r['담당자(부)']||'', remarks: r['비고']||'' });
} }
}); });
resolve(data); if (list.length > 0) parsedData[sheetName] = list;
});
resolve(parsedData);
} catch (err) { reject(err); } } catch (err) { reject(err); }
}; };
reader.readAsBinaryString(file); reader.readAsBinaryString(file);

View File

@@ -13,9 +13,13 @@ export const ASSET_SCHEMA = {
CORP: { key: '법인', db: 'corp', ui: '구매법인' }, CORP: { key: '법인', db: 'corp', ui: '구매법인' },
ASSET_CODE: { key: '자산코드', db: 'asset_code', ui: '자산번호' }, ASSET_CODE: { key: '자산코드', db: 'asset_code', ui: '자산번호' },
PURCHASE_YM: { key: '구매연월', db: 'purchase_date', ui: '구매연월' }, PURCHASE_YM: { key: '구매연월', db: 'purchase_date', ui: '구매연월' },
START_DATE: { key: '시작일', db: 'start_date', ui: '시작일' },
ORG: { key: '현사용조직', db: 'current_org', ui: '현 사용조직' }, ORG: { key: '현사용조직', db: 'current_org', ui: '현 사용조직' },
PREV_ORG: { key: '이전사용조직', db: 'prev_org', ui: '이전 사용조직' }, PREV_ORG: { key: '이전사용조직', db: 'prev_org', ui: '이전 사용조직' },
LOCATION: { key: '위치', db: 'location', ui: '설치위치' }, LOCATION: { key: '위치', db: 'location', ui: '설치위치' },
DETAIL_LOCATION:{ key: '상세위치', db: 'detail_location', ui: '상세위치' },
PURPOSE: { key: '용도', db: 'purpose', ui: '용도' },
DETAILS: { key: '상세', db: 'details', ui: '상세' },
MANAGER_MAIN: { key: '담당자_정', db: 'manager_main', ui: '담당자' }, MANAGER_MAIN: { key: '담당자_정', db: 'manager_main', ui: '담당자' },
MANAGER_SUB: { key: '담당자_부', db: 'manager_sub', ui: '담당자(부)' }, MANAGER_SUB: { key: '담당자_부', db: 'manager_sub', ui: '담당자(부)' },
PRICE: { key: '금액', db: 'price', ui: '도입금액' }, PRICE: { key: '금액', db: 'price', ui: '도입금액' },
@@ -24,7 +28,7 @@ export const ASSET_SCHEMA = {
REMARKS: { key: '비고', db: 'remarks', ui: '비고' }, REMARKS: { key: '비고', db: 'remarks', ui: '비고' },
// ─── 하드웨어 상세 (Hardware) ─── // ─── 하드웨어 상세 (Hardware) ───
USER: { key: '사용자', db: 'purpose', ui: '사용자' }, USER: { key: '사용자', db: 'user_name', ui: '사용자' },
MODEL: { key: '모델명', db: 'model_name', ui: '모델명' }, MODEL: { key: '모델명', db: 'model_name', ui: '모델명' },
MAINBOARD: { key: '메인보드', db: 'mainboard', ui: '메인보드' }, MAINBOARD: { key: '메인보드', db: 'mainboard', ui: '메인보드' },
OS: { key: 'OS', db: 'os', ui: '운영체제' }, OS: { key: 'OS', db: 'os', ui: '운영체제' },
@@ -32,11 +36,13 @@ export const ASSET_SCHEMA = {
RAM: { key: 'RAM', db: 'ram', ui: 'RAM' }, RAM: { key: 'RAM', db: 'ram', ui: 'RAM' },
STORAGE1: { key: 'SSD1', db: 'storage1', ui: 'Storage 1' }, STORAGE1: { key: 'SSD1', db: 'storage1', ui: 'Storage 1' },
STORAGE2: { key: 'SSD2', db: 'storage2', ui: 'Storage 2' }, STORAGE2: { key: 'SSD2', db: 'storage2', ui: 'Storage 2' },
STORAGE3: { key: 'HDD1', db: 'storage3', ui: 'Storage 3' },
IP_ADDR: { key: 'IP주소', db: 'ip_address', ui: 'IP 주소 1' }, IP_ADDR: { key: 'IP주소', db: 'ip_address', ui: 'IP 주소 1' },
IP_ADDR2: { key: 'IP2', db: 'ip2', ui: 'IP 주소 2' }, IP_ADDR2: { key: 'IP2', db: 'ip2', ui: 'IP 주소 2' },
MAC_ADDR: { key: 'MACaddress', db: 'mac_address', ui: 'MAC 주소' }, MAC_ADDR: { key: 'MACaddress', db: 'mac_address', ui: 'MAC 주소' },
STATUS: { key: '현재상태', db: 'status', ui: '현재상태' }, STATUS: { key: '현재상태', db: 'status', ui: '현재상태' },
STORE_LOC: { key: '보관위치', db: 'storage_location',ui: '보관위치' }, STORE_LOC: { key: '보관위치', db: 'storage_location',ui: '보관위치' },
MONITORING: { key: '모니터링', db: 'monitoring', ui: '모니터링' },
// ─── 소프트웨어/클라우드 상세 (SW/Cloud) ─── // ─── 소프트웨어/클라우드 상세 (SW/Cloud) ───
PRODUCT: { key: '제품명', db: 'product_name', ui: '제품/서비스명' }, PRODUCT: { key: '제품명', db: 'product_name', ui: '제품/서비스명' },

View File

@@ -12,6 +12,7 @@ export interface MasterAssetData {
cloud: SoftwareAsset[]; // 클라우드 배열 추가 cloud: SoftwareAsset[]; // 클라우드 배열 추가
swUsers: SWUser[]; swUsers: SWUser[];
logs: HardwareLog[]; logs: HardwareLog[];
domain: any[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용) // 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[]; hw: HardwareAsset[];
@@ -41,7 +42,8 @@ export const state: AppState = {
hw: [], // 호환용 hw: [], // 호환용
sw: [], // 호환용 sw: [], // 호환용
swUsers: [], swUsers: [],
logs: [] logs: [],
domain: []
} }
}; };
@@ -59,6 +61,7 @@ export async function loadMasterDataFromDB() {
{ key: 'subSw', url: `http://${location.hostname}:3000/api/sw/sub` }, { key: 'subSw', url: `http://${location.hostname}:3000/api/sw/sub` },
{ key: 'permSw', url: `http://${location.hostname}:3000/api/sw/perm` }, { key: 'permSw', url: `http://${location.hostname}:3000/api/sw/perm` },
{ key: 'cloud', url: `http://${location.hostname}:3000/api/cloud` }, { key: 'cloud', url: `http://${location.hostname}:3000/api/cloud` },
{ key: 'domain', url: `http://${location.hostname}:3000/api/ops/domain` },
{ key: 'swUsers', url: `http://${location.hostname}:3000/api/sw-users` }, { key: 'swUsers', url: `http://${location.hostname}:3000/api/sw-users` },
{ key: 'logs', url: `http://${location.hostname}:3000/api/logs` } { key: 'logs', url: `http://${location.hostname}:3000/api/logs` }
]; ];

View File

@@ -7,6 +7,8 @@ import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initUploadPreviewModal, openUploadPreview } from './components/Modal/UploadPreviewModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide'; import { initGuide } from './components/Guide';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide'; import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
@@ -109,6 +111,11 @@ function initApp() {
}, closeAllModals); }, closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
initDomainModal();
initUploadPreviewModal(async () => {
await loadMasterDataFromDB();
refreshView();
});
initGuide(); initGuide();
// DB 데이터 로드 및 초기 화면 렌더링 // DB 데이터 로드 및 초기 화면 렌더링
@@ -119,6 +126,8 @@ function initApp() {
}); });
} catch (e) { console.error('❌ Initialization failed:', e); } } catch (e) { console.error('❌ Initialization failed:', e); }
console.log('🚀 ITAM App Version 2.1.0 Loaded');
// 버튼 이벤트 바인딩 // 버튼 이벤트 바인딩
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));
@@ -127,10 +136,17 @@ function initApp() {
uploadInput?.addEventListener('change', async (e) => { uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
if (file) { if (file) {
console.log('📂 File selected:', file.name);
try {
const data = await parseExcel(file); const data = await parseExcel(file);
state.masterData = { ...state.masterData, ...data }; console.log('📊 Parsed data keys:', Object.keys(data));
await Promise.all([saveAllHardwareToDB(), saveAllSoftwareToDB()]); openUploadPreview(data);
refreshView(); // Clear input so same file can be selected again
uploadInput.value = '';
} catch (err) {
alert('엑셀 파일을 읽는 중 오류가 발생했습니다.');
console.error(err);
}
} }
}); });
@@ -142,6 +158,8 @@ function initApp() {
openHwModal({ id: Math.random().toString(36).substring(2, 9), type: defaultType, : '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : '' } as any, 'add'); openHwModal({ id: Math.random().toString(36).substring(2, 9), type: defaultType, : '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : '' } as any, 'add');
} else if (cat === 'sw') { } else if (cat === 'sw') {
openSwModal({ id: Math.random().toString(36).substring(2, 9), type: tab === '대시보드' ? '구독SW' : tab, : '', : '', 수량: 1, : '', : '', : '', : '한맥' } as any, 'add'); openSwModal({ id: Math.random().toString(36).substring(2, 9), type: tab === '대시보드' ? '구독SW' : tab, : '', : '', 수량: 1, : '', : '', : '', : '한맥' } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
} }
}); });

View File

@@ -269,8 +269,7 @@ body {
/* --- Layout Frame --- */ /* --- Layout Frame --- */
.content-area { .content-area {
flex: 1; flex: 1;
padding: 0 2rem; padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
/* 좌우 여백만 유지 */
overflow: hidden; overflow: hidden;
/* 전체 스크롤 차단 */ /* 전체 스크롤 차단 */
display: flex; display: flex;

View File

@@ -0,0 +1,74 @@
import { state } from '../../core/state';
import { formatPrice } from '../../core/utils';
import { createIcons, Plus, Edit2, Trash2 } from 'lucide';
import { openDomainModal } from '../../components/Modal/DomainModal';
export function renderDomainList(container: HTMLElement) {
container.innerHTML = '';
const header = document.createElement('div');
header.className = 'list-header';
header.innerHTML = `
<div class="list-title-area">
<h2 class="list-title">도메인 관리</h2>
</div>
`;
container.appendChild(header);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th style="text-align:center; width:50px;">No.</th>
<th style="text-align:center;">유형</th>
<th style="text-align:center;">법인</th>
<th style="text-align:left;">서비스명</th>
<th style="text-align:left;">관리도메인</th>
<th style="text-align:center;">시작일</th>
<th style="text-align:center;">만료일</th>
<th style="text-align:right;">금액</th>
<th style="text-align:center;">담당자</th>
<th style="text-align:center;">담당자(부)</th>
<th style="text-align:left;">비고</th>
</tr>
</thead>
<tbody>
${state.masterData.domain.length === 0 ? `
<tr>
<td colspan="11" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 도메인 정보가 없습니다.</td>
</tr>
` : state.masterData.domain.map((item, idx) => `
<tr class="domain-row" data-id="${item.id}" style="cursor:pointer;">
<td style="text-align:center;">${idx + 1}</td>
<td style="text-align:center;"><span class="badge badge-${item.type}">${item.type}</span></td>
<td style="text-align:center;">${item.corp || ''}</td>
<td>${item.service_name || ''}</td>
<td>${item.domain_name || ''}</td>
<td style="text-align:center;">${item.start_date || ''}</td>
<td style="text-align:center;">${item.expiry_date || ''}</td>
<td style="text-align:right;">${formatPrice(item.price)}</td>
<td style="text-align:center;">${item.manager_main || ''}</td>
<td style="text-align:center;">${item.manager_sub || ''}</td>
<td class="text-truncate" style="max-width:200px;">${item.remarks || ''}</td>
</tr>
`).join('')}
</tbody>
`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
// 이벤트 바인딩
table.querySelectorAll('.domain-row').forEach(row => {
row.addEventListener('click', () => {
const id = row.getAttribute('data-id');
const item = state.masterData.domain.find(d => d.id === id);
if (item) openDomainModal(item);
});
});
createIcons({ icons: { Plus, Edit2, Trash2 } });
}

View File

@@ -6,7 +6,7 @@ import { createIcons, Paperclip, RefreshCcw } from 'lucide';
/** /**
* PC 자산 목록 뷰 * PC 자산 목록 뷰
* 담당자(부) 추가 및 정렬 보정 * 설치위치 컬럼 제거
*/ */
export function renderPcList(container: HTMLElement) { export function renderPcList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.pc); const fullList = sortAssets(state.masterData.pc);
@@ -37,20 +37,19 @@ export function renderPcList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center;">No</th> <th class="text-center">No</th>
<th style="text-align:center;">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.USER.ui}</th> <th class="text-center">${ASSET_SCHEMA.USER.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.LOCATION.ui}</th> <th class="text-center">담당자(정/부)</th>
<th style="text-align:center;">담당자(정/부)</th> <th class="text-center">${ASSET_SCHEMA.MAINBOARD.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.MAINBOARD.ui}</th> <th class="text-center">${ASSET_SCHEMA.CPU.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.CPU.ui}</th> <th class="text-center">${ASSET_SCHEMA.RAM.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.RAM.ui}</th> <th class="text-center">Storage</th>
<th style="text-align:center;">Storage</th> <th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center">${ASSET_SCHEMA.DOC_NAME.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.DOC_NAME.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -63,7 +62,6 @@ export function renderPcList(container: HTMLElement) {
const updateTable = () => { const updateTable = () => {
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement; const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement; const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
@@ -79,7 +77,7 @@ export function renderPcList(container: HTMLElement) {
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center; padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="13" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
return; return;
} }
@@ -88,7 +86,6 @@ export function renderPcList(container: HTMLElement) {
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
const storage = [asset[ASSET_SCHEMA.STORAGE1.key], asset[ASSET_SCHEMA.STORAGE2.key]].filter(v => v).join(' / '); const storage = [asset[ASSET_SCHEMA.STORAGE1.key], asset[ASSET_SCHEMA.STORAGE2.key]].filter(v => v).join(' / ');
const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || ''; const mainManager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '';
const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || ''; const subManager = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '';
const managerHtml = [ const managerHtml = [
@@ -97,20 +94,19 @@ export function renderPcList(container: HTMLElement) {
].filter(v => v !== '').join(' / '); ].filter(v => v !== '').join(' / ');
tr.innerHTML = ` tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td> <td class="text-center">${idx+1}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CORP.key]}</td> <td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td> <td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td> <td class="text-center">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.USER.key]||''}</td> <td class="text-center">${asset[ASSET_SCHEMA.USER.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.LOCATION.key]||''}</td> <td class="text-center">${managerHtml || '-'}</td>
<td style="text-align:center;">${managerHtml || '-'}</td> <td class="text-center">${asset[ASSET_SCHEMA.MAINBOARD.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.MAINBOARD.key]||'-'}</td> <td class="text-center">${asset[ASSET_SCHEMA.CPU.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CPU.key]||''}</td> <td class="text-center">${asset[ASSET_SCHEMA.RAM.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.RAM.key]||''}</td> <td class="text-center">${formatInline(storage)}</td>
<td style="text-align:center;">${formatInline(storage)}</td> <td class="text-center">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td> <td class="text-right">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
<td style="text-align:right;">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td> <td class="text-center">${asset[ASSET_SCHEMA.DOC_NAME.key] ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.DOC_NAME.key] ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
`; `;
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);

View File

@@ -6,6 +6,7 @@ import { renderEquipmentList } from './List/EquipmentListView';
import { renderMobileList } from './List/MobileListView'; import { renderMobileList } from './List/MobileListView';
import { renderSwList } from './List/SwListView'; import { renderSwList } from './List/SwListView';
import { renderCloudList } from './List/CloudListView'; import { renderCloudList } from './List/CloudListView';
import { renderDomainList } from './List/DomainListView';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide'; import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
/** /**
@@ -40,10 +41,9 @@ export function renderSWTable(mainContent: HTMLElement) {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`; container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
} }
} else if (state.activeCategory === 'ops') { } else if (state.activeCategory === 'ops') {
if (['도메인', '메일', '메신저', '청구비용'].includes(tab)) { if (tab === '도메인') renderDomainList(container);
renderCloudList(container); else {
} else { container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">운영 서비스(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영 서비스 뷰가 정의되지 않았습니다.</div>`;
} }
} }