feat: restore database from backup and fix date formatting in restore script

This commit is contained in:
2026-04-23 13:37:45 +09:00
parent b996b18dbc
commit e5b4eb8295
26 changed files with 2027 additions and 1662 deletions

View File

@@ -1,42 +1,34 @@
/**
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모달 모듈입니다.
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
*/
export function closeModals() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
}
export function initBaseModal() {
const closeAllModals = () => {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
};
// ESC 키로 닫기
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModals();
if (e.key === 'Escape') closeAllModals();
});
// 배경(Overlay) 및 닫기/취소 버튼 클릭 시 닫기
// 배경(Overlay) 클릭 시 닫기 (동적 생성된 모달 대응을 위해 이벤트 위임 고려 가능하나 일단 단순 구현)
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 1. 오버레이 클릭 시 닫기
if (target.classList.contains('modal-overlay')) {
closeModals();
}
// 2. 닫기 또는 취소 버튼 클릭 시 닫기 (이름 패턴 기준)
const btn = target.closest('button');
if (btn && (btn.id.startsWith('btn-close-') || btn.id.startsWith('btn-cancel-'))) {
closeModals();
closeAllModals();
}
});
return { closeAllModals: closeModals };
return { closeAllModals };
}
/**
* 특정 모달 열기 (기존의 classList 조작을 정형화)
* 특정 모달을 엽니다.
* @param modalId 모달 엘리먼트의 ID
*/
export function openModal(id: string) {
const modal = document.getElementById(id);
export function openModal(modalId: string) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
}

View File

@@ -98,7 +98,7 @@ export function openSwUsageDetail(title: string, list: SoftwareAsset[]) {
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === sw.id).length;
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${assigned}</td><td>${Number(sw.) - assigned}</td>`;
tbody.appendChild(tr);

View File

@@ -19,23 +19,23 @@ import {
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const STATUS_LIST = ['사용중', '보관중', '수리중', '폐기예정', '기타'];
const STATUS_LIST = ['대여중', '보관중', '수리중', '기타'];
// 필드 ID ↔ 데이터 Key 매핑
// 필드 ID ↔ 데이터 Key 매핑 (유지보수 시 이 부분만 수정)
const HW_FIELD_MAP: Record<string, string> = {
'유형': 'type',
'법인': '법인',
'자산코드': '자산코드',
'현사용조직': '사용조직',
'현사용조직': '사용조직',
'이전사용조직': '이전사용조직',
'상세용도': '서버용도',
'상세용도': '상세용도',
'모델명': '모델명',
'명칭': '명칭',
'보관위치': '보관위치',
'현재상태': '상태',
'현재상태': '현재상태',
'IP주소': 'IP주소',
'IP2': 'IP2',
'원격접속': '원격방법',
'원격접속': '원격접속',
'서버ID': '서버ID',
'서버PW': '서버PW',
'모니터링': '모니터링',
@@ -45,14 +45,16 @@ const HW_FIELD_MAP: Record<string, string> = {
'SSD1': 'SSD1',
'SSD2': 'SSD2',
'HW사양': 'HW사양',
'담당자_정': '관리조직',
'구매일': '도입일',
'금액': '구매',
'담당자_정': '담당자_정',
'담당자_부': '담당자_부',
'구매일': '구매연월',
'금액': '금액',
'비고': '비고',
'사용자': '사용자'
};
const HW_FORM_HTML = `
<!-- Group 1: 기본 정보 -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="hw-법인">구매법인</label>
@@ -70,7 +72,7 @@ const HW_FORM_HTML = `
<input type="text" id="hw-사용자" />
</div>
<div class="form-group">
<label for="hw-현사용조직">사용조직</label>
<label for="hw-현사용조직">사용조직</label>
<select id="hw-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="hw-이전사용조직-group">
@@ -78,17 +80,15 @@ const HW_FORM_HTML = `
<input type="text" id="hw-이전사용조직" readonly />
</div>
<div class="form-group" id="hw-유형-group">
<label for="hw-유형">자산구분</label>
<label for="hw-유형">유형</label>
<select id="hw-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group" id="hw-상세용도-group" style="display:none;">
<label for="hw-상세용도">서버용도</label>
<div class="form-group" id="hw-상세용도-group">
<label for="hw-상세용도">상세유형</label>
<select id="hw-상세용도">
<option value="">선택하세요</option>
<option value="DB서버">DB 서버</option>
<option value="웹서버">웹 서버</option>
<option value="어플리케이션">어플리케이션</option>
<option value="가상화">가상화 호스트</option>
<option value="">선택</option>
<option value="서버">서버</option>
<option value="개인PC">개인PC</option>
</select>
</div>
@@ -105,29 +105,37 @@ const HW_FORM_HTML = `
<div class="form-section-title server-only" id="hw-network-title">네트워크 정보 (Connectivity)</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 server-only" id="hw-ip2-group"><label for="hw-IP2">IP 주소 2</label><input type="text" id="hw-IP2" /></div>
<div class="form-group server-only" id="hw-remote-group"><label for="hw-원격접속">원격 방법</label><input type="text" id="hw-원격접속" /></div>
<div class="form-group server-only" id="hw-remote-group"><label for="hw-원격접속">원격 도구</label><input type="text" id="hw-원격접속" /></div>
<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 class="form-group server-only" id="hw-server-pw-group"><label for="hw-서버PW">서버 PW</label><input type="password" id="hw-서버PW" /></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 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>
<div class="form-section-title" id="hw-spec-title">하드웨어 사양 (Specifications)</div>
<div class="form-section-title" id="hw-spec-title">시스템 사양 (Specifications)</div>
<div class="form-group" id="hw-model-group"><label for="hw-모델명">모델명</label><input type="text" id="hw-모델명" /></div>
<div class="form-group" id="hw-os-group"><label for="hw-OS">운영체제 (OS)</label><input type="text" id="hw-OS" /></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 server-only" id="hw-monitoring-group"><label for="hw-모니터링">모니터링 여부</label><input type="text" id="hw-모니터링" /></div>
<div class="form-group full-width non-server" id="hw-hwspec-group"><label for="hw-HW사양">사양 상세</label><textarea id="hw-HW사양" rows="2"></textarea></div>
<div class="form-section-title" id="hw-loc-title">설치 위치 및 관리</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 loc-standard"><label for="hw-위치-상세">세부 설치 위치</label><select id="hw-위치-상세"><option value="">선택</option></select></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 loc-standard"><label for="hw-위치-상세">세 위치</label><select id="hw-위치-상세"><option value="">선택</option></select></div>
<div class="form-group" id="hw-위치-기타-group" style="display:none;"><label for="hw-위치-기타">직접 입력 (기타)</label><input type="text" id="hw-위치-기타" /></div>
<div class="form-group"><label for="hw-담당자_정">자산관리조직</label><input type="text" id="hw-담당자_정" readonly /></div>
<div class="form-group"><label for="hw-구매일">도입일</label><input type="text" id="hw-구매일" /></div>
<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>
<div class="form-group"><label for="hw-담당자_정">담당자(정)</label><input type="text" id="hw-담당자_정" /></div>
<div class="form-group"><label for="hw-담당자_부">담당자(부)</label><input type="text" id="hw-담당자_부" /></div>
<div class="form-group"><label for="hw-구매일">구매연월</label><input type="text" id="hw-구매일" placeholder="YYYYMM" maxlength="6" /></div>
<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>
<div class="form-group full-width"><label for="hw-비고">비고</label><textarea id="hw-비고" rows="2"></textarea></div>
<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>
`;
function renderHwHistory(assetId: string) {
@@ -169,39 +177,68 @@ function applyTypeSpecificUI(type: string) {
const opOnly = document.querySelectorAll('.op-only');
const standardLoc = document.querySelectorAll('.loc-standard');
// 초기화
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.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'; });
const isServer = upperType.includes('서버') || upperType.includes('SERVER');
const osLabel = document.querySelector('label[for="hw-OS"]') as HTMLElement;
const ramLabel = document.querySelector('label[for="hw-RAM"]') as HTMLElement;
const modelLabel = document.querySelector('label[for="hw-모델명"]') as HTMLElement;
if (osLabel) osLabel.innerText = '운영체제 (OS)';
if (ramLabel) ramLabel.innerText = 'RAM 용량';
if (modelLabel) modelLabel.innerText = '모델명';
const isMobileGroup = ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품');
const isOpType = isMobileGroup || isEquipGroup;
const isPcType = upperType === 'PC' || upperType === '개인PC' || upperType === '노트북';
if (isServer) {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
if (groups.detailPurpose) groups.detailPurpose.style.display = 'flex';
if (groups.networkTitle) groups.networkTitle.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
} else if (isPcType) {
if (groups.user) groups.user.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
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 (isOpType) {
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 (groups.hwSpec) groups.hwSpec.style.display = 'flex';
} else {
nonServer.forEach(el => (el as HTMLElement).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 {
if (groups.hwSpec) groups.hwSpec.style.display = 'flex';
}
}
else if (isPcType) {
if (groups.user) groups.user.style.display = 'flex';
if (groups.specTitle) groups.specTitle.style.display = 'flex';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'hwSpec'].forEach(k => { if (groups[k]) groups[k]!.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 {
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';
['model', 'os', 'cpu', 'ram', 'ssd1', 'ssd2', 'monitoring'].forEach(k => { if (groups[k]) groups[k]!.style.display = 'flex'; });
}
}
@@ -217,7 +254,12 @@ export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view')
});
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-위치-기타');
applyTypeSpecificUI(asset.type);
renderHwHistory(asset.id);
@@ -229,11 +271,12 @@ export function openHwModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view')
export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('hw-asset-modal')) {
const html = createModalFrameHTML('hw', '자산 상세 정보', HW_FORM_HTML, {
historyTitle: '유지보수 및 변경 이력',
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;">
@@ -241,7 +284,7 @@ export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
<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 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>
@@ -251,6 +294,7 @@ export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
document.body.insertAdjacentHTML('beforeend', logModalHTML);
}
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
@@ -285,7 +329,7 @@ export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
const purchaseDate = getFieldValue('hw-구매일');
const typeCode = TYPE_PREFIX_MAP[typeValue] || 'ETC';
const dateStr = purchaseDate.replace(/[^0-9]/g, '');
if (dateStr.length < 6) { alert('올바른 도입일(YYYYMM)을 입력해주세요.'); return; }
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}`);
@@ -294,6 +338,19 @@ export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
} 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', () => {
if (!currentAsset) return;
if (!isEditMode) {
@@ -308,52 +365,94 @@ export function initHwModal(onSave: () => void, closeModalsCb: () => void) {
return;
}
// 데이터 추출 (자동 매핑)
const extracted = autoExtractForm('hw', HW_FIELD_MAP);
if (!extracted.) {
alert('자산번호가 없습니다. [생성] 버튼을 눌러 자산번호를 부여해주세요.');
alert('자산번호가 없습니다. [생성] 버튼을 눌러 자산번호를 먼저 부여해주세요.');
return;
}
const diffLogs: string[] = [];
const compareFields = [
{ key: '실사용조직', label: '실사용조직' },
{ key: '위치', label: '위치' },
{ key: '상태', label: '상태' },
{ key: 'IP주소', label: 'IP' }
];
const upperType = (extracted.type || '').toUpperCase();
const isOpType = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t)) || upperType.includes('비품') || ['모바일', '태블릿', '휴대폰'].some(t => upperType.includes(t));
const currentLocation = currentAsset. || '';
const newLocation = getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타');
// --- 자동 변경 이력 생성 로직 ---
// 모든 하드웨어 유형에 대해 자동 로깅 적용
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: '모델명' }
];
compareFields.forEach(f => {
let oldVal = (currentAsset as any)[f.key] || '';
let newVal = (extracted as any)[f.key] || '';
if (f.key === '위치') { oldVal = currentLocation; newVal = newLocation; }
if (f.key === 'IP주소') { newVal = getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server'); }
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-위치-기타');
if (oldVal !== newVal) {
diffLogs.push(`${f.label}: ${oldVal || '(없음)'}${newVal || '(없음)'}`);
}
});
compareFields.forEach(f => {
let oldVal = '';
let 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')
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 = {
...currentAsset,
...extracted,
IP주소: getFieldValue('hw-IP주소') || getFieldValue('hw-IP주소-non-server'),
위치: newLocation
관리자: extracted.담당자_정,
위치: isOpType ? extracted.보관위치 : getCombinedLocation('hw-위치-빌딩', 'hw-위치-상세', 'hw-위치-기타')
};
// 현 사용조직 변경 시 이전 사용조직 자동 업데이트
if (currentAsset. && currentAsset. !== extracted.) {
updated. = currentAsset.;
}
// 비 PC 자산에 대해 상세유형(상세용도)을 유형과 동기화
if (updated.type !== 'PC') {
updated. = updated.type;
}
saveHardwareAsset(updated);
onSave();
setEditLock('hw-asset-form', 'view', {

View File

@@ -1,180 +1,213 @@
import { createIcons, Save, Edit2, RotateCcw } from 'lucide';
import { LOCATION_DATA } from './SharedData';
// 공통 옵션 생성 함수
export const generateOptionsHTML = (options: string[]) =>
options.map(opt => `<option value="${opt}">${opt}</option>`).join('');
/**
* 모달 조작 및 UI 생성을 위한 공통 유틸리티
*/
// 필드 값 설정 유틸리티
// 1. Select 박스의 Option HTML 생성
export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string {
let html = includeSelectHint ? '<option value="">선택</option>' : '';
html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join('');
return html;
}
// 2. 안전하게 폼 필드 값 설정 (Null 에러 방지)
export function setFieldValue(id: string, value: any) {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) {
if (el.type === 'checkbox') (el as HTMLInputElement).checked = !!value;
else el.value = value || '';
el.value = value || '';
}
}
// 필드 값 가져오기 유틸리티
// 3. 안전하게 폼 필드 값 읽기
export function getFieldValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (!el) return '';
if (el.type === 'checkbox') return (el as HTMLInputElement).checked ? 'Y' : 'N';
return el.value || '';
return el ? el.value : '';
}
// 폼 자동 채우기
export function autoFillForm(prefix: string, data: any, fieldMap: Record<string, string>) {
Object.keys(fieldMap).forEach(fieldId => {
const dataKey = fieldMap[fieldId];
setFieldValue(`${prefix}-${fieldId}`, data[dataKey]);
});
}
// 폼 데이터 자동 추출
export function autoExtractForm(prefix: string, fieldMap: Record<string, string>): any {
const extracted: any = {};
Object.keys(fieldMap).forEach(fieldId => {
const dataKey = fieldMap[fieldId];
extracted[dataKey] = getFieldValue(`${prefix}-${fieldId}`);
});
return extracted;
}
// 모달 편집 잠금/해제 유틸리티
export function setEditLock(formId: string, mode: 'view' | 'edit' | 'add', options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: string,
addLogBtnId?: string
}) {
const form = document.getElementById(formId) as HTMLFormElement;
if (!form) return;
const isView = mode === 'view';
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement;
if (el.id.includes('자산코드') || el.id.includes('asset-id') || el.classList.contains('is-readonly-field')) {
el.readOnly = true;
el.disabled = false;
} else {
if (el.tagName === 'SELECT') (el as HTMLSelectElement).disabled = isView;
else (el as HTMLInputElement).readOnly = isView;
}
});
const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (saveBtn) {
saveBtn.innerHTML = isView
? `<i data-lucide="edit-2" style="width:16px; height:16px;"></i> 수정`
: `<i data-lucide="save" style="width:16px; height:16px;"></i> 저장`;
saveBtn.className = isView ? 'btn btn-primary' : 'btn btn-success';
}
if (revertBtn) revertBtn.classList.toggle('hidden', isView);
if (generateBtn) generateBtn.classList.toggle('hidden', isView);
if (addLogBtn) addLogBtn.classList.toggle('hidden', isView);
createIcons({ icons: { Save, Edit2, RotateCcw } });
}
// 위치 정보 파싱 및 설정
// 4. 위치 정보 파싱 및 UI 세팅
export function parseAndSetLocation(locationStr: string, bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
if (!bldgSelect || !detailSelect) return;
// 초기화
bldgSelect.value = '';
detailSelect.innerHTML = '<option value="">선택</option>';
if (etcGroup) etcGroup.style.display = 'none';
if (!locationStr) return;
const parts = locationStr.split(' > ');
if (parts.length >= 1) {
bldgSelect.value = parts[0];
bldgSelect.dispatchEvent(new Event('change'));
if (parts.length >= 2) {
setTimeout(() => {
detailSelect.value = parts[1];
if (parts[1] === '기타' && parts[2]) {
if (etcGroup) etcGroup.style.display = 'flex';
etcInput.value = parts[2];
}
}, 0);
const parts = locationStr.split(' ');
const bldg = parts[0];
if (LOCATION_DATA[bldg]) {
bldgSelect.value = bldg;
// 상세 목록 갱신
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
const detail = parts[1];
if (detail) {
detailSelect.value = detail;
if (detail === '기타' && etcGroup && etcInput) {
etcGroup.style.display = 'flex';
etcInput.value = parts.slice(2).join(' ');
}
}
}
}
// 위치 정보 취합
export function getCombinedLocation(bldgId: string, detailId: string, etcId: string): string {
const bldg = getFieldValue(bldgId);
const detail = getFieldValue(detailId);
const etc = getFieldValue(etcId);
if (!bldg) return '';
if (detail === '기타') return `${bldg} > 기타 > ${etc}`;
return detail ? `${bldg} > ${detail}` : bldg;
}
// 위치 이벤트 바인딩
import { LOCATION_DATA } from './SharedData';
// 5. 위치 종속성(Cascade) 이벤트 바인딩
export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
bldgSelect?.addEventListener('change', () => {
if (!bldgSelect || !detailSelect) return;
bldgSelect.addEventListener('change', () => {
const bldg = bldgSelect.value;
const details = LOCATION_DATA[bldg] || [];
detailSelect.innerHTML = `<option value="">선택</option>` + generateOptionsHTML(details) + `<option value="기타">직접 입력(기타)</option>`;
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []);
if (etcGroup) etcGroup.style.display = 'none';
if (etcInput) etcInput.value = '';
});
detailSelect?.addEventListener('change', () => {
if (etcGroup) etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
detailSelect.addEventListener('change', () => {
if (etcGroup) {
etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
}
});
}
// 모달 프레임 HTML 생성 (2열 그리드 표준 레이아웃)
export function createModalFrameHTML(id: string, title: string, formHTML: string, options: { historyTitle?: string, addLogBtnId?: string }) {
// 6. 위치 문자열 조합 (저장용)
export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string {
const bldg = getFieldValue(bldgId);
const detail = getFieldValue(detailId);
const etc = getFieldValue(etcInputId);
let combined = bldg;
if (detail) combined += ` ${detail}`;
if (detail === '기타' && etc) combined += ` ${etc}`;
return combined.trim();
}
// 7. 조회/수정 모드 UI 통합 제어
export function setEditLock(
formId: string,
mode: 'view' | 'add' | 'edit',
options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: string,
addLogBtnId?: string
}
) {
const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form || !saveBtn || !revertBtn) return;
if (mode === 'add' || mode === 'edit') {
// 편집 모드 활성화
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
// 번호 생성 버튼은 '추가(add)' 시에만 노출
if (generateBtn) {
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
}
// 내역 추가 버튼 노출
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.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="${id}-asset-modal" class="modal-overlay hidden">
<div class="modal-content modal-lg">
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="${id}-modal-title">${title}</h2>
<button id="btn-close-${id}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
<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="${id}-asset-form" class="grid-form">
<input type="hidden" id="${id}-asset-id" />
<input type="hidden" id="${id}-asset-type" />
${formHTML}
<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 || 'btn-add-log'}" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
<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="${id}-history-list" class="history-timeline"></div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-${id}-asset" class="btn btn-outline btn-danger">삭제</button>
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-${id}-edit" class="btn btn-outline hidden">취소</button>
<button id="btn-save-${id}-asset" class="btn btn-primary">수정</button>
<button id="btn-cancel-${id}-modal" class="btn btn-outline">닫기</button>
<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,8 +1,9 @@
import { state, saveSoftwareAsset, deleteSoftwareAsset } from '../../core/state';
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, UserPlus } from 'lucide';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { closeModals } from './BaseModal';
import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide';
import { CORP_LIST, TYPE_PREFIX_MAP } from './SharedData';
import {
generateOptionsHTML,
setFieldValue,
@@ -12,198 +13,273 @@ import {
autoFillForm,
autoExtractForm
} from './ModalUtils';
import { openSwUserModal } from './SWUserModal';
let currentAsset: SoftwareAsset | null = null;
let currentSwAsset: SoftwareAsset | null = null;
let isEditMode = false;
const SW_FIELD_MAP: Record<string, string> = {
'유형': 'type',
'법인': '법인',
'부서': '부서',
'제품명': '소프트웨어명',
'구매일': '구매일',
'만료일': '만료일',
'자산번호': '자산번호',
'제품명': '제품명',
'수량': '수량',
'금액': '금액',
'구매일': '구매일',
'납품업체': '납품업체',
'비고': '비고',
'자산번호': '자산번호'
'플랫폼명': '플랫폼명',
'부서': '부서',
'계정명': '계정명',
'결제수단': '결제수단',
'연결카드번호': '연결카드번호',
'결제일': '결제일',
'당월청구액': '당월청구액',
'라이선스유형': '라이선스유형',
'만료일': '만료일',
'라이선스키': '라이선스키'
};
const SW_FORM_HTML = `
<div class="form-section-title">소프트웨어 기본 정보</div>
<div class="form-group">
<label for="sw-유형">라이선스 유형</label>
<select id="sw-유형">
<option value="구독SW">구독 라이선스</option>
<option value="영구SW">영구 라이선스</option>
</select>
</div>
<!-- Group 1: 기본 정보 -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="sw-법인">구매법인</label>
<select id="sw-법인">${generateOptionsHTML(CORP_LIST)}</select>
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="sw-부서">관리부서</label>
<select id="sw-부서">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label for="sw-제품명">제품명 (S/W명)</label>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group">
<div class="form-group sw-standard-field">
<label for="sw-자산번호">자산번호</label>
<input type="text" id="sw-자산번호" placeholder="관리번호 입력" />
</div>
<div class="form-group">
<label for="sw-수량">총 라이선스 수량</label>
<input type="number" id="sw-수량" value="1" />
</div>
<div class="form-section-title">계약 및 금액</div>
<div class="form-group">
<label for="sw-구매일">구매일 (계약시작)</label>
<input type="date" id="sw-구매일" />
</div>
<div class="form-group sw-sub-only">
<label for="sw-만료일">만료일 (계약종료)</label>
<input type="date" id="sw-만료일" />
</div>
<div class="form-group">
<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 class="input-with-btn">
<input type="text" id="sw-자산번호" readonly class="is-readonly-field" placeholder="번호 생성을 클릭하세요" />
<button type="button" id="btn-generate-sw-code" class="btn btn-outline btn-sm">생성</button>
</div>
</div>
<div class="form-group full-width">
<label for="sw-비고">비고 (특이사항)</label>
<textarea id="sw-비고" rows="2"></textarea>
<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>
<div class="form-section-title">
사용자 할당 현황
<button type="button" id="btn-add-sw-user" class="btn btn-outline btn-xs" style="margin-left: 0.5rem;">
<i data-lucide="user-plus" style="width:12px; height:12px;"></i> 할당 추가
</button>
</div>
<div class="full-width">
<div id="sw-user-list-container" class="mini-table-container">
<table class="itam-table mini">
<thead>
<tr>
<th>부서</th>
<th>이름</th>
<th>사번</th>
<th>사용기간</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="sw-user-list-body"></tbody>
</table>
<!-- Group 2: 라이선스 및 계약 -->
<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>
<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: 관리 정보 -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field"><label for="sw-구매일">구매연월</label><input type="text" id="sw-구매일" placeholder="YYYYMM" maxlength="6" /></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>
<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>
`;
function renderSwUsers(swId: string) {
const body = document.getElementById('sw-user-list-body');
if (!body) return;
const users = (state.masterData.swUsers || []).filter(u => u.swId === swId || u.sw_id === swId);
if (users.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="empty-row">할당된 사용자가 없습니다.</td></tr>';
return;
}
body.innerHTML = users.map(u => `
<tr>
<td>${u. || '-'}</td>
<td>${u. || '-'}</td>
<td>${u. || '-'}</td>
<td><small>${u. || '-'}</small></td>
<td><button class="btn-icon text-danger btn-delete-user" data-id="${u.id}"><i data-lucide="x" style="width:14px; height:14px;"></i></button></td>
</tr>
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('');
createIcons({ icons: { X } });
body.querySelectorAll('.btn-delete-user').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-id');
state.masterData.swUsers = state.masterData.swUsers.filter(u => u.id !== id);
renderSwUsers(swId);
});
});
}
export function openSwModal(asset: SoftwareAsset) {
currentAsset = asset;
const modal = document.getElementById('sw-asset-modal')!;
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
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('');
}
isEditMode = false;
function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const keyGroup = document.getElementById('sw-license-key-group');
const typeGroup = document.getElementById('sw-license-type-group');
const expiryGroup = document.getElementById('sw-expiry-group');
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '구독SW') {
if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex';
if (expiryGroup) expiryGroup.style.display = 'flex';
} else {
if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'none';
}
}
}
export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' = 'view') {
currentSwAsset = asset;
const modal = document.getElementById('sw-asset-modal')!;
setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
});
isEditMode = (mode === 'add');
autoFillForm('sw', asset, SW_FIELD_MAP);
const subOnly = document.querySelectorAll('.sw-sub-only');
subOnly.forEach(el => (el as HTMLElement).style.display = asset.type === '구독SW' ? 'block' : 'none');
renderSwUsers(asset.id);
applySwTypeUI(asset.type);
renderUserSummary(asset.id);
renderSwHistory(asset.id);
modal.classList.remove('hidden');
createIcons({ icons: { X, Save, Edit2, RotateCcw, UserPlus } });
createIcons({ icons: { X, History, Plus } });
}
export function initSwModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('sw-asset-modal')) {
const html = createModalFrameHTML('sw', '소프트웨어 상세 정보', SW_FORM_HTML, {
historyTitle: '라이선스 변경 이력',
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 saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-유형') as HTMLSelectElement;
const userUpdateBtn = document.getElementById('btn-open-sw-update')!;
const logAddBtn = document.getElementById('btn-add-sw-log')!;
const logModal = document.getElementById('sw-log-modal')!;
typeSelect?.addEventListener('change', () => {
const subOnly = document.querySelectorAll('.sw-sub-only');
subOnly.forEach(el => (el as HTMLElement).style.display = typeSelect.value === '구독SW' ? 'block' : 'none');
const closeModalAction = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
});
isEditMode = false;
if (currentSwAsset) openSwModal(currentSwAsset, 'view');
});
const handleClose = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', handleClose);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', handleClose);
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', () => {
if (!currentAsset) return;
if (!currentSwAsset) return;
if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', { saveBtnId: 'btn-save-sw-asset', revertBtnId: 'btn-revert-sw-edit' });
setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
});
isEditMode = true;
return;
}
const extracted = autoExtractForm('sw', SW_FIELD_MAP);
const updated = { ...currentAsset, ...extracted };
const updated = { ...currentSwAsset, ...extracted, 수량: parseInt(extracted. || '0') };
let targetList: SoftwareAsset[] = [];
if (updated.type === '구독SW') targetList = state.masterData.subSw;
else if (updated.type === '영구SW') targetList = state.masterData.permSw;
else targetList = (state.masterData as any).cloud || [];
const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated; else targetList.push(updated);
saveSoftwareAsset(updated);
onSave();
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit',
generateBtnId: 'btn-generate-sw-code',
addLogBtnId: 'btn-add-sw-log'
});
isEditMode = false;
setEditLock('sw-asset-form', 'view', { saveBtnId: 'btn-save-sw-asset', revertBtnId: 'btn-revert-sw-edit' });
});
deleteBtn.addEventListener('click', () => {
if (currentAsset && confirm('이 소프트웨어 자산을 삭제하시겠습니까?')) {
deleteSoftwareAsset(currentAsset.id, currentAsset.type);
onSave();
handleClose();
if (currentSwAsset && confirm('삭제하시겠습니까?')) {
const type = currentSwAsset.type;
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);
onSave(); closeModalAction();
}
});
document.getElementById('btn-add-sw-user')?.addEventListener('click', () => {
if (!currentAsset) return;
openSwUserModal(currentAsset.id, () => renderSwUsers(currentAsset!.id));
userUpdateBtn.addEventListener('click', () => { if (currentSwAsset) openSwUserModal(currentSwAsset); });
logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden');
(document.getElementById('new-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-log-details') as HTMLTextAreaElement).value = '';
});
document.getElementById('btn-close-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-sw-log')?.addEventListener('click', () => {
if (!currentSwAsset) return;
const date = (document.getElementById('new-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-log-details') as HTMLTextAreaElement).value;
if (!date || !details) return;
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({ id: Math.random().toString(36).substring(2, 9), assetId: currentSwAsset.id, date, user: '관리자', details });
logModal.classList.add('hidden'); renderSwHistory(currentSwAsset.id);
});
}

View File

@@ -1,12 +1,12 @@
import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar } from 'lucide';
import { createIcons, Edit2, X, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
let currentSwUserAsset: SoftwareAsset | null = null;
let tempSwUsers: any[] = [];
let tempSwUsers: SWUser[] = [];
const SW_USER_MODAL_HTML = `
<div id="sw-user-modal" class="modal-overlay hidden">
@@ -74,24 +74,8 @@ const SW_USER_MODAL_HTML = `
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용 시작일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-시작일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').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="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>사용 종료일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-종료일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').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="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
<label>사용기간</label>
<input type="text" id="new-user-사용기간" placeholder="ex) 2024-01-01 ~ 2024-12-31" />
</div>
<div class="form-group">
<label>신청서 (증빙)</label>
@@ -121,9 +105,7 @@ export function openSwUserModal(asset: SoftwareAsset) {
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
법인: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
tempSwUsers = existingMapping ? JSON.parse(JSON.stringify(existingMapping.userDataList || [])) : [];
renderUserList();
modal.classList.remove('hidden');
@@ -142,7 +124,7 @@ function renderUserList() {
tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
@@ -187,20 +169,11 @@ function openUserEditSubModal(idx: number = -1) {
if (idx > -1) {
const user = tempSwUsers[idx];
setFieldValue('new-user-법인', user.);
setFieldValue('new-user-법인', user. || user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
// 사용기간 파싱 (yyyy-mm-dd ~ yyyy-mm-dd)
if (user. && user..includes('~')) {
const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim());
} else {
setFieldValue('new-user-시작일', '');
setFieldValue('new-user-종료일', '');
}
setFieldValue('new-user-사용기간', user.);
} else {
setFieldValue('new-user-법인', currentSwUserAsset?.);
}
@@ -217,12 +190,6 @@ export function initSwUserModal(onSave: () => void, closeModals: () => void) {
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
createIcons({ icons: { Calendar } });
addUserBtn.addEventListener('click', () => openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => {
@@ -236,7 +203,7 @@ export function initSwUserModal(onSave: () => void, closeModals: () => void) {
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = {
sw_id: currentSwUserAsset!.id,
userData: tempSwUsers.map(u => [u., u., u., u., u., u.])
userDataList: tempSwUsers
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
@@ -266,11 +233,11 @@ function saveUserDataToList() {
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : '');
const userData: any = {
법인: getFieldValue('new-user-법인'),
구매법인: getFieldValue('new-user-법인'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
사용기간: getFieldValue('new-user-사용기간'),
};