Merge Setting and SW_Table branches into main, fix conflicts, and resolve TS build errors

This commit is contained in:
2026-04-17 15:28:39 +09:00
12 changed files with 478 additions and 148 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 { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { renderTable } from '../../views/AssetTableView';
import { createIcons, Paperclip } from 'lucide'; import { createIcons, Paperclip } from 'lucide';
let currentAsset: HardwareAsset | null = null; let currentAsset: HardwareAsset | null = null;

View File

@@ -1,50 +1,48 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler'; import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { openModal } from './BaseModal';
import { createIcons, X, History, Plus } from 'lucide';
const SW_MODAL_HTML = ` const SW_MODAL_HTML = `
<div id="sw-asset-modal" class="modal-overlay hidden"> <div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="sw-modal-title">S/W 상세 정보</h2> <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> <button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form"> <form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" /> <input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" /> <input type="hidden" id="sw-asset-type" />
<div class="form-group"> <div class="form-group">
<label for="sw-분야">분야</label> <label for="sw-분야">분야</label>
<select id="sw-분야" required> <select id="sw-분야" required>
<option value="업무공통">업무공통</option> <option value="업무공통">업무공통</option><option value="개발S/W">개발S/W</option><option value="디자인">디자인</option><option value="설계S/W">설계S/W</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-법인">법인</label> <label for="sw-법인">법인</label>
<select id="sw-법인" required> <select id="sw-법인" required>
<option value="한맥">한맥 (HM)</option> <option value="한맥">한맥 (HM)</option><option value="삼안 (SM)">삼안 (SM)</option><option value="바론 (BR)">바론 (BR)</option>
<option value="삼안 (SM)">삼안 (SM)</option>
<option value="바론 (BR)">바론 (BR)</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-부서">부서</label> <label for="sw-부서">부서</label>
<input type="text" id="sw-부서" placeholder="ex) 경영지원팀" required /> <input type="text" id="sw-부서" placeholder="ex) 경영지원팀" required />
</div> </div>
<div class="form-group">
<label for="sw-제품명">제품명</label>
<input type="text" id="sw-제품명" required />
</div>
<div class="form-group"> <div class="form-group">
<label for="sw-구매일">구매일</label> <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>
<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>
<div class="form-group" id="sw-유지보수-group" style="display:none;"> <div class="form-group" id="sw-유지보수-group" style="display:none;">
<label for="sw-유지보수여부">유지보수 여부</label> <label for="sw-유지보수여부">유지보수 여부</label>
@@ -54,7 +52,7 @@ const SW_MODAL_HTML = `
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-금액">금액</label> <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>
<div class="form-group"> <div class="form-group">
<label for="sw-수량">수량 (보유량)</label> <label for="sw-수량">수량 (보유량)</label>
@@ -68,12 +66,23 @@ const SW_MODAL_HTML = `
<label for="sw-납품업체">납품업체</label> <label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" /> <input type="text" id="sw-납품업체" />
</div> </div>
<div class="form-group"> <div class="form-group full-width">
<label for="sw-비고">비고</label> <label for="sw-비고">비고</label>
<input type="text" id="sw-비고" /> <input type="text" id="sw-비고" />
</div> </div>
</form> </form>
</div> </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"> <div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
@@ -84,25 +93,64 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </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) { export let currentAsset: SoftwareAsset | null = null;
if (!document.getElementById('sw-asset-modal')) { export let isEditMode = false;
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
export function setEditMode(edit: boolean) {
isEditMode = edit;
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement; 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 btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement; const btnRevertEdit = document.getElementById('btn-revert-sw-edit') as HTMLButtonElement;
const btnCloseHeader = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
const btnCloseFooter = document.getElementById('btn-close-sw-footer') 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) { if (edit) {
swForm.classList.add('is-edit-mode'); swForm.classList.add('is-edit-mode');
swForm.classList.remove('is-view-mode'); swForm.classList.remove('is-view-mode');
@@ -117,9 +165,9 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
btnCloseFooter.classList.remove('hidden'); btnCloseFooter.classList.remove('hidden');
if (currentAsset) fillFormData(currentAsset); 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-id') as HTMLInputElement).value = asset.id;
(document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type; (document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('sw-분야') as HTMLSelectElement).value = asset. || '업무공통'; (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.;
(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).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. || ''; (document.getElementById('sw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-수량') as HTMLInputElement).value = String(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('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)); btnRevertEdit?.addEventListener('click', () => setEditMode(false));
btnCloseHeader?.addEventListener('click', closeModals); btnCloseHeader?.addEventListener('click', closeModals);
btnCloseFooter?.addEventListener('click', closeModals); btnCloseFooter?.addEventListener('click', closeModals);
@@ -150,6 +222,10 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
if (!swForm.checkValidity()) { swForm.reportValidity(); return; } if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value; 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 = { const newAsset: SoftwareAsset = {
id: id || Math.random().toString(36).substring(2, 9), id: id || Math.random().toString(36).substring(2, 9),
type: (document.getElementById('sw-asset-type') as HTMLInputElement).value, 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,
: (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).checked,
: (document.getElementById('sw-금액') as HTMLInputElement).value, : (document.getElementById('sw-금액') as HTMLInputElement).value,
수량: parseInt((document.getElementById('sw-수량') as HTMLInputElement).value || '1', 10), 수량: parseInt((document.getElementById('sw-수량') as HTMLInputElement).value || '1', 10),
@@ -187,6 +263,109 @@ export function initSwModal(renderContent: () => void, closeModals: () => void)
renderContent(); 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) { export function openSwModal(asset?: SoftwareAsset) {
@@ -217,6 +396,10 @@ export function openSwModal(asset?: SoftwareAsset) {
deleteBtn.style.display = 'none'; deleteBtn.style.display = 'none';
(document.getElementById('sw-asset-id') as HTMLInputElement).value = ''; (document.getElementById('sw-asset-id') as HTMLInputElement).value = '';
(document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab; (document.getElementById('sw-asset-type') as HTMLInputElement).value = state.activeSubTab;
document.getElementById('btn-open-sw-update')!.style.display = 'none';
renderSwHistory('');
setEditMode(true); setEditMode(true);
} }
createIcons({ icons: { X, History, Plus } });
} }

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ export interface HardwareAsset {
서버PW?: string; 서버PW?: string;
모니터링?: string; 모니터링?: string;
비고?: string; 비고?: string;
현사용조직?: string;
} }

View File

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

View File

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

View File

@@ -265,3 +265,78 @@
color: var(--text-muted); color: var(--text-muted);
font-size: 0.8125rem; 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({ createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } 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); 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> <thead>
<tr> <tr>
<th style="text-align:center;">No.</th> <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> <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 = ''; tbody.innerHTML = '';
if (filtered.length === 0) { 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; return;
} }
@@ -89,11 +90,40 @@ export function renderSwList(container: HTMLElement) {
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length;
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10); const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned; 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'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
tr.innerHTML = ` tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td> <td style="text-align:center;">${idx+1}</td>
<td style="text-align:center;">${statusHtml}</td>
<td>${asset.||''}</td> <td>${asset.||''}</td>
<td>${asset.}</td> <td>${asset.}</td>
<td>${asset.||''}</td> <td>${asset.||''}</td>

View File

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