1 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
27 changed files with 1718 additions and 965 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),

View File

@@ -61,7 +61,6 @@
<!-- Footer --> <!-- Footer -->
<footer class="main-footer"> <footer class="main-footer">
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
<p>Powered by BARON Consultant Co,Ltd</p> <p>Powered by BARON Consultant Co,Ltd</p>
</footer> </footer>
</div> </div>

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",

299
server.js
View File

@@ -26,96 +26,118 @@ 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), vendor 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; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS ops_domain_assets ( CREATE TABLE IF NOT EXISTS ops_domain_assets (
id VARCHAR(50) PRIMARY KEY, type VARCHAR(50), corp VARCHAR(100), id VARCHAR(50) PRIMARY KEY, type VARCHAR(50), corp VARCHAR(100), service_name VARCHAR(255), domain_name VARCHAR(255),
service_name VARCHAR(255), domain_name VARCHAR(255), start_date VARCHAR(50), start_date VARCHAR(50), expiry_date VARCHAR(50), price VARCHAR(100), manager_main VARCHAR(100),
expiry_date VARCHAR(50), price VARCHAR(100), manager_main VARCHAR(100),
manager_sub VARCHAR(100), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP manager_sub VARCHAR(100), remarks TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
// 기존 테이블들에 vendor 컬럼이 없는 경우 추가 (Migration)
const [cols] = await pool.query("SHOW COLUMNS FROM pc_assets LIKE 'vendor'");
if (cols.length === 0) {
for (const table of ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets']) {
await pool.query(`ALTER TABLE ${table} ADD COLUMN vendor VARCHAR(100) AFTER price`);
}
}
console.log('✅ All ITAM tables ensured.'); console.log('✅ All ITAM tables ensured.');
} finally { } finally {
connection.release(); connection.release();
} }
} }
// 하드웨어 쿼리 헬퍼 (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();
@@ -129,7 +151,6 @@ async function batchSave(tableName, assets, getQuery) {
await connection.commit(); await connection.commit();
return { success: true, count: assets.length }; return { success: true, count: assets.length };
} catch (err) { } catch (err) {
console.error(`❌ Batch Save Error (${tableName}):`, err.message);
await connection.rollback(); await connection.rollback();
throw err; throw err;
} finally { } finally {
@@ -137,78 +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, vendor, 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.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.메인보드||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.SSD3||'', a.모니터링||'', a.금액||'', a.납품업체||a.vendor||'', 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,
용도: (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,
SSD3: r.storage3,
모니터링: r.monitoring,
금액: r.price,
납품업체: r.vendor,
비고: 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) => {
@@ -221,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');
@@ -239,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');
@@ -257,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');
@@ -275,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');
@@ -293,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 }); }
}); });
@@ -311,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 }); }
}); });
@@ -371,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');
@@ -411,20 +322,13 @@ 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(); await connection.commit(); connection.release(); res.json({ success: true });
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/ops/domain', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM ops_domain_assets ORDER BY created_at DESC'); const [rows] = await pool.query('SELECT * FROM ops_domain_assets ORDER BY created_at DESC');
@@ -442,39 +346,20 @@ app.post('/api/ops/domain/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 자산번호 자동 생성 API
app.get('/api/generate-asset-code', async (req, res) => { app.get('/api/generate-asset-code', async (req, res) => {
const { prefix } = req.query; const { prefix } = req.query; if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
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

@@ -1,9 +1,8 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { closeModals, openModal } from './BaseModal'; import { closeModals, openModal } from './BaseModal';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setEditLock } from './ModalUtils'; import { generateOptionsHTML } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide'; import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
let currentItem: any = null; let currentItem: any = null;
@@ -13,7 +12,6 @@ const DOMAIN_MODAL_HTML = `
<div class="modal-header"> <div class="modal-header">
<h2 id="domain-modal-title">도메인 정보</h2> <h2 id="domain-modal-title">도메인 정보</h2>
<div style="display:flex; gap:0.5rem; align-items:center;"> <div style="display:flex; gap:0.5rem; align-items:center;">
<button id="btn-edit-domain-header" class="btn-icon header-edit-btn" title="수정"><i data-lucide="edit-2"></i></button>
<button id="btn-close-domain-modal" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-domain-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div> </div>
</div> </div>
@@ -88,23 +86,19 @@ const DOMAIN_MODAL_HTML = `
<!-- Group 3: 기타 (Additional) --> <!-- Group 3: 기타 (Additional) -->
<div class="form-section-title" style="display:flex; align-items:center; gap:0.5rem; margin-top:1.5rem;"> <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> <i data-lucide="edit-2" style="width:16px; height:16px; color:var(--primary-color);"></i>
구매 정보 기타 사항
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>구매업체</label> <label>비고</label>
<textarea id="domain-remarks" rows="1" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea> <textarea id="domain-remarks" rows="3" style="width:100%; border:1px solid var(--border-color); border-radius:4px; padding:0.625rem;"></textarea>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-domain" class="btn btn-outline btn-danger">삭제</button> <button id="btn-cancel-domain" class="btn btn-outline">취소</button>
<div class="footer-actions"> <button id="btn-save-domain" class="btn btn-primary"><i data-lucide="save"></i> 저장하기</button>
<button id="btn-revert-domain" class="btn btn-outline hidden">수정 취소</button>
<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>
</div> </div>
</div> </div>
@@ -118,47 +112,15 @@ export function initDomainModal() {
const modal = document.getElementById('domain-asset-modal')!; const modal = document.getElementById('domain-asset-modal')!;
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals()); document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals()); document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals());
document.getElementById('btn-save-domain')?.addEventListener('click', () => saveDomain());
const saveBtn = document.getElementById('btn-save-domain');
const revertBtn = document.getElementById('btn-revert-domain');
const deleteBtn = document.getElementById('btn-delete-domain');
const headerEditBtn = document.getElementById('btn-edit-domain-header');
saveBtn?.addEventListener('click', () => {
if (!currentItem) return;
if (saveBtn.textContent === '수정') {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
return;
}
saveDomain();
});
headerEditBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
});
revertBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (currentItem) openDomainModal(currentItem);
});
deleteBtn?.addEventListener('click', () => {
if (currentItem && confirm('정말 삭제하시겠습니까?')) {
state.masterData.domain = state.masterData.domain.filter(d => d.id !== currentItem.id);
saveDomainBatch();
}
});
} }
export function openDomainModal(item: any = null) { export function openDomainModal(item: any = null) {
currentItem = item; currentItem = item;
const isEdit = !!item; const isEdit = !!item;
const mode = isEdit ? 'view' : 'add';
const titleEl = document.getElementById('domain-modal-title'); const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 상세' : '신규 도메인 등록'; if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 수정' : '신규 도메인 등록';
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
const setVal = (id: string, val: any) => { const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
@@ -169,40 +131,17 @@ export function openDomainModal(item: any = null) {
setVal('domain-corp', item?.corp || ''); setVal('domain-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || ''); setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || ''); setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', formatExcelDate(item?.start_date)); setVal('domain-start-date', item?.start_date || '');
setVal('domain-expiry-date', formatExcelDate(item?.expiry_date)); setVal('domain-expiry-date', item?.expiry_date || '');
setVal('domain-price', item?.price || ''); setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || ''); setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || ''); setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || ''); setVal('domain-remarks', item?.remarks || '');
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
openModal('domain-asset-modal'); openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } }); createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
} }
async function saveDomainBatch() {
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) {
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
} else {
throw new Error('DB 저장 실패');
}
} catch (err) {
console.error(err);
alert('저장 중 오류가 발생했습니다.');
}
}
async function saveDomain() { async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || ''; const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
@@ -225,17 +164,29 @@ async function saveDomain() {
return; return;
} }
if (currentItem && currentItem.id.startsWith('DOM-')) { 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);
} else if (currentItem) {
const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id); const idx = state.masterData.domain.findIndex(d => d.id === currentItem.id);
if (idx > -1) state.masterData.domain[idx] = newDomain; if (idx > -1) state.masterData.domain[idx] = newDomain;
} else { } else {
state.masterData.domain.push(newDomain); state.masterData.domain.push(newDomain);
} }
await saveDomainBatch(); 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

@@ -45,17 +45,14 @@ const HW_FIELD_MAP: Record<string, string> = {
'모니터링': '모니터링', '모니터링': '모니터링',
'OS': ASSET_SCHEMA.OS.key, 'OS': ASSET_SCHEMA.OS.key,
'CPU': ASSET_SCHEMA.CPU.key, 'CPU': ASSET_SCHEMA.CPU.key,
'GPU': ASSET_SCHEMA.GPU.key,
'RAM': ASSET_SCHEMA.RAM.key, 'RAM': ASSET_SCHEMA.RAM.key,
'SSD1': ASSET_SCHEMA.STORAGE1.key, 'SSD1': ASSET_SCHEMA.STORAGE1.key,
'SSD2': ASSET_SCHEMA.STORAGE2.key, 'SSD2': ASSET_SCHEMA.STORAGE2.key,
'SSD3': ASSET_SCHEMA.STORAGE3.key,
'HW사양': 'HW사양', 'HW사양': 'HW사양',
'담당자_정': ASSET_SCHEMA.MANAGER_MAIN.key, '담당자_정': ASSET_SCHEMA.MANAGER_MAIN.key,
'담당자_부': ASSET_SCHEMA.MANAGER_SUB.key, '담당자_부': ASSET_SCHEMA.MANAGER_SUB.key,
'구매일': ASSET_SCHEMA.PURCHASE_YM.key, '구매일': ASSET_SCHEMA.PURCHASE_YM.key,
'금액': ASSET_SCHEMA.PRICE.key, '금액': ASSET_SCHEMA.PRICE.key,
'납품업체': ASSET_SCHEMA.VENDOR.key,
'비고': ASSET_SCHEMA.REMARKS.key, '비고': ASSET_SCHEMA.REMARKS.key,
'사용자': ASSET_SCHEMA.USER.key '사용자': ASSET_SCHEMA.USER.key
}; };
@@ -121,11 +118,9 @@ const HW_FORM_HTML = `
<div class="form-group pc-only" id="hw-mainboard-group"><label for="hw-메인보드">${ASSET_SCHEMA.MAINBOARD.ui}</label><input type="text" id="hw-메인보드" /></div> <div class="form-group pc-only" id="hw-mainboard-group"><label for="hw-메인보드">${ASSET_SCHEMA.MAINBOARD.ui}</label><input type="text" id="hw-메인보드" /></div>
<div class="form-group" id="hw-os-group"><label for="hw-OS">${ASSET_SCHEMA.OS.ui}</label><input type="text" id="hw-OS" /></div> <div class="form-group" id="hw-os-group"><label for="hw-OS">${ASSET_SCHEMA.OS.ui}</label><input type="text" id="hw-OS" /></div>
<div class="form-group" id="hw-cpu-group"><label for="hw-CPU">${ASSET_SCHEMA.CPU.ui}</label><input type="text" id="hw-CPU" /></div> <div class="form-group" id="hw-cpu-group"><label for="hw-CPU">${ASSET_SCHEMA.CPU.ui}</label><input type="text" id="hw-CPU" /></div>
<div class="form-group" id="hw-gpu-group"><label for="hw-GPU">${ASSET_SCHEMA.GPU.ui}</label><input type="text" id="hw-GPU" /></div>
<div class="form-group" id="hw-ram-group"><label for="hw-RAM">${ASSET_SCHEMA.RAM.ui}</label><input type="text" id="hw-RAM" /></div> <div class="form-group" id="hw-ram-group"><label for="hw-RAM">${ASSET_SCHEMA.RAM.ui}</label><input type="text" id="hw-RAM" /></div>
<div class="form-group" id="hw-ssd1-group"><label for="hw-SSD1">${ASSET_SCHEMA.STORAGE1.ui}</label><input type="text" id="hw-SSD1" /></div> <div class="form-group" id="hw-ssd1-group"><label for="hw-SSD1">${ASSET_SCHEMA.STORAGE1.ui}</label><input type="text" id="hw-SSD1" /></div>
<div class="form-group" id="hw-ssd2-group"><label for="hw-SSD2">${ASSET_SCHEMA.STORAGE2.ui}</label><input type="text" id="hw-SSD2" /></div> <div class="form-group" id="hw-ssd2-group"><label for="hw-SSD2">${ASSET_SCHEMA.STORAGE2.ui}</label><input type="text" id="hw-SSD2" /></div>
<div class="form-group" id="hw-ssd3-group"><label for="hw-SSD3">${ASSET_SCHEMA.STORAGE3.ui}</label><input type="text" id="hw-SSD3" /></div>
<div class="form-group server-only" id="hw-monitoring-group"><label for="hw-모니터링">모니터링 여부</label><input type="text" id="hw-모니터링" /></div> <div class="form-group server-only" id="hw-monitoring-group"><label for="hw-모니터링">모니터링 여부</label><input type="text" id="hw-모니터링" /></div>
<div class="form-group full-width non-server" id="hw-hwspec-group"><label for="hw-HW사양">사양 상세</label><textarea id="hw-HW사양" rows="2"></textarea></div> <div class="form-group full-width non-server" id="hw-hwspec-group"><label for="hw-HW사양">사양 상세</label><textarea id="hw-HW사양" rows="2"></textarea></div>
@@ -137,7 +132,6 @@ const HW_FORM_HTML = `
<div class="form-group"><label for="hw-담당자_부">${ASSET_SCHEMA.MANAGER_SUB.ui}</label><input type="text" id="hw-담당자_부" /></div> <div class="form-group"><label for="hw-담당자_부">${ASSET_SCHEMA.MANAGER_SUB.ui}</label><input type="text" id="hw-담당자_부" /></div>
<div class="form-group"><label for="hw-구매일">${ASSET_SCHEMA.PURCHASE_YM.ui}</label><input type="text" id="hw-구매일" placeholder="YYYYMM" maxlength="6" /></div> <div class="form-group"><label for="hw-구매일">${ASSET_SCHEMA.PURCHASE_YM.ui}</label><input type="text" id="hw-구매일" placeholder="YYYYMM" maxlength="6" /></div>
<div class="form-group"><label for="hw-금액">${ASSET_SCHEMA.PRICE.ui}</label><input type="text" id="hw-금액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div> <div class="form-group"><label for="hw-금액">${ASSET_SCHEMA.PRICE.ui}</label><input type="text" id="hw-금액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div>
<div class="form-group" id="hw-vendor-group"><label for="hw-납품업체">${ASSET_SCHEMA.VENDOR.ui}</label><input type="text" id="hw-납품업체" /></div>
<div class="form-group full-width"><label for="hw-비고">${ASSET_SCHEMA.REMARKS.ui}</label><textarea id="hw-비고" rows="2"></textarea></div> <div class="form-group full-width"><label for="hw-비고">${ASSET_SCHEMA.REMARKS.ui}</label><textarea id="hw-비고" rows="2"></textarea></div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.DOC_NAME.ui} (파일 증빙)</label> <label>${ASSET_SCHEMA.DOC_NAME.ui} (파일 증빙)</label>
@@ -176,13 +170,10 @@ function applyTypeSpecificUI(type: string) {
os: document.getElementById('hw-os-group'), os: document.getElementById('hw-os-group'),
cpu: document.getElementById('hw-cpu-group'), cpu: document.getElementById('hw-cpu-group'),
ram: document.getElementById('hw-ram-group'), ram: document.getElementById('hw-ram-group'),
gpu: document.getElementById('hw-gpu-group'),
ssd1: document.getElementById('hw-ssd1-group'), ssd1: document.getElementById('hw-ssd1-group'),
ssd2: document.getElementById('hw-ssd2-group'), ssd2: document.getElementById('hw-ssd2-group'),
ssd3: document.getElementById('hw-ssd3-group'),
hwSpec: document.getElementById('hw-hwspec-group'), hwSpec: document.getElementById('hw-hwspec-group'),
monitoring: document.getElementById('hw-monitoring-group'), monitoring: document.getElementById('hw-monitoring-group'),
vendor: document.getElementById('hw-vendor-group'),
user: document.querySelector('.pc-only') as HTMLElement user: document.querySelector('.pc-only') as HTMLElement
}; };
@@ -233,16 +224,16 @@ function applyTypeSpecificUI(type: string) {
if (upperType === '노트북') { if (upperType === '노트북') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'none'; if (groups.detailPurpose) groups.detailPurpose.style.display = 'none';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex'); nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'hwSpec', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; }); ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else { } else {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex'; if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
if (detailPurpose === '서버') { if (detailPurpose === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex'; if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'monitoring', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; }); ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else { } else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex'); nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'hwSpec', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; }); ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} }
} }
} }
@@ -250,7 +241,7 @@ function applyTypeSpecificUI(type: string) {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex'; if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex'; if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'gpu', 'ram', 'ssd1', 'ssd2', 'ssd3', 'monitoring', 'vendor'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; }); ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} }
} }

View File

@@ -1,38 +1,36 @@
import { openModal, closeModals } from './BaseModal'; import { openModal, closeModals } from './BaseModal';
import { createIcons, X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } from 'lucide'; import { createIcons, X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } from 'lucide';
import { state, loadMasterDataFromDB } from '../../core/state'; import { state, loadMasterDataFromDB } from '../../core/state';
import { TYPE_PREFIX_MAP } from './SharedData'; import { TYPE_PREFIX_MAP, LOCATION_DATA } from './SharedData';
import { ASSET_SCHEMA } from '../../core/schema';
let parsedData: any = null; let parsedData: any = null;
let currentTab: string = ''; let currentTab: string = '';
let onSuccessCallback: (() => void) | null = null; let onSuccessCallback: (() => void) | null = null;
let hasAttemptedBulkGenerate: boolean = false;
const UPLOAD_PREVIEW_MODAL_HTML = ` const UPLOAD_PREVIEW_MODAL_HTML = `
<div id="upload-preview-modal" class="modal-overlay hidden"> <div id="upload-preview-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="width: 90vw; max-width: 1400px; height: 85vh; display: flex; flex-direction: column;"> <div class="modal-content wide" style="width: 95vw; max-width: 1500px; height: 90vh; display: flex; flex-direction: column;">
<div class="modal-header"> <div class="modal-header">
<div style="display:flex; align-items:center; gap:0.75rem;"> <div style="display:flex; align-items:center; gap:0.75rem;">
<div style="background:var(--primary-light); padding:0.5rem; border-radius:8px;"> <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:var(--primary-color);"></i> <i data-lucide="file-spreadsheet" style="width:20px; height:20px; color:white;"></i>
</div> </div>
<div> <div>
<h2 id="upload-preview-title">데이터 업로드 검토</h2> <h2 id="upload-preview-title" style="color:white;">데이터 업로드 최종 검토</h2>
<p style="font-size:12px; color:var(--text-muted); margin-top:2px;">업로드 전 데이터를 확인하고 수정 사항이 있는지 검토하세요.</p> <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>
</div> </div>
<button id="btn-close-upload-preview" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-upload-preview" class="btn-icon" style="color:white;"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body" style="display:flex; padding:0; overflow:hidden; flex: 1;"> <div class="modal-body" style="display:flex; padding:0; overflow:hidden; flex: 1;">
<!-- Sidebar for Tabs --> <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 id="upload-tab-sidebar" style="width:240px; 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 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 id="upload-tabs-container" style="display:flex; flex-direction:column; gap:0.5rem;">
<!-- Tabs will be injected here -->
</div>
</div> </div>
<!-- Content Area -->
<div style="flex:1; display:flex; flex-direction:column; background:white; overflow:hidden;"> <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 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;"> <div style="display:flex; align-items:center; gap:0.5rem;">
@@ -42,20 +40,18 @@ const UPLOAD_PREVIEW_MODAL_HTML = `
<i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 자산코드 일괄 생성 <i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 자산코드 일괄 생성
</button> </button>
</div> </div>
<div style="font-size:12px; color:var(--text-muted);"> <div id="upload-warning-text" style="font-size:12px; color:#dc2626; font-weight:600; display:none;">
* 아래 데이터가 신규로 추가되거나 기존 데이터가 갱신됩니다. * 자산번호가 누락된 항목이 있습니다. 일괄 생성을 눌러주세요.
</div> </div>
</div> </div>
<div id="upload-preview-table-wrapper" style="flex:1; overflow:auto; padding:0;"> <div id="upload-preview-table-wrapper" style="flex:1; overflow:auto; padding:0;"></div>
<!-- Table will be injected here -->
</div>
</div> </div>
</div> </div>
<div class="modal-footer" style="background:#f9fafb; border-top:1px solid var(--border-color); flex-shrink: 0;"> <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;"> <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-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;"> <button id="btn-confirm-upload" class="btn btn-primary" style="height:40px; padding:0 2rem;">
<i data-lucide="save"></i> 최종 데이터 저장하기 <i data-lucide="save"></i> 최종 데이터 저장하기
</button> </button>
@@ -70,29 +66,20 @@ export function initUploadPreviewModal(onSuccess?: () => void) {
if (!document.getElementById('upload-preview-modal')) { if (!document.getElementById('upload-preview-modal')) {
document.body.insertAdjacentHTML('beforeend', UPLOAD_PREVIEW_MODAL_HTML); document.body.insertAdjacentHTML('beforeend', UPLOAD_PREVIEW_MODAL_HTML);
} }
document.getElementById('btn-close-upload-preview')?.addEventListener('click', closeModals); document.getElementById('btn-close-upload-preview')?.addEventListener('click', closeModals);
document.getElementById('btn-cancel-upload')?.addEventListener('click', closeModals); document.getElementById('btn-cancel-upload')?.addEventListener('click', closeModals);
document.getElementById('btn-confirm-upload')?.addEventListener('click', () => { document.getElementById('btn-confirm-upload')?.addEventListener('click', () => confirmUpload());
confirmUpload(); document.getElementById('btn-bulk-generate-codes')?.addEventListener('click', () => generateBulkCodes());
});
document.getElementById('btn-bulk-generate-codes')?.addEventListener('click', () => {
generateBulkCodes();
});
} }
export function openUploadPreview(data: any) { export function openUploadPreview(data: any) {
parsedData = data; parsedData = data;
hasAttemptedBulkGenerate = false;
const tabNames = Object.keys(data); const tabNames = Object.keys(data);
if (tabNames.length === 0) { if (tabNames.length === 0) { alert('업로드할 데이터가 없습니다.'); return; }
alert('업로드할 데이터가 없습니다.');
return;
}
currentTab = tabNames[0]; currentTab = tabNames[0];
renderTabs(); renderTabs();
renderCurrentTable(); renderCurrentTable();
openModal('upload-preview-modal'); openModal('upload-preview-modal');
createIcons({ icons: { X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } }); createIcons({ icons: { X, Check, Database, Save, FileSpreadsheet, Layers, RefreshCcw } });
} }
@@ -101,36 +88,19 @@ function renderTabs() {
const container = document.getElementById('upload-tabs-container'); const container = document.getElementById('upload-tabs-container');
if (!container) return; if (!container) return;
container.innerHTML = ''; container.innerHTML = '';
Object.keys(parsedData).forEach(tab => { Object.keys(parsedData).forEach(tab => {
const btn = document.createElement('div'); const btn = document.createElement('div');
btn.className = `upload-tab-btn ${tab === currentTab ? 'active' : ''}`; btn.className = `upload-tab-btn ${tab === currentTab ? 'active' : ''}`;
btn.style.cssText = ` btn.style.cssText = `
padding: 0.75rem 1rem; padding: 0.6rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500;
border-radius: 8px; display: flex; justify-content: space-between; align-items: center; transition: all 0.2s;
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'}; background: ${tab === currentTab ? 'white' : 'transparent'};
color: ${tab === currentTab ? 'var(--primary-color)' : 'var(--text-main)'}; color: ${tab === currentTab ? 'var(--primary-color)' : 'var(--text-main)'};
box-shadow: ${tab === currentTab ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'}; box-shadow: ${tab === currentTab ? '0 2px 4px rgba(0,0,0,0.05)' : 'none'};
border: 1px solid ${tab === currentTab ? 'var(--border-color)' : 'transparent'}; 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.innerHTML = ` btn.onclick = () => { syncCurrentDataFromDOM(); currentTab = tab; renderTabs(); renderCurrentTable(); };
<span>${tab}</span>
<span style="font-size:11px; opacity:0.6;">${parsedData[tab].length}</span>
`;
btn.onclick = () => {
currentTab = tab;
renderTabs();
renderCurrentTable();
};
container.appendChild(btn); container.appendChild(btn);
}); });
} }
@@ -139,6 +109,7 @@ function renderCurrentTable() {
const tableWrapper = document.getElementById('upload-preview-table-wrapper'); const tableWrapper = document.getElementById('upload-preview-table-wrapper');
const tabNameEl = document.getElementById('current-tab-name'); const tabNameEl = document.getElementById('current-tab-name');
const tabCountEl = document.getElementById('current-tab-count'); const tabCountEl = document.getElementById('current-tab-count');
const warningText = document.getElementById('upload-warning-text');
if (!tableWrapper || !tabNameEl || !tabCountEl) return; if (!tableWrapper || !tabNameEl || !tabCountEl) return;
const data = parsedData[currentTab]; const data = parsedData[currentTab];
@@ -147,58 +118,155 @@ function renderCurrentTable() {
const generateBtn = document.getElementById('btn-bulk-generate-codes'); const generateBtn = document.getElementById('btn-bulk-generate-codes');
const isHwTab = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'].includes(currentTab); const isHwTab = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'].includes(currentTab);
if (generateBtn) { if (generateBtn) { isHwTab ? generateBtn.classList.remove('hidden') : generateBtn.classList.add('hidden'); }
if (isHwTab) generateBtn.classList.remove('hidden');
else generateBtn.classList.add('hidden');
}
if (!data || data.length === 0) { if (!data || data.length === 0) {
tableWrapper.innerHTML = '<div style="padding:4rem; text-align:center; color:var(--text-muted);">표시할 데이터가 없습니다.</div>'; tableWrapper.innerHTML = '<div style="padding:4rem; text-align:center; color:var(--text-muted);">표시할 데이터가 없습니다.</div>';
return; return;
} }
// Get headers from first item keys, excluding 'id' and 'type' for cleaner view const rawHeaders = Object.keys(data[0]).filter(k => k !== 'id' && k !== 'type' && k !== '상세용도');
const headers = Object.keys(data[0]).filter(k => k !== 'id' && k !== 'type'); 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 = ` let tableHTML = `
<table class="preview-table" style="width:100%; border-collapse:collapse; min-width:max-content;"> <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);"> <thead style="position:sticky; top:0; z-index:10; background:#f8fafc; box-shadow:0 1px 0 var(--border-color);">
<tr> <tr>
<th style="padding:0.75rem 1rem; text-align:center; font-size:12px; border-bottom:1px solid var(--border-color); width:50px;">No.</th> <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>
${headers.map(h => `<th style="padding:0.75rem 1rem; text-align:left; font-size:12px; border-bottom:1px solid var(--border-color); color:var(--text-muted);">${h}</th>`).join('')} ${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> </tr>
</thead> </thead>
<tbody> <tbody>
${data.map((row: any, idx: number) => ` ${data.map((row: any, idx: number) => {
<tr style="border-bottom:1px solid #f1f5f9;"> const isCodeMissing = isHwTab && !row[ASSET_SCHEMA.ASSET_CODE.key];
<td style="padding:0.75rem 1rem; text-align:center; font-size:13px; color:var(--text-muted);">${idx + 1}</td> if (isCodeMissing) hasAnyMissingCode = true;
${headers.map(h => `<td style="padding:0.75rem 1rem; font-size:13px;">${row[h] || '-'}</td>`).join('')} const showRedBox = hasAttemptedBulkGenerate && isCodeMissing;
</tr> const trStyle = showRedBox ? 'outline: 2px solid #ff3d00; outline-offset: -2px; background: #fff5f2;' : 'border-bottom:1px solid #f1f5f9;';
`).join('')}
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> </tbody>
</table> </table>
`; `;
tableWrapper.innerHTML = tableHTML; 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() { async function confirmUpload() {
const confirmBtn = document.getElementById('btn-confirm-upload') as HTMLButtonElement; syncCurrentDataFromDOM();
if (confirmBtn) { const tabNames = Object.keys(parsedData);
confirmBtn.disabled = true; const hwTabs = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
confirmBtn.innerHTML = '<i data-lucide="loader-2" class="animate-spin"></i> 저장 중...'; for (const tab of tabNames) {
createIcons({ icons: { Save } }); 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;
}
}
} }
try { const confirmBtn = document.getElementById('btn-confirm-upload') as HTMLButtonElement;
const tabNames = Object.keys(parsedData); if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.innerHTML = '저장 중...'; }
let successCount = 0;
try {
let successCount = 0;
const API_BASE = `http://${location.hostname}:3000`;
for (const tab of tabNames) { for (const tab of tabNames) {
const data = parsedData[tab]; const data = parsedData[tab];
let endpoint = ''; let endpoint = '';
const API_BASE = `http://${location.hostname}:3000`;
if (tab === '개인PC') endpoint = `${API_BASE}/api/pc/batch`; 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/server/batch`;
else if (tab === '스토리지') endpoint = `${API_BASE}/api/storage/batch`; else if (tab === '스토리지') endpoint = `${API_BASE}/api/storage/batch`;
@@ -208,102 +276,48 @@ async function confirmUpload() {
else if (tab === '영구SW') endpoint = `${API_BASE}/api/sw/perm/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/cloud/batch`;
else if (tab === '도메인') endpoint = `${API_BASE}/api/ops/domain/batch`; else if (tab === '도메인') endpoint = `${API_BASE}/api/ops/domain/batch`;
if (endpoint) { if (endpoint) {
try { const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
const response = await fetch(endpoint, { if (response.ok) successCount++;
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
successCount++;
} else {
const errRes = await response.json();
throw new Error(`[${tab}] ${errRes.error || '저장 실패'}`);
}
} catch (e: any) {
alert(`카테고리 '${tab}' 저장 중 오류: ${e.message}`);
throw e; // Stop processing further tabs
}
} }
} }
if (successCount > 0) { if (onSuccessCallback) onSuccessCallback(); closeModals(); alert(`${successCount}개 카테고리의 데이터가 성공적으로 업로드되었습니다.`); }
if (successCount > 0) { else { alert('데이터 업로드에 실패했습니다.'); }
if (onSuccessCallback) onSuccessCallback(); } catch (err) { alert('업로드 중 오류가 발생했습니다.'); }
closeModals(); finally { if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.innerHTML = '<i data-lucide="save"></i> 최종 데이터 저장하기'; createIcons({ icons: { Save } }); } }
alert(`${successCount}개 카테고리의 데이터가 성공적으로 업로드되었습니다.`);
} else {
alert('데이터 업로드에 실패했습니다.');
}
} catch (err) {
console.error(err);
// 상세 에러는 내부 catch에서 이미 alert으로 띄움
} finally {
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i data-lucide="save"></i> 최종 데이터 저장하기';
createIcons({ icons: { Save } });
}
}
} }
async function generateBulkCodes() { async function generateBulkCodes() {
syncCurrentDataFromDOM();
const data = parsedData[currentTab]; const data = parsedData[currentTab];
if (!data) return; if (!data) return;
hasAttemptedBulkGenerate = true;
const generateBtn = document.getElementById('btn-bulk-generate-codes') as HTMLButtonElement; const generateBtn = document.getElementById('btn-bulk-generate-codes') as HTMLButtonElement;
if (generateBtn) { if (generateBtn) { generateBtn.disabled = true; generateBtn.innerHTML = '생성 중...'; }
generateBtn.disabled = true;
generateBtn.innerHTML = '<i data-lucide="refresh-ccw" class="animate-spin"></i> 생성 중...';
createIcons({ icons: { RefreshCcw } });
}
try { try {
// Group rows by prefix (type + purchase_ym) const rowsToProcess = data.filter((r: any) => !r[ASSET_SCHEMA.ASSET_CODE.key]);
const rowsToProcess = data.filter((r: any) => !r.); if (rowsToProcess.length === 0) { alert('이미 모든 항목에 자산코드가 부여되어 있습니다.'); return; }
if (rowsToProcess.length === 0) {
alert('이미 모든 항목에 자산코드가 부여되어 있습니다.');
return;
}
const groups: Record<string, any[]> = {}; const groups: Record<string, any[]> = {};
rowsToProcess.forEach((r: any) => { rowsToProcess.forEach((r: any) => {
const type = r. || r. || r.type || 'ETC'; const type = r. || r. || r.type || 'ETC';
const typeCode = TYPE_PREFIX_MAP[type] || 'ETC'; const typeCode = TYPE_PREFIX_MAP[type] || 'ETC';
const purchaseYM = String(r. || '').replace(/[^0-9]/g, ''); const purchaseYM = String(r[ASSET_SCHEMA.PURCHASE_YM.key] || '').replace(/[^0-9]/g, '');
if (purchaseYM.length < 6) { if (purchaseYM.length < 6) return;
// Fallback or skip
return;
}
const prefix = `${typeCode}-${purchaseYM.substring(0, 6)}-`; const prefix = `${typeCode}-${purchaseYM.substring(0, 6)}-`;
if (!groups[prefix]) groups[prefix] = []; if (!groups[prefix]) groups[prefix] = [];
groups[prefix].push(r); groups[prefix].push(r);
}); });
for (const prefix in groups) { for (const prefix in groups) {
const rows = groups[prefix]; const rows = groups[prefix];
// Fetch current next code for this prefix
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}`); const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}`);
const result = await res.json(); const result = await res.json();
if (result.nextCode) { if (result.nextCode) {
let baseNum = parseInt(result.nextCode.replace(prefix, '')); let baseNum = parseInt(result.nextCode.replace(prefix, ''));
rows.forEach((r, idx) => { rows.forEach((r, idx) => { r[ASSET_SCHEMA.ASSET_CODE.key] = `${prefix}${(baseNum + idx).toString().padStart(4, '0')}`; });
r. = `${prefix}${(baseNum + idx).toString().padStart(4, '0')}`;
});
} }
} }
renderCurrentTable(); renderCurrentTable();
alert(`${rowsToProcess.length}건의 자산코드 생성되었습니다.`); alert('자산코드 생성이 완료되었습니다.');
} catch (err) { } catch (err) { alert('자산코드 생성 중 오류가 발생했습니다.'); }
console.error(err); finally { if (generateBtn) { generateBtn.disabled = false; generateBtn.innerHTML = '<i data-lucide="refresh-ccw"></i> 자산코드 일괄 생성'; createIcons({ icons: { RefreshCcw } }); } }
alert('자산코드 생성 중 오류가 발생했습니다.');
} finally {
if (generateBtn) {
generateBtn.disabled = false;
generateBtn.innerHTML = '<i data-lucide="refresh-ccw"></i> 자산코드 일괄 생성';
createIcons({ icons: { RefreshCcw } });
}
}
} }

View File

@@ -3,11 +3,11 @@ import { state } from '../core/state';
const MENU_CONFIG = { const MENU_CONFIG = {
hw: { hw: {
label: '하드웨어', label: '하드웨어',
tabs: ['대시보드', '서버', '개인PC', '모바일기기', '스토리지', '전산비품'] tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품', '모바일기기']
}, },
sw: { sw: {
label: '소프트웨어', label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW'] tabs: ['대시보드', '구독SW', '영구SW', '클라우드']
}, },
ops: { ops: {
label: '운영 서비스', label: '운영 서비스',

View File

@@ -1,4 +1,7 @@
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;
@@ -6,8 +9,10 @@ export interface HardwareAsset {
type: string; type: string;
법인: string; 법인: string;
자산코드: string; 자산코드: string;
사용자: string;
명칭: string; 명칭: string;
위치: string; 위치: string;
상세위치: string;
관리자: string; 관리자: string;
IP주소: string; IP주소: string;
MACaddress: string; MACaddress: string;
@@ -72,65 +77,176 @@ export interface MasterAssetData {
logs: HardwareLog[]; logs: HardwareLog[];
} }
const PC_HEADERS = ['법인', '자산코드', '구매연월', '사용자', '현사용조직', '이전사용조직', '위치', '담당자(정)', '담당자(부)', '모델명', 'OS', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'SSD3', '메인보드', 'IP주소', '금액', '납품업체', '품의서명', '비고']; const PC_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.USER.ui, ASSET_SCHEMA.ORG.ui,
const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매연월', '금액', '납품업체', '품의서명', '비고']; ASSET_SCHEMA.MAINBOARD.ui, ASSET_SCHEMA.OS.ui, ASSET_SCHEMA.CPU.ui, 'GPU', ASSET_SCHEMA.RAM.ui,
const EQUIP_HEADERS = ['법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고']; ASSET_SCHEMA.STORAGE1.ui, ASSET_SCHEMA.STORAGE2.ui, ASSET_SCHEMA.STORAGE3.ui,
const MOBILE_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고']; 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 SUB_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스유형', '계정명', '비고']; const BASE_HW_HEADERS = [
const PERM_SW_HEADERS = ['분야', '법인', '제품명', '부서', '수량', '금액', '구매일', '납품업체', '시작일', '만료일', '라이선스키', '계정명', '비고']; ASSET_SCHEMA.CORP.ui, '유형', ASSET_SCHEMA.ASSET_CODE.ui, ASSET_SCHEMA.PURCHASE_YM.ui,
const CLOUD_HEADERS = ['플랫폼명', '법인', '제품명', '부서', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고']; ASSET_SCHEMA.ORG.ui, ASSET_SCHEMA.LOCATION.ui, ASSET_SCHEMA.DETAIL_LOCATION.ui,
ASSET_SCHEMA.PURPOSE.ui, ASSET_SCHEMA.DETAILS.ui, // 용도, 상세 추가
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 DOMAIN_HEADERS = ['유형', '법인', '서비스명', '관리도메인', '시작일', '만료일', '금액', '담당자', '담당자(부)', '비고']; const SERVER_HEADERS = [...BASE_HW_HEADERS];
const STORAGE_HEADERS = [...BASE_HW_HEADERS];
export function downloadTemplate() { // 1. 전산비품/모바일기기: 현 사용조직 추가
const wb = XLSX.utils.book_new(); const EQUIP_HEADERS = [
const tabConfigs = [ ASSET_SCHEMA.CORP.ui, '유형', ASSET_SCHEMA.ASSET_CODE.ui, ASSET_SCHEMA.MODEL.ui,
{ name: '개인PC', headers: PC_HEADERS }, ASSET_SCHEMA.ORG.ui, // 현 사용조직 추가
{ name: '서버', headers: SERVER_HEADERS }, ASSET_SCHEMA.STORE_LOC.ui, ASSET_SCHEMA.DETAIL_LOCATION.ui,
{ name: '스토리지', headers: STORAGE_HEADERS }, ASSET_SCHEMA.MANAGER_MAIN.ui, ASSET_SCHEMA.MANAGER_SUB.ui,
{ name: '전산비품', headers: EQUIP_HEADERS }, ASSET_SCHEMA.PURCHASE_YM.ui, ASSET_SCHEMA.PRICE.ui, ASSET_SCHEMA.REMARKS.ui
{ name: '모바일기기', headers: MOBILE_HEADERS }, ];
{ name: '구독SW', headers: SUB_SW_HEADERS }, const MOBILE_HEADERS = [...EQUIP_HEADERS];
{ name: '영구SW', headers: PERM_SW_HEADERS },
{ name: '클라우드', headers: CLOUD_HEADERS },
{ name: '도메인', headers: DOMAIN_HEADERS }
];
const sampleData: Record<string, any[]> = { 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];
'개인PC': ['(주)에이치엠', 'PC-24001', '202401', '홍길동', '기술팀', '-', '서울본사 7층', '김관리', '이부관', 'LG Gram 16', 'Windows 11', 'i7-1360P', 'RTX 3050', '16GB', '512GB', '-', '-', 'LG Mainboard', '192.168.0.10', '1500000', 'LG전자', '2024_상반기_PC구매.pdf', '신규 입사자 지급용'], 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];
'서버': ['(주)에이치엠', 'SRV-24001', '202401', '물리', '웹서버', '운영 웹 서버', '인프라팀', '-', 'IDC 센터 1-A', '박서버', '최백업', '10.0.0.1', '10.0.0.2', 'RDP', 'admin', '********', 'Dell PowerEdge R750', 'Ubuntu 22.04', 'Xeon Gold 6330', '128GB', '-', '1TB SSD', '1TB SSD', '2TB HDD', 'Zabbix', '8500000', '델테크놀로지스', '2024_IDC_확장품의.pdf', '운영 환경 전용'],
'도메인': ['도메인', '(주)에이치엠', '대표홈페이지', 'hm-corp.com', '2024-01-01', '2025-01-01', '55000', '홍길동', '이부관', '가비아 자동갱신'] 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: ['태블릿', '모바일', '노트북']
}; };
tabConfigs.forEach(config => { refSheet.getColumn(1).values = ['CorpList', ...corps];
const data = [config.headers]; refSheet.getColumn(2).values = ['OrgList', ...orgs];
if (sampleData[config.name]) { refSheet.getColumn(3).values = ['LocList', ...buildings];
data.push(sampleData[config.name]); refSheet.getColumn(4).values = ['ServerTypes', ...typeLists.server];
} refSheet.getColumn(5).values = ['StorageTypes', ...typeLists.storage];
const ws = XLSX.utils.aoa_to_sheet(data); refSheet.getColumn(6).values = ['EquipTypes', ...typeLists.equip];
ws['!cols'] = Array(config.headers.length).fill({ wch: 20 }); refSheet.getColumn(7).values = ['MobileTypes', ...typeLists.mobile];
XLSX.utils.book_append_sheet(wb, ws, config.name);
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);
}); });
XLSX.writeFile(wb, 'itam_assets_template.xlsx'); const tabConfigs = [
{ name: '개인PC', headers: PC_HEADERS, typeRef: '' },
{ name: '서버', headers: SERVER_HEADERS, typeRef: 'ServerTypes' },
{ name: '스토리지', headers: STORAGE_HEADERS, typeRef: 'StorageTypes' },
{ name: '전산비품', headers: EQUIP_HEADERS, typeRef: 'EquipTypes' },
{ 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 => {
const sheet = workbook.addWorksheet(config.name);
sheet.getRow(1).values = config.headers;
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; });
});
refSheet.state = 'veryHidden';
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ITAM_Standard_Template_${new Date().toISOString().split('T')[0]}.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., a._정, a._부, a., a.OS, a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.SSD3, a., a.IP주소, a., a., a., a.] }, {
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.type, 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.SSD3, a., a., a., a., a.] }, tab: '개인PC', list: masterData.pc, headers: PC_HEADERS,
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, 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.] }, 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., a.OS, 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., 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., 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),
{ tab: '클라우드', list: masterData.cloud, headers: CLOUD_HEADERS, map: (a: any) => [a., a., a., a., a., a., a., a., a., a.] }, 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] } { 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);
@@ -138,66 +254,81 @@ export function exportToExcel(masterData: MasterAssetData) {
XLSX.writeFile(wb, `itam_master_${new Date().toISOString().split('T')[0]}.xlsx`); XLSX.writeFile(wb, `itam_master_${new Date().toISOString().split('T')[0]}.xlsx`);
} }
/**
* 엑셀 날짜 데이터(숫자 또는 문자열)를 YYYY-MM-DD 형식의 문자열로 변환
*/
export function formatExcelDate(val: any): string {
if (!val) return '';
if (typeof val === 'number') {
// 엑셀 날짜 숫자 (1899-12-30 기준 일수)
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0];
}
// 이미 문자열인 경우 기호 통일 (YYYY.MM.DD -> YYYY-MM-DD)
if (typeof val === 'string') {
return val.replace(/\./g, '-').trim();
}
return val ? String(val) : '';
}
export async function parseExcel(file: File): Promise<any> { 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: 'array' }); const workbook = XLSX.read(e.target?.result, { type: 'binary' });
const parsedData: any = {}; const parsedData: any = {};
workbook.SheetNames.forEach(sheetName => {
workbook.SheetNames.forEach(rawSheetName => { if (sheetName === 'RefData') return;
const sheetName = rawSheetName.trim(); const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
const ws = workbook.Sheets[rawSheetName];
const rows = XLSX.utils.sheet_to_json(ws, { defval: "" }) as any[];
const list: any[] = []; const list: any[] = [];
rows.forEach(r => {
rows.forEach(rawR => {
// 헤더명에 공백이 포함된 경우 대비하여 키 정리 (trim)
const r: any = {};
Object.keys(rawR).forEach(k => { r[k.trim()] = rawR[k]; });
const common = { id: Math.random().toString(36).substring(2, 9) }; const common = { id: Math.random().toString(36).substring(2, 9) };
const mapVal = (schemaItem: any) => r[schemaItem.ui] || '';
if (sheetName === '개인PC') { if (sheetName === '개인PC') {
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6); list.push({
list.push({ ...common, type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 구매연월: purchaseYM, 사용자: r['사용자']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', SSD3: r['SSD3']||'', 메인보드: r['메인보드']||'', IP주소: r['IP주소']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }); ...common, type: 'PC', : '개인PC',
} else if (sheetName === '서버') { [ASSET_SCHEMA.CORP.key]: mapVal(ASSET_SCHEMA.CORP),
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6); [ASSET_SCHEMA.ASSET_CODE.key]: mapVal(ASSET_SCHEMA.ASSET_CODE),
list.push({ ...common, type: '서버', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 구매연월: purchaseYM, 상세용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||'', 서버ID: r['서버 ID']||'', 서버PW: r['서버 PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||'', SSD2: r['Storage 2']||'', SSD3: r['Storage 3']||'', 모니터링: r['모니터링']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', type2: r['유형']||'물리' }); [ASSET_SCHEMA.USER.key]: mapVal(ASSET_SCHEMA.USER),
} else if (sheetName === '스토리지') { [ASSET_SCHEMA.ORG.key]: mapVal(ASSET_SCHEMA.ORG),
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6); [ASSET_SCHEMA.MAINBOARD.key]: mapVal(ASSET_SCHEMA.MAINBOARD),
list.push({ ...common, type: '스토리지', 법인: r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }); [ASSET_SCHEMA.OS.key]: mapVal(ASSET_SCHEMA.OS),
} else if (sheetName === '전산비품') { [ASSET_SCHEMA.CPU.key]: mapVal(ASSET_SCHEMA.CPU),
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6); GPU: r['GPU'] || '',
list.push({ ...common, type: '전산비품', 법인: r['법인']||'', 비품유형: r['비품유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }); [ASSET_SCHEMA.RAM.key]: mapVal(ASSET_SCHEMA.RAM),
} else if (sheetName === '모바일기기') { [ASSET_SCHEMA.STORAGE1.key]: mapVal(ASSET_SCHEMA.STORAGE1),
const purchaseYM = formatExcelDate(r['구매연월']).replace(/-/g, '').substring(0, 6); [ASSET_SCHEMA.STORAGE2.key]: mapVal(ASSET_SCHEMA.STORAGE2),
list.push({ ...common, type: '모바일기기', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', 기기유형: r['기기유형']||'', OS: r['OS']||'', 구매연월: purchaseYM, 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }); [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') {
list.push({ ...common, type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: formatExcelDate(r['구매일']), 시작일: formatExcelDate(r['시작일']), 만료일: formatExcelDate(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') {
list.push({ ...common, type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: formatExcelDate(r['구매일']), 시작일: formatExcelDate(r['시작일']), 만료일: formatExcelDate(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 === '클라우드') { } else if (sheetName === '클라우드') {
list.push({ ...common, type: '클라우드', 플랫폼명: r['플랫폼명']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 계정명: r['계정명']||'', 결제수단: r['결제수단']||'', 결제일: r['결제일']||'', 연결카드번호: r['연결카드번호']||'', 당월청구액: r['당월청구액']||'', 비고: r['비고']||'' }); 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 === '도메인') { } else if (sheetName === '도메인') {
list.push({ ...common, type: r['유형']||'도메인', corp: r['법인']||'', service_name: r['서비스명']||'', domain_name: r['관리도메인']||'', start_date: formatExcelDate(r['시작일']), expiry_date: formatExcelDate(r['만료일']), price: r['금액']||'', manager_main: r['담당자']||'', manager_sub: r['담당자(부)']||'', remarks: r['비고']||'' }); 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['비고']||'' });
} }
}); });
if (list.length > 0) parsedData[sheetName] = list; if (list.length > 0) parsedData[sheetName] = list;
@@ -205,6 +336,6 @@ export async function parseExcel(file: File): Promise<any> {
resolve(parsedData); resolve(parsedData);
} catch (err) { reject(err); } } catch (err) { reject(err); }
}; };
reader.readAsArrayBuffer(file); reader.readAsBinaryString(file);
}); });
} }

View File

@@ -13,19 +13,22 @@ 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: '도입금액' },
VENDOR: { key: '납품업체', db: 'vendor', ui: '납품업체' }, VENDOR: { key: '납품업체', db: 'vendor', ui: '납품업체' },
DOC_NAME: { key: '품의서명', db: 'doc_name', ui: '품의서' }, DOC_NAME: { key: '품의서명', db: 'doc_name', ui: '품의서' },
REMARKS: { key: '비고', db: 'remarks', ui: '비고' }, REMARKS: { key: '비고', db: 'remarks', ui: '비고' },
DETAIL_PURPOSE: { key: '상세용도', db: 'detail_purpose', 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: '운영체제' },
@@ -33,13 +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 주소' },
GPU: { key: 'GPU', db: 'gpu', ui: 'GPU' },
STORAGE3: { key: 'SSD3', db: 'storage3', ui: 'Storage 3' },
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

@@ -28,7 +28,7 @@ export interface AppState {
// 초기 상태 // 초기 상태
export const state: AppState = { export const state: AppState = {
activeCategory: 'hw', activeCategory: 'dashboard',
activeSubTab: '대시보드', activeSubTab: '대시보드',
masterData: { masterData: {
pc: [], pc: [],

View File

@@ -1,46 +0,0 @@
/**
* 공통 테이블 핸들러
*/
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: string;
direction: SortDirection;
}
/**
* 테이블 헤더에 정렬 이벤트를 바인딩합니다.
* @param table 대상 테이블 요소
* @param currentState 현재 정렬 상태
* @param onSort 정렬 변경 시 호출될 콜백
*/
export function setupTableSorting(
table: HTMLTableElement,
currentState: SortState,
onSort: (key: string, direction: SortDirection) => void
) {
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(th => {
const key = th.getAttribute('data-sort')!;
th.classList.add('sortable');
// 현재 정렬 상태 표시
if (currentState.key === key) {
th.classList.add(currentState.direction);
} else {
th.classList.remove('asc', 'desc');
}
th.onclick = () => {
let nextDirection: SortDirection = 'asc';
if (currentState.key === key) {
nextDirection = currentState.direction === 'asc' ? 'desc' : 'asc';
}
onSort(key, nextDirection);
};
});
}

View File

@@ -71,55 +71,22 @@ export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: stri
} }
/** /**
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순) * 자산 목록 정렬 (방안 C: 구매법인별 -> 자산번호 순)
*/ */
export function sortAssets<T>(list: T[]): T[] { export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => { return [...list].sort((a: any, b: any) => {
// 1순위: 법인 (가나다순) // 1순위: 구매법인 (한글 가나다순)
const corpA = String(a. || a.corp || '').trim(); const corpA = String(a. || '').trim();
const corpB = String(b. || b.corp || '').trim(); const corpB = String(b. || '').trim();
if (corpA < corpB) return -1; if (corpA < corpB) return -1;
if (corpA > corpB) return 1; if (corpA > corpB) return 1;
// 2순위: 자산번호/코드 (영문/숫자순) // 2순위: 자산번호 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim(); const codeA = String(a. || a. || '').trim();
const codeB = String(b. || b. || b.id || '').trim(); const codeB = String(b. || b. || '').trim();
if (codeA < codeB) return -1; if (codeA < codeB) return -1;
if (codeA > codeB) return 1; if (codeA > codeB) return 1;
return 0; return 0;
}); });
} }
/**
* 동적 정렬 함수
* @param list 정렬할 목록
* @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc')
*/
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
// 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA;
}
// 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10);
const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA;
}
// 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}

View File

@@ -163,21 +163,9 @@ function initApp() {
} }
}); });
// 시크릿 클라우드 트리거
document.getElementById('secret-cloud-trigger')?.addEventListener('click', () => {
state.activeCategory = 'sw';
state.activeSubTab = '클라우드';
const mainContent = document.getElementById('main-content')!;
renderSWTable(mainContent);
});
createIcons({ createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
}); });
window.addEventListener('refresh-view', () => {
console.log('🔄 Refreshing view due to event');
refreshView();
});
} }
document.addEventListener('DOMContentLoaded', initApp); document.addEventListener('DOMContentLoaded', initApp);

View File

@@ -64,14 +64,11 @@
background-color: var(--white); background-color: var(--white);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
overflow: auto; overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
} }
table { table {
width: 100%; width: 100%;
border-collapse: separate; border-collapse: collapse;
border-spacing: 0;
table-layout: auto; table-layout: auto;
} }
@@ -82,21 +79,15 @@ th, td {
white-space: nowrap; white-space: nowrap;
} }
thead {
position: sticky;
top: 0;
z-index: 50;
}
th { th {
background-color: #FAFAFA !important; background-color: #FAFAFA;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 10;
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */ box-shadow: inset 0 -1px 0 var(--border-color);
text-transform: none; text-transform: none;
} }
@@ -132,40 +123,3 @@ tbody tr:hover {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
position: relative;
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
}
th.sortable:hover {
background-color: #F3F4F6;
color: var(--primary-color);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
opacity: 0.3;
transition: all 0.2s;
}
th.sortable.asc::after {
content: '▲';
opacity: 1;
color: var(--primary-color);
}
th.sortable.desc::after {
content: '▼';
opacity: 1;
color: var(--primary-color);
}

View File

@@ -65,25 +65,22 @@ export function renderHwDashboard(container: HTMLElement) {
container.innerHTML = ` container.innerHTML = `
<div class="view-container"> <div class="view-container">
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;"> <div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card stat-card">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">전체 평균 사용 연수</span> <div class="stat-label">전체 평균 사용 연수</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">전체 자산 기준 (권장 4.5년)</div> <div class="stat-value">${avgAge}<span class="unit">년</span></div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${avgAge}년</div> <div class="stat-footer">권장 교체 주기: 4.5년</div>
<div style="width: 100%; height: 4px; background-color: var(--dash-primary); border-radius: 2px; margin-top: 0.5rem;"></div>
</div> </div>
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card stat-card ${over5Rate >= 20 ? 'critical' : ''}">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">5년 이상 노후 자산 비율</span> <div class="stat-label">5년 이상 노후 자산 비율</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">총 ${over5YearsCount}대 해당</div> <div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div>
<div style="font-size: 2rem; font-weight:700; color:${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">${over5Rate}%</div> <div class="stat-footer">${over5YearsCount}대의 자산이 교체 대상을 초과함</div>
<div style="width: 100%; height: 4px; background-color: ${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'}; border-radius: 2px; margin-top: 0.5rem;"></div>
</div> </div>
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card stat-card">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">최신 도입 모델 (${latestYear}년)</span> <div class="stat-label">최신 도입 모델 (${latestYear}년)</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">자산번호: ${(latestAsset as any)?. || '-'}</div> <div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${(latestAsset as any)?. || '정보 없음'}">
<div style="font-size: 1.25rem; font-weight:700; color:var(--primary-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; height: 3rem; display: flex; align-items: center;" title="${(latestAsset as any)?. || '정보 없음'}">
${(latestAsset as any)?. || '정보 없음'} ${(latestAsset as any)?. || '정보 없음'}
</div> </div>
<div style="width: 100%; height: 4px; background-color: var(--primary-color); border-radius: 2px; margin-top: 0.5rem;"></div> <div class="stat-footer">가장 최근 자산번호: ${(latestAsset as any)?. || '-'}</div>
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ export function renderSwDashboard(container: HTMLElement) {
let subCost2026 = 0; let subCost2026 = 0;
let permCost2026 = 0; let permCost2026 = 0;
let cloudCost2026 = 0;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -21,8 +22,8 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {}; const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0); categories.forEach(c => costByCat[c] = 0);
// 통합 SW 데이터 (클라우드 제외) // 통합 SW 데이터
const allSw = [...state.masterData.subSw, ...state.masterData.permSw]; const allSw = [...state.masterData.subSw, ...state.masterData.permSw, ...state.masterData.cloud];
allSw.forEach(sw => { allSw.forEach(sw => {
const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id); const userMapping = state.masterData.swUsers.find(u => u.sw_id === sw.id);
@@ -43,6 +44,7 @@ export function renderSwDashboard(container: HTMLElement) {
if (sw. && sw..startsWith('2026')) { if (sw. && sw..startsWith('2026')) {
if (sw.type === '구독SW') subCost2026 += price; if (sw.type === '구독SW') subCost2026 += price;
else if (sw.type === '영구SW') permCost2026 += price; else if (sw.type === '영구SW') permCost2026 += price;
else if (sw.type === '클라우드') cloudCost2026 += price;
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price; if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price; if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
@@ -58,6 +60,7 @@ export function renderSwDashboard(container: HTMLElement) {
const cost = Number(log.cost) || 0; const cost = Number(log.cost) || 0;
if (asset.type === '구독SW') subCost2026 += cost; if (asset.type === '구독SW') subCost2026 += cost;
else if (asset.type === '영구SW') permCost2026 += cost; else if (asset.type === '영구SW') permCost2026 += cost;
else if (asset.type === '클라우드') cloudCost2026 += cost;
if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost; if (costByCorp[asset.] !== undefined) costByCorp[asset.] += cost;
if (asset. && costByCat[asset.] !== undefined) costByCat[asset.] += cost; if (asset. && costByCat[asset.] !== undefined) costByCat[asset.] += cost;
@@ -121,7 +124,7 @@ export function renderSwDashboard(container: HTMLElement) {
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3> <h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;"> <div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 누적 비용 (2026)</span> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 누적 비용 (2026)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">갱신 및 추가 비용 합계</div> <div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">갱신 및 추가 비용 합계</div>
@@ -134,6 +137,12 @@ export function renderSwDashboard(container: HTMLElement) {
<div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${permCost2026.toLocaleString()}</div> <div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${permCost2026.toLocaleString()}</div>
<div style="width: 100%; height: 4px; background-color: #3b82f6; border-radius: 2px; margin-top: 0.5rem;"></div> <div style="width: 100%; height: 4px; background-color: #3b82f6; border-radius: 2px; margin-top: 0.5rem;"></div>
</div> </div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">클라우드 누적 비용 (2026)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">월별 청구액 누적 합계</div>
<div style="font-size: 2rem; font-weight:700; color:#f59e0b;">₩ ${cloudCost2026.toLocaleString()}</div>
<div style="width: 100%; height: 4px; background-color: #f59e0b; border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,6 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal'; import { openSwModal } from '../../components/Modal/SWModal';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, Cloud, CreditCard, DollarSign, RefreshCcw } from 'lucide'; import { createIcons, Cloud, CreditCard, DollarSign, RefreshCcw } from 'lucide';
/** /**
@@ -11,7 +9,6 @@ import { createIcons, Cloud, CreditCard, DollarSign, RefreshCcw } from 'lucide';
*/ */
export function renderCloudList(container: HTMLElement) { export function renderCloudList(container: HTMLElement) {
const getFullList = () => state.masterData.cloud || []; const getFullList = () => state.masterData.cloud || [];
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -40,15 +37,15 @@ export function renderCloudList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center" style="width:50px;">No.</th> <th class="text-center">No.</th>
<th data-sort="${ASSET_SCHEMA.PLATFORM.key}">${ASSET_SCHEMA.PLATFORM.ui}</th> <th>${ASSET_SCHEMA.PLATFORM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="부서">담당부서</th> <th class="text-center">담당부서</th>
<th data-sort="${ASSET_SCHEMA.PRODUCT.key}">용도(프로젝트)</th> <th>용도(프로젝트)</th>
<th data-sort="${ASSET_SCHEMA.ACCOUNT.key}">${ASSET_SCHEMA.ACCOUNT.ui}</th> <th>${ASSET_SCHEMA.ACCOUNT.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PAY_METHOD.key}">${ASSET_SCHEMA.PAY_METHOD.ui}</th> <th class="text-center">${ASSET_SCHEMA.PAY_METHOD.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PAY_DAY.key}">${ASSET_SCHEMA.PAY_DAY.ui}</th> <th class="text-center">${ASSET_SCHEMA.PAY_DAY.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.BILLING.key}">${ASSET_SCHEMA.BILLING.ui}</th> <th class="text-center">${ASSET_SCHEMA.BILLING.ui}</th>
<th>${ASSET_SCHEMA.REMARKS.ui}</th> <th>${ASSET_SCHEMA.REMARKS.ui}</th>
</tr> </tr>
</thead> </thead>
@@ -66,7 +63,7 @@ export function renderCloudList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const payment = paymentSelect ? paymentSelect.value : ''; const payment = paymentSelect ? paymentSelect.value : '';
let filtered = getFullList().filter(asset => { const filtered = getFullList().filter(asset => {
const kwMatch = !keyword || const kwMatch = !keyword ||
(asset[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) || (asset[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) ||
(asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword) ||
@@ -75,10 +72,6 @@ export function renderCloudList(container: HTMLElement) {
return kwMatch && payMatch; return kwMatch && payMatch;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -112,12 +105,6 @@ export function renderCloudList(container: HTMLElement) {
tr.addEventListener('click', () => openSwModal(asset, 'view')); tr.addEventListener('click', () => openSwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } }); createIcons({ icons: { Cloud, CreditCard, DollarSign, RefreshCcw } });
}; };

View File

@@ -1,18 +1,11 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { formatPrice, dynamicSort, createBadge } from '../../core/utils'; import { formatPrice } from '../../core/utils';
import { createIcons, Plus, Edit2, Trash2 } from 'lucide'; import { createIcons, Plus, Edit2, Trash2 } from 'lucide';
import { openDomainModal } from '../../components/Modal/DomainModal'; import { openDomainModal } from '../../components/Modal/DomainModal';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { formatExcelDate } from '../../core/excelHandler';
// 정렬 상태를 모듈 수준에서 관리하여 화면 갱신 시에도 유지되도록 함
let persistentSortState: SortState = { key: '', direction: 'asc' };
export function renderDomainList(container: HTMLElement) { export function renderDomainList(container: HTMLElement) {
container.innerHTML = ''; container.innerHTML = '';
const fullList = state.masterData.domain;
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'list-header'; header.className = 'list-header';
header.innerHTML = ` header.innerHTML = `
@@ -24,76 +17,58 @@ export function renderDomainList(container: HTMLElement) {
const tableWrapper = document.createElement('div'); const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container'; tableWrapper.className = 'table-container';
const table = document.createElement('table'); const table = document.createElement('table');
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center; width:50px;">No.</th> <th style="text-align:center; width:50px;">No.</th>
<th style="text-align:center;" data-sort="type">유형</th> <th style="text-align:center;">유형</th>
<th style="text-align:center;" data-sort="corp">법인</th> <th style="text-align:center;">법인</th>
<th style="text-align:left;" data-sort="service_name">서비스명</th> <th style="text-align:left;">서비스명</th>
<th style="text-align:left;" data-sort="domain_name">관리도메인</th> <th style="text-align:left;">관리도메인</th>
<th style="text-align:left;" data-sort="remarks">구매업체</th> <th style="text-align:center;">시작일</th>
<th style="text-align:center;" data-sort="start_date">시작일</th> <th style="text-align:center;">만료일</th>
<th style="text-align:center;" data-sort="expiry_date">만료일</th> <th style="text-align:right;">금액</th>
<th style="text-align:right;" data-sort="price">금액</th> <th style="text-align:center;">담당자</th>
<th style="text-align:center;" data-sort="manager_main">담당자(정/부)</th> <th style="text-align:center;">담당자(부)</th>
<th style="text-align:left;">비고</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <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); tableWrapper.appendChild(table);
container.appendChild(tableWrapper); container.appendChild(tableWrapper);
const tbody = table.querySelector('tbody')!;
const updateTable = () => { // 이벤트 바인딩
let filtered = [...fullList]; table.querySelectorAll('.domain-row').forEach(row => {
row.addEventListener('click', () => {
if (persistentSortState.key) { const id = row.getAttribute('data-id');
filtered = dynamicSort(filtered, persistentSortState.key, persistentSortState.direction); const item = state.masterData.domain.find(d => d.id === id);
} if (item) openDomainModal(item);
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; padding: 3rem; color: var(--text-muted);">등록된 도메인 정보가 없습니다.</td></tr>`;
return;
}
filtered.forEach((item, idx) => {
const tr = document.createElement('tr');
tr.className = 'domain-row';
tr.style.cursor = 'pointer';
const managerHtml = [
item.manager_main ? `${createBadge('정', 'primary')} ${item.manager_main}` : '',
item.manager_sub ? `${createBadge('부', 'muted')} ${item.manager_sub}` : ''
].filter(v => v !== '').join(' / ');
tr.innerHTML = `
<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>${item.remarks || ''}</td>
<td style="text-align:center;">${formatExcelDate(item.start_date)}</td>
<td style="text-align:center;">${formatExcelDate(item.expiry_date)}</td>
<td style="text-align:right;">${formatPrice(item.price)}</td>
<td style="text-align:center;">${managerHtml || '-'}</td>
`;
tr.addEventListener('click', (e) => {
console.log('Row clicked:', item.domain_name);
openDomainModal(item);
});
tbody.appendChild(tr);
}); });
});
setupTableSorting(table, persistentSortState, (key, dir) => {
persistentSortState = { key, direction: dir };
updateTable();
});
};
updateTable();
createIcons({ icons: { Plus, Edit2, Trash2 } }); createIcons({ icons: { Plus, Edit2, Trash2 } });
} }

View File

@@ -1,8 +1,7 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils'; import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -11,7 +10,6 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderEquipmentList(container: HTMLElement) { export function renderEquipmentList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.equip); const fullList = sortAssets(state.masterData.equip);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -38,16 +36,16 @@ export function renderEquipmentList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center" style="width:50px;">No.</th> <th class="text-center">No.</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th> <th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.TYPE.key}">유형</th> <th class="text-center">유형</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th> <th>${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th> <th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th> <th class="text-center">담당자(정/부)</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -64,7 +62,7 @@ export function renderEquipmentList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) ||
@@ -73,10 +71,6 @@ export function renderEquipmentList(container: HTMLElement) {
return matchKeyword && matchCorp; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -114,12 +108,6 @@ export function renderEquipmentList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } }); createIcons({ icons: { RefreshCcw } });
}; };

View File

@@ -1,8 +1,7 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils'; import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -11,7 +10,6 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderMobileList(container: HTMLElement) { export function renderMobileList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.mobile); const fullList = sortAssets(state.masterData.mobile);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -38,15 +36,15 @@ export function renderMobileList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center" style="width:50px;">No.</th> <th class="text-center">No.</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STATUS.key}">${ASSET_SCHEMA.STATUS.ui}</th> <th class="text-center">${ASSET_SCHEMA.STATUS.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.MODEL.key}">${ASSET_SCHEMA.MODEL.ui}</th> <th>${ASSET_SCHEMA.MODEL.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.STORE_LOC.key}">${ASSET_SCHEMA.STORE_LOC.ui}</th> <th class="text-center">${ASSET_SCHEMA.STORE_LOC.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th> <th class="text-center">담당자(정/부)</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -63,7 +61,7 @@ export function renderMobileList(container: HTMLElement) {
const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : ''; const keyword = keywordInput ? keywordInput.value.toLowerCase().trim() : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.MODEL.key]||'').toLowerCase().includes(keyword) ||
@@ -72,10 +70,6 @@ export function renderMobileList(container: HTMLElement) {
return matchKeyword && matchCorp; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="9" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -112,12 +106,6 @@ export function renderMobileList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { RefreshCcw } }); createIcons({ icons: { RefreshCcw } });
}; };

View File

@@ -1,17 +1,15 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils'; import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, Paperclip, RefreshCcw } from 'lucide'; 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);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -39,19 +37,19 @@ export function renderPcList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center; width:50px;">No</th> <th class="text-center">No</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.USER.key}">${ASSET_SCHEMA.USER.ui}</th> <th class="text-center">${ASSET_SCHEMA.USER.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.MAINBOARD.key}">${ASSET_SCHEMA.MAINBOARD.ui}</th> <th class="text-center">담당자(정/부)</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.CPU.key}">${ASSET_SCHEMA.CPU.ui}</th> <th class="text-center">${ASSET_SCHEMA.MAINBOARD.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.RAM.key}">${ASSET_SCHEMA.RAM.ui}</th> <th class="text-center">${ASSET_SCHEMA.CPU.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.STORAGE1.key}">Storage</th> <th class="text-center">${ASSET_SCHEMA.RAM.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.PURCHASE_YM.key}">${ASSET_SCHEMA.PURCHASE_YM.ui}</th> <th class="text-center">Storage</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.PRICE.key}">${ASSET_SCHEMA.PRICE.ui}</th> <th class="text-center">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.DOC_NAME.ui}</th> <th class="text-center">${ASSET_SCHEMA.PRICE.ui}</th>
<th style="text-align:center;" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th> <th class="text-center">${ASSET_SCHEMA.DOC_NAME.ui}</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -64,11 +62,10 @@ 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 : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.USER.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.USER.key]||'').toLowerCase().includes(keyword) ||
@@ -78,13 +75,9 @@ export function renderPcList(container: HTMLElement) {
return matchKeyword && matchCorp; return matchKeyword && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" 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;
} }
@@ -93,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 = [
@@ -102,29 +94,23 @@ 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.MAINBOARD.key]||'-'}</td> <td class="text-center">${managerHtml || '-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.CPU.key]||''}</td> <td class="text-center">${asset[ASSET_SCHEMA.MAINBOARD.key]||'-'}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.RAM.key]||''}</td> <td class="text-center">${asset[ASSET_SCHEMA.CPU.key]||''}</td>
<td style="text-align:center;">${formatInline(storage)}</td> <td class="text-center">${asset[ASSET_SCHEMA.RAM.key]||''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td> <td class="text-center">${formatInline(storage)}</td>
<td style="text-align:right;">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td> <td class="text-center">${asset[ASSET_SCHEMA.PURCHASE_YM.key] || ''}</td>
<td style="text-align:center;">${asset[ASSET_SCHEMA.DOC_NAME.key] ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td> <td class="text-right">${Number(asset[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
<td style="text-align:center;">${managerHtml || '-'}</td> <td class="text-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);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Paperclip, RefreshCcw } }); createIcons({ icons: { Paperclip, RefreshCcw } });
}; };

View File

@@ -1,8 +1,7 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils'; import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -11,7 +10,6 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderServerList(container: HTMLElement) { export function renderServerList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.server); const fullList = sortAssets(state.masterData.server);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -44,14 +42,14 @@ export function renderServerList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center" style="width:50px;">No</th> <th class="text-center">No</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="${ASSET_SCHEMA.DETAIL_PURPOSE.key}">${ASSET_SCHEMA.DETAIL_PURPOSE.ui}</th> <th>용도</th>
<th data-sort="상세">상세</th> <th>상세</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th> <th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th> <th class="text-center">담당자(정/부)</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -70,7 +68,7 @@ export function renderServerList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : ''; const orgUnit = orgSelect ? orgSelect.value : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String(asset[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword) || String(asset[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword) ||
@@ -80,10 +78,6 @@ export function renderServerList(container: HTMLElement) {
return matchKeyword && matchCorp && matchOrg; return matchKeyword && matchCorp && matchOrg;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -106,7 +100,7 @@ export function renderServerList(container: HTMLElement) {
<td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td> <td class="text-center">${asset[ASSET_SCHEMA.CORP.key]}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td> <td class="text-center">${asset[ASSET_SCHEMA.ORG.key]||'-'}</td>
<td class="text-center">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td> <td class="text-center">${asset[ASSET_SCHEMA.ASSET_CODE.key]}</td>
<td>${formatInline(asset[ASSET_SCHEMA.DETAIL_PURPOSE.key])}</td> <td>${formatInline(asset.)}</td>
<td>${formatInline(asset.)}</td> <td>${formatInline(asset.)}</td>
<td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td> <td class="text-center">${formatInline(asset[ASSET_SCHEMA.LOCATION.key])}</td>
<td class="text-center">${managerHtml || '-'}</td> <td class="text-center">${managerHtml || '-'}</td>
@@ -114,11 +108,6 @@ export function renderServerList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
}; };
document.getElementById('filter-keyword')?.addEventListener('input', updateTable); document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,8 +1,7 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, createBadge, sortAssets, dynamicSort } from '../../core/utils'; import { formatInline, createBadge, sortAssets } from '../../core/utils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { createIcons, RefreshCcw } from 'lucide'; import { createIcons, RefreshCcw } from 'lucide';
/** /**
@@ -11,7 +10,6 @@ import { createIcons, RefreshCcw } from 'lucide';
*/ */
export function renderStorageList(container: HTMLElement) { export function renderStorageList(container: HTMLElement) {
const fullList = sortAssets(state.masterData.storage); const fullList = sortAssets(state.masterData.storage);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -44,14 +42,14 @@ export function renderStorageList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th class="text-center" style="width:50px;">No</th> <th class="text-center">No</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.CORP.key}">${ASSET_SCHEMA.CORP.ui}</th> <th class="text-center">${ASSET_SCHEMA.CORP.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ORG.key}">${ASSET_SCHEMA.ORG.ui}</th> <th class="text-center">${ASSET_SCHEMA.ORG.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.ASSET_CODE.key}">${ASSET_SCHEMA.ASSET_CODE.ui}</th> <th class="text-center">${ASSET_SCHEMA.ASSET_CODE.ui}</th>
<th data-sort="용도">용도</th> <th>용도</th>
<th data-sort="상세">상세</th> <th>상세</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.LOCATION.key}">${ASSET_SCHEMA.LOCATION.ui}</th> <th class="text-center">${ASSET_SCHEMA.LOCATION.ui}</th>
<th class="text-center" data-sort="${ASSET_SCHEMA.MANAGER_MAIN.key}">담당자(정/부)</th> <th class="text-center">담당자(정/부)</th>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -70,7 +68,7 @@ export function renderStorageList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
const orgUnit = orgSelect ? orgSelect.value : ''; const orgUnit = orgSelect ? orgSelect.value : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || const matchKeyword = !keyword ||
String((asset as any)[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) || String((asset as any)[ASSET_SCHEMA.ASSET_CODE.key]||'').toLowerCase().includes(keyword) ||
String((asset as any)[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword); String((asset as any)[ASSET_SCHEMA.ORG.key]||'').toLowerCase().includes(keyword);
@@ -79,10 +77,6 @@ export function renderStorageList(container: HTMLElement) {
return matchKeyword && matchCorp && matchOrg; return matchKeyword && matchCorp && matchOrg;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`; tbody.innerHTML = `<tr><td colspan="8" class="text-center" style="padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
@@ -113,11 +107,6 @@ export function renderStorageList(container: HTMLElement) {
tr.addEventListener('click', () => openHwModal(asset, 'view')); tr.addEventListener('click', () => openHwModal(asset, 'view'));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
}; };
document.getElementById('filter-keyword')?.addEventListener('input', updateTable); document.getElementById('filter-keyword')?.addEventListener('input', updateTable);

View File

@@ -1,8 +1,7 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal'; import { openSwModal } from '../../components/Modal/SWModal';
import { openSwUserModal } from '../../components/Modal/SWUserModal'; import { openSwUserModal } from '../../components/Modal/SWUserModal';
import { sortAssets, dynamicSort, formatPrice } from '../../core/utils'; import { sortAssets, formatPrice } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { CORP_LIST } from '../../components/Modal/SharedData'; import { CORP_LIST } from '../../components/Modal/SharedData';
import { generateOptionsHTML } from '../../components/Modal/ModalUtils'; import { generateOptionsHTML } from '../../components/Modal/ModalUtils';
import { createIcons, Edit2, Users, RefreshCcw } from 'lucide'; import { createIcons, Edit2, Users, RefreshCcw } from 'lucide';
@@ -11,8 +10,6 @@ export function renderSwList(container: HTMLElement) {
const isSub = state.activeSubTab === '구독SW'; const isSub = state.activeSubTab === '구독SW';
const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw); const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw);
let sortState: SortState = { key: '', direction: 'asc' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
filterBar.innerHTML = ` filterBar.innerHTML = `
@@ -46,17 +43,17 @@ export function renderSwList(container: HTMLElement) {
table.innerHTML = ` table.innerHTML = `
<thead> <thead>
<tr> <tr>
<th style="text-align:center; width: 50px;">No.</th> <th style="text-align:center;">No.</th>
<th style="text-align:center;" data-sort="상태">상태</th> <th style="text-align:center;">상태</th>
<th style="text-align:center;" data-sort="분야">분야</th> <th style="text-align:center;">분야</th>
<th style="text-align:center;" data-sort="법인">법인</th> <th style="text-align:center;">법인</th>
<th style="text-align:center;" data-sort="부서">부서</th> <th style="text-align:center;">부서</th>
<th style="text-align:center;" data-sort="제품명">제품명</th> <th style="text-align:center;">제품명</th>
<th style="text-align:center;" data-sort="구매일">구매일</th> <th style="text-align:center;">구매일</th>
<th style="text-align:center;" data-sort="시작일">시작일</th> <th style="text-align:center;">시작일</th>
<th style="text-align:center;" data-sort="만료일">만료일</th> <th style="text-align:center;">만료일</th>
<th style="text-align:center;" data-sort="금액">금액</th> <th style="text-align:center;">금액</th>
<th style="text-align:center;" data-sort="수량">수량</th> <th style="text-align:center;">수량</th>
<th style="text-align:center;">사용가능</th> <th style="text-align:center;">사용가능</th>
<th style="text-align:center;">사용자</th> <th style="text-align:center;">사용자</th>
</tr> </tr>
@@ -77,17 +74,13 @@ export function renderSwList(container: HTMLElement) {
const field = fieldSelect ? fieldSelect.value : ''; const field = fieldSelect ? fieldSelect.value : '';
const corp = corpSelect ? corpSelect.value : ''; const corp = corpSelect ? corpSelect.value : '';
let filtered = fullList.filter(asset => { const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword); const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchField = !field || asset. === field; const matchField = !field || asset. === field;
const matchCorp = !corp || asset. === corp; const matchCorp = !corp || asset. === corp;
return matchKeyword && matchField && matchCorp; return matchKeyword && matchField && matchCorp;
}); });
if (sortState.key) {
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
}
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
@@ -95,8 +88,7 @@ export function renderSwList(container: HTMLElement) {
} }
filtered.forEach((asset, idx) => { filtered.forEach((asset, idx) => {
const mapping = state.masterData.swUsers.find(u => u.sw_id === asset.id); const assigned = state.masterData.swUsers.filter(u => u.sw_id === asset.id).length;
const assigned = mapping ? (mapping.userData || []).length : 0;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10); const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned; const avail = qty - assigned;
@@ -162,12 +154,6 @@ export function renderSwList(container: HTMLElement) {
}); });
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
updateTable();
});
createIcons({ icons: { Edit2, Users, RefreshCcw } }); createIcons({ icons: { Edit2, Users, RefreshCcw } });
}; };