Refactor: SW 상세 모달 동적 필드 전환 및 클라우드 통합, 자산 유형 명칭 일원화

This commit is contained in:
2026-04-23 17:22:38 +09:00
parent fdc29b23c1
commit 55e9cd4cd9
11 changed files with 167 additions and 450 deletions

View File

@@ -47,6 +47,7 @@ async function initDB() {
server_id VARCHAR(100), server_id VARCHAR(100),
server_pw VARCHAR(100), server_pw VARCHAR(100),
model_name VARCHAR(255), model_name VARCHAR(255),
mainboard VARCHAR(255) COMMENT '메인보드',
os VARCHAR(100), os VARCHAR(100),
cpu VARCHAR(255), cpu VARCHAR(255),
ram VARCHAR(100), ram VARCHAR(100),
@@ -73,11 +74,14 @@ 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 '자산번호',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명', product_name VARCHAR(255) COMMENT '제품명',
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 '구매일',
start_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 '비고',
@@ -91,11 +95,14 @@ 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 '자산번호',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명', product_name VARCHAR(255) COMMENT '제품명',
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 '구매일',
start_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

@@ -59,7 +59,7 @@ async function ensureTables() {
current_org VARCHAR(100), prev_org VARCHAR(100), location VARCHAR(255), current_org VARCHAR(100), prev_org VARCHAR(100), location VARCHAR(255),
manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50), manager_main VARCHAR(100), manager_sub VARCHAR(100), ip_address VARCHAR(50),
remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100), remote_tool VARCHAR(100), server_id VARCHAR(100), server_pw VARCHAR(100),
model_name VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100), model_name VARCHAR(255), mainboard VARCHAR(255), os VARCHAR(100), cpu VARCHAR(100), ram VARCHAR(100), gpu VARCHAR(100),
storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT storage1 VARCHAR(100), storage2 VARCHAR(100), storage3 VARCHAR(100), monitoring VARCHAR(100), price VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
@@ -70,16 +70,18 @@ async function ensureTables() {
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS sw_sub_assets ( CREATE TABLE IF NOT EXISTS sw_sub_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), product_name VARCHAR(255), id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100),
category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), license_type VARCHAR(100), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT start_date VARCHAR(50), expiry_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS sw_perm_assets ( CREATE TABLE IF NOT EXISTS sw_perm_assets (
id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100), product_name VARCHAR(255), id VARCHAR(50) PRIMARY KEY, corp VARCHAR(100), asset_code VARCHAR(100),
category VARCHAR(100), dept VARCHAR(100), product_name VARCHAR(255),
license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50), license_key VARCHAR(255), quantity INT, price VARCHAR(100), purchase_date VARCHAR(50),
vendor VARCHAR(100), remarks TEXT start_date VARCHAR(50), vendor VARCHAR(100), remarks TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`); `);
await connection.query(` await connection.query(`
@@ -120,7 +122,7 @@ const hardwareInsertSQL = (table) => `
INSERT INTO ${table} ( INSERT INTO ${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, mainboard, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks storage1, storage2, storage3, monitoring, price, remarks
) VALUES ? ) VALUES ?
`; `;
@@ -128,7 +130,7 @@ const hardwareInsertSQL = (table) => `
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.메인보드||'', 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.비고||''
]; ];
@@ -137,7 +139,7 @@ const mapHardware = (r, defaultType) => ({
상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org, 상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org,
이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub, 이전사용조직: 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, 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, 모델명: r.model_name, 메인보드: r.mainboard, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1,
SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks
}); });
@@ -238,9 +240,11 @@ app.get('/api/sw/sub', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM sw_sub_assets'); const [rows] = await pool.query('SELECT * FROM sw_sub_assets');
res.json(rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code,
라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 분야: r.category, 부서: r.dept, 제품명: r.product_name,
만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks 라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date, 만료일: r.expiry_date,
납품업체: r.vendor, 비고: r.remarks
}))); })));
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
@@ -248,8 +252,11 @@ app.get('/api/sw/sub', async (req, res) => {
app.post('/api/sw/sub/batch', async (req, res) => { app.post('/api/sw/sub/batch', async (req, res) => {
try { try {
const result = await batchSave('sw_sub_assets', req.body, (assets) => ({ const result = await batchSave('sw_sub_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, product_name, license_type, quantity, price, purchase_date, expiry_date, vendor, remarks) VALUES ?`, sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, category, dept, product_name, license_type, quantity, price, purchase_date, start_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.만료일||'', a.납품업체||'', a.비고||'']) values: assets.map(a => [
a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.만료일||'', a.납품업체||'', a.비고||''
])
})); }));
res.json(result); res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
@@ -260,8 +267,10 @@ app.get('/api/sw/perm', async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM sw_perm_assets'); const [rows] = await pool.query('SELECT * FROM sw_perm_assets');
res.json(rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name, id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date, 분야: r.category, 부서: r.dept, 제품명: r.product_name,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price,
구매일: r.purchase_date, 시작일: r.start_date,
납품업체: r.vendor, 비고: r.remarks 납품업체: r.vendor, 비고: r.remarks
}))); })));
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
@@ -270,8 +279,11 @@ app.get('/api/sw/perm', async (req, res) => {
app.post('/api/sw/perm/batch', async (req, res) => { app.post('/api/sw/perm/batch', async (req, res) => {
try { try {
const result = await batchSave('sw_perm_assets', req.body, (assets) => ({ const result = await batchSave('sw_perm_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, product_name, license_key, quantity, price, purchase_date, vendor, remarks) VALUES ?`, sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, category, dept, product_name, license_key, quantity, price, purchase_date, start_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.납품업체||'', a.비고||'']) values: assets.map(a => [
a.id, a.법인||'', a.자산번호||'', a.분야||'', a.부서||'', a.제품명||'',
a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.시작일||'', a.납품업체||'', a.비고||''
])
})); }));
res.json(result); res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }

View File

@@ -1,317 +0,0 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Save, X, Edit2, RotateCcw, History, Plus } from 'lucide';
const CLOUD_MODAL_HTML = `
<div id="cloud-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="cloud-modal-title">클라우드 서비스 상세</h2>
<button id="btn-close-cloud-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="cloud-asset-form" class="grid-form">
<input type="hidden" id="cloud-asset-id" />
<div class="form-group"><label>플랫폼명</label><input type="text" id="cloud-플랫폼명" placeholder="예: AWS, Cafe24" required /></div>
<div class="form-group">
<label>담당법인</label>
<select id="cloud-법인" required>
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<div class="form-group" style="grid-column: span 2;"><label>사용용도(프로젝트/제품명)</label><input type="text" id="cloud-제품명" required /></div>
<div class="form-group"><label>담당부서</label><input type="text" id="cloud-부서" /></div>
<div class="form-group"><label>계정명(이메일)</label><input type="text" id="cloud-계정명" /></div>
<div class="form-group"><label>결제수단</label>
<select id="cloud-결제수단">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group"><label>연결카드번호(뒷4자리)</label><input type="text" id="cloud-연결카드번호" placeholder="1234" /></div>
<div class="form-group"><label>결제일(기준일)</label><input type="number" min="1" max="31" id="cloud-결제일" placeholder="15" /></div>
<div class="form-group"><label>당월 청구액(원)</label><input type="text" id="cloud-당월청구액" placeholder="0" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" /></div>
<div class="form-group" style="grid-column: span 2;"><label>비고</label><input type="text" id="cloud-비고" /></div>
</form>
</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-open-cloud-update" class="btn btn-outline btn-sm"><i data-lucide="plus" style="width:14px;height:14px;"></i> 내역 추가</button>
</div>
<div id="cloud-history-list" class="history-timeline">
<div class="empty-history">내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer" style="justify-content: space-between;">
<button id="btn-delete-cloud-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-cloud-edit" class="btn btn-outline hidden">취소</button>
<button id="btn-close-cloud-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-cloud-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<div id="cloud-update-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-cloud-update" 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="cloud-update-date" />
</div>
<div class="form-group">
<label>청구 금액(원)</label>
<input type="text" id="cloud-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 150,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="cloud-update-note" placeholder="예: 트래픽 초과로 인한 요금 증가" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-cloud-update" class="btn btn-outline">취소</button>
<button id="btn-save-cloud-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
`;
export let currentCloudAsset: SoftwareAsset | null = null;
export let isCloudEditMode = false;
export function setCloudEditMode(edit: boolean) {
isCloudEditMode = edit;
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const btnSave = document.getElementById('btn-save-cloud-asset') as HTMLButtonElement;
const btnRevert = document.getElementById('btn-revert-cloud-edit') as HTMLButtonElement;
const btnClose = document.getElementById('btn-close-cloud-footer') as HTMLButtonElement;
if (edit) {
form.classList.add('is-edit-mode');
form.classList.remove('is-view-mode');
btnSave.textContent = '저장';
btnRevert.classList.remove('hidden');
btnClose.classList.add('hidden');
Array.from(form.elements).forEach((el: any) => el.disabled = false);
} else {
form.classList.add('is-view-mode');
form.classList.remove('is-edit-mode');
btnSave.textContent = '수정';
btnRevert.classList.add('hidden');
btnClose.classList.remove('hidden');
Array.from(form.elements).forEach((el: any) => el.disabled = true);
if (currentCloudAsset) fillCloudFormData(currentCloudAsset);
}
}
export function fillCloudFormData(asset: SoftwareAsset) {
(document.getElementById('cloud-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('cloud-플랫폼명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-법인') as HTMLSelectElement).value = asset. || '한맥';
(document.getElementById('cloud-제품명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-부서') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-결제수단') as HTMLSelectElement).value = asset. || '';
(document.getElementById('cloud-연결카드번호') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-결제일') as HTMLInputElement).value = asset. || '';
const billing = asset. ? asset..replace(/[^0-9]/g, '') : '';
(document.getElementById('cloud-당월청구액') as HTMLInputElement).value = billing ? Number(billing).toLocaleString() : '';
(document.getElementById('cloud-비고') as HTMLInputElement).value = asset. || '';
document.getElementById('btn-open-cloud-update')!.style.display = 'flex';
renderCloudHistory(asset.id);
}
function renderCloudHistory(assetId: string) {
const historyList = document.getElementById('cloud-history-list');
if (!historyList) return;
if (!state.masterData.logs) state.masterData.logs = [];
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('');
createIcons({ icons: { X, History, Plus } });
}
export function initCloudModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('cloud-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', CLOUD_MODAL_HTML);
}
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const btnRevert = document.getElementById('btn-revert-cloud-edit');
const btnSave = document.getElementById('btn-save-cloud-asset');
const btnDelete = document.getElementById('btn-delete-cloud-asset');
document.getElementById('btn-close-cloud-modal')?.addEventListener('click', closeModals);
document.getElementById('btn-close-cloud-footer')?.addEventListener('click', closeModals);
btnRevert?.addEventListener('click', (e) => {
e.preventDefault();
setCloudEditMode(false);
});
btnSave?.addEventListener('click', (e) => {
e.preventDefault();
if (!isCloudEditMode) {
setCloudEditMode(true);
return;
}
if (!form.checkValidity()) { form.reportValidity(); return; }
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
const billingRaw = (document.getElementById('cloud-당월청구액') as HTMLInputElement).value.replace(/[^0-9]/g, '');
const newAsset: SoftwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: '클라우드',
: (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value,
: (document.getElementById('cloud-법인') as HTMLSelectElement).value,
: (document.getElementById('cloud-제품명') as HTMLInputElement).value,
: (document.getElementById('cloud-부서') as HTMLInputElement).value,
: (document.getElementById('cloud-계정명') as HTMLInputElement).value,
: (document.getElementById('cloud-결제수단') as HTMLSelectElement).value,
: (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value,
: (document.getElementById('cloud-결제일') as HTMLInputElement).value,
당월청구액: billingRaw,
: (document.getElementById('cloud-비고') as HTMLInputElement).value,
: '', : '', 수량: 1, : ''
};
if (id) {
const idx = state.masterData.sw.findIndex(a => a.id === id);
if (idx !== -1) state.masterData.sw[idx] = newAsset;
} else {
state.masterData.sw.push(newAsset);
const now = new Date();
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: newAsset.id,
date: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`,
user: '관리자',
details: '신규 등록'
});
}
closeModals();
renderContent();
});
btnDelete?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
if (confirm('클라우드 자산을 삭제하시겠습니까?')) {
state.masterData.sw = state.masterData.sw.filter(a => a.id !== id);
closeModals();
renderContent();
}
});
// 클라우드 업데이트 (이력) 모달 로직
const updateModal = document.getElementById('cloud-update-modal')!;
document.getElementById('btn-open-cloud-update')?.addEventListener('click', () => {
updateModal.classList.remove('hidden');
(document.getElementById('cloud-update-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('cloud-update-cost') as HTMLInputElement).value = '';
(document.getElementById('cloud-update-note') as HTMLInputElement).value = '';
});
const closeUpdateModal = () => updateModal.classList.add('hidden');
document.getElementById('btn-close-cloud-update')?.addEventListener('click', closeUpdateModal);
document.getElementById('btn-cancel-cloud-update')?.addEventListener('click', closeUpdateModal);
document.getElementById('btn-save-cloud-update')?.addEventListener('click', () => {
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
if (!id) return;
const date = (document.getElementById('cloud-update-date') as HTMLInputElement).value;
const costRaw = (document.getElementById('cloud-update-cost') as HTMLInputElement).value.replace(/[^0-9]/g, '');
const note = (document.getElementById('cloud-update-note') as HTMLInputElement).value;
if (!date) return alert('업데이트 일자를 입력하세요.');
let details = '결제/상태 업데이트';
if (costRaw) details += ` (비용: ₩ ${Number(costRaw).toLocaleString()})`;
if (note) details += `\n메모: ${note}`;
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: id,
date,
user: '관리자',
details
});
// 금액 업데이트 반영
if (costRaw) {
const idx = state.masterData.sw.findIndex(a => a.id === id);
if (idx !== -1) {
state.masterData.sw[idx]. = costRaw;
(document.getElementById('cloud-당월청구액') as HTMLInputElement).value = Number(costRaw).toLocaleString();
}
}
closeUpdateModal();
renderCloudHistory(id);
renderContent();
});
createIcons({ icons: { Save, X, Edit2, RotateCcw, History, Plus } });
}
export function openCloudModal(asset?: SoftwareAsset) {
currentCloudAsset = asset || null;
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-cloud-asset')!;
openModal('cloud-asset-modal');
form.reset();
if (asset) {
document.getElementById('cloud-modal-title')!.textContent = '클라우드 서비스 상세';
deleteBtn.style.display = 'block';
fillCloudFormData(asset);
setCloudEditMode(false);
} else {
document.getElementById('cloud-modal-title')!.textContent = '신규 클라우드 서비스 등록';
deleteBtn.style.display = 'none';
(document.getElementById('cloud-asset-id') as HTMLInputElement).value = '';
document.getElementById('btn-open-cloud-update')!.style.display = 'none';
renderCloudHistory('');
setCloudEditMode(true);
}
createIcons({ icons: { History, Plus } });
}

View File

@@ -449,11 +449,7 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
saveHardwareAsset(updated); saveHardwareAsset(updated);
onSave(); onSave();
setEditLock('hw-asset-form', 'view', { closeModalAction();
saveBtnId: 'btn-save-hw-asset',
revertBtnId: 'btn-revert-hw-edit'
});
isEditMode = false;
}); });
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {

View File

@@ -1,7 +1,7 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state'; import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal'; import { openModal, closeModals } from './BaseModal';
import { createIcons, History, X, Paperclip } from 'lucide'; import { createIcons, History, X, Paperclip, Calendar } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData'; import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData';
import { import {
generateOptionsHTML, generateOptionsHTML,
@@ -9,7 +9,8 @@ import {
getFieldValue, getFieldValue,
parseAndSetLocation, parseAndSetLocation,
bindLocationEvents, bindLocationEvents,
getCombinedLocation getCombinedLocation,
applyDateMask
} from './ModalUtils'; } from './ModalUtils';
let currentAsset: HardwareAsset | null = null; let currentAsset: HardwareAsset | null = null;
@@ -67,6 +68,10 @@ const PC_MODAL_HTML = `
<label for="pc-모델명">모델명</label> <label for="pc-모델명">모델명</label>
<input type="text" id="pc-모델명" /> <input type="text" id="pc-모델명" />
</div> </div>
<div class="form-group">
<label for="pc-메인보드">메인보드</label>
<input type="text" id="pc-메인보드" />
</div>
<div class="form-group"> <div class="form-group">
<label for="pc-OS">운영체제 (OS)</label> <label for="pc-OS">운영체제 (OS)</label>
<input type="text" id="pc-OS" /> <input type="text" id="pc-OS" />
@@ -105,7 +110,13 @@ const PC_MODAL_HTML = `
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="pc-구매일">구매일</label> <label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" /> <div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="pc-구매일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('pc-구매일-picker'); p.value = document.getElementById('pc-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="pc-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('pc-구매일').value = this.value" tabindex="-1" />
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="pc-금액">금액</label> <label for="pc-금액">금액</label>
@@ -184,7 +195,7 @@ export function openPcModal(asset: HardwareAsset, mode: 'view' | 'add' | 'edit'
modal.classList.remove('hidden'); modal.classList.remove('hidden');
applyPcTypeSpecificUI(); applyPcTypeSpecificUI();
createIcons({ icons: { X, History, Paperclip } }); createIcons({ icons: { X, History, Paperclip, Calendar } });
} }
function applyPcTypeSpecificUI() { function applyPcTypeSpecificUI() {
@@ -199,9 +210,10 @@ function applyPcTypeSpecificUI() {
const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement; const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement;
const locationFields = document.querySelectorAll('.pc-location-field'); const locationFields = document.querySelectorAll('.pc-location-field');
const etcGroup = document.getElementById('pc-위치-기타-group'); const etcGroup = document.getElementById('pc-위치-기타-group');
const mainboardGroup = document.getElementById('pc-메인보드')?.closest('.form-group') as HTMLElement;
// 초기화 (숨김) // 초기화 (숨김)
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'none'; }); [modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group, mainboardGroup].forEach(g => { if(g) g.style.display = 'none'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'none'); locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (etcGroup) etcGroup.style.display = 'none'; if (etcGroup) etcGroup.style.display = 'none';
@@ -214,7 +226,7 @@ function applyPcTypeSpecificUI() {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
} }
else if (type === 'PC' || type === '노트북') { else if (type === 'PC' || type === '노트북') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; }); [modelGroup, mainboardGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
if (detailPurpose === '서버') { if (detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex'); locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
} }
@@ -243,6 +255,7 @@ function fillFormData(asset: HardwareAsset) {
setFieldValue('pc-현사용조직', asset.); setFieldValue('pc-현사용조직', asset.);
setFieldValue('pc-이전사용조직', asset.); setFieldValue('pc-이전사용조직', asset.);
setFieldValue('pc-상세용도', (asset as any).); setFieldValue('pc-상세용도', (asset as any).);
setFieldValue('pc-메인보드', (asset as any). || '');
parseAndSetLocation(asset., 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타'); parseAndSetLocation(asset., 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
@@ -278,6 +291,9 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타'); bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
// 날짜 마스킹 적용
applyDateMask(document.getElementById('pc-구매일') as HTMLInputElement);
const handleClose = () => { closeModalsCb(); isEditMode = false; }; const handleClose = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose); document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose);
document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose); document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose);
@@ -317,6 +333,7 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
RAM: getFieldValue('pc-RAM'), RAM: getFieldValue('pc-RAM'),
SSD1: getFieldValue('pc-SSD1'), SSD1: getFieldValue('pc-SSD1'),
SSD2: getFieldValue('pc-SSD2'), SSD2: getFieldValue('pc-SSD2'),
메인보드: getFieldValue('pc-메인보드'),
구매일: getFieldValue('pc-구매일'), 구매일: getFieldValue('pc-구매일'),
금액: getFieldValue('pc-금액'), 금액: getFieldValue('pc-금액'),
납품업체: getFieldValue('pc-납품업체'), 납품업체: getFieldValue('pc-납품업체'),
@@ -325,10 +342,7 @@ export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
saveHardwareAsset(updated); saveHardwareAsset(updated);
onSave(); onSave();
isEditMode = false; handleClose();
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
saveBtn.textContent = '수정';
revertBtn?.classList.add('hidden');
}); });
deleteBtn?.addEventListener('click', () => { deleteBtn?.addEventListener('click', () => {

View File

@@ -27,10 +27,17 @@ const SW_MODAL_HTML = `
<div class="modal-form-area"> <div class="modal-form-area">
<form id="sw-asset-form" class="grid-form"> <form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" /> <input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" />
<!-- Group 1: 기본 정보 (Identity) --> <!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div> <div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="sw-asset-type">자산 유형</label>
<select id="sw-asset-type" required>
<option value="구독SW">구독SW</option>
<option value="영구SW">영구SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="sw-분야">분야</label> <label for="sw-분야">분야</label>
<select id="sw-분야" required> <select id="sw-분야" required>
@@ -40,14 +47,11 @@ const SW_MODAL_HTML = `
<option value="설계S/W">설계S/W</option> <option value="설계S/W">설계S/W</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-법인">법인</label> <label for="sw-법인">법인</label>
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select> <select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group sw-standard-field">
<label for="sw-자산번호">자산번호</label>
<input type="text" id="sw-자산번호" readonly placeholder="자동 생성" />
</div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="sw-제품명">제품명 / 서비스명</label> <label for="sw-제품명">제품명 / 서비스명</label>
<input type="text" id="sw-제품명" required /> <input type="text" id="sw-제품명" required />
@@ -57,7 +61,7 @@ const SW_MODAL_HTML = `
<input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" /> <input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-부서">부서</label> <label for="sw-부서">조직 / 부서</label>
<input type="text" id="sw-부서" /> <input type="text" id="sw-부서" />
</div> </div>
@@ -148,14 +152,10 @@ const SW_MODAL_HTML = `
</div> </div>
</form> </form>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem;"> <div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;"> <button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3> <i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm"> </button>
할당 관리 <i data-lucide="users" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="sw-assigned-users-summary" class="user-summary-grid"></div>
</div> </div>
</div> </div>
@@ -250,10 +250,10 @@ function applySwTypeUI(type: string) {
if (keyGroup) keyGroup.style.display = 'none'; if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex'; if (typeGroup) typeGroup.style.display = 'flex';
if (expiryGroup) expiryGroup.style.display = 'flex'; if (expiryGroup) expiryGroup.style.display = 'flex';
} else { } else if (type === '영구SW') {
if (keyGroup) keyGroup.style.display = 'flex'; if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none'; if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'flex'; if (expiryGroup) expiryGroup.style.display = 'none'; // 영구는 유지보수 기간이 비고에 들어가는 경우가 많아 만료일 숨김 처리
} }
} }
} }
@@ -263,7 +263,7 @@ function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-asset-type', asset.type); setFieldValue('sw-asset-type', asset.type);
setFieldValue('sw-분야', asset. || '업무공통'); setFieldValue('sw-분야', asset. || '업무공통');
setFieldValue('sw-법인', asset.); setFieldValue('sw-법인', asset.);
setFieldValue('sw-자산번호', asset. || '');
setFieldValue('sw-부서', asset. || ''); setFieldValue('sw-부서', asset. || '');
setFieldValue('sw-제품명', asset.); setFieldValue('sw-제품명', asset.);
setFieldValue('sw-수량', asset.); setFieldValue('sw-수량', asset.);
@@ -287,25 +287,10 @@ function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-라이선스키', (asset as any). || ''); setFieldValue('sw-라이선스키', (asset as any). || '');
} }
renderUserSummary(asset.id);
renderSwHistory(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) { function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list'); const container = document.getElementById('sw-history-list');
@@ -354,6 +339,11 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const userAssignBtn = document.getElementById('btn-open-sw-user')!; const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!; const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
typeSelect?.addEventListener('change', () => {
applySwTypeUI(typeSelect.value);
});
// 날짜 스마트 마스킹 적용 // 날짜 스마트 마스킹 적용
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => { ['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
@@ -392,7 +382,7 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
분야: getFieldValue('sw-분야'), 분야: getFieldValue('sw-분야'),
법인: getFieldValue('sw-법인'), 법인: getFieldValue('sw-법인'),
부서: getFieldValue('sw-부서'), 부서: getFieldValue('sw-부서'),
자산번호: getFieldValue('sw-자산번호'),
제품명: getFieldValue('sw-제품명'), 제품명: getFieldValue('sw-제품명'),
수량: parseInt(getFieldValue('sw-수량') || '0'), 수량: parseInt(getFieldValue('sw-수량') || '0'),
금액: getFieldValue('sw-금액'), 금액: getFieldValue('sw-금액'),
@@ -418,21 +408,27 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
} }
// 데이터 저장 로직 (state 업데이트) // 데이터 저장 로직 (state 업데이트)
const oldType = currentSwAsset.type;
const newType = updated.type;
// 유형이 변경된 경우 기존 리스트에서 삭제
if (oldType !== newType) {
if (oldType === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== updated.id);
else if (oldType === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== updated.id);
else if (oldType === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== updated.id);
}
let targetList: SoftwareAsset[] = []; let targetList: SoftwareAsset[] = [];
if (type === '구독SW') targetList = state.masterData.subSw; if (newType === '구독SW') targetList = state.masterData.subSw;
else if (type === '영구SW') targetList = state.masterData.permSw; else if (newType === '영구SW') targetList = state.masterData.permSw;
else if (type === '클라우드') targetList = state.masterData.cloud; else if (newType === '클라우드') targetList = state.masterData.cloud;
const idx = targetList.findIndex(a => a.id === updated.id); const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated; if (idx > -1) targetList[idx] = updated;
else targetList.push(updated); else targetList.push(updated);
onSave(); onSave();
setEditLock('sw-asset-form', 'view', { closeModalAction();
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false;
}); });
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {

View File

@@ -27,8 +27,7 @@ const SW_USER_MODAL_HTML = `
<table> <table>
<thead> <thead>
<tr> <tr>
<th>구매법인</th> <th>조직</th>
<th>부서/팀</th>
<th>직위</th> <th>직위</th>
<th>이름</th> <th>이름</th>
<th>사용기간</th> <th>사용기간</th>
@@ -58,11 +57,7 @@ const SW_USER_MODAL_HTML = `
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;"> <form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
<input type="hidden" id="edit-user-index" value="-1" /> <input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group"> <div class="form-group">
<label>구매법인</label> <label>조직</label>
<select id="new-user-법인">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label>부서/팀</label>
<select id="new-user-부서">${generateOptionsHTML(ORG_LIST)}</select> <select id="new-user-부서">${generateOptionsHTML(ORG_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -135,14 +130,13 @@ function renderUserList() {
tbody.innerHTML = ''; tbody.innerHTML = '';
if (tempSwUsers.length === 0) { if (tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>'; tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return; return;
} }
tempSwUsers.forEach((user, idx) => { tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
@@ -187,7 +181,6 @@ function openUserEditSubModal(idx: number = -1) {
if (idx > -1) { if (idx > -1) {
const user = tempSwUsers[idx]; const user = tempSwUsers[idx];
setFieldValue('new-user-법인', user.);
setFieldValue('new-user-부서', user.); setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.); setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.); setFieldValue('new-user-이름', user.);
@@ -201,8 +194,6 @@ function openUserEditSubModal(idx: number = -1) {
setFieldValue('new-user-시작일', ''); setFieldValue('new-user-시작일', '');
setFieldValue('new-user-종료일', ''); setFieldValue('new-user-종료일', '');
} }
} else {
setFieldValue('new-user-법인', currentSwUserAsset?.);
} }
subModal.classList.remove('hidden'); subModal.classList.remove('hidden');
@@ -236,7 +227,7 @@ export function initSwUserModal(onSave: () => void, closeModals: () => void) {
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id); const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = { const newMapping = {
sw_id: currentSwUserAsset!.id, sw_id: currentSwUserAsset!.id,
userData: tempSwUsers.map(u => [u., u., u., u., u., u.]) userData: tempSwUsers.map(u => ['', u., u., u., u., u.])
}; };
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any; if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
@@ -266,7 +257,6 @@ function saveUserDataToList() {
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : ''); const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : '');
const userData: any = { const userData: any = {
법인: getFieldValue('new-user-법인'),
부서: getFieldValue('new-user-부서'), 부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'), 직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'), 이름: getFieldValue('new-user-이름'),

View File

@@ -41,6 +41,7 @@ export interface HardwareAsset {
현사용조직?: string; 현사용조직?: string;
이전사용조직?: string; 이전사용조직?: string;
detail_purpose?: string; detail_purpose?: string;
메인보드?: string;
} }
export interface SoftwareAsset { export interface SoftwareAsset {
@@ -109,7 +110,7 @@ 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', '구매일', '금액', '납품업체', '품의서명', '비고'];
@@ -148,7 +149,7 @@ 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., 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.] },
@@ -174,7 +175,7 @@ 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['위치']||'', 모델명: r['모델명']||'', 메인보드: r['메인보드']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
} else if (sheetName === '서버') { } else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', 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['구매일']||'', 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 === '스토리지') {

View File

@@ -20,10 +20,14 @@ async function apiBatchSave(url: string, data: any[], label: string) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
if (!response.ok) throw new Error(`${label} DB 저장 실패`); if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`);
}
console.log(`${label} DB 저장 완료`); console.log(`${label} DB 저장 완료`);
} catch (err) { } catch (err) {
console.error(`${label} DB 저장 오류:`, err); console.error(`${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${err.message}`);
} }
} }
@@ -37,15 +41,16 @@ const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/bat
const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드'); const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자'); const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자');
// 모든 하드웨어 DB 동기화 // 화면 갱신 통합 핸들러 (대시보드 vs 리스트)
async function saveAllHardwareToDB() { function refreshView() {
await Promise.all([ const mainContent = document.getElementById('main-content')!;
savePcToDB(), if (!mainContent) return;
saveServerToDB(),
saveStorageToDB(), if (state.activeSubTab === '대시보드') {
saveEquipToDB(), renderDashboard(mainContent);
saveMobileToDB() } else {
]); renderSWTable(mainContent);
}
} }
// 모든 소프트웨어 DB 동기화 // 모든 소프트웨어 DB 동기화
@@ -56,6 +61,22 @@ async function saveAllSoftwareToDB() {
saveCloudToDB(), saveCloudToDB(),
saveSwUsersToDB() saveSwUsersToDB()
]); ]);
// 저장 후 최신 데이터 다시 로드 (정합성)
await loadMasterDataFromDB();
refreshView();
}
// 모든 하드웨어 DB 동기화
async function saveAllHardwareToDB() {
await Promise.all([
savePcToDB(),
saveServerToDB(),
saveStorageToDB(),
saveEquipToDB(),
saveMobileToDB()
]);
await loadMasterDataFromDB();
refreshView();
} }
// --- App Initialization --- // --- App Initialization ---
@@ -76,17 +97,15 @@ function initApp() {
}); });
// 모달 초기화 // 모달 초기화
initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); initPcModal(() => saveAllHardwareToDB(), closeAllModals);
initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals); initHwModal(() => saveAllHardwareToDB(), closeAllModals);
initSwModal(() => { initSwModal(() => saveAllSoftwareToDB(), closeAllModals);
saveAllSoftwareToDB();
renderSWTable(mainContent);
}, closeAllModals);
initSwUserModal(() => { initSwUserModal(() => {
saveSwUsersToDB(); saveSwUsersToDB().then(() => {
renderSWTable(mainContent); loadMasterDataFromDB().then(() => refreshView());
});
}, closeAllModals); }, closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();

View File

@@ -53,15 +53,20 @@
border: none !important; border: none !important;
} }
.modal-header .btn-icon i, .btn-icon {
.modal-header .btn-icon svg { display: inline-flex;
width: 20px !important; /* Original natural size */ align-items: center;
height: 20px !important; justify-content: center;
stroke: #FFFFFF !important; background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--primary-color);
transition: opacity 0.2s;
} }
.modal-header .btn-icon:hover { .btn-icon:hover {
background: none !important; opacity: 0.7;
} }
.modal-body { .modal-body {
@@ -102,8 +107,7 @@
/* Modal Readonly/Edit Mode Interaction */ /* Modal Readonly/Edit Mode Interaction */
.grid-form.is-view-mode input, .grid-form.is-view-mode input,
.grid-form.is-view-mode select, .grid-form.is-view-mode select,
.grid-form.is-view-mode textarea, .grid-form.is-view-mode textarea {
.grid-form.is-view-mode button {
border: none !important; border: none !important;
background-color: transparent !important; background-color: transparent !important;
padding-left: 0 !important; padding-left: 0 !important;
@@ -117,6 +121,13 @@
box-shadow: none !important; box-shadow: none !important;
} }
.grid-form.is-view-mode button {
pointer-events: none !important;
background: none !important;
border: none !important;
opacity: 0.8;
}
.grid-form.is-view-mode select::-ms-expand { .grid-form.is-view-mode select::-ms-expand {
display: none !important; display: none !important;
} }

View File

@@ -55,7 +55,6 @@ 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>
</tr> </tr>
</thead> </thead>
<tbody id="dynamic-tbody"></tbody> <tbody id="dynamic-tbody"></tbody>
@@ -83,7 +82,7 @@ export function renderSwList(container: HTMLElement) {
tbody.innerHTML = ''; tbody.innerHTML = '';
if (filtered.length === 0) { if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="12" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return; return;
} }
@@ -126,22 +125,11 @@ export function renderSwList(container: HTMLElement) {
<td style="text-align:right;">${formatPrice(asset.)}</td> <td style="text-align:right;">${formatPrice(asset.)}</td>
<td style="text-align:center;">${qty}</td> <td style="text-align:center;">${qty}</td>
<td style="text-align:center;"><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td> <td style="text-align:center;"><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td>
<td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;">
<button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2"></i></button>
<button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users"></i></button>
</td>
`; `;
tr.addEventListener('click', (e) => { tr.addEventListener('click', (e) => {
if (!(e.target as HTMLElement).closest('button')) { openSwModal(asset, 'view');
openSwModal(asset, 'view');
}
}); });
tr.querySelector('.btn-edit')?.addEventListener('click', (e) => {
e.stopPropagation();
openSwModal(asset, 'edit');
});
tr.querySelector('.btn-users')?.addEventListener('click', (e) => { e.stopPropagation(); openSwUserModal(asset); });
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
createIcons({ icons: { Edit2, Users, RefreshCcw } }); createIcons({ icons: { Edit2, Users, RefreshCcw } });