diff --git a/docs/issues/issue_sw_modal_refactor.md b/docs/issues/issue_sw_modal_refactor.md
new file mode 100644
index 0000000..72883d0
--- /dev/null
+++ b/docs/issues/issue_sw_modal_refactor.md
@@ -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. 확인 사항
+- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
+- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.
diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts
index a330711..a88faaa 100644
--- a/src/components/Modal/HWModal.ts
+++ b/src/components/Modal/HWModal.ts
@@ -1,7 +1,7 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
-import { renderTable } from '../../views/AssetTableView';
-import { createIcons, Paperclip } from 'lucide';
+import { renderSWTable } from '../../views/SW_Table';
+import { createIcons, X, Paperclip } from 'lucide';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
@@ -319,7 +319,7 @@ export function initHwModal() {
const idx = state.masterData.hw.findIndex(a => a.id === assetId);
if (idx > -1) {
state.masterData.hw[idx] = updated;
- renderTable(document.getElementById('main-content')!);
+ renderSWTable(document.getElementById('main-content')!);
switchToViewMode();
}
});
@@ -328,7 +328,7 @@ export function initHwModal() {
if (!currentAsset) return;
if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
state.masterData.hw = state.masterData.hw.filter(a => a.id !== currentAsset!.id);
- renderTable(document.getElementById('main-content')!);
+ renderSWTable(document.getElementById('main-content')!);
closeModal();
}
});
diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts
index 5ae0625..9c5d7e5 100644
--- a/src/components/Modal/SWModal.ts
+++ b/src/components/Modal/SWModal.ts
@@ -1,78 +1,87 @@
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 = `
-
+
S/W 상세 정보
-
+
+
+
+
+
계약 업데이트 반영
+
+
+
+
+
+
+
+
+
+
+
+
+ ~
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
export function initSwModal(renderContent: () => void, closeModals: () => void) {
@@ -104,6 +159,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,
@@ -112,7 +171,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),
@@ -141,6 +200,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 = '