8 Commits

Author SHA1 Message Date
e1cdcfd93a feat: 하드웨어 자동 변경 이력 생성 및 자산 관리 프로세스 고도화 2026-04-22 17:15:58 +09:00
af37df7f2d merge: Equip_table 통합 및 자산번호 YYYYMM 체계 확립 2026-04-22 11:28:18 +09:00
d52c2c4200 feat: 구매연월 표준화 및 자산번호 YYYYMM 형식 적용 2026-04-22 11:24:15 +09:00
4b765aba2e feat: 자산 유형별 UI 최적화 및 자산번호 자동 생성 기능 구현
- CPU/GPU/RAM/HDD 등 부품 유형별 필드 라벨 동적 변경 로직 추가\n- 유형별 불필요한 사양 필드 숨김 처리 및 UI 레이아웃 정교화\n- 서버측 자산번호 생성 API (/api/generate-asset-code) 구현\n- 모달 내 자산번호 자동 생성 버튼 이벤트 연동 및 백엔드 동기화
2026-04-22 10:11:45 +09:00
7247737ce0 merge: 통합 HW 모달 구현 (PC 상세유형 복구 + 전산비품/모바일 확장 통합) 2026-04-21 18:09:13 +09:00
ba7ce796d1 feat: 전산비품 및 모바일기기 관리 기능 확장 (보관위치, 상태관리, 분출이력) 2026-04-21 17:52:46 +09:00
5ff991693a feat: 하드웨어 대시보드 노후도 중심 개편 및 자산 연령 계산 유틸리티 추가 2026-04-21 16:59:21 +09:00
a576d54a2d backup: stable baseline before hardware dashboard revamp 2026-04-21 16:35:29 +09:00
20 changed files with 1139 additions and 1174 deletions

49
db_fix_data.js Normal file
View File

@@ -0,0 +1,49 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateData() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔄 기존 데이터 보정 시작 (상세유형 = 유형)...');
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
for (const table of tables) {
// 1. 유형(type)이 비어있는 경우 기본값 채우기 (보정 전 단계)
let defaultType = '기타';
if (table === 'server_assets') defaultType = '서버';
else if (table === 'pc_assets') defaultType = '개인PC';
else if (table === 'storage_assets') defaultType = '스토리지';
else if (table === 'equip_assets') defaultType = '전산비품';
else if (table === 'mobile_assets') defaultType = '모바일기기';
await connection.query(`UPDATE ${table} SET type = ? WHERE type IS NULL OR type = ''`, [defaultType]);
// 2. 개인PC가 아닌 데이터들에 대해 상세유형 = 유형 업데이트
const [result] = await connection.query(`
UPDATE ${table}
SET detail_purpose = type
WHERE type NOT IN ('개인PC', 'PC')
`);
console.log(`${table}: ${result.affectedRows}개 데이터 보정 완료`);
}
console.log('✨ 모든 기존 데이터 보정이 완료되었습니다.');
await connection.end();
}
migrateData().catch(err => {
console.error('❌ 데이터 보정 실패:', err);
process.exit(1);
});

View File

@@ -32,7 +32,7 @@ async function initDB() {
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인', corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호', asset_code VARCHAR(100) COMMENT '자산번호',
purchase_date VARCHAR(50) COMMENT '구매일자', purchase_date VARCHAR(50) COMMENT '구매연월',
type VARCHAR(50) COMMENT '유형', type VARCHAR(50) COMMENT '유형',
detail_purpose VARCHAR(50) COMMENT '상세용도', detail_purpose VARCHAR(50) COMMENT '상세용도',
purpose VARCHAR(255) COMMENT '용도', purpose VARCHAR(255) COMMENT '용도',
@@ -57,6 +57,8 @@ async function initDB() {
monitoring VARCHAR(100), monitoring VARCHAR(100),
price VARCHAR(100) COMMENT '금액', price VARCHAR(100) COMMENT '금액',
remarks TEXT, remarks TEXT,
storage_location VARCHAR(255) COMMENT '보관위치',
status VARCHAR(50) COMMENT '현재상태',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='${comment}'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='${comment}';
`; `;
@@ -77,7 +79,7 @@ async function initDB() {
license_type VARCHAR(100) COMMENT '라이선스 유형', license_type VARCHAR(100) COMMENT '라이선스 유형',
quantity INT COMMENT '수량', quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액', price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매', purchase_date VARCHAR(50) COMMENT '구매연월',
expiry_date VARCHAR(50) COMMENT '만료일', expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '납품업체', vendor VARCHAR(255) COMMENT '납품업체',
remarks TEXT COMMENT '비고', remarks TEXT COMMENT '비고',
@@ -95,7 +97,7 @@ async function initDB() {
license_key VARCHAR(255) COMMENT '라이선스 키', license_key VARCHAR(255) COMMENT '라이선스 키',
quantity INT COMMENT '수량', quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액', price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매', purchase_date VARCHAR(50) COMMENT '구매연월',
vendor VARCHAR(255) COMMENT '납품업체', vendor VARCHAR(255) COMMENT '납품업체',
remarks TEXT COMMENT '비고', remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP

View File

@@ -84,25 +84,54 @@ const hardwareInsertSQL = (table) => `
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details, id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, location, manager_main, manager_sub, ip_address, current_org, prev_org, location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu, remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks storage1, storage2, storage3, monitoring, price, remarks,
storage_location, status
) VALUES ? ) VALUES ?
`; `;
const getHardwareValues = (a) => [ const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'', a.id, a.법인||'', a.자산코드||'', a.구매연월||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'', a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'', a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'' a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'',
a.보관위치||'', a.현재상태||''
]; ];
const mapHardware = (r, defaultType) => ({ const mapHardware = (r, defaultType) => {
id: r.id, 법인: r.corp, 자산코드: r.asset_code, 구매일: r.purchase_date, type: r.type || defaultType, const type = r.type || defaultType;
상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org, return {
이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub, id: r.id,
IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw, 법인: r.corp,
모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1, 자산코드: r.asset_code,
SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks 구매연월: r.purchase_date,
}); type: type,
상세용도: (type !== '개인PC' && !r.detail_purpose) ? type : r.detail_purpose,
용도: r.purpose,
상세: r.details,
현사용조직: r.current_org,
이전사용조직: r.prev_org,
위치: r.location,
담당자_정: r.manager_main,
담당자_부: r.manager_sub,
IP주소: r.ip_address,
원격접속: r.remote_tool,
서버ID: r.server_id,
서버PW: r.server_pw,
모델명: r.model_name,
OS: r.os,
CPU: r.cpu,
RAM: r.ram,
GPU: r.gpu,
SSD1: r.storage1,
SSD2: r.storage2,
HDD1: r.storage3,
모니터링: r.monitoring,
금액: r.price,
비고: r.remarks,
보관위치: r.storage_location,
현재상태: r.status
};
};
// --- API 라우트 정의 --- // --- API 라우트 정의 ---
@@ -320,6 +349,34 @@ app.post('/api/sw-users/batch', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
// 자산번호 자동 생성 API
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try {
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
let maxNum = 0;
for (const table of tables) {
const [rows] = await pool.query(
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`,
[`${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(3, '0');
res.json({ nextCode: `${prefix}${nextNum}` });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// 초기화 및 서버 기동 // 초기화 및 서버 기동
ensureTables().then(() => { ensureTables().then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {

View File

@@ -49,7 +49,7 @@ export function openDashboardDetail(title: string, list: HardwareAsset[]) {
if (!thead) return; if (!thead) return;
titleEl.textContent = title; titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매</th><th>금액</th></tr>`; thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매연월</th><th>금액</th></tr>`;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (list.length === 0) { if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;

View File

@@ -1,7 +1,7 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state'; import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset, MasterAssetData } from '../../core/excelHandler'; import { HardwareAsset, HardwareLog } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal'; import { closeModals } from './BaseModal';
import { createIcons, Paperclip } from 'lucide'; import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA, TYPE_PREFIX_MAP } from './SharedData'; import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA, TYPE_PREFIX_MAP } from './SharedData';
import { import {
generateOptionsHTML, generateOptionsHTML,
@@ -10,337 +10,288 @@ import {
parseAndSetLocation, parseAndSetLocation,
bindLocationEvents, bindLocationEvents,
getCombinedLocation, getCombinedLocation,
setEditLock setEditLock,
createModalFrameHTML,
autoFillForm,
autoExtractForm
} from './ModalUtils'; } from './ModalUtils';
let currentAsset: HardwareAsset | null = null; let currentAsset: HardwareAsset | null = null;
let isEditMode = false; let isEditMode = false;
const HW_MODAL_HTML = ` const STATUS_LIST = ['대여중', '보관중', '수리중', '기타'];
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="hw-asset-form" class="grid-form">
<input type="hidden" id="hw-asset-id" />
<input type="hidden" id="hw-asset-type" />
<!-- Group 1: 기본 정보 (Identity) --> // 필드 ID ↔ 데이터 Key 매핑 (유지보수 시 이 부분만 수정)
<div class="form-section-title">기본 정보 (Identity)</div> const HW_FIELD_MAP: Record<string, string> = {
<div class="form-group"> '유형': 'type',
<label for="hw-법인">구매법인</label> '법인': '법인',
<select id="hw-법인" required>${generateOptionsHTML(CORP_LIST)}</select> '자산코드': '자산코드',
</div> '현사용조직': '현사용조직',
<div class="form-group"> '이전사용조직': '이전사용조직',
<label for="hw-자산코드">자산번호/코드</label> '상세용도': '상세용도',
<div style="display:flex; gap:0.5rem;"> '모델명': '모델명',
<input type="text" id="hw-자산코드" readonly placeholder="번호 생성을 클릭하세요" required /> '명칭': '명칭',
<button type="button" id="btn-generate-hw-code" class="btn btn-outline" style="white-space:nowrap; padding:0 10px; font-size:0.8rem;">번호 생성</button> '보관위치': '보관위치',
</div> '현재상태': '현재상태',
</div> 'IP주소': 'IP주소',
<div class="form-group"> 'IP2': 'IP2',
<label for="hw-현사용조직">현 사용조직</label> '원격접속': '원격접속',
<select id="hw-현사용조직">${generateOptionsHTML(ORG_LIST)}</select> '서버ID': '서버ID',
</div> '서버PW': '서버PW',
<div class="form-group" id="hw-이전사용조직-group"> '모니터링': '모니터링',
<label for="hw-이전사용조직">이전 사용조직</label> 'OS': 'OS',
<input type="text" id="hw-이전사용조직" readonly /> 'CPU': 'CPU',
</div> 'RAM': 'RAM',
<div class="form-group" id="hw-유형-group"> 'SSD1': 'SSD1',
<label for="hw-유형">유형</label> 'SSD2': 'SSD2',
<select id="hw-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select> 'HW사양': 'HW사양',
</div> '담당자_정': '담당자_정',
<div class="form-group" id="hw-상세용도-group" style="display:none;"> '담당자_부': '담당자_부',
<label for="hw-상세용도">상세용도</label> '구매일': '구매연월',
<select id="hw-상세용도"> '금액': '금액',
<option value="">선택</option> '비고': '비고',
<option value="서버">서버</option> '사용자': '사용자'
<option value="개인PC">개인PC</option> };
</select>
</div>
<div class="form-group server-only">
<label for="hw-용도">용도</label>
<input type="text" id="hw-용도" />
</div>
<div class="form-group server-only">
<label for="hw-상세">상세 내용</label>
<input type="text" id="hw-상세" />
</div>
<div class="form-group non-server" id="hw-명칭-group">
<label for="hw-명칭">명칭</label>
<input type="text" id="hw-명칭" />
</div>
<div class="form-group full-width server-only">
<label for="hw-비고">비고</label>
<input type="text" id="hw-비고" />
</div>
<!-- Group 2: 네트워크 정보 (Connectivity) --> const HW_FORM_HTML = `
<div class="form-section-title server-only" id="hw-network-title">네트워크 정보 (Connectivity)</div> <!-- Group 1: 기본 정보 -->
<div class="form-group server-only" id="hw-ip-group"> <div class="form-section-title">기본 정보 (Identity)</div>
<label for="hw-IP주소">IP 주소 1</label> <div class="form-group">
<input type="text" id="hw-IP주소" /> <label for="hw-법인">구매법인</label>
</div> <select id="hw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
<div class="form-group server-only" id="hw-ip2-group"> </div>
<label for="hw-IP2">IP 주소 2</label> <div class="form-group">
<input type="text" id="hw-IP2" /> <label for="hw-자산코드">자산번호</label>
</div> <div class="input-with-btn">
<div class="form-group server-only" id="hw-remote-group"> <input type="text" id="hw-자산코드" readonly class="is-readonly-field" placeholder="번호 생성을 클릭하세요" required />
<label for="hw-원격접속">원격 도구 (Anydesk/Chrome 등)</label> <button type="button" id="btn-generate-hw-code" class="btn btn-outline btn-sm">생성</button>
<input type="text" id="hw-원격접속" /> </div>
</div> </div>
<div class="form-group server-only" id="hw-server-id-group"> <div class="form-group pc-only">
<label for="hw-서버ID">서버 ID</label> <label for="hw-사용자">사용자</label>
<input type="text" id="hw-서버ID" /> <input type="text" id="hw-사용자" />
</div> </div>
<div class="form-group server-only" id="hw-server-pw-group"> <div class="form-group">
<label for="hw-서버PW">서버 PW</label> <label for="hw-현사용조직">현 사용조직</label>
<input type="text" id="hw-서버PW" /> <select id="hw-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div> </div>
<div class="form-group non-server" id="hw-ip-non-server-group"> <div class="form-group" id="hw-이전사용조직-group">
<label for="hw-IP주소-non-server">IP 주소</label> <label for="hw-이전사용조직">이전 사용조직</label>
<input type="text" id="hw-IP주소-non-server" /> <input type="text" id="hw-이전사용조직" readonly />
</div> </div>
<div class="form-group" id="hw-유형-group">
<label for="hw-유형">유형</label>
<select id="hw-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group" id="hw-상세용도-group">
<label for="hw-상세용도">상세유형</label>
<select id="hw-상세용도">
<option value="">선택</option>
<option value="서버">서버</option>
<option value="개인PC">개인PC</option>
</select>
</div>
<!-- Group 3: 시스템 사양 (Specifications) --> <div class="form-section-title op-only" id="hw-op-title">운영 및 상태 관리</div>
<div class="form-section-title" id="hw-spec-title">시스템 사양 (Specifications)</div> <div class="form-group op-only">
<div class="form-group" id="hw-model-group"> <label for="hw-보관위치">보관위치</label>
<label for="hw-모델명">모델명</label> <input type="text" id="hw-보관위치" placeholder="예: 7층 비품창고" />
<input type="text" id="hw-모델명" /> </div>
</div> <div class="form-group op-only">
<div class="form-group" id="hw-os-group"> <label for="hw-현재상태">현재상태</label>
<label for="hw-OS">운영체제 (OS)</label> <select id="hw-현재상태">${generateOptionsHTML(STATUS_LIST)}</select>
<input type="text" id="hw-OS" /> </div>
</div>
<div class="form-group" id="hw-cpu-group">
<label for="hw-CPU">CPU 사양</label>
<input type="text" id="hw-CPU" />
</div>
<div class="form-group" id="hw-ram-group">
<label for="hw-RAM">RAM 용량</label>
<input type="text" id="hw-RAM" />
</div>
<div class="form-group" id="hw-ssd1-group">
<label for="hw-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="hw-SSD1" />
</div>
<div class="form-group" id="hw-ssd2-group">
<label for="hw-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="hw-SSD2" />
</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사양">H/W 사양 상세</label>
<textarea id="hw-HW사양" rows="2"></textarea>
</div>
<!-- Group 4: 관리 및 운영 (Operation) --> <div class="form-section-title server-only" id="hw-network-title">네트워크 정보 (Connectivity)</div>
<div class="form-section-title" id="hw-op-title">관리 및 운영 (Operation)</div> <div class="form-group server-only" id="hw-ip-group"><label for="hw-IP주소">IP 주소 1</label><input type="text" id="hw-IP주소" /></div>
<div class="form-group hw-location-field"> <div class="form-group server-only" id="hw-ip2-group"><label for="hw-IP2">IP 주소 2</label><input type="text" id="hw-IP2" /></div>
<label for="hw-위치-빌딩">설치위치 (건물)</label> <div class="form-group server-only" id="hw-remote-group"><label for="hw-원격접속">원격 도구</label><input type="text" id="hw-원격접속" /></div>
<select id="hw-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select> <div class="form-group server-only" id="hw-server-id-group"><label for="hw-서버ID">서버 ID</label><input type="text" id="hw-서버ID" /></div>
</div> <div class="form-group server-only" id="hw-server-pw-group"><label for="hw-서버PW">서버 PW</label><input type="text" id="hw-서버PW" /></div>
<div class="form-group hw-location-field"> <div class="form-group non-server" id="hw-ip-non-server-group"><label for="hw-IP주소-non-server">IP 주소</label><input type="text" id="hw-IP주소-non-server" /></div>
<label for="hw-위치-상세">상세 위치</label>
<select id="hw-위치-상세"> <div class="form-section-title" id="hw-spec-title">시스템 사양 (Specifications)</div>
<option value="">건물을 먼저 선택하세요</option> <div class="form-group" id="hw-model-group"><label for="hw-모델명">모델명</label><input type="text" id="hw-모델명" /></div>
</select> <div class="form-group" id="hw-os-group"><label for="hw-OS">운영체제 (OS)</label><input type="text" id="hw-OS" /></div>
</div> <div class="form-group" id="hw-cpu-group"><label for="hw-CPU">CPU 사양</label><input type="text" id="hw-CPU" /></div>
<div class="form-group" id="hw-위치-기타-group" style="display:none;"> <div class="form-group" id="hw-ram-group"><label for="hw-RAM">RAM 용량</label><input type="text" id="hw-RAM" /></div>
<label for="hw-위치-기타">직접 입력 (기타)</label> <div class="form-group" id="hw-ssd1-group"><label for="hw-SSD1">Storage 1 (SSD/HDD)</label><input type="text" id="hw-SSD1" /></div>
<input type="text" id="hw-위치-기타" placeholder="상세 위치를 입력하세요" /> <div class="form-group" id="hw-ssd2-group"><label for="hw-SSD2">Storage 2 (SSD/HDD)</label><input type="text" id="hw-SSD2" /></div>
</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"> <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>
<label for="hw-담당자_정">담당자 (정)</label>
<input type="text" id="hw-담당자_정" /> <div class="form-section-title" id="hw-loc-title">설치 위치 및 관리</div>
</div> <div class="form-group loc-standard"><label for="hw-위치-빌딩">설치위치 (건물)</label><select id="hw-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select></div>
<div class="form-group"> <div class="form-group loc-standard"><label for="hw-위치-상세">상세 위치</label><select id="hw-위치-상세"><option value="">선택</option></select></div>
<label for="hw-담당자_부">담당자 (부)</label> <div class="form-group" id="hw-위치-기타-group" style="display:none;"><label for="hw-위치-기타">직접 입력 (기타)</label><input type="text" id="hw-위치-기타" /></div>
<input type="text" id="hw-담당자_" /> <div class="form-group"><label for="hw-담당자_정">담당자(정)</label><input type="text" id="hw-담당자_" /></div>
</div> <div class="form-group"><label for="hw-담당자_부">담당자(부)</label><input type="text" id="hw-담당자_부" /></div>
<div class="form-group non-server" id="hw-purchase-date-group"> <div class="form-group"><label for="hw-구매일">구매연월</label><input type="text" id="hw-구매일" placeholder="YYYYMM" maxlength="6" /></div>
<label for="hw-구매일">구매일</label> <div class="form-group"><label for="hw-금액">금액</label><input type="text" id="hw-금액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div>
<input type="text" id="hw-구매일" /> <div class="form-group full-width"><label for="hw-비고">비고</label><textarea id="hw-비고" rows="2"></textarea></div>
</div> <div class="form-group full-width">
<div class="form-group non-server" id="hw-price-group"> <label>품의서 (파일 증빙)</label>
<label for="hw-금액">금액</label> <div style="display:flex; align-items:center; gap:0.5rem;">
<input type="text" id="hw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\d))/g, ',')" /> <input type="file" id="hw-품의서" />
</div> <span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
<div class="form-group full-width">
<label>품의서 (파일 증빙)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" />
<span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div> </div>
</div> </div>
`; `;
export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') { function renderHwHistory(assetId: string) {
currentAsset = asset; const container = document.getElementById('hw-history-list');
const modal = document.getElementById('hw-asset-modal')!; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId).sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime());
// 1. 잠금 상태 통합 제어 (데이터 유무가 아닌 호출 mode에만 의존) if (logs.length === 0) { container.innerHTML = '<div class="empty-history">기록된 이력이 없습니다.</div>'; return; }
setEditLock('hw-asset-form', mode, { container.innerHTML = logs.map(l => `
saveBtnId: 'btn-save-hw-asset', <div class="history-item">
revertBtnId: 'btn-revert-hw-edit', <div class="history-date">${l.date}</div>
generateBtnId: 'btn-generate-hw-code' <div class="history-user">${l.user}</div>
}); <div class="history-details">${l.details.replace(/\n/g, '<br>')}</div>
</div>
isEditMode = (mode === 'add'); `).join('');
// 2. 데이터 바인딩
fillHwFormData(asset);
modal.classList.remove('hidden');
applyTypeSpecificUI(asset.type);
createIcons({ icons: { Paperclip } });
} }
function applyTypeSpecificUI(type: string) { function applyTypeSpecificUI(type: string) {
const detailPurpose = getFieldValue('hw-상세용도'); const detailPurpose = getFieldValue('hw-상세용도');
const form = document.getElementById('hw-asset-form') as HTMLFormElement; const upperType = (type || '').toUpperCase();
if (!form) return;
const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const locationFields = document.querySelectorAll('.hw-location-field');
const groups: Record<string, HTMLElement | null> = { const groups: Record<string, HTMLElement | null> = {
detailPurpose: document.getElementById('hw-상세용도-group'), detailPurpose: document.getElementById('hw-상세용도-group'),
networkTitle: document.getElementById('hw-network-title'),
specTitle: document.getElementById('hw-spec-title'),
opTitle: document.getElementById('hw-op-title'),
model: document.getElementById('hw-model-group'), model: document.getElementById('hw-model-group'),
ip: document.getElementById('hw-ip-group'),
ip2: document.getElementById('hw-ip2-group'),
remote: document.getElementById('hw-remote-group'),
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'),
ssd1: document.getElementById('hw-ssd1-group'), ssd1: document.getElementById('hw-ssd1-group'),
ssd2: document.getElementById('hw-ssd2-group'), ssd2: document.getElementById('hw-ssd2-group'),
monitoring: document.getElementById('hw-monitoring-group'),
serverId: document.getElementById('hw-server-id-group'),
serverPw: document.getElementById('hw-server-pw-group'),
hwSpec: document.getElementById('hw-hwspec-group'), hwSpec: document.getElementById('hw-hwspec-group'),
ipNonServer: document.getElementById('hw-ip-non-server-group'), monitoring: document.getElementById('hw-monitoring-group'),
type: document.getElementById('hw-유형-group'), user: document.querySelector('.pc-only') as HTMLElement
networkTitle: document.getElementById('hw-network-title'),
specTitle: document.getElementById('hw-spec-title'),
opTitle: document.getElementById('hw-op-title')
}; };
// 1. 초기화 const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const opOnly = document.querySelectorAll('.op-only');
const standardLoc = document.querySelectorAll('.loc-standard');
// 초기화
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none'); serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.forEach(el => (el as HTMLElement).style.display = 'none'); nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
locationFields.forEach(el => (el as HTMLElement).style.display = 'none'); opOnly.forEach(el => (el as HTMLElement).style.display = 'none');
standardLoc.forEach(el => (el as HTMLElement).style.display = 'flex');
Object.values(groups).forEach(g => { if (g) g.style.display = 'none'; }); Object.values(groups).forEach(g => { if (g) g.style.display = 'none'; });
if (groups.type) groups.type.style.display = 'flex';
if (groups.opTitle) groups.opTitle.style.display = 'flex';
// 2. PC 유형일 때 상세용도 선택창 노출 (복구 핵심) const osLabel = document.querySelector('label[for="hw-OS"]') as HTMLElement;
if (type === 'PC' || type === '개인PC' || type === '노트북') { const ramLabel = document.querySelector('label[for="hw-RAM"]') as HTMLElement;
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex'; const modelLabel = document.querySelector('label[for="hw-모델명"]') as HTMLElement;
if (osLabel) osLabel.innerText = '운영체제 (OS)';
// 상세용도가 '서버'인 경우 서버용 필드 노출, 아니면 일반 PC용 필드 노출 if (ramLabel) ramLabel.innerText = 'RAM 용량';
if (detailPurpose === '서버') { if (modelLabel) modelLabel.innerText = '모델명';
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); const isMobileGroup = ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
if (groups.networkTitle) groups.networkTitle.style.display = 'flex'; const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품');
['ip', 'ip2', 'remote', 'serverId', 'serverPw', 'monitoring', 'model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2'].forEach(k => { const isOpType = isMobileGroup || isEquipGroup;
if (groups[k]) groups[k]!.style.display = 'flex'; const isPcType = upperType === 'PC' || upperType === '개인PC' || upperType === '노트북';
});
if (groups.opTitle) groups.opTitle.style.display = isOpType ? 'flex' : 'none';
if (isOpType) {
opOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
standardLoc.forEach(el => (el as HTMLElement).style.display = 'none');
if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
if (['CPU', 'GPU'].some(t => upperType.includes(t))) {
if (groups.os && osLabel) { osLabel.innerText = '출시연월'; groups.os.style.display = 'flex'; }
} else if (['RAM', 'HDD'].some(t => upperType.includes(t))) {
if (groups.ram && ramLabel) { ramLabel.innerText = '용량'; groups.ram.style.display = 'flex'; }
if (upperType.includes('HDD') && modelLabel) modelLabel.innerText = 'S/N';
} else { } else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex'); if (groups.hwSpec) groups.hwSpec.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec', 'ipNonServer'].forEach(k => {
if (groups[k]) groups[k]!.style.display = 'flex';
});
} }
} }
else if (type === '서버') { else if (isPcType) {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex'); if (groups.user) groups.user.style.display = 'flex';
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
Object.values(groups).forEach(g => { if (g) g.style.display = 'flex'; });
}
else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.ip) groups.ip.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex'; if (groups.specTitle) groups.specTitle.style.display = 'flex';
if (groups.model) groups.model.style.display = 'flex';
if (groups.ssd1) groups.ssd1.style.display = 'flex'; // 노트북은 상세유형 선택창 숨김
} if (upperType === '노트북') {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'none';
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else {
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
if (detailPurpose === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else {
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}
}
else { else {
// 기타 유형 (CPU, RAM, 모바일 등) serverOnly.forEach(el => (el as HTMLElement).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';
if (groups.model) groups.model.style.display = 'flex'; ['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} }
} }
function fillHwFormData(asset: HardwareAsset) { export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') {
setFieldValue('hw-asset-id', asset.id); currentAsset = asset;
setFieldValue('hw-asset-type', asset.type); const modal = document.getElementById('hw-asset-modal')!;
setFieldValue('hw-법인', asset.);
setFieldValue('hw-자산코드', asset.); setEditLock('hw-asset-form', mode, {
setFieldValue('hw-현사용조직', asset.); saveBtnId: 'btn-save-hw-asset',
setFieldValue('hw-이전사용조직', asset.); revertBtnId: 'btn-revert-hw-edit',
setFieldValue('hw-상세용도', (asset as any).); generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
});
isEditMode = (mode === 'add');
// 데이터 채우기 (자동 매핑)
autoFillForm('hw', asset, HW_FIELD_MAP);
setFieldValue('hw-명칭', asset. || asset.);
if (!asset. && asset.) setFieldValue('hw-구매일', asset.);
parseAndSetLocation(asset., 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타'); parseAndSetLocation(asset., 'hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
applyTypeSpecificUI(asset.type);
renderHwHistory(asset.id);
setFieldValue('hw-모델명', asset.); modal.classList.remove('hidden');
setFieldValue('hw-OS', asset.OS); createIcons({ icons: { X, Save, Edit2, RotateCcw, History, Plus, Paperclip } });
setFieldValue('hw-CPU', asset.CPU);
setFieldValue('hw-RAM', asset.RAM);
setFieldValue('hw-SSD1', asset.SSD1);
setFieldValue('hw-SSD2', asset.SSD2);
setFieldValue('hw-담당자_정', asset._정 || asset.);
setFieldValue('hw-담당자_부', asset._부);
const isServerGrade = asset.type === '서버' || (asset as any). === '서버' || asset.type === '스토리지' || ['NAS', 'DAS'].includes(asset.type);
if (isServerGrade) {
setFieldValue('hw-용도', asset. || (asset as any).purpose);
setFieldValue('hw-상세', asset. || (asset as any).details);
setFieldValue('hw-비고', asset. || (asset as any).remarks);
setFieldValue('hw-구매일', asset. || (asset as any).purchase_date);
setFieldValue('hw-유형', asset.storage유형 || asset.type);
setFieldValue('hw-IP주소', asset.IP주소 || (asset as any).ip_address);
setFieldValue('hw-IP2', (asset as any).IP2 || (asset as any).ip_address_2);
setFieldValue('hw-원격접속', asset. || (asset as any).remote_tool);
setFieldValue('hw-서버ID', (asset as any).ID || (asset as any).server_id);
setFieldValue('hw-서버PW', (asset as any).PW || (asset as any).server_pw);
setFieldValue('hw-모니터링', asset. || (asset as any).monitoring);
} else {
setFieldValue('hw-명칭', asset. || asset.);
setFieldValue('hw-구매일', asset. || (asset as any).purchase_date);
setFieldValue('hw-금액', asset. || (asset as any).price);
setFieldValue('hw-HW사양', asset.HW사양 || asset. || (asset as any).details);
setFieldValue('hw-IP주소-non-server', asset.IP주소 || (asset as any).ip_address);
}
} }
export function initHwModal(onSave: () => void, closeModals: () => void) { export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('hw-asset-modal')) { if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML); const html = createModalFrameHTML('hw', '자산 상세 정보', HW_FORM_HTML, {
historyTitle: '분출 및 변경 이력',
addLogBtnId: 'btn-add-hw-log'
});
document.body.insertAdjacentHTML('beforeend', html);
// 이력 추가 모달 HTML도 함께 추가
const logModalHTML = `
<div id="hw-log-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header"><h2>이력 추가</h2><button id="btn-close-hw-log" class="btn-icon"><i data-lucide="x"></i></button></div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group"><label>날짜</label><input type="date" id="new-hw-log-date" /></div>
<div class="form-group"><label>변경/분출 내용</label><textarea id="new-hw-log-details" rows="3" placeholder="예: [분출] 기술팀 홍길동, [수리] 배터리 교체 등"></textarea></div>
</div>
</div>
<div class="modal-footer"><div></div><div class="footer-actions"><button id="btn-cancel-hw-log" class="btn btn-outline">취소</button><button id="btn-confirm-hw-log" class="btn btn-primary">추가</button></div></div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', logModalHTML);
} }
const form = document.getElementById('hw-asset-form') as HTMLFormElement; const form = document.getElementById('hw-asset-form') as HTMLFormElement;
@@ -349,25 +300,28 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const deleteBtn = document.getElementById('btn-delete-hw-asset')!; const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const typeSelect = document.getElementById('hw-유형') as HTMLSelectElement; const typeSelect = document.getElementById('hw-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('hw-상세용도') as HTMLSelectElement; const detailPurposeSelect = document.getElementById('hw-상세용도') as HTMLSelectElement;
const logAddBtn = document.getElementById('btn-add-hw-log')!;
const logModal = document.getElementById('hw-log-modal')!;
[typeSelect, detailPurposeSelect].forEach(el => { [typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyTypeSpecificUI(typeSelect.value)); el?.addEventListener('change', () => applyTypeSpecificUI(typeSelect.value));
}); });
bindLocationEvents('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타'); bindLocationEvents('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타-group', 'hw-위치-기타');
const closeModalAction = () => { closeModals(); isEditMode = false; }; const closeModalAction = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-hw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-close-hw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-hw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-cancel-hw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
setEditLock('hw-asset-form', 'view', { setEditLock('hw-asset-form', 'view', {
saveBtnId: 'btn-save-hw-asset', saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit', revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code' generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
}); });
isEditMode = false; isEditMode = false;
if (currentAsset) fillHwFormData(currentAsset); if (currentAsset) openHwModal(currentAsset, 'view');
}); });
document.getElementById('btn-generate-hw-code')?.addEventListener('click', async () => { document.getElementById('btn-generate-hw-code')?.addEventListener('click', async () => {
@@ -375,8 +329,8 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
const purchaseDate = getFieldValue('hw-구매일'); const purchaseDate = getFieldValue('hw-구매일');
const typeCode = TYPE_PREFIX_MAP[typeValue] || 'ETC'; const typeCode = TYPE_PREFIX_MAP[typeValue] || 'ETC';
const dateStr = purchaseDate.replace(/[^0-9]/g, ''); const dateStr = purchaseDate.replace(/[^0-9]/g, '');
if (dateStr.length < 4) { alert('올바른 구매일(연월)을 입력해주세요.'); return; } if (dateStr.length < 6) { alert('올바른 구매연월(YYYYMM)을 입력해주세요.'); return; }
const prefix = `${typeCode}-${dateStr.substring(2, 6)}-`; const prefix = `${typeCode}-${dateStr.substring(0, 6)}-`;
try { try {
const res = await fetch(`http://localhost:3000/api/generate-asset-code?prefix=${prefix}`); const res = await fetch(`http://localhost:3000/api/generate-asset-code?prefix=${prefix}`);
const data = await res.json(); const data = await res.json();
@@ -384,74 +338,156 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
} catch (err) { alert('자산번호 생성에 실패했습니다.'); } } catch (err) { alert('자산번호 생성에 실패했습니다.'); }
}); });
// YYYYMM 입력 제한 로직 (숫자 6자리)
['hw-구매일', 'hw-OS'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
el?.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
const label = document.querySelector(`label[for="${id}"]`) as HTMLElement;
// OS 필드의 경우 라벨이 '출시연월'일 때만 숫자 제한 적용
if (id === 'hw-OS' && label?.innerText !== '출시연월') return;
target.value = target.value.replace(/[^0-9]/g, '').substring(0, 6);
});
});
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
if (!currentAsset) return; if (!currentAsset) return;
if (!isEditMode) { if (!isEditMode) {
setEditLock('hw-asset-form', 'edit', { setEditLock('hw-asset-form', 'edit', {
saveBtnId: 'btn-save-hw-asset', saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit' revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
}); });
isEditMode = true; isEditMode = true;
applyTypeSpecificUI(getFieldValue('hw-유형'));
return; return;
} }
const type = typeSelect.value; // 데이터 추출 (자동 매핑)
const detailPurpose = detailPurposeSelect.value; const extracted = autoExtractForm('hw', HW_FIELD_MAP);
if (!extracted.) {
alert('자산번호가 없습니다. [생성] 버튼을 눌러 자산번호를 먼저 부여해주세요.');
return;
}
const upperType = (extracted.type || '').toUpperCase();
const isOpType = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품') || ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
// --- 자동 변경 이력 생성 로직 ---
// 모든 하드웨어 유형에 대해 자동 로깅 적용
if (HW_TYPE_LIST.includes(extracted.type) || extracted.type === '개인PC') {
const diffLogs: string[] = [];
const compareFields = [
{ key: '현사용조직', label: '현사용조직' },
{ key: '위치', label: '설치위치' },
{ key: '관리자', label: '담당자' },
{ key: '현재상태', label: '상태' },
{ key: 'IP주소', label: 'IP' },
{ key: '상세용도', label: '상세유형' },
{ key: '모델명', label: '모델명' }
];
const currentIp = currentAsset.IP주소 || '';
const newIp = getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server');
const currentLocation = currentAsset. || '';
const newLocation = isOpType ? extracted.보관위치 : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타');
compareFields.forEach(f => {
let oldVal = '';
let newVal = '';
if (f.key === 'IP주소') {
oldVal = currentIp;
newVal = newIp;
} else if (f.key === '위치') {
oldVal = currentLocation;
newVal = newLocation;
} else if (f.key === '관리자') {
oldVal = currentAsset._정 || '';
newVal = extracted._정 || '';
} else if (f.key === '상세용도') {
oldVal = currentAsset. || '';
// 비 PC 자산은 유형을 상세유형으로 간주
newVal = (extracted.type !== 'PC' && extracted.type !== '개인PC') ? extracted.type : (extracted. || '');
} else {
oldVal = (currentAsset as any)[f.key] || '';
newVal = extracted[f.key] || '';
}
if (oldVal !== newVal) {
diffLogs.push(`${f.label}: ${oldVal || '(없음)'}${newVal || '(없음)'}`);
}
});
if (diffLogs.length > 0) {
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: currentAsset.id,
date: new Date().toISOString().split('T')[0],
user: '관리자',
details: diffLogs.join('\n')
});
}
}
// ----------------------------
const updated: any = { const updated: any = {
...currentAsset, ...currentAsset,
법인: getFieldValue('hw-법인'), ...extracted,
자산코드: getFieldValue('hw-자산코드'), IP주소: getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server'),
현사용조직: getFieldValue('hw-현사용조직'), 관리자: extracted.담당자_정,
이전사용조직: getFieldValue('hw-이전사용조직'), 위치: isOpType ? extracted.보관위치 : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타')
위치: getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타'),
모델명: getFieldValue('hw-모델명'),
OS: getFieldValue('hw-OS'),
CPU: getFieldValue('hw-CPU'),
RAM: getFieldValue('hw-RAM'),
SSD1: getFieldValue('hw-SSD1'),
SSD2: getFieldValue('hw-SSD2'),
담당자_정: getFieldValue('hw-담당자_정'),
관리자: getFieldValue('hw-담당자_정'),
담당자_부: getFieldValue('hw-담당자_부'),
type: type,
상세용도: detailPurpose
}; };
if (type === '서버' || (type === 'PC' && detailPurpose === '서버') || ['스토리지', 'NAS', 'DAS'].includes(type)) { // 현 사용조직 변경 시 이전 사용조직 자동 업데이트
updated. = getFieldValue('hw-용도'); if (currentAsset. && currentAsset. !== extracted.) {
updated. = getFieldValue('hw-상세'); updated. = currentAsset.;
updated. = getFieldValue('hw-비고'); }
updated.storage유형 = type;
updated.IP주소 = getFieldValue('hw-IP주소'); // 비 PC 자산에 대해 상세유형(상세용도)을 유형과 동기화
updated.IP2 = getFieldValue('hw-IP2'); if (updated.type !== 'PC') {
updated. = getFieldValue('hw-원격접속'); updated. = updated.type;
updated.ID = getFieldValue('hw-서버ID');
updated.PW = getFieldValue('hw-서버PW');
updated. = getFieldValue('hw-모니터링');
} else {
updated. = getFieldValue('hw-명칭');
updated. = getFieldValue('hw-구매일');
updated. = getFieldValue('hw-금액');
updated.HW사양 = getFieldValue('hw-HW사양');
updated.IP주소 = getFieldValue('hw-IP주소-non-server');
} }
saveHardwareAsset(updated); saveHardwareAsset(updated);
onSave(); onSave();
setEditLock('hw-asset-form', 'view', { setEditLock('hw-asset-form', 'view', {
saveBtnId: 'btn-save-hw-asset', saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit' revertBtnId: 'btn-revert-hw-edit',
generateBtnId: 'btn-generate-hw-code',
addLogBtnId: 'btn-add-hw-log'
}); });
isEditMode = false; isEditMode = false;
}); });
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
if (!currentAsset) return; if (currentAsset && confirm('정말로 삭제하시겠습니까?')) {
if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
deleteHardwareAsset(currentAsset.id); deleteHardwareAsset(currentAsset.id);
onSave(); onSave();
closeModals(); closeModalAction();
} }
}); });
logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden');
(document.getElementById('new-hw-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value = '';
});
document.getElementById('btn-close-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-hw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-hw-log')?.addEventListener('click', () => {
if (!currentAsset) return;
const date = (document.getElementById('new-hw-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-hw-log-details') as HTMLTextAreaElement).value;
if (!date || !details) return;
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({ id: Math.random().toString(36).substring(2, 9), assetId: currentAsset.id, date, user: '관리자', details });
logModal.classList.add('hidden');
renderHwHistory(currentAsset.id);
});
} }

View File

@@ -103,13 +103,15 @@ export function setEditLock(
options: { options: {
saveBtnId: string, saveBtnId: string,
revertBtnId: string, revertBtnId: string,
generateBtnId?: string generateBtnId?: string,
addLogBtnId?: string
} }
) { ) {
const form = document.getElementById(formId) as HTMLFormElement; const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId); const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId); const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null; const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form || !saveBtn || !revertBtn) return; if (!form || !saveBtn || !revertBtn) return;
@@ -118,10 +120,14 @@ export function setEditLock(
form.classList.remove('is-view-mode'); form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode'); form.classList.add('is-edit-mode');
saveBtn.textContent = '저장'; saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김 (닫기가 대신함) revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
// 번호 생성 버튼은 '추가' 시에만 노출 // 번호 생성 버튼은 '추가(add)' 시에만 노출
if (generateBtn) generateBtn.classList.toggle('hidden', mode !== 'add'); if (generateBtn) {
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
}
// 내역 추가 버튼 노출
if (addLogBtn) addLogBtn.style.display = 'flex';
} else { } else {
// 조회 모드 (잠금) // 조회 모드 (잠금)
form.classList.remove('is-edit-mode'); form.classList.remove('is-edit-mode');
@@ -129,7 +135,79 @@ export function setEditLock(
saveBtn.textContent = '수정'; saveBtn.textContent = '수정';
revertBtn.classList.add('hidden'); revertBtn.classList.add('hidden');
// 조회 모드에서는 번호 생성 버튼 무조건 숨김 // 조회 모드에서는 버튼들 숨김
if (generateBtn) generateBtn.classList.add('hidden'); if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none';
} }
} }
/**
* 8. 공통 모달 프레임 템플릿 생성
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
* @param title 모달 제목
* @param formContent 각 모달마다 다른 폼 본문 HTML
* @param options 설정 (이력 영역 제목 등)
*/
export function createModalFrameHTML(
idPrefix: string,
title: string,
formContent: string,
options: { historyTitle: string, addLogBtnId: string }
): string {
return `
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="${idPrefix}-modal-title">${title}</h2>
<button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="${idPrefix}-asset-form" class="grid-form">
<input type="hidden" id="${idPrefix}-asset-id" />
<input type="hidden" id="${idPrefix}-asset-type" />
${formContent}
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
/**
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
*/
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
});
}
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
const result: any = {};
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
});
return result;
}

View File

@@ -1,362 +0,0 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { createIcons, History, X, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData';
import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
parseAndSetLocation,
bindLocationEvents,
getCombinedLocation
} from './ModalUtils';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const PC_MODAL_HTML = `
<div id="pc-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="pc-modal-title">개인PC 상세 정보</h2>
<button id="btn-close-pc-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="pc-asset-form" class="grid-form">
<input type="hidden" id="pc-asset-id" />
<input type="hidden" id="pc-asset-type" value="개인PC" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="pc-법인">구매법인</label>
<select id="pc-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산번호/코드</label>
<input type="text" id="pc-자산코드" readonly placeholder="자동 생성됩니다" required />
</div>
<div class="form-group">
<label for="pc-유형">유형</label>
<select id="pc-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-상세용도">상세용도</label>
<select id="pc-상세용도">
<option value="개인PC">개인PC</option>
<option value="서버">서버</option>
</select>
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-현사용조직">현 사용조직</label>
<select id="pc-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="pc-이전사용조직-group">
<label for="pc-이전사용조직">이전 사용조직</label>
<input type="text" id="pc-이전사용조직" readonly />
</div>
<div class="form-section-title">시스템 사양 (Specifications)</div>
<div class="form-group">
<label for="pc-모델명">모델명</label>
<input type="text" id="pc-모델명" />
</div>
<div class="form-group">
<label for="pc-OS">운영체제 (OS)</label>
<input type="text" id="pc-OS" />
</div>
<div class="form-group">
<label for="pc-CPU">CPU 사양</label>
<input type="text" id="pc-CPU" />
</div>
<div class="form-group">
<label for="pc-RAM">RAM 용량</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-section-title" id="pc-location-title">관리 및 운영 (Operation)</div>
<div class="form-group pc-location-field">
<label for="pc-위치-빌딩">설치위치 (건물)</label>
<select id="pc-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div>
<div class="form-group pc-location-field">
<label for="pc-위치-상세">상세 위치</label>
<select id="pc-위치-상세">
<option value="">건물을 먼저 선택하세요</option>
</select>
</div>
<div class="form-group" id="pc-위치-기타-group" style="display:none;">
<label for="pc-위치-기타">직접 입력 (기타)</label>
<input type="text" id="pc-위치-기타" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" />
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label>품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3>
</div>
<div id="pc-history-list" class="history-timeline">
<div class="empty-history">이력이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-pc-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-pc-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-pc-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function openPcModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') {
currentAsset = asset;
const modal = document.getElementById('pc-asset-modal');
if (!modal) return;
const form = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset')!;
const revertBtn = document.getElementById('btn-revert-pc-edit')!;
if (form) form.reset();
if (mode === 'add') {
isEditMode = true;
if (form) {
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
}
saveBtn.textContent = '저장';
revertBtn.classList.add('hidden');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'none';
} else {
isEditMode = false;
if (form) {
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
}
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'flex';
}
fillFormData(asset);
renderHistory(asset.id);
modal.classList.remove('hidden');
applyPcTypeSpecificUI();
createIcons({ icons: { X, History, Paperclip } });
}
function applyPcTypeSpecificUI() {
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const modelGroup = document.getElementById('pc-모델명')?.closest('.form-group') as HTMLElement;
const osGroup = document.getElementById('pc-OS')?.closest('.form-group') as HTMLElement;
const cpuGroup = document.getElementById('pc-CPU')?.closest('.form-group') as HTMLElement;
const ramGroup = document.getElementById('pc-RAM')?.closest('.form-group') as HTMLElement;
const ssd1Group = document.getElementById('pc-SSD1')?.closest('.form-group') as HTMLElement;
const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement;
const locationFields = document.querySelectorAll('.pc-location-field');
const etcGroup = document.getElementById('pc-위치-기타-group');
// 초기화 (숨김)
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'none'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (etcGroup) etcGroup.style.display = 'none';
if (type === '서버') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
[modelGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (type === 'PC' || type === '노트북') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
if (detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
}
else if (['CPU', 'GPU', '모바일'].includes(type)) {
if (modelGroup) modelGroup.style.display = 'flex';
}
else if (type === 'RAM') {
if (ramGroup) ramGroup.style.display = 'flex';
}
else if (type === 'HDD') {
if (ssd1Group) ssd1Group.style.display = 'flex';
}
else if (type === '태블릿') {
if (modelGroup) modelGroup.style.display = 'flex';
if (ssd1Group) ssd1Group.style.display = 'flex';
}
}
function fillFormData(asset: HardwareAsset) {
setFieldValue('pc-asset-id', asset.id);
setFieldValue('pc-법인', asset.);
setFieldValue('pc-자산코드', asset.);
setFieldValue('pc-유형', asset.type);
setFieldValue('pc-사용자', asset.);
setFieldValue('pc-현사용조직', asset.);
setFieldValue('pc-이전사용조직', asset.);
setFieldValue('pc-상세용도', (asset as any).);
parseAndSetLocation(asset., 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
setFieldValue('pc-모델명', asset.);
setFieldValue('pc-OS', asset.OS);
setFieldValue('pc-CPU', asset.CPU);
setFieldValue('pc-RAM', asset.RAM);
setFieldValue('pc-SSD1', asset.SSD1);
setFieldValue('pc-SSD2', asset.SSD2);
setFieldValue('pc-구매일', asset.);
setFieldValue('pc-금액', asset.);
setFieldValue('pc-납품업체', asset.);
setFieldValue('pc-품의서명', asset.);
}
export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('pc-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', PC_MODAL_HTML);
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset');
const revertBtn = document.getElementById('btn-revert-pc-edit');
const deleteBtn = document.getElementById('btn-delete-pc-asset');
// 유형 및 상세용도 리스너
const typeSelect = document.getElementById('pc-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('pc-상세용도') as HTMLSelectElement;
[typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyPcTypeSpecificUI());
});
bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
const handleClose = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose);
document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose);
revertBtn?.addEventListener('click', () => {
isEditMode = false;
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentAsset) fillFormData(currentAsset);
});
saveBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
isEditMode = true;
pcForm.classList.replace('is-view-mode', 'is-edit-mode');
saveBtn.textContent = '저장';
revertBtn?.classList.remove('hidden');
return;
}
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const updated: any = {
...currentAsset,
법인: getFieldValue('pc-법인'),
자산코드: getFieldValue('pc-자산코드'),
현사용조직: getFieldValue('pc-현사용조직'),
이전사용조직: getFieldValue('pc-이전사용조직'),
사용자: getFieldValue('pc-사용자'),
상세용도: detailPurpose,
위치: getCombinedLocation('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타'),
모델명: getFieldValue('pc-모델명'),
OS: getFieldValue('pc-OS'),
CPU: getFieldValue('pc-CPU'),
RAM: getFieldValue('pc-RAM'),
SSD1: getFieldValue('pc-SSD1'),
SSD2: getFieldValue('pc-SSD2'),
구매일: getFieldValue('pc-구매일'),
금액: getFieldValue('pc-금액'),
납품업체: getFieldValue('pc-납품업체'),
type: type || 'PC'
};
saveHardwareAsset(updated);
onSave();
isEditMode = false;
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
saveBtn.textContent = '수정';
revertBtn?.classList.add('hidden');
});
deleteBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (confirm('삭제하시겠습니까?')) {
deleteHardwareAsset(currentAsset.id);
onSave();
handleClose();
}
});
}
function renderHistory(assetId: string) {
const historyList = document.getElementById('pc-history-list');
if (!historyList) return;
const logs = state.masterData.logs
.filter(l => l.assetId === assetId)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (logs.length === 0) {
historyList.innerHTML = '<div class="empty-history">이력이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">수정자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
}

View File

@@ -1,184 +1,120 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler'; import { SoftwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal'; import { closeModals } from './BaseModal';
import { openSwUserModal } from './SWUserModal'; import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide'; import { createIcons, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide';
import { CORP_LIST } from './SharedData'; import { CORP_LIST, TYPE_PREFIX_MAP } from './SharedData';
import { import {
generateOptionsHTML, generateOptionsHTML,
setFieldValue, setFieldValue,
getFieldValue, getFieldValue,
setEditLock setEditLock,
createModalFrameHTML,
autoFillForm,
autoExtractForm
} from './ModalUtils'; } from './ModalUtils';
let currentSwAsset: SoftwareAsset | null = null; let currentSwAsset: SoftwareAsset | null = null;
let isEditMode = false; let isEditMode = false;
const SW_MODAL_HTML = ` const SW_FIELD_MAP: Record<string, string> = {
<div id="sw-asset-modal" class="modal-overlay hidden"> '법인': '법인',
<div class="modal-content wide"> '자산번호': '자산번호',
<div class="modal-header"> '제품명': '제품명',
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2> '수량': '수량',
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> '금액': '금액',
</div> '구매일': '구매일',
<div class="modal-body"> '납품업체': '납품업체',
<div class="modal-body-split"> '비고': '비고',
<div class="modal-form-area"> '플랫폼명': '플랫폼명',
<form id="sw-asset-form" class="grid-form"> '부서': '부서',
<input type="hidden" id="sw-asset-id" /> '계정명': '계정명',
<input type="hidden" id="sw-asset-type" /> '결제수단': '결제수단',
'연결카드번호': '연결카드번호',
'결제일': '결제일',
'당월청구액': '당월청구액',
'라이선스유형': '라이선스유형',
'만료일': '만료일',
'라이선스키': '라이선스키'
};
<!-- Group 1: 기본 정보 (Identity) --> const SW_FORM_HTML = `
<div class="form-section-title">기본 정보 (Identity)</div> <!-- Group 1: 기본 정보 -->
<div class="form-group"> <div class="form-section-title">기본 정보 (Identity)</div>
<label for="sw-법인">구매법인</label> <div class="form-group">
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select> <label for="sw-법인">구매법인</label>
</div> <select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
<div class="form-group sw-standard-field"> </div>
<label for="sw-자산번호">자산번호</label> <div class="form-group sw-standard-field">
<input type="text" id="sw-자산번호" readonly placeholder="자동 생성" /> <label for="sw-자산번호">자산번호</label>
</div> <div class="input-with-btn">
<div class="form-group full-width"> <input type="text" id="sw-자산번호" readonly class="is-readonly-field" placeholder="번호 생성을 클릭하세요" />
<label for="sw-제품명">제품명 / 서비스명</label> <button type="button" id="btn-generate-sw-code" class="btn btn-outline btn-sm">생성</button>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group cloud-only">
<label for="sw-플랫폼명">플랫폼명</label>
<input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" />
</div>
<div class="form-group cloud-only">
<label for="sw-부서">담당부서</label>
<input type="text" id="sw-부서" />
</div>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field" id="sw-license-type-group">
<label for="sw-라이선스유형">라이선스 유형</label>
<input type="text" id="sw-라이선스유형" />
</div>
<div class="form-group sw-standard-field" id="sw-license-key-group">
<label for="sw-라이선스키">라이선스 키</label>
<input type="text" id="sw-라이선스키" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-수량">보유 수량</label>
<input type="number" id="sw-수량" min="0" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-금액">도입 금액</label>
<input type="text" id="sw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only">
<label for="sw-계정명">계정명 (이메일)</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제수단">결제수단</label>
<select id="sw-결제수단">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group cloud-only">
<label for="sw-연결카드번호">연결카드번호(뒷4자리)</label>
<input type="text" id="sw-연결카드번호" maxlength="4" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제일">결제일 (기준일)</label>
<input type="number" id="sw-결제일" min="1" max="31" />
</div>
<div class="form-group cloud-only">
<label for="sw-당월청구액">당월 청구액(원)</label>
<input type="text" id="sw-당월청구액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label for="sw-구매일">구매일</label>
<input type="text" id="sw-구매일" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">만료일 (구독)</label>
<input type="text" id="sw-만료일" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group full-width">
<label for="sw-비고">비고</label>
<textarea id="sw-비고" rows="2"></textarea>
</div>
</form>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem;">
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
할당 관리 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="sw-assigned-users-summary" class="user-summary-grid"></div>
</div>
</div>
<div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<button type="button" id="btn-add-sw-log" class="btn btn-outline btn-sm cloud-only">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="sw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="form-group full-width">
<label for="sw-제품명">제품명 / 서비스명</label>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group cloud-only"><label for="sw-플랫폼명">플랫폼명</label><input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" /></div>
<div class="form-group cloud-only"><label for="sw-부서">담당부서</label><input type="text" id="sw-부서" /></div>
<!-- 클라우드 이력 추가를 위한 간이 모달 --> <!-- Group 2: 라이선스 및 계약 -->
<div id="sw-log-modal" class="modal-overlay hidden" style="z-index: 1100;"> <div class="form-section-title">라이선스 및 계약 정보</div>
<div class="modal-content" style="max-width: 400px;"> <div class="form-group sw-standard-field" id="sw-license-type-group"><label for="sw-라이선스유형">라이선스 유형</label><input type="text" id="sw-라이선스유형" /></div>
<div class="modal-header"> <div class="form-group sw-standard-field" id="sw-license-key-group"><label for="sw-라이선스키">라이선스 키</label><input type="text" id="sw-라이선스키" /></div>
<h2>업데이트 내역 추가</h2> <div class="form-group sw-standard-field"><label for="sw-수량">보유 수량</label><input type="number" id="sw-수량" min="0" /></div>
<button id="btn-close-sw-log" class="btn-icon"><i data-lucide="x"></i></button> <div class="form-group sw-standard-field"><label for="sw-금액">도입 금액</label><input type="text" id="sw-금액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div>
</div>
<div class="modal-body"> <div class="form-group cloud-only"><label for="sw-계정명">계정명 (이메일)</label><input type="text" id="sw-계정명" /></div>
<div class="grid-form" style="grid-template-columns: 1fr;"> <div class="form-group cloud-only"><label for="sw-결제수단">결제수단</label><select id="sw-결제수단"><option value="">선택안함</option><option value="법인카드">법인카드</option><option value="인보이스">인보이스</option></select></div>
<div class="form-group"> <div class="form-group cloud-only"><label for="sw-연결카드번호">연결카드번호(뒷4자리)</label><input type="text" id="sw-연결카드번호" maxlength="4" /></div>
<label>날짜</label> <div class="form-group cloud-only"><label for="sw-결제일">결제일 (기준일)</label><input type="number" id="sw-결제일" min="1" max="31" /></div>
<input type="date" id="new-log-date" /> <div class="form-group cloud-only"><label for="sw-당월청구액">당월 청구액(원)</label><input type="text" id="sw-당월청구액" oninput="this.value=this.value.replace(/[^0-9]/g,'').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g,',')" /></div>
</div>
<div class="form-group"> <!-- Group 4: 관리 정보 -->
<label>상세 내용</label> <div class="form-section-title">관리 및 비고</div>
<textarea id="new-log-details" rows="3" placeholder="예: 결제 금액 변동, 담당자 변경 등"></textarea> <div class="form-group sw-standard-field"><label for="sw-구매일">구매연월</label><input type="text" id="sw-구매일" placeholder="YYYYMM" maxlength="6" /></div>
</div> <div class="form-group sw-standard-field" id="sw-expiry-group"><label for="sw-만료일">만료일 (구독)</label><input type="text" id="sw-만료일" /></div>
</div> <div class="form-group sw-standard-field"><label for="sw-납품업체">납품업체</label><input type="text" id="sw-납품업체" /></div>
</div> <div class="form-group full-width"><label for="sw-비고">비고</label><textarea id="sw-비고" rows="2"></textarea></div>
<div class="modal-footer">
<div></div> <div id="sw-user-section" class="user-management-section" style="margin-top: 2rem;">
<div class="footer-actions"> <div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<button id="btn-cancel-sw-log" class="btn btn-outline">취소</button> <h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3>
<button id="btn-confirm-sw-log" class="btn btn-primary">추가</button> <button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">할당 관리 <i data-lucide="plus" style="width:14px; height:14px;"></i></button>
</div>
</div>
</div> </div>
<div id="sw-assigned-users-summary" class="user-summary-grid"></div>
</div> </div>
`; `;
function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId).sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `
<div class="history-item">
<div class="history-date">${l.date}</div>
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
}
function renderUserSummary(swId: string) {
const container = document.getElementById('sw-assigned-users-summary');
if (!container) return;
const userMapping = state.masterData.swUsers.find(u => u.sw_id === swId);
if (!userMapping || !userMapping.userData || userMapping.userData.length === 0) {
container.innerHTML = '<div class="empty-summary">할당된 사용자가 없습니다.</div>';
return;
}
container.innerHTML = userMapping.userData.map(u => `
<div class="user-badge-item"><span class="u-name">${u[3] || '이름없음'}</span><span class="u-dept">${u[1] || '부서없음'}</span></div>
`).join('');
}
function applySwTypeUI(type: string) { function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only'); const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field'); const swFields = document.querySelectorAll('.sw-standard-field');
@@ -195,7 +131,6 @@ function applySwTypeUI(type: string) {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none'); cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex'); swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block'; if (userSection) userSection.style.display = 'block';
if (type === '구독SW') { if (type === '구독SW') {
if (keyGroup) keyGroup.style.display = 'none'; if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex'; if (typeGroup) typeGroup.style.display = 'flex';
@@ -208,92 +143,41 @@ function applySwTypeUI(type: string) {
} }
} }
function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.type);
setFieldValue('sw-법인', asset.);
setFieldValue('sw-자산번호', asset. || '');
setFieldValue('sw-제품명', asset.);
setFieldValue('sw-수량', asset.);
setFieldValue('sw-금액', asset.);
setFieldValue('sw-구매일', asset. || '');
setFieldValue('sw-납품업체', asset. || '');
setFieldValue('sw-비고', asset. || '');
if (asset.type === '클라우드') {
setFieldValue('sw-플랫폼명', (asset as any). || '');
setFieldValue('sw-부서', (asset as any). || '');
setFieldValue('sw-계정명', (asset as any). || '');
setFieldValue('sw-결제수단', (asset as any). || '');
setFieldValue('sw-연결카드번호', (asset as any). || '');
setFieldValue('sw-결제일', (asset as any). || '');
setFieldValue('sw-당월청구액', (asset as any). || '');
} else if (asset.type === '구독SW') {
setFieldValue('sw-라이선스유형', (asset as any). || '');
setFieldValue('sw-만료일', (asset as any). || '');
} else {
setFieldValue('sw-라이선스키', (asset as any). || '');
}
renderUserSummary(asset.id);
renderSwHistory(asset.id);
}
function renderUserSummary(swId: string) {
const container = document.getElementById('sw-assigned-users-summary');
if (!container) return;
const userMapping = state.masterData.swUsers.find(u => u.sw_id === swId);
if (!userMapping || !userMapping.userData || userMapping.userData.length === 0) {
container.innerHTML = '<div class="empty-summary">할당된 사용자가 없습니다.</div>';
return;
}
container.innerHTML = userMapping.userData.map(u => `
<div class="user-badge-item">
<span class="u-name">${u[3] || '이름없음'}</span>
<span class="u-dept">${u[1] || '부서없음'}</span>
</div>
`).join('');
}
function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) {
container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>';
return;
}
container.innerHTML = logs.map(l => `
<div class="history-item">
<div class="history-date">${l.date}</div>
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details}</div>
</div>
`).join('');
}
export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' = 'view') { export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' = 'view') {
currentSwAsset = asset; currentSwAsset = asset;
const modal = document.getElementById('sw-asset-modal')!; const modal = document.getElementById('sw-asset-modal')!;
// 수정 잠금 상태 제어
setEditLock('sw-asset-form', mode, { setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset', saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit' revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
}); });
isEditMode = (mode === 'add'); isEditMode = (mode === 'add');
autoFillForm('sw', asset, SW_FIELD_MAP);
fillSwFormData(asset);
applySwTypeUI(asset.type); applySwTypeUI(asset.type);
renderUserSummary(asset.id);
renderSwHistory(asset.id);
modal.classList.remove('hidden'); modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } }); createIcons({ icons: { X, History, Plus } });
} }
export function initSwModal(onSave: () => void, closeModals: () => void) { export function initSwModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('sw-asset-modal')) { if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML); const html = createModalFrameHTML('sw', '소프트웨어 상세 정보', SW_FORM_HTML, {
historyTitle: '업데이트 내역',
addLogBtnId: 'btn-add-sw-log'
});
document.body.insertAdjacentHTML('beforeend', html);
const logModalHTML = `
<div id="sw-log-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header"><h2>업데이트 내역 추가</h2><button id="btn-close-sw-log" class="btn-icon"><i data-lucide="x"></i></button></div>
<div class="modal-body"><div class="grid-form" style="grid-template-columns: 1fr;"><div class="form-group"><label>날짜</label><input type="date" id="new-log-date" /></div><div class="form-group"><label>상세 내용</label><textarea id="new-log-details" rows="3"></textarea></div></div></div>
<div class="modal-footer"><div></div><div class="footer-actions"><button id="btn-cancel-sw-log" class="btn btn-outline">취소</button><button id="btn-confirm-sw-log" class="btn btn-primary">추가</button></div></div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', logModalHTML);
} }
const form = document.getElementById('sw-asset-form') as HTMLFormElement; const form = document.getElementById('sw-asset-form') as HTMLFormElement;
@@ -302,123 +186,100 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const userUpdateBtn = document.getElementById('btn-open-sw-update')!; const userUpdateBtn = document.getElementById('btn-open-sw-update')!;
const logAddBtn = document.getElementById('btn-add-sw-log')!; const logAddBtn = document.getElementById('btn-add-sw-log')!;
const logModal = document.getElementById('sw-log-modal')!;
const closeModalAction = () => { closeModals(); isEditMode = false; }; const closeModalAction = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction); document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', { setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset', saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit' revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
}); });
isEditMode = false; isEditMode = false;
if (currentSwAsset) fillSwFormData(currentSwAsset); if (currentSwAsset) openSwModal(currentSwAsset, 'view');
});
document.getElementById('btn-generate-sw-code')?.addEventListener('click', async () => {
const typeValue = getFieldValue('sw-asset-type');
const purchaseDate = getFieldValue('sw-구매일');
const typeCode = TYPE_PREFIX_MAP[typeValue] || 'SW';
const dateStr = purchaseDate.replace(/[^0-9]/g, '');
if (dateStr.length < 6) { alert('올바른 구매연월(YYYYMM)을 입력해주세요.'); return; }
const prefix = `${typeCode}-${dateStr.substring(0, 6)}-`;
try {
const res = await fetch(`http://localhost:3000/api/generate-asset-code?prefix=${prefix}`);
const data = await res.json();
if (data.nextCode) setFieldValue('sw-자산번호', data.nextCode);
} catch (err) { alert('자산번호 생성에 실패했습니다.'); }
});
// YYYYMM 입력 제한 로직 (숫자 6자리)
document.getElementById('sw-구매일')?.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
target.value = target.value.replace(/[^0-9]/g, '').substring(0, 6);
}); });
saveBtn.addEventListener('click', () => { saveBtn.addEventListener('click', () => {
if (!currentSwAsset) return; if (!currentSwAsset) return;
if (!isEditMode) { if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', { setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset', saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit' revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
}); });
isEditMode = true; isEditMode = true;
return; return;
} }
const extracted = autoExtractForm('sw', SW_FIELD_MAP);
const updated = { ...currentSwAsset, ...extracted, 수량: parseInt(extracted. || '0') };
const type = getFieldValue('sw-asset-type');
const updated: any = {
...currentSwAsset,
법인: getFieldValue('sw-법인'),
자산번호: getFieldValue('sw-자산번호'),
제품명: getFieldValue('sw-제품명'),
수량: parseInt(getFieldValue('sw-수량') || '0'),
금액: getFieldValue('sw-금액'),
구매일: getFieldValue('sw-구매일'),
납품업체: getFieldValue('sw-납품업체'),
비고: getFieldValue('sw-비고'),
type: type
};
if (type === '클라우드') {
updated. = getFieldValue('sw-플랫폼명');
updated. = getFieldValue('sw-부서');
updated. = getFieldValue('sw-계정명');
updated. = getFieldValue('sw-결제수단');
updated. = getFieldValue('sw-연결카드번호');
updated. = getFieldValue('sw-결제일');
updated. = getFieldValue('sw-당월청구액');
} else if (type === '구독SW') {
updated. = getFieldValue('sw-라이선스유형');
updated. = getFieldValue('sw-만료일');
} else {
updated. = getFieldValue('sw-라이선스키');
}
// 데이터 저장 로직 (state 업데이트)
let targetList: SoftwareAsset[] = []; let targetList: SoftwareAsset[] = [];
if (type === '구독SW') targetList = state.masterData.subSw; if (updated.type === '구독SW') targetList = state.masterData.subSw;
else if (type === '영구SW') targetList = state.masterData.permSw; else if (updated.type === '영구SW') targetList = state.masterData.permSw;
else if (type === '클라우드') targetList = state.masterData.cloud; else targetList = (state.masterData as any).cloud || [];
const idx = targetList.findIndex(a => a.id === updated.id); const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated; if (idx > -1) targetList[idx] = updated; else targetList.push(updated);
else targetList.push(updated);
onSave(); onSave();
setEditLock('sw-asset-form', 'view', { setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset', saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit' revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
}); });
isEditMode = false; isEditMode = false;
}); });
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
if (!currentSwAsset) return; if (currentSwAsset && confirm('삭제하시겠습니까?')) {
if (confirm('삭제하시겠습니까?')) {
const type = currentSwAsset.type; const type = currentSwAsset.type;
if (type === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== currentSwAsset!.id); if (type === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== currentSwAsset!.id);
else if (type === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== currentSwAsset!.id); else if (type === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== currentSwAsset!.id);
else if (type === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== currentSwAsset!.id); onSave(); closeModalAction();
onSave();
closeModalAction();
} }
}); });
userUpdateBtn.addEventListener('click', () => { userUpdateBtn.addEventListener('click', () => { if (currentSwAsset) openSwUserModal(currentSwAsset); });
if (currentSwAsset) openSwUserModal(currentSwAsset);
});
// 이력 추가 모달 로직
const logModal = document.getElementById('sw-log-modal')!;
logAddBtn.addEventListener('click', () => { logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden'); logModal.classList.remove('hidden');
(document.getElementById('new-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0]; (document.getElementById('new-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-log-details') as HTMLTextAreaElement).value = ''; (document.getElementById('new-log-details') as HTMLTextAreaElement).value = '';
}); });
document.getElementById('btn-close-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden')); document.getElementById('btn-close-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden')); document.getElementById('btn-cancel-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-sw-log')?.addEventListener('click', () => { document.getElementById('btn-confirm-sw-log')?.addEventListener('click', () => {
if (!currentSwAsset) return; if (!currentSwAsset) return;
const date = (document.getElementById('new-log-date') as HTMLInputElement).value; const date = (document.getElementById('new-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-log-details') as HTMLTextAreaElement).value; const details = (document.getElementById('new-log-details') as HTMLTextAreaElement).value;
if (!date || !details) return;
if (!date || !details) { alert('날짜와 내용을 입력해주세요.'); return; }
state.masterData.logs = state.masterData.logs || []; state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({ state.masterData.logs.push({ id: Math.random().toString(36).substring(2, 9), assetId: currentSwAsset.id, date, user: '관리자', details });
id: Math.random().toString(36).substring(2, 9), logModal.classList.add('hidden'); renderSwHistory(currentSwAsset.id);
assetId: currentSwAsset.id,
date,
user: '관리자',
details
});
logModal.classList.add('hidden');
renderSwHistory(currentSwAsset.id);
}); });
} }

View File

@@ -29,5 +29,6 @@ export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', 'PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO', '서버': 'SVR', 'PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'CPU': 'CPU', 'HDD': 'HDD', 'RAM': 'RAM', 'GPU': 'GPU', 'CPU': 'CPU', 'HDD': 'HDD', 'RAM': 'RAM', 'GPU': 'GPU',
'모바일': 'MOB', '노트북': 'PC', '태블릿': 'TAB', '모바일': 'MOB', '노트북': 'PC', '태블릿': 'TAB',
'개인PC': 'PC', '모바일기기': 'MOB' '개인PC': 'PC', '모바일기기': 'MOB',
'구독SW': 'SSW', '영구SW': 'PSW'
}; };

View File

@@ -27,7 +27,7 @@ export interface HardwareAsset {
용량?: string; 용량?: string;
담당자_정?: string; 담당자_정?: string;
담당자_부?: string; 담당자_부?: string;
구매?: string; 구매연월?: string;
금액?: string; 금액?: string;
납품업체: string; 납품업체: string;
품의서명: string; 품의서명: string;
@@ -40,6 +40,8 @@ export interface HardwareAsset {
비고?: string; 비고?: string;
현사용조직?: string; 현사용조직?: string;
이전사용조직?: string; 이전사용조직?: string;
보관위치?: string;
현재상태?: string;
} }
export interface SoftwareAsset { export interface SoftwareAsset {
@@ -49,7 +51,7 @@ export interface SoftwareAsset {
법인: string; 법인: string;
부서?: string; 부서?: string;
제품명: string; 제품명: string;
구매: string; 구매연월: string;
구독일?: string; 구독일?: string;
만료일?: string; 만료일?: string;
라이선스유형?: string; 라이선스유형?: string;
@@ -103,14 +105,14 @@ export interface MasterAssetData {
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기']; const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
const SW_TABS = ['구독SW', '영구SW', '클라우드']; const SW_TABS = ['구독SW', '영구SW', '클라우드'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매', '금액', '납품업체', '품의서명', '비고']; const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고']; const SERVER_HEADERS = ['구매법인', '자산번호', '구매연월', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고'];
const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매', '금액', '납품업체', '품의서명', '비고']; const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매', '금액', '납품업체', '품의서명', '비고']; const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매', '금액', '납품업체', '품의서명', '비고']; const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매연월', '금액', '납품업체', '품의서명', '비고'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고']; const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고']; const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매연월', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고'];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고']; const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
export function downloadTemplate() { export function downloadTemplate() {
@@ -142,13 +144,13 @@ export function downloadTemplate() {
export function exportToExcel(masterData: MasterAssetData) { export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
const exportMap = [ const exportMap = [
{ tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a., a., a., a., a.] }, { tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a., a., a., a., a.] },
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] }, { tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] },
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] }, { tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] }, { tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] },
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a., a., a., a., a.] }, { tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a., a., a., a., a.] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a., a.] }, { tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] } { tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] }
]; ];
exportMap.forEach(m => { exportMap.forEach(m => {
@@ -168,19 +170,19 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
workbook.SheetNames.forEach(sheetName => { workbook.SheetNames.forEach(sheetName => {
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[]; const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
if (sheetName === '개인PC') { if (sheetName === '개인PC') {
rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' })); rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매연월: r['구매연월']||r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
} else if (sheetName === '서버') { } else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' })); rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매연월: r['구매연월']||r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' }));
} else if (sheetName === '스토리지') { } else if (sheetName === '스토리지') {
rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' })); rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매연월: r['구매연월']||r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' }));
} else if (sheetName === '전산비품') { } else if (sheetName === '전산비품') {
rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' })); rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매연월: r['구매연월']||r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }));
} else if (sheetName === '모바일기기') { } else if (sheetName === '모바일기기') {
rows.forEach(r => data.mobile.push({ id: Math.random().toString(36).substring(2, 9), type: '모바일기기', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', OS: r['OS']||'', 구매: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' })); rows.forEach(r => data.mobile.push({ id: Math.random().toString(36).substring(2, 9), type: '모바일기기', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', OS: r['OS']||'', 구매연월: r['구매연월']||r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' }));
} else if (sheetName === '구독SW') { } else if (sheetName === '구독SW') {
rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매: r['구매일']||'', 만료일: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매연월: r['구매연월']||r['구매일']||'', 만료일: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} else if (sheetName === '영구SW') { } else if (sheetName === '영구SW') {
rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매: r['구매일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' })); rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매연월: r['구매연월']||r['구매일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} }
}); });
resolve(data); resolve(data);

View File

@@ -14,6 +14,7 @@ export interface MasterAssetData {
logs: HardwareLog[]; logs: HardwareLog[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용) // 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[];
sw: SoftwareAsset[]; sw: SoftwareAsset[];
} }
@@ -36,6 +37,7 @@ export const state: AppState = {
subSw: [], subSw: [],
permSw: [], permSw: [],
cloud: [], cloud: [],
hw: [], // 호환용
sw: [], // 호환용 sw: [], // 호환용
swUsers: [], swUsers: [],
logs: [] logs: []
@@ -90,6 +92,15 @@ export async function loadMasterDataFromDB() {
...state.masterData.cloud ...state.masterData.cloud
]; ];
// 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
console.log('✅ 모든 DB 데이터 로드 및 통합 완료'); console.log('✅ 모든 DB 데이터 로드 및 통합 완료');
return true; return true;
} catch (err) { } catch (err) {
@@ -110,18 +121,25 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
const type = updatedAsset.type || ''; const type = updatedAsset.type || '';
const detailPurpose = (updatedAsset as any). || updatedAsset.detail_purpose || ''; const detailPurpose = (updatedAsset as any). || updatedAsset.detail_purpose || '';
// 1. 타겟 카테고리 결정 (유연한 검색) // 1. 타겟 카테고리 결정 (사용자 정의 그룹 기준)
let targetKey: keyof MasterAssetData = 'equip'; let targetKey: keyof MasterAssetData = 'equip';
if (type.includes('서버') || detailPurpose.includes('서버')) { const upperType = type.toUpperCase();
const isServer = type.includes('서버') || detailPurpose.includes('서버');
const isStorage = ['NAS', 'DAS', '스토리지'].some(t => type.includes(t));
const isMobileGroup = ['모바일', '태블릿', '노트북', '휴대폰', '핸드폰'].some(t => type.includes(t));
const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t));
const isPc = type === 'PC' || type === '개인PC' || detailPurpose === '개인PC';
if (isServer) {
targetKey = 'server'; targetKey = 'server';
} else if (['NAS', 'DAS', '스토리지'].some(t => type.includes(t))) { } else if (isStorage) {
targetKey = 'storage'; targetKey = 'storage';
} else if (['모바일', '태블릿', '휴대폰', '핸드폰', '노트북'].some(t => type.includes(t))) { } else if (isMobileGroup) {
targetKey = 'mobile'; targetKey = 'mobile';
} else if (type === 'PC' || type === '개인PC' || detailPurpose === '개인PC') { } else if (isPc) {
targetKey = 'pc'; targetKey = 'pc';
} else if (['CPU', 'GPU', 'RAM', 'HDD'].some(t => type.toUpperCase().includes(t))) { } else if (isEquipGroup) {
targetKey = 'equip'; targetKey = 'equip';
} }
@@ -137,6 +155,15 @@ export function saveHardwareAsset(updatedAsset: HardwareAsset) {
// 3. 새로운 타겟 카테고리에 추가 // 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset); (state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset);
// 4. 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
} }
/** /**
@@ -151,4 +178,13 @@ export function deleteHardwareAsset(assetId: string) {
if (idx > -1) arr.splice(idx, 1); if (idx > -1) arr.splice(idx, 1);
} }
}); });
// 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
} }

View File

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

View File

@@ -4,7 +4,6 @@ import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table'; import { renderSWTable } from './views/SW_Table';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAsset, SWUser } from './core/excelHandler'; import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAsset, SWUser } from './core/excelHandler';
import { initBaseModal } from './components/Modal/BaseModal'; import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
@@ -75,7 +74,6 @@ function initApp() {
}); });
// 모달 초기화 // 모달 초기화
initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initSwModal(() => { initSwModal(() => {
@@ -125,8 +123,14 @@ function initApp() {
const cat = state.activeCategory; const cat = state.activeCategory;
if (cat === 'hw') { if (cat === 'hw') {
// 하드웨어 대시보드 또는 개별 탭에서 추가 // 탭 명칭을 실제 유형명으로 매핑
const defaultType = (tab === '대시보드') ? '' : tab; let defaultType = '';
if (tab === '개인PC') defaultType = 'PC';
else if (tab === '서버') defaultType = '서버';
else if (tab === '스토리지') defaultType = '스토리지';
else if (tab === '전산비품') defaultType = 'CPU';
else if (tab === '모바일기기') defaultType = '모바일';
openHwModal({ openHwModal({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: defaultType, type: defaultType,

View File

@@ -167,6 +167,10 @@
background-color: var(--white); background-color: var(--white);
} }
.form-group textarea {
resize: none;
}
.form-group input:focus, .form-group input:focus,
.form-group select:focus, .form-group select:focus,
.form-group textarea:focus { .form-group textarea:focus {
@@ -213,6 +217,9 @@
} }
.history-header { .history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -225,6 +232,35 @@
color: var(--text-main); color: var(--text-main);
} }
/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */
.is-readonly-field {
border-color: transparent !important;
background-color: transparent !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 600 !important;
cursor: default;
padding-left: 0 !important;
}
/* 입력 필드 + 버튼 그룹 (자산번호 생성 등) */
.input-with-btn {
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.input-with-btn input {
flex: 1;
min-width: 0; /* flex 컨테이너 안에서 너비 압축 방지 */
}
.input-with-btn .btn {
flex-shrink: 0;
white-space: nowrap;
}
.history-timeline { .history-timeline {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;

View File

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

View File

@@ -22,13 +22,32 @@ export function renderEquipmentList(container: HTMLElement) {
<button id="btn-reset-filters" class="btn btn-outline btn-reset"> <button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화 <i data-lucide="refresh-ccw"></i> 필터 초기화
</button> </button>
<button id="btn-add-equip" class="btn btn-primary" style="margin-left: auto;">
<i data-lucide="plus"></i> 자산 추가
</button>
`; `;
container.appendChild(filterBar); container.appendChild(filterBar);
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 = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>유형</th><th>자산번호</th><th>모델명</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">상태</th>
<th style="text-align:center;">구매법인</th>
<th style="text-align:center;">유형</th>
<th style="text-align:center;">자산번호</th>
<th style="text-align:center;">모델명</th>
<th style="text-align:center;">보관위치</th>
<th style="text-align:center;">관리자</th>
<th style="text-align:center;">구매연월</th>
<th style="text-align:center;">금액</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table); tableWrapper.appendChild(table);
container.appendChild(tableWrapper); container.appendChild(tableWrapper);
@@ -56,19 +75,31 @@ export function renderEquipmentList(container: HTMLElement) {
filtered.forEach((asset, idx) => { filtered.forEach((asset, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
const statusColors: Record<string, string> = {
'대여중': '#3b82f6',
'보관중': '#1E5149',
'수리중': '#ef4444',
'기타': '#6b7280'
};
const statusColor = statusColors[asset. || '보관중'] || '#6b7280';
const statusBadge = `<span style="background:${statusColor}; color:white; padding:2px 6px; border-radius:4px; font-size:0.75rem; font-weight:bold;">${asset. || '보관중'}</span>`;
tr.innerHTML = ` tr.innerHTML = `
<td>${idx+1}</td> <td style="text-align:center;">${idx + 1}</td>
<td>${asset.}</td> <td style="text-align:center;">${statusBadge}</td>
<td>${asset.||''}</td> <td style="text-align:center;">${asset.}</td>
<td>${asset.type}</td> <td style="text-align:center;">${asset.type}</td>
<td>${asset.}</td> <td style="font-weight:600; color:var(--primary-color);">${asset. || '-'}</td>
<td>${formatInline(asset.)}</td> <td>${formatInline(asset. || asset.)}</td>
<td>${formatInline(asset._정 || asset.)}</td> <td style="text-align:center;">${asset. || '-'}</td>
<td>${asset.||''}</td> <td style="text-align:center;">${formatInline(asset._정 || asset.)}</td>
<td>${asset.||''}</td> <td style="text-align:center;">${asset. || ''}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td> <td style="text-align:right;">${asset. || '0'}</td>
`; `;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); }); tr.addEventListener('click', (e) => {
if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view');
});
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
}; };

View File

@@ -22,13 +22,32 @@ export function renderMobileList(container: HTMLElement) {
<button id="btn-reset-filters" class="btn btn-outline btn-reset"> <button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> 필터 초기화 <i data-lucide="refresh-ccw"></i> 필터 초기화
</button> </button>
<button id="btn-add-mobile" class="btn btn-primary" style="margin-left: auto;">
<i data-lucide="plus"></i> 자산 추가
</button>
`; `;
container.appendChild(filterBar); container.appendChild(filterBar);
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 = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>유형</th><th>자산번호</th><th>모델명</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; table.innerHTML = `
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">상태</th>
<th style="text-align:center;">구매법인</th>
<th style="text-align:center;">자산코드</th>
<th style="text-align:center;">명칭</th>
<th style="text-align:center;">보관위치</th>
<th style="text-align:center;">관리자</th>
<th style="text-align:center;">구매연월</th>
<th style="text-align:center;">금액</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
`;
tableWrapper.appendChild(table); tableWrapper.appendChild(table);
container.appendChild(tableWrapper); container.appendChild(tableWrapper);
@@ -56,19 +75,30 @@ export function renderMobileList(container: HTMLElement) {
filtered.forEach((asset, idx) => { filtered.forEach((asset, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
const statusColors: Record<string, string> = {
'대여중': '#3b82f6',
'보관중': '#1E5149',
'수리중': '#ef4444',
'기타': '#6b7280'
};
const statusColor = statusColors[asset. || '보관중'] || '#6b7280';
const statusBadge = `<span style="background:${statusColor}; color:white; padding:2px 6px; border-radius:4px; font-size:0.75rem; font-weight:bold;">${asset. || '보관중'}</span>`;
tr.innerHTML = ` tr.innerHTML = `
<td>${idx+1}</td> <td style="text-align:center;">${idx + 1}</td>
<td>${asset.}</td> <td style="text-align:center;">${statusBadge}</td>
<td>${asset.||''}</td> <td style="text-align:center;">${asset.}</td>
<td>${asset.type}</td> <td style="font-weight:600; color:var(--primary-color);">${asset. || '-'}</td>
<td>${asset.}</td> <td>${formatInline(asset. || asset.)}</td>
<td>${formatInline(asset.)}</td> <td style="text-align:center;">${asset. || '-'}</td>
<td>${formatInline(asset._정 || asset.)}</td> <td style="text-align:center;">${formatInline(asset. || asset._정)}</td>
<td>${asset.||''}</td> <td style="text-align:center;">${asset. || ''}</td>
<td>${asset.||''}</td> <td style="text-align:right;">${asset. || '0'}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td>
`; `;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); }); tr.addEventListener('click', (e) => {
if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view');
});
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
}; };
@@ -79,7 +109,8 @@ export function renderMobileList(container: HTMLElement) {
(document.getElementById('filter-keyword') as HTMLInputElement).value = ''; (document.getElementById('filter-keyword') as HTMLInputElement).value = '';
(document.getElementById('filter-corp') as HTMLSelectElement).value = ''; (document.getElementById('filter-corp') as HTMLSelectElement).value = '';
updateTable(); updateTable();
}); });
updateTable();
}
updateTable();
}

View File

@@ -1,5 +1,5 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openPcModal } from '../../components/Modal/PCModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { formatInline, sortAssets } from '../../core/utils'; import { formatInline, sortAssets } from '../../core/utils';
import { createIcons, Paperclip, RefreshCcw } from 'lucide'; import { createIcons, Paperclip, RefreshCcw } from 'lucide';
@@ -28,7 +28,7 @@ export function renderPcList(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 = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>RAM</th><th>Storage</th><th>구매</th><th>금액</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; table.innerHTML = `<thead><tr><th>No</th><th>구매법인</th><th>현 사용조직</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>RAM</th><th>Storage</th><th>구매연월</th><th>금액</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table); tableWrapper.appendChild(table);
container.appendChild(tableWrapper); container.appendChild(tableWrapper);
@@ -69,12 +69,12 @@ export function renderPcList(container: HTMLElement) {
<td>${asset.CPU||''}</td> <td>${asset.CPU||''}</td>
<td>${asset.RAM||''}</td> <td>${asset.RAM||''}</td>
<td>${formatInline(storage)}</td> <td>${formatInline(storage)}</td>
<td>${asset.||''}</td> <td>${asset. || asset. || ''}</td>
<td>${asset.||''}</td> <td>${asset.||''}</td>
<td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td> <td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td><button class="btn btn-outline btn-sm">수정</button></td> <td><button class="btn btn-outline btn-sm">수정</button></td>
`; `;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset, 'view'); }); tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset, 'view'); });
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
createIcons({ icons: { Paperclip } }); createIcons({ icons: { Paperclip } });

View File

@@ -102,7 +102,8 @@ export function renderServerList(container: HTMLElement) {
(document.getElementById('filter-corp') as HTMLSelectElement).value = ''; (document.getElementById('filter-corp') as HTMLSelectElement).value = '';
(document.getElementById('filter-org-unit') as HTMLSelectElement).value = ''; (document.getElementById('filter-org-unit') as HTMLSelectElement).value = '';
updateTable(); updateTable();
}); });
updateTable();
}
updateTable();
}

View File

@@ -49,7 +49,7 @@ export function renderSwList(container: HTMLElement) {
<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> <th style="text-align:center;">제품명</th>
<th style="text-align:center;">구매</th> <th style="text-align:center;">구매연월</th>
${isSub ? '<th style="text-align:center;">구독일</th>' : ''} ${isSub ? '<th style="text-align:center;">구독일</th>' : ''}
<th style="text-align:center;">금액</th> <th style="text-align:center;">금액</th>
<th style="text-align:center;">수량</th> <th style="text-align:center;">수량</th>