3 Commits

12 changed files with 545 additions and 170 deletions

View File

@@ -0,0 +1,33 @@
# [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현
## 1. 개요
소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다.
## 2. 작업 상세 내용
### A. S/W 목록(Table) 개선
- **상태 자동 계산 시스템 도입**:
- 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시.
- 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시.
- **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임.
### B. 상세 정보 모달 개편 (`SWModal.ts`)
- **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계.
- **날짜 입력 필드 개선**:
- '구매일' 필드에 캘린더 피커(Calendar Picker) 적용.
- '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용.
- 직접 입력("yyyy-mm-dd") 형식도 동시 지원.
### C. 계약 업데이트(갱신) 관리 기능
- **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현.
- 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적.
- 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동.
## 3. 관련 파일
- `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링.
- `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직.
- `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일.
## 4. 확인 사항
- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.

View File

@@ -1,6 +1,5 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { renderTable } from '../../views/AssetTableView';
import { createIcons, Paperclip } from 'lucide';
let currentAsset: HardwareAsset | null = null;

View File

@@ -1,50 +1,48 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, X, History, Plus } from 'lucide';
const SW_MODAL_HTML = `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title">S/W 상세 정보</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" />
<div class="form-group">
<label for="sw-분야">분야</label>
<select id="sw-분야" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
<option value="업무공통">업무공통</option><option value="개발S/W">개발S/W</option><option value="디자인">디자인</option><option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label for="sw-법인">법인</label>
<select id="sw-법인" required>
<option value="한맥">한맥 (HM)</option>
<option value="삼안 (SM)">삼안 (SM)</option>
<option value="바론 (BR)">바론 (BR)</option>
<option value="한맥">한맥 (HM)</option><option value="삼안 (SM)">삼안 (SM)</option><option value="바론 (BR)">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="sw-부서">부서</label>
<input type="text" id="sw-부서" placeholder="ex) 경영지원팀" required />
</div>
<div class="form-group">
<label for="sw-제품명">제품명</label>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group">
<label for="sw-구매일">구매일</label>
<input type="text" id="sw-구매일" placeholder="ex) 2024-01-01" />
<input type="date" id="sw-구매일" />
</div>
<div class="form-group" id="sw-구독일-group" style="grid-column: span 2;">
<label>구독 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="date" id="sw-구독일-시작" style="flex: 1;" />
<span>~</span>
<input type="date" id="sw-구독일-종료" style="flex: 1;" />
</div>
<div class="form-group" id="sw-구독일-group">
<label for="sw-구독일">구독일(시작~끝)</label>
<input type="text" id="sw-구독일" placeholder="ex) 2024-01-01 ~ 2024-12-31" />
</div>
<div class="form-group" id="sw-유지보수-group" style="display:none;">
<label for="sw-유지보수여부">유지보수 여부</label>
@@ -54,7 +52,7 @@ const SW_MODAL_HTML = `
</div>
<div class="form-group">
<label for="sw-금액">금액</label>
<input type="text" id="sw-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
<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">
<label for="sw-수량">수량 (보유량)</label>
@@ -68,12 +66,23 @@ const SW_MODAL_HTML = `
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group">
<div class="form-group full-width">
<label for="sw-비고">비고</label>
<input type="text" id="sw-비고" />
</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-sw-update" class="btn btn-outline btn-sm"><i data-lucide="plus" style="width:14px;height:14px;"></i> 업데이트 추가</button>
</div>
<div id="sw-history-list" class="history-timeline">
<div class="empty-history">내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
@@ -84,25 +93,64 @@ const SW_MODAL_HTML = `
</div>
</div>
</div>
<div id="sw-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-sw-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="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 구독 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="date" id="sw-update-start" style="flex: 1;" />
<span>~</span>
<input type="date" id="sw-update-end" 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, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" placeholder="ex) 500,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
`;
export function initSwModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
export let currentAsset: SoftwareAsset | null = null;
export let isEditMode = false;
export function setEditMode(edit: boolean) {
isEditMode = edit;
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-sw-footer') as HTMLButtonElement;
let isEditMode = false;
let currentAsset: SoftwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
swForm.classList.add('is-edit-mode');
swForm.classList.remove('is-view-mode');
@@ -117,9 +165,9 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
}
function fillFormData(asset: SoftwareAsset) {
export function fillFormData(asset: SoftwareAsset) {
(document.getElementById('sw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('sw-분야') as HTMLSelectElement).value = asset. || '업무공통';
@@ -127,15 +175,39 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
(document.getElementById('sw-부서') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-제품명') as HTMLInputElement).value = asset.;
(document.getElementById('sw-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-구독일') as HTMLInputElement).value = asset. || '';
if (asset.) {
const parts = asset..split('~');
(document.getElementById('sw-구독일-시작') as HTMLInputElement).value = parts[0]?.trim() || '';
(document.getElementById('sw-구독일-종료') as HTMLInputElement).value = parts[1]?.trim() || '';
} else {
(document.getElementById('sw-구독일-시작') as HTMLInputElement).value = '';
(document.getElementById('sw-구독일-종료') as HTMLInputElement).value = '';
}
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-수량') as HTMLInputElement).value = String(asset.);
(document.getElementById('sw-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-비고') as HTMLInputElement).value = asset. || '';
document.getElementById('btn-open-sw-update')!.style.display = 'flex';
renderSwHistory(asset.id);
}
export function initSwModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-sw-footer') as HTMLButtonElement;
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);
@@ -150,6 +222,10 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
const start = (document.getElementById('sw-구독일-시작') as HTMLInputElement).value;
const end = (document.getElementById('sw-구독일-종료') as HTMLInputElement).value;
const Str = (start || end) ? `${start || ''} ~ ${end || ''}` : '';
const newAsset: SoftwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: (document.getElementById('sw-asset-type') as HTMLInputElement).value,
@@ -158,7 +234,7 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
: (document.getElementById('sw-부서') as HTMLInputElement).value,
: (document.getElementById('sw-제품명') as HTMLInputElement).value,
: (document.getElementById('sw-구매일') as HTMLInputElement).value,
: (document.getElementById('sw-구독일') as HTMLInputElement).value,
구독일: 구독일Str,
: (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked,
: (document.getElementById('sw-금액') as HTMLInputElement).value,
수량: parseInt((document.getElementById('sw-수량') as HTMLInputElement).value || '1', 10),
@@ -187,6 +263,109 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
renderContent();
}
});
// Update Sub-modal integration
const subModal = document.getElementById('sw-update-modal')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
const btnCloseUpdate = document.getElementById('btn-close-sw-update')!;
const btnCancelUpdate = document.getElementById('btn-cancel-sw-update')!;
const btnSaveUpdate = document.getElementById('btn-save-sw-update')!;
const closeUpdateModal = () => subModal.classList.add('hidden');
btnCloseUpdate?.addEventListener('click', closeUpdateModal);
btnCancelUpdate?.addEventListener('click', closeUpdateModal);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
const isSub = (document.getElementById('sw-asset-type') as HTMLInputElement).value === '구독SW';
subModal.classList.remove('hidden');
// Set default values
(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 = '';
if (isSub) {
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:none');
} else {
document.querySelector('.sub-sw-update')!.setAttribute('style', 'display:none');
document.querySelector('.perm-sw-update')!.setAttribute('style', 'display:flex; flex-direction:column;');
(document.getElementById('sw-update-maintenance') as HTMLInputElement).checked = (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked;
}
});
btnSaveUpdate?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
if (!id) { alert('자산이 저장되지 않았습니다. 메인 폼을 먼저 저장해주세요.'); return; }
const isSub = (document.getElementById('sw-asset-type') as HTMLInputElement).value === '구독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 || (isSub ? '구독 갱신' : '유지보수 계약')}\n`;
if (cost) details += `발생 비용: ${cost}\n`;
if (isSub) {
if (periodStr) details += `구독 변경: -> ${periodStr}\n`;
// Always update main fields if period is provided
if (periodStr) {
(document.getElementById('sw-구독일-시작') as HTMLInputElement).value = start;
(document.getElementById('sw-구독일-종료') as HTMLInputElement).value = end;
}
} else {
details += `유지보수 상태: -> ${maintenance ? '유효' : '없음'}\n`;
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = maintenance;
}
if (cost) (document.getElementById('sw-금액') as HTMLInputElement).value = cost;
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: id,
date,
details,
user: '관리자'
});
closeUpdateModal();
renderSwHistory(id);
// 메인 테이블 리렌더링도 트리거 (뒤에 보일 수 있게)
renderContent();
});
}
function renderSwHistory(assetId: string) {
const historyList = document.getElementById('sw-history-list');
if (!historyList) return;
const logs = state.masterData.logs
.filter(l => l.assetId === assetId)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (logs.length === 0) {
historyList.innerHTML = '<div class="empty-history">업데이트 내역이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">작업자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\\n/g, '<br>')}</div>
</div>
`).join('');
createIcons({ icons: { X, History, Plus } });
}
export function openSwModal(asset?: SoftwareAsset) {
@@ -217,6 +396,10 @@ export function openSwModal(asset?: SoftwareAsset) {
deleteBtn.style.display = 'none';
(document.getElementById('sw-asset-id') as HTMLInputElement).value = '';
(document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab;
document.getElementById('btn-open-sw-update')!.style.display = 'none';
renderSwHistory('');
setEditMode(true);
}
createIcons({ icons: { X, History, Plus } });
}

View File

@@ -116,14 +116,14 @@ export function initSwUserModal(renderContent: () => void, closeModals: () => vo
btnSaveSwUserMapping?.addEventListener('click', () => {
state.masterData.swUsers = state.masterData.swUsers.filter(u => u.swId !== currentSwUserAssetId);
state.masterData.swUsers.push(...tempSwUsers);
document.getElementById('sw-user-modal')?.classList.add('hidden');
closeModals();
renderContent();
});
btnCancelUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
btnCloseUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
btnCancelUserModal?.addEventListener('click', () => document.getElementById('sw-user-modal')?.classList.add('hidden'));
btnCloseUserModal?.addEventListener('click', () => document.getElementById('sw-user-modal')?.classList.add('hidden'));
btnCancelUserModal?.addEventListener('click', closeModals);
btnCloseUserModal?.addEventListener('click', closeModals);
}
function renderUserList() {

View File

@@ -38,23 +38,16 @@ const STORAGE_MODAL_HTML = `
</div>
`;
export function initStorageModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('storage-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', STORAGE_MODAL_HTML);
}
export let currentAsset: HardwareAsset | null = null;
export let isEditMode = false;
export function setEditMode(edit: boolean) {
isEditMode = edit;
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-storage-edit') as HTMLButtonElement;
const btnSaveStorage = document.getElementById('btn-save-storage-asset') as HTMLButtonElement;
const btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
const btnRevertEdit = document.getElementById('btn-revert-storage-edit') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-storage-footer') as HTMLButtonElement;
let isEditMode = false;
let currentAsset: HardwareAsset | null = null;
const setEditMode = (edit: boolean) => {
isEditMode = edit;
if (edit) {
storageForm.classList.add('is-edit-mode');
storageForm.classList.remove('is-view-mode');
@@ -69,9 +62,9 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset);
}
};
}
function fillFormData(asset: HardwareAsset) {
export function fillFormData(asset: HardwareAsset) {
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('storage-법인') as HTMLInputElement).value = asset.;
(document.getElementById('storage-유형') as HTMLInputElement).value = asset.storage유형 || 'NAS';
@@ -86,6 +79,18 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
(document.getElementById('storage-금액') as HTMLInputElement).value = asset. || '';
}
export function initStorageModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('storage-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', STORAGE_MODAL_HTML);
}
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const btnRevertEdit = document.getElementById('btn-revert-storage-edit') as HTMLButtonElement;
const btnSaveStorage = document.getElementById('btn-save-storage-asset') as HTMLButtonElement;
const btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-storage-footer') as HTMLButtonElement;
btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals);

View File

@@ -38,6 +38,8 @@ export interface HardwareAsset {
서버PW?: string;
모니터링?: string;
비고?: string;
현사용조직?: string;
이전사용조직?: string;
}
@@ -88,10 +90,12 @@ export interface MasterAssetData {
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품'];
const SW_TABS = ['구독SW', '영구SW'];
const HW_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', '구매일', '금액', '납품업체', '품의서명'];
const SERVER_HEADERS = ['법인', '자산번호', '유형', '용도', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소', '원격접속', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage1', 'Storage2', 'Storage3', '모니터링', '비고'];
const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명'];
// 확장된 헤더 (상세 페이지의 모든 필드 포함)
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['법인', '자산번호', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소', '원격접속', '서버ID', '서버PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'SSD1', 'SSD2', 'HDD1', '모니터링', '비고'];
const STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고'];
const HW_HEADERS = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
@@ -109,16 +113,24 @@ export function downloadTemplate() {
if (tab === '개인PC') {
hd = PC_HEADERS;
wscols = [{wch:15}, {wch:25}, {wch:15}, {wch:20}, {wch:20}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
wscols = Array(hd.length).fill({wch: 15});
wscols[1] = {wch: 25}; // 자산코드
wscols[12] = {wch: 30}; // HW사양
} else if (tab === '서버') {
hd = SERVER_HEADERS;
wscols = [{wch:15}, {wch:20}, {wch:15}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:30}];
wscols = Array(hd.length).fill({wch: 15});
wscols[3] = {wch: 25}; // 용도
wscols[4] = {wch: 30}; // 상세내용
} else if (tab === '스토리지') {
hd = STORAGE_HEADERS;
wscols = [{wch:15}, {wch:15}, {wch:25}, {wch:25}, {wch:20}, {wch:25}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
wscols = Array(hd.length).fill({wch: 15});
wscols[2] = {wch: 25}; // 자산코드
wscols[3] = {wch: 25}; // 명칭
} else {
hd = HW_HEADERS;
wscols = [{wch:15}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:40}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
wscols = Array(hd.length).fill({wch: 15});
wscols[2] = {wch: 25}; // 명칭
wscols[7] = {wch: 30}; // HW사양
}
const ws = XLSX.utils.aoa_to_sheet([hd]);
@@ -158,27 +170,44 @@ export function exportToExcel(masterData: MasterAssetData) {
if (tab === '개인PC') {
wsData = [
PC_HEADERS,
...targetAssets.map(a => [a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a., a., a., a.])
...targetAssets.map(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.
])
];
colsConfig = [{wch:15}, {wch:25}, {wch:15}, {wch:20}, {wch:20}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
colsConfig = Array(PC_HEADERS.length).fill({wch: 15});
colsConfig[1] = {wch: 25};
colsConfig[12] = {wch: 30};
} else if (tab === '서버') {
wsData = [
SERVER_HEADERS,
...targetAssets.map(a => [a., a., a.storage유형 || '물리', a. || '', a., a._정 || '', a._부 || '', a.IP주소, a. || '', a. || '', a.OS, a.CPU, a.RAM, a.GPU || '', a.SSD1 || '', a.SSD2 || '', a.HDD1 || '', a. || '', a. || ''])
...targetAssets.map(a => [
a., a., a.storage유형 || '물리', a. || '', a. || '', a. || '', a. || '', a., a._정 || '', a._부 || '',
a.IP주소, a. || '', a.ID || '', a.PW || '', a. || '', a.OS, a.CPU, a.RAM, a.GPU || '', a.SSD1 || '', a.SSD2 || '', a.HDD1 || '', a. || '', a. || ''
])
];
colsConfig = [{wch:15}, {wch:20}, {wch:15}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:30}];
colsConfig = Array(SERVER_HEADERS.length).fill({wch: 15});
colsConfig[3] = {wch: 25};
colsConfig[4] = {wch: 30};
} else if (tab === '스토리지') {
wsData = [
STORAGE_HEADERS,
...targetAssets.map(a => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a.])
...targetAssets.map(a => [
a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.
])
];
colsConfig = [{wch:15}, {wch:15}, {wch:25}, {wch:25}, {wch:20}, {wch:25}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
colsConfig = Array(STORAGE_HEADERS.length).fill({wch: 15});
colsConfig[2] = {wch: 25};
colsConfig[3] = {wch: 25};
} else {
wsData = [
HW_HEADERS,
...targetAssets.map(a => [a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a.])
...targetAssets.map(a => [
a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.
])
];
colsConfig = [{wch:15}, {wch:20}, {wch:25}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:40}, {wch:20}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
colsConfig = Array(HW_HEADERS.length).fill({wch: 15});
colsConfig[2] = {wch: 25};
colsConfig[7] = {wch: 30};
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
@@ -252,11 +281,16 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
: '',
위치: row['위치'] || '',
사용자: row['사용자'] || '',
: '', IP주소: '', MACaddress: '', HW사양: '', OS: '',
: '',
IP주소: row['IP주소'] || '',
MACaddress: '',
HW사양: row['HW사양'] || '',
OS: row['OS'] || '',
CPU: row['CPU'] || '', GPU: row['GPU'] || '', RAM: row['RAM'] || '',
SSD1: row['SSD1'] || '', SSD2: row['SSD2'] || '', HDD1: row['HDD1'] || '', HDD2: row['HDD2'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
비고: row['비고'] || ''
});
} else if (sheetName === '서버') {
hwAssets.push({
@@ -265,14 +299,24 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
법인: row['법인'] || '',
자산코드: row['자산번호'] || row['자산코드'] || '',
명칭: row['용도'] || row['명칭'] || '',
용도: row['용도'] || '', 위치: row['설치위치'] || row['위치'] || '',
용도: row['용도'] || '',
상세: row['상세내용'] || row['상세'] || '',
현사용조직: row['현사용조직'] || '',
이전사용조직: row['이전사용조직'] || '',
위치: row['설치위치'] || row['위치'] || '',
관리자: row['담당자(정)'] || '', 담당자_정: row['담당자(정)'] || '', 담당자_부: row['담당자(부)'] || '',
IP주소: row['IP 주소'] || row['IP주소'] || '', IP2: row['IP2'] || '',
원격접속: row['원격접속'] || '', 서버ID: row['서버ID'] || '', 서버PW: row['서버PW'] || '',
IP주소: row['IP 주소'] || row['IP주소'] || '',
원격접속: row['원격접속'] || '',
서버ID: row['서버ID'] || '',
서버PW: row['서버PW'] || '',
모델명: row['모델명'] || '', OS: row['OS'] || '',
CPU: row['CPU'] || '', RAM: row['RAM'] || '', GPU: row['GPU'] || '',
SSD1: row['Storage1'] || row['SSD1'] || '', SSD2: row['Storage2'] || row['SSD2'] || '', HDD1: row['Storage3'] || row['HDD1'] || '',
모니터링: row['모니터링'] || '', 비고: row['비고'] || '', storage유형: row['유형'] || '물리',
SSD1: row['SSD1'] || row['Storage1'] || '',
SSD2: row['SSD2'] || row['Storage2'] || '',
HDD1: row['HDD1'] || row['Storage3'] || '',
모니터링: row['모니터링'] || '',
비고: row['비고'] || '',
storage유형: row['유형'] || '물리',
MACaddress: '', HW사양: '', : '', : '', : '', : '',
});
} else if (sheetName === '스토리지') {
@@ -285,6 +329,7 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
담당자_정: row['담당자(정)'] || '', 담당자_부: row['담당자(부)'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
비고: row['비고'] || ''
});
} else {
hwAssets.push({
@@ -295,6 +340,7 @@ export async function parseExcel(file: File): Promise<MasterAssetData> {
HW사양: row['HW사양'] || '', OS: row['OS'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
비고: row['비고'] || ''
});
}
});

View File

@@ -14,7 +14,9 @@ const dummy = generateDummyData();
// 서버 데이터만 실제 데이터로 교체
const mergedHw: HardwareAsset[] = [
...dummy.hw.filter(a => a.type !== '서버'),
...realServerData.map(s => ({
...realServerData.map((serverData: any) => {
const s = serverData;
return {
id: s.id || Math.random().toString(36).substring(2, 9),
type: '서버',
법인: s.법인,
@@ -47,7 +49,7 @@ const mergedHw: HardwareAsset[] = [
서버PW: s.서버PW || '',
모니터링: s.모니터링 || '',
비고: s.비고 || ''
}))
}})
];
// --- Initial State ---

View File

@@ -6,6 +6,8 @@
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: #dc2626;

View File

@@ -265,3 +265,78 @@
color: var(--text-muted);
font-size: 0.8125rem;
}
/* Dashboard Detail Modal Table Fixed Header */
#dashboard-detail-modal .modal-body {
overflow-y: auto;
max-height: calc(80vh - 120px);
padding: 0;
}
/* 모달 내부의 table-container 기존 전역 스타일 무력화 */
#dashboard-detail-modal .table-container {
border: none;
border-radius: 0;
box-shadow: none;
max-height: none;
overflow: visible;
}
#dashboard-detail-modal table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
#dashboard-detail-modal thead th {
position: sticky;
top: 0;
background-color: var(--bg-light);
z-index: 10;
border-bottom: 2px solid var(--border-color);
box-shadow: none;
padding: 0.75rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-main);
text-align: left;
white-space: nowrap;
}
#dashboard-detail-modal tbody td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
font-size: 0.8125rem;
color: var(--text-main);
white-space: nowrap;
}
#dashboard-detail-modal tbody tr:last-child td {
border-bottom: none;
}
#dashboard-detail-modal tbody tr:hover {
background-color: var(--bg-light);
}
/* 뱃지 스타일 (대시보드 상세 목록용) */
#dashboard-detail-modal .badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.5;
}
#dashboard-detail-modal .badge-outline {
border: 1px solid var(--primary-color);
color: var(--primary-color);
background: var(--primary-light);
}
#dashboard-detail-modal .badge-sw {
border: 1px solid #3b82f6;
color: #3b82f6;
background: #eff6ff;
}

View File

@@ -42,8 +42,8 @@ export function renderTable(mainContent: HTMLElement) {
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw }
});
} catch (err) {
} catch (err: any) {
console.error('❌ Error rendering table view:', err);
mainContent.innerHTML = `<div style="padding:2rem; color:var(--danger);">목록을 불러오는 중 오류가 발생했습니다: ${err.message}</div>`;
mainContent.innerHTML = `<div style="padding:2rem; color:var(--danger);">목록을 불러오는 중 오류가 발생했습니다: ${err?.message || err}</div>`;
}
}

View File

@@ -44,6 +44,7 @@ export function renderSwList(container: HTMLElement) {
<thead>
<tr>
<th style="text-align:center;">No.</th>
<th style="text-align:center;">상태</th>
<th style="text-align:center;">분야</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">부서</th>
@@ -81,7 +82,7 @@ export function renderSwList(container: HTMLElement) {
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="${isSub ? 12 : 11}" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
@@ -89,11 +90,40 @@ export function renderSwList(container: HTMLElement) {
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
let statusHtml = '';
if (isSub) {
let isExpired = false;
if (asset.) {
const parts = asset..split('~');
const endDateStr = parts[parts.length - 1].trim().replace(/\./g, '-');
const endDate = new Date(endDateStr);
if (!isNaN(endDate.getTime())) {
endDate.setHours(23, 59, 59, 999);
if (endDate < new Date()) {
isExpired = true;
}
}
}
if (isExpired) {
statusHtml = `<span style="background: var(--danger, #ef4444); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">만료</span>`;
} else {
statusHtml = `<span style="background: var(--primary-color, #1E5149); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">사용중</span>`;
}
} else {
if (asset.) {
statusHtml = `<span style="background: #3b82f6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">유효</span>`;
} else {
statusHtml = `<span style="background: #6b7280; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">없음</span>`;
}
}
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td>
<td style="text-align:center;">${statusHtml}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>

View File

@@ -15,8 +15,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]