feat: migrate ServerPC data to asset_pc, enhance filters with location, and standardize page headers

- 서버PC 자산을 asset_pc 테이블로 통합 마이그레이션 및 스키마 확장 (위치, IP 정보 복구 완료)

- 하드웨어 자산 페이지의 구매법인 필터를 자산위치 필터로 교체 및 동적 데이터 바인딩 적용

- 모든 자산 리스트 페이지 상단에 설명(Description) 필드 추가 및 헤더 표준화

- 상세 모달 내 삭제 버튼 기능 구현 및 서버PC 용도 필드 노출 오류 수정

- 현 사용조직 필터 리스트가 비어있던 DOM 셀렉터 버그 수정
This commit is contained in:
2026-05-26 17:33:03 +09:00
parent d34ebb8500
commit 82bbe85e23
43 changed files with 2055 additions and 1871 deletions

View File

@@ -1,9 +1,9 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { state, saveAsset, deleteAsset } from '../../core/state';
import { openModal, closeModals } from './BaseModal';
import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar } from 'lucide';
import { CORP_LIST } from './SharedData';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import {
generateOptionsHTML,
setFieldValue,
@@ -12,7 +12,7 @@ import {
applyDateMask
} from './ModalUtils';
let currentSwAsset: SoftwareAsset | null = null;
let currentSwAsset: any | null = null;
let isEditMode = false;
const SW_MODAL_HTML = `
@@ -26,21 +26,21 @@ const SW_MODAL_HTML = `
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-id" name="id" />
<!-- Group 1: 기본 정보 (Identity) -->
<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>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label for="sw-분야">분야</label>
<select id="sw-분야" required>
<label for="sw-분야">${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
@@ -49,65 +49,61 @@ const SW_MODAL_HTML = `
</div>
<div class="form-group">
<label for="sw-법인">법인</label>
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
<label for="sw-법인">${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label for="sw-제품명">제품명 / 서비스명</label>
<input type="text" id="sw-제품명" required />
<label for="sw-제품명">${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required />
</div>
<div class="form-group cloud-only">
<label for="sw-플랫폼명">플랫폼</label>
<input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" />
<label for="sw-플랫폼명">${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div>
<div class="form-group">
<label for="sw-부서">조직 / 부서</label>
<input type="text" id="sw-부서" />
<label for="sw-부서">${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" />
</div>
<div class="form-group sw-user-tracking">
<label for="sw-user-current">${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" />
</div>
<div class="form-group sw-user-tracking">
<label for="sw-previous-user">${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" />
</div>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<label for="sw-수량">보유 수량</label>
<input type="number" id="sw-수량" min="0" />
<label for="sw-수량">${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" 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, ',')" />
<label for="sw-금액">${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only">
<label for="sw-계정명">계정명 (이메일)</label>
<input type="text" id="sw-계정명" />
<label for="sw-계정명">${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제수단">결제수단</label>
<select id="sw-결제수단">
<label for="sw-결제수단">${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group cloud-only">
<label for="sw-연결카드번호">연결카드번호(뒷4자리)</label>
<input type="text" id="sw-연결카드번호" maxlength="4" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제일">결제일 (기준일)</label>
<input type="number" id="sw-결제일" min="1" max="31" />
</div>
<div class="form-group cloud-only">
<label for="sw-당월청구액">당월 청구액(원)</label>
<input type="text" id="sw-당월청구액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label for="sw-구매일">구매일</label>
<label for="sw-구매일">${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" style="flex:1;" />
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
@@ -115,23 +111,25 @@ const SW_MODAL_HTML = `
</div>
</div>
<div class="form-group sw-standard-field">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
<label for="sw-납품업체">${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-시작일">시작일 (구독/유지보수)</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-시작일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-시작일-picker'); p.value = document.getElementById('sw-시작일').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="sw-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-시작일').value = this.value" tabindex="-1" />
</div>
<label for="sw-개발담당자">${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-기획담당자">${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-영업담당자">${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">만료일 (종료일)</label>
<label for="sw-만료일">${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" style="flex:1;" />
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
@@ -139,8 +137,8 @@ const SW_MODAL_HTML = `
</div>
</div>
<div class="form-group full-width">
<label for="sw-비고">비고</label>
<textarea id="sw-비고" rows="2"></textarea>
<label for="sw-비고">${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea>
</div>
</form>
@@ -194,12 +192,6 @@ const SW_MODAL_HTML = `
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
</div>
</div>
<div class="form-group perm-sw-update" style="display:none;">
<label>유지보수 체결 (상태 연동)</label>
<label style="display:flex; align-items:center; gap:0.5rem; height: 38px; cursor: pointer;">
<input type="checkbox" id="sw-update-maintenance" /> 유효 상태로 갱신
</label>
</div>
<div class="form-group">
<label>발생 비용</label>
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
@@ -226,53 +218,59 @@ function applySwTypeUI(type: string) {
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
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';
userTracking.forEach(el => (el as HTMLElement).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' || type === '영구SW') {
if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
// 외부SW에만 현 사용자/직전 사용자 표시 (내부SW는 user tracking 제외 요청됨)
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
}
}
}
function fillSwFormData(asset: SoftwareAsset) {
function fillSwFormData(asset: any) {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.type);
setFieldValue('sw-분야', asset. || '업무공통');
setFieldValue('sw-법인', asset.);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
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.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-previous_dept', asset.previous_dept || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-시작일', asset.start_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드') {
setFieldValue('sw-플랫폼명', (asset as any). || '');
setFieldValue('sw-계정명', (asset as any). || '');
setFieldValue('sw-결제수단', (asset as any). || '');
setFieldValue('sw-연결카드번호', (asset as any). || '');
setFieldValue('sw-결제일', (asset as any). || '');
setFieldValue('sw-당월청구액', (asset as any). || '');
} else if (asset.type === '구독SW' || asset.type === '영구SW') {
setFieldValue('sw-만료일', (asset as any). || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
renderSwHistory(asset.id);
}
function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
@@ -290,11 +288,10 @@ function renderSwHistory(swId: string) {
`).join('');
}
export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' | 'edit' = 'view') {
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = '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'
@@ -303,7 +300,7 @@ export function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' | 'edit'
isEditMode = (mode === 'add' || mode === 'edit');
fillSwFormData(asset);
applySwTypeUI(asset.type);
applySwTypeUI(asset.asset_type || asset.type);
modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } });
@@ -326,7 +323,6 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
applySwTypeUI(typeSelect.value);
});
// 날짜 스마트 마스킹 적용
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
@@ -346,7 +342,7 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
if (currentSwAsset) fillSwFormData(currentSwAsset);
});
saveBtn.addEventListener('click', () => {
saveBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', {
@@ -358,65 +354,37 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
}
const type = getFieldValue('sw-asset-type');
const updated: any = {
...currentSwAsset,
분야: getFieldValue('sw-분야'),
법인: getFieldValue('sw-법인'),
부서: getFieldValue('sw-부서'),
const formData = new FormData(form);
const updated: any = { ...currentSwAsset };
formData.forEach((value, key) => {
updated[key] = value;
});
// Mapping for generic saveAsset
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
제품명: getFieldValue('sw-제품명'),
수량: parseInt(getFieldValue('sw-수량') || '0'),
금액: getFieldValue('sw-금액'),
구매일: getFieldValue('sw-구매일'),
시작일: getFieldValue('sw-시작일'),
납품업체: getFieldValue('sw-납품업체'),
비고: getFieldValue('sw-비고'),
type: type
};
if (type === '클라우드') {
updated. = getFieldValue('sw-플랫폼명');
updated. = getFieldValue('sw-계정명');
updated. = getFieldValue('sw-결제수단');
updated. = getFieldValue('sw-연결카드번호');
updated. = getFieldValue('sw-결제일');
updated. = getFieldValue('sw-당월청구액').replace(/,/g, '');
} else if (type === '구독SW' || type === '영구SW') {
updated. = getFieldValue('sw-만료일');
const success = await saveAsset(categoryKey, updated);
if (success) {
onSave();
closeModalAction();
}
// 데이터 저장 로직 (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[] = [];
if (newType === '구독SW') targetList = state.masterData.subSw;
else if (newType === '영구SW') targetList = state.masterData.permSw;
else if (newType === '클라우드') targetList = state.masterData.cloud;
const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated;
else targetList.push(updated);
onSave();
closeModalAction();
});
deleteBtn.addEventListener('click', () => {
deleteBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (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);
else if (type === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== currentSwAsset!.id);
onSave();
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = currentSwAsset.asset_type || currentSwAsset.type;
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
const success = await deleteAsset(categoryKey, currentSwAsset.id);
if (success) {
alert('성공적으로 삭제되었습니다.');
onSave(); // Refresh list
closeModalAction();
}
});
@@ -441,62 +409,37 @@ export function initSwModal(onSave: () => void, closeModals: () => void) {
alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.');
return;
}
subModal.classList.remove('hidden');
(document.getElementById('sw-update-date') as HTMLInputElement).value = new Date().toISOString().substring(0, 10);
(document.getElementById('sw-update-start') as HTMLInputElement).value = '';
(document.getElementById('sw-update-end') as HTMLInputElement).value = '';
(document.getElementById('sw-update-cost') as HTMLInputElement).value = '';
(document.getElementById('sw-update-note') as HTMLInputElement).value = '';
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:none');
});
btnSaveUpdate?.addEventListener('click', (e) => {
btnSaveUpdate?.addEventListener('click', async (e) => {
e.preventDefault();
const isSub = getFieldValue('sw-asset-type') === '구독SW';
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const maintenance = (document.getElementById('sw-update-maintenance') as HTMLInputElement).checked;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
const periodStr = (start || end) ? `${start || ''} ~ ${end || ''}` : '';
let details = `[업데이트] ${note || '계약 갱신'}\n`;
if (cost) details += `비용 추가: ${cost}\n`;
if (periodStr) details += `계약 변경: -> ${periodStr}\n`;
// 메인 폼에 시작일 만료일 자동 세팅
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
// 금액 갱신 (선택사항)
if (cost) {
if (getFieldValue('sw-asset-type') === '클라우드') {
setFieldValue('sw-당월청구액', cost);
} else {
setFieldValue('sw-금액', cost);
}
}
if (cost) setFieldValue('sw-금액', cost);
// 이력 탭 갱신 (메모리상)
if (!state.masterData.logs) state.masterData.logs = [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: currentSwAsset ? currentSwAsset.id : 'NEW',
date,
details,
cost: cost ? Number(String(cost).replace(/,/g, '')) : 0,
user: '관리자'
// Save as log
const log = {
assetId: currentSwAsset.id,
date,
details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`,
user: '관리자'
};
// Call generic API for logs (could be added to state.ts)
await fetch(`http://${location.hostname}:3000/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdateModal();
renderSwHistory(currentSwAsset ? currentSwAsset.id : '');
onSave(); // 로그 즉시 저장
onSave();
});
}