12 Commits

Author SHA1 Message Date
a805d9ce06 refactor: 프로젝트 구조 최적화 및 컴포넌트 기반 모달 시스템 도입
주요 정리 내용:
- 핵심 엔진 분리: state, excelHandler 등을 src/core/ 디렉토리로 격리
- 모달 컴포넌트화: index.html의 거대 HTML 구조를 각 모달 TS 파일로 내장 및 동적 주입
- index.html 최적화: 수백 줄의 중복 코드를 제거하여 슬림한 Shell 구조로 변환
- 전역 복구: 병합 과정에서 발생한 한글 인코딩 깨짐 전수 복구 및 빌드 오류 해결
- 경로 정합성: 파일 구조 변경에 따른 모든 import 경로 일괄 업데이트
2026-04-15 13:56:04 +09:00
7c4ccf6bba feat: 서버 상세 모달 그룹화 및 전역 UI/UX 통일, 조회/수정 모드 구현
주요 변경 사항:
- 서버 자산 상세 정보 4개 그룹(Identity, Connectivity, Specs, Operation)으로 최적화
- 모달 내 조회/수정 모드 전환 및 수정 강조색(#FF3D00) 적용
- 모든 모달의 버튼 사이즈 및 폰트 스타일 가이드 준수 통일
- 수정 취소(Revert) 기능 및 누락된 대시보드 상세 모달 추가
- TypeScript 타입 오류 및 런타임 렌더링 결함 긴급 복구
2026-04-15 12:15:59 +09:00
3c28c664da merge: 공동작업자의 PC 및 SW 관리 기능과 서버 자산 관리 기능 통합 (충돌 해결) 2026-04-15 10:10:57 +09:00
d94be9a494 chore: 관리대장 엑셀 파일 삭제 및 데이터 통합 완료에 따른 환경 정리 2026-04-15 09:58:27 +09:00
157330b06d feat: 서버 자산 관리 리스트 고도화 및 3개 관리대장 데이터 통합 2026-04-14 18:01:52 +09:00
c14eff8278 feat: 개인 PC 수정 이력(Log) 시스템 구현 및 UI 개선 2026-04-14 17:22:06 +09:00
c83fa1cc5a PC_Table view 및 스타일 추가 2026-04-14 16:13:54 +09:00
818beae0df feat: S/W 자산 조회용 검색/필터 바 추가 및 테이블 레이아웃 카드화
- S/W 테이블 상단에 제품명/부서 및 분야/법인 필터링 바 추가

- 조회 바와 테이블을 각각 별도의 카드 스타일로 분리하여 시각적 완성도 높임

- 하드웨어 테이블에도 일관된 카드 레이아웃 구조 적용
2026-04-14 14:09:28 +09:00
3c98ce948a feat: 소프트웨어 대시보드 시각화 개선 및 금액 열 추가 (#4) 2026-04-14 13:18:51 +09:00
7860edd8d0 feat: S/W 테이블 분야/부서 스키마 확장 및 UI 개선
변경 파일 세부사항:

- index.html: S/W 자산 폼에 분야(select), 부서(input) 요소 추가

- src/components/Modal/SWModal.ts: 모달 오픈/저장 시 분야, 부서 데이터 Get/Set 바인딩 적용

- src/dummyDataGenerator.ts: S/W 더미 데이터 생성 시 임의의 분야 및 부서 값 할당 추가

- src/excelHandler.ts: 엑셀 파일 내 분야/부서 컬럼 정의, JSON 매핑 및 Export 로직 추가

- src/style.css: S/W 데이터 테이블(.sw-table td, th) 가운데 정렬 CSS 적용

- src/views/AssetTableView.ts: S/W 테이블 헤더 전면 개편 및 관리(아이콘) 스타일 개선
2026-04-14 11:46:46 +09:00
77563994e9 feat: Update AssetTableView and DashboardView to improve software management UI 2026-04-14 10:13:54 +09:00
e4914ee66d feat: 서버 통합 표준 헤더(21개 컬럼) 적용 및 UI/디자인 최적화 2026-04-14 09:55:58 +09:00
23 changed files with 3831 additions and 1387 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
node_modules/ node_modules/
.gemini/ .gemini
.env .env
dist/ dist/
*.log *.log

140
debug_excel.json Normal file
View File

@@ -0,0 +1,140 @@
{
"서버 관리대장(기술개발센터).xlsx": [
[
"마천사무실"
],
[
"실사진",
"구성",
"자산번호",
"명칭(주기)",
"상세",
"유형",
"담당자",
null,
"IP",
"원격접속",
null,
null,
"모델명",
"OS",
"CPU",
"RAM",
"GPU",
"Storage1",
"Storage2",
"Storage3"
],
[
null,
null,
null,
null,
null,
null,
"정",
"부",
null,
"접속도구",
"아이디",
"비밀번호"
],
[
null,
null,
"",
"GSIM NAS",
"팀 내부 자료 저장 , 정사영상 및 지도 데이터 저장 , Gitea 및 Git 내장 NAS",
"NAS",
null,
null,
null,
null,
null,
null,
"Synology DS923+"
],
[
null,
null,
"",
"그래픽스개발팀\r\n데이터 백업 NAS",
"그래픽스 개발팀 데이터 백업용 NAS",
"NAS",
null,
null,
null,
null,
null,
null,
"Synology DS923+"
]
],
"서버 관리대장(한맥빌딩).xlsx": [
[
"한맥빌딩(MDF 실)"
],
[
"실사진",
"구성",
null,
null,
null,
null,
null,
null,
null,
"서버번호",
"명칭(주기)",
"유형",
"IP",
"모델명",
"용도",
"담당자",
"OS",
"CPU",
"RAM",
"GPU",
"Storage1",
"Storage2",
"Storage3"
],
[],
[
null,
null,
null,
null,
null,
null,
null,
null,
null,
1,
"NAS 2",
"NAS",
"192.168.9.23",
"DS414j",
"한라 기업부설연구소 공용 NAS",
"이준하 차장"
],
[
null,
null,
"NAS2",
"NAS 1\r\n(DS224+)",
"NAS4",
"NAS 5\r\n(DS923+)",
"NAS 6\r\n(DS923+)",
null,
null,
2,
"NAS 1",
"NAS",
"192.168.9.32",
"DS224+",
"한라 사업지원, 경영지원, 업무, 안전품질, 운영 공용 NAS",
"이준하 차장"
]
]
}

View File

@@ -6,7 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title> <title>ITAM 자산관리 ERP</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/style.css" /> <link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head> </head>
@@ -46,15 +47,14 @@
<h2 id="current-tab-title">하드웨어 / 대시보드</h2> <h2 id="current-tab-title">하드웨어 / 대시보드</h2>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<!-- 엑셀 컨트롤러 묶음 --> <button id="btn-download-template" class="btn btn-outline" title="통합 양식 다운로드">
<button id="btn-download-template" class="btn btn-outline" title="초기 데이터 입력을 위한 전체 엑셀 템플릿 다운로드">
<i data-lucide="download"></i> 통합 양식 다운로드 <i data-lucide="download"></i> 통합 양식 다운로드
</button> </button>
<label for="excel-upload" class="btn btn-outline" title="작성된 엑셀 파일 일괄 업로드"> <label for="excel-upload" class="btn btn-outline" title="엑셀 파일 업로드">
<i data-lucide="upload"></i> 엑셀 업로드 <i data-lucide="upload"></i> 엑셀 업로드
</label> </label>
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" /> <input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
<button id="btn-export-excel" class="btn btn-primary" title="마스터 데이터 전체를 엑셀 저장"> <button id="btn-export-excel" class="btn btn-primary" title="일괄 엑셀 저장">
<i data-lucide="file-spreadsheet"></i> 일괄 엑셀 저장 <i data-lucide="file-spreadsheet"></i> 일괄 엑셀 저장
</button> </button>
<button id="btn-add-asset" class="btn btn-primary hidden"> <button id="btn-add-asset" class="btn btn-primary hidden">
@@ -64,523 +64,12 @@
</header> </header>
<main class="content-area" id="main-content"> <main class="content-area" id="main-content">
<!-- 대시보드 뷰, 또는 데이터 테이블 뷰가 JavaScript로 주입됩니다 --> <!-- 컴포넌트에 의해 동적으로 채워짐 -->
</main> </main>
</div> </div>
</div> </div>
<!-- HW Asset Modal --> <!-- 모든 모달은 각 TS 컴포넌트 내부에서 동적으로 주입됨 -->
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="hw-asset-form" class="grid-form">
<input type="hidden" id="hw-asset-id" />
<input type="hidden" id="hw-asset-type" /> <!-- 개인PC, 서버 등 저장용 -->
<div class="form-group">
<label for="hw-법인">법인</label>
<input type="text" id="hw-법인" required />
</div>
<div class="form-group" id="hw-비품유형-group" style="display:none;">
<label for="hw-비품유형">비품유형</label>
<select id="hw-비품유형" class="form-control" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px;">
<option value="노트북">노트북</option>
<option value="태블릿">태블릿</option>
<option value="휴대폰">휴대폰</option>
</select>
</div>
<div class="form-group">
<label for="hw-자산코드">자산코드</label>
<input type="text" id="hw-자산코드" required />
</div>
<div class="form-group">
<label for="hw-명칭">명칭</label>
<input type="text" id="hw-명칭" required />
</div>
<div class="form-group">
<label for="hw-위치">위치</label>
<input type="text" id="hw-위치" />
</div>
<div class="form-group">
<label for="hw-관리자">관리자</label>
<input type="text" id="hw-관리자" />
</div>
<div class="form-group">
<label for="hw-IP주소">IP주소</label>
<input type="text" id="hw-IP주소" />
</div>
<div class="form-group">
<label for="hw-MACaddress">MAC address</label>
<input type="text" id="hw-MACaddress" />
</div>
<div class="form-group">
<label for="hw-OS">OS</label>
<input type="text" id="hw-OS" />
</div>
<div class="form-group full-width">
<label for="hw-HW사양">H/W 사양</label>
<textarea id="hw-HW사양" rows="2"></textarea>
</div>
<div class="form-group">
<label for="hw-구매일">구매일</label>
<input type="text" id="hw-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group">
<label for="hw-금액">금액</label>
<input type="text" id="hw-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group">
<label for="hw-납품업체">납품업체</label>
<input type="text" id="hw-납품업체" />
</div>
<div class="form-group full-width">
<label style="font-size:0.875rem;">품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" style="font-size:0.875rem;" />
<span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-hw-modal" class="btn btn-outline">취소</button>
<button id="btn-save-hw-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
</div>
<!-- PC Asset Modal -->
<div id="pc-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2 id="pc-modal-title">개인PC 상세 정보</h2>
<button id="btn-close-pc-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="pc-asset-form" class="grid-form">
<input type="hidden" id="pc-asset-id" />
<input type="hidden" id="pc-asset-type" value="개인PC" />
<div class="form-group">
<label for="pc-법인">법인</label>
<select id="pc-법인" required style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 0.875rem;">
<option value="한맥">한맥 (HM)</option>
<option value="삼안">삼안 (SM)</option>
<option value="바론">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산코드</label>
<input type="text" id="pc-자산코드" placeholder="ex) HM-PC-2018-001" required />
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-위치">위치</label>
<input type="text" id="pc-위치" />
</div>
<div class="form-group">
<label for="pc-CPU">CPU</label>
<input type="text" id="pc-CPU" />
</div>
<div class="form-group">
<label for="pc-GPU">GPU</label>
<input type="text" id="pc-GPU" />
</div>
<div class="form-group">
<label for="pc-RAM">RAM</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">SSD1</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">SSD2</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-group">
<label for="pc-HDD1">HDD1</label>
<input type="text" id="pc-HDD1" />
</div>
<div class="form-group">
<label for="pc-HDD2">HDD2</label>
<input type="text" id="pc-HDD2" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label style="font-size:0.875rem;">품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" style="font-size:0.875rem;" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-pc-modal" class="btn btn-outline">취소</button>
<button id="btn-save-pc-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
</div>
<!-- Storage Asset Modal -->
<div id="storage-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2 id="storage-modal-title">스토리지 상세 정보</h2>
<button id="btn-close-storage-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="storage-asset-form" class="grid-form">
<input type="hidden" id="storage-asset-id" />
<input type="hidden" id="storage-asset-type" value="스토리지" />
<div class="form-group">
<label for="storage-법인">법인</label>
<select id="storage-법인" required style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 0.875rem;">
<option value="한맥">한맥 (HM)</option>
<option value="삼안">삼안 (SM)</option>
<option value="바론">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="storage-유형">유형</label>
<select id="storage-유형" required style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 0.875rem;">
<option value="NAS">NAS</option>
<option value="DAS">DAS</option>
</select>
</div>
<div class="form-group">
<label for="storage-자산코드">자산코드</label>
<input type="text" id="storage-자산코드" placeholder="ex) HM-NAS-2024-001" required />
</div>
<div class="form-group">
<label for="storage-명칭">명칭</label>
<input type="text" id="storage-명칭" required />
</div>
<div class="form-group">
<label for="storage-위치">위치</label>
<input type="text" id="storage-위치" />
</div>
<div class="form-group">
<label for="storage-모델명">모델명</label>
<input type="text" id="storage-모델명" />
</div>
<div class="form-group">
<label for="storage-용량">용량</label>
<input type="text" id="storage-용량" />
</div>
<div class="form-group">
<label for="storage-담당자_정">담당자(정)</label>
<input type="text" id="storage-담당자_정" />
</div>
<div class="form-group">
<label for="storage-담당자_부">담당자(부)</label>
<input type="text" id="storage-담당자_부" />
</div>
<div class="form-group">
<label for="storage-IP주소">IP주소</label>
<input type="text" id="storage-IP주소" />
</div>
<div class="form-group">
<label for="storage-MAC주소">MAC 주소</label>
<input type="text" id="storage-MAC주소" />
</div>
<div class="form-group">
<label for="storage-구매일">구매일</label>
<input type="text" id="storage-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group">
<label for="storage-금액">금액</label>
<input type="text" id="storage-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group">
<label for="storage-납품업체">납품업체</label>
<input type="text" id="storage-납품업체" />
</div>
<div class="form-group full-width">
<label style="font-size:0.875rem;">품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="storage-품의서" style="font-size:0.875rem;" />
<span id="storage-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-storage-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-storage-modal" class="btn btn-outline">취소</button>
<button id="btn-save-storage-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
</div>
<!-- SW Asset Modal -->
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content">
<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">
<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 style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 0.875rem;">
<option value="한맥">한맥 (HM)</option>
<option value="삼안">삼안 (SM)</option>
<option value="바론">바론 (BR)</option>
</select>
</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" />
</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>
<label style="display:flex; align-items:center; gap:0.5rem; height: 38px;">
<input type="checkbox" id="sw-유지보수여부" /> 대상 여부
</label>
</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, ',')" />
</div>
<div class="form-group">
<label for="sw-수량">수량 (보유량)</label>
<input type="number" id="sw-수량" min="1" value="1" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; font-family: inherit; font-size: 0.875rem;" />
</div>
<div class="form-group">
<label for="sw-계정명">계정명</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group">
<label for="sw-비고">비고</label>
<input type="text" id="sw-비고" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-sw-modal" class="btn btn-outline">취소</button>
<button id="btn-save-sw-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
</div>
<!-- SW User Management Modal -->
<div id="sw-user-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-header">
<h2 id="sw-user-modal-title">S/W 할당 사용자 목록</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="sw-user-asset-id" />
<div style="text-align: right; margin-bottom: 0.5rem;">
<button type="button" id="btn-open-add-user" class="btn btn-primary" style="padding: 0.25rem 1rem;"><i data-lucide="plus"></i> 새 사용자 추가</button>
</div>
<div class="table-container" style="max-height: 250px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 4px; margin-top:0;">
<table style="width:100%; border-collapse: collapse; font-size:0.875rem;">
<thead style="position: sticky; top: 0; background: var(--bg-light); z-index: 10;">
<tr style="border-bottom: 1px solid var(--border);">
<th style="padding:0.5rem; text-align:left;">법인</th>
<th style="padding:0.5rem; text-align:left;">부서/팀</th>
<th style="padding:0.5rem; text-align:left;">직위</th>
<th style="padding:0.5rem; text-align:left;">이름</th>
<th style="padding:0.5rem; text-align:center;">사용기간</th>
<th style="padding:0.5rem; text-align:center;">첨부파일</th>
<th style="padding:0.5rem; text-align:center;">관리</th>
</tr>
</thead>
<tbody id="user-list-body">
<!-- Users will be injected here -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer" style="justify-content: flex-end;">
<div class="footer-actions">
<button id="btn-cancel-sw-user-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-user-mapping" class="btn btn-primary">변경사항 저장</button>
</div>
</div>
</div>
</div>
<!-- SW User Form Modal (Add / Edit) -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="sw-user-edit-modal-title">사용자 추가</h2>
<button id="btn-close-sw-user-edit-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-user-idx" value="-1" />
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:0.5rem;">
<div class="form-group">
<label for="new-user-법인" style="font-size:0.875rem;">법인</label>
<select id="new-user-법인" class="form-control" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<div class="form-group">
<label for="new-user-부서" style="font-size:0.875rem;">부서</label>
<input type="text" id="new-user-부서" placeholder="ex: 기술부" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;" />
</div>
<div class="form-group">
<label for="new-user-팀" style="font-size:0.875rem;"></label>
<input type="text" id="new-user-팀" placeholder="ex: 개발1팀" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;" />
</div>
<div class="form-group">
<label for="new-user-직위" style="font-size:0.875rem;">직위</label>
<input type="text" id="new-user-직위" placeholder="ex: 대리" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;" />
</div>
<div class="form-group" style="grid-column: span 2;">
<label for="new-user-이름" style="font-size:0.875rem;">이름 <span style="color:var(--danger)">*</span></label>
<input type="text" id="new-user-이름" placeholder="ex: 홍길동" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;" required />
</div>
<div class="form-group" style="grid-column: span 2;">
<label for="new-user-사용기간" style="font-size:0.875rem;">사용기간</label>
<input type="text" id="new-user-사용기간" placeholder="ex: 2024.01~12" style="width:100%; padding:0.5rem; border:1px solid var(--border); border-radius:4px;" />
</div>
<div class="form-group" style="grid-column: span 2;">
<label style="font-size:0.875rem;">신청서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="new-user-신청서" style="font-size:0.875rem;" />
<span id="new-user-신청서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</div>
</div>
<div class="modal-footer" style="justify-content: flex-end;">
<div class="footer-actions">
<button id="btn-cancel-sw-user-edit" class="btn btn-outline">취소</button>
<button id="btn-save-edit-user" class="btn btn-primary">확인</button>
</div>
</div>
</div>
</div>
<!-- Dashboard Detail Modal -->
<div id="dashboard-detail-modal" class="modal-overlay hidden" style="z-index: 1200;">
<div class="modal-content" style="max-width: 1000px; max-height: 80vh; display: flex; flex-direction: column;">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title">상세 자산 목록</h2>
<button id="btn-close-dashboard-detail" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body" style="overflow-y: auto; flex: 1; padding: 0;">
<div class="table-container" style="box-shadow: none; border-radius: 0;">
<table style="width: 100%;">
<thead>
<tr>
<th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th>
<th>위치</th><th>담당/사용자</th><th>구매일</th><th>금액</th>
</tr>
</thead>
<tbody id="dashboard-detail-tbody">
</tbody>
</table>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -2,33 +2,22 @@
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다. * 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
*/ */
export function initBaseModal() { export function initBaseModal() {
const modals = document.querySelectorAll('.modal-overlay');
const closeButtons = document.querySelectorAll('.btn-icon, [id^="btn-cancel-"]');
// 모든 모달 닫기 함수
const closeAllModals = () => { const closeAllModals = () => {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden')); modals.forEach(modal => modal.classList.add('hidden'));
// SW 관련 추가 모달 처리
document.getElementById('sw-user-modal')?.classList.add('hidden');
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
document.getElementById('dashboard-detail-modal')?.classList.add('hidden');
}; };
// 닫기 버튼 이벤트 바인딩
closeButtons.forEach(btn => {
btn.addEventListener('click', closeAllModals);
});
// ESC 키로 닫기 // ESC 키로 닫기
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllModals(); if (e.key === 'Escape') closeAllModals();
}); });
// 배경(Overlay) 클릭 시 닫기 // 배경(Overlay) 클릭 시 닫기 (동적 생성된 모달 대응을 위해 이벤트 위임 고려 가능하나 일단 단순 구현)
modals.forEach(modal => { document.addEventListener('click', (e) => {
modal.addEventListener('click', (e) => { const target = e.target as HTMLElement;
if (e.target === modal) closeAllModals(); if (target.classList.contains('modal-overlay')) {
}); closeAllModals();
}
}); });
return { closeAllModals }; return { closeAllModals };

View File

@@ -0,0 +1,109 @@
import { HardwareAsset, SoftwareAsset } from '../../core/excelHandler';
import { state } from '../../core/state';
const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="max-width: 1000px;">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="table-container">
<table style="width:100%;">
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
`;
export function initDashboardDetailModal() {
if (!document.getElementById('dashboard-detail-modal')) {
document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML);
}
const modal = document.getElementById('dashboard-detail-modal')!;
const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!;
const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!;
const closeModal = () => modal.classList.add('hidden');
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
}
export function openDashboardDetail(title: string, list: HardwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset. || asset. || asset._정 || '-';
let name = asset. || asset. || '-';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.type}</td><td>${asset.}</td><td>${name}</td><td>${asset.||'-'}</td><td>${manager}</td><td>${asset.||'-'}</td><td>${asset.||'-'}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
export function openSwDashboardDetail(title: string, list: SoftwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>법인</th><th>제품명</th><th>수량</th><th>금액</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.type}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openSwUsageDetail(title: string, list: SoftwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const avail = qty - assigned;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.}</td><td>${sw.}</td><td>${qty}</td><td>${assigned}</td><td>${avail}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}

View File

@@ -1,112 +1,335 @@
import { state } from '../../state'; import { state } from '../../core/state';
import { HardwareAsset } from '../../excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { renderTable } from '../../views/AssetTableView';
import { createIcons, Paperclip } from 'lucide';
/** let currentAsset: HardwareAsset | null = null;
* 하드웨어(서버, 전산비품 등) 모달 초기화 및 로직 제어 let isEditMode = false;
*/
export function initHWModal(renderContent: () => void, closeModals: () => void) {
const hwForm = document.getElementById('hw-asset-form') as HTMLFormElement;
const btnSaveHw = document.getElementById('btn-save-hw-asset') as HTMLButtonElement;
const btnDeleteHw = document.getElementById('btn-delete-hw-asset') as HTMLButtonElement;
// 저장 버튼 이벤트 const HW_MODAL_HTML = `
btnSaveHw?.addEventListener('click', (e) => { <div id="hw-asset-modal" class="modal-overlay hidden">
e.preventDefault(); <div class="modal-content wide">
if (!hwForm.checkValidity()) { hwForm.reportValidity(); return; } <div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="hw-asset-form" class="grid-form">
<input type="hidden" id="hw-asset-id" />
<input type="hidden" id="hw-asset-type" />
<!-- Group 1: 기본 정보 -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="hw-법인">법인</label>
<input type="text" id="hw-법인" required />
</div>
<div class="form-group">
<label for="hw-자산코드">자산번호/코드</label>
<input type="text" id="hw-자산코드" required />
</div>
<div class="form-group server-only">
<label for="hw-용도">용도</label>
<input type="text" id="hw-용도" />
</div>
<div class="form-group server-only">
<label for="hw-상세">상세 내용</label>
<input type="text" id="hw-상세" />
</div>
<div class="form-group non-server">
<label for="hw-명칭">명칭</label>
<input type="text" id="hw-명칭" />
</div>
<div class="form-group full-width server-only">
<label for="hw-비고">비고</label>
<input type="text" id="hw-비고" />
</div>
<!-- Group 2: 네트워크 정보 -->
<div class="form-section-title server-only">네트워크 정보 (Connectivity)</div>
<div class="form-group server-only">
<label for="hw-IP주소">IP 주소 1</label>
<input type="text" id="hw-IP주소" />
</div>
<div class="form-group server-only">
<label for="hw-IP2">IP 주소 2</label>
<input type="text" id="hw-IP2" />
</div>
<div class="form-group server-only">
<label for="hw-원격접속">원격 도구 (Anydesk/Chrome 등)</label>
<input type="text" id="hw-원격접속" />
</div>
<div class="form-group server-only">
<label for="hw-서버ID">서버 ID</label>
<input type="text" id="hw-서버ID" />
</div>
<div class="form-group server-only">
<label for="hw-서버PW">서버 PW</label>
<input type="text" id="hw-서버PW" />
</div>
<div class="form-group non-server" id="hw-IP주소-group">
<label for="hw-IP주소-non-server">IP 주소</label>
<input type="text" id="hw-IP주소-non-server" />
</div>
<!-- Group 3: 시스템 사양 -->
<div class="form-section-title">시스템 사양 (Specifications)</div>
<div class="form-group">
<label for="hw-모델명">모델명</label>
<input type="text" id="hw-모델명" />
</div>
<div class="form-group">
<label for="hw-OS">운영체제 (OS)</label>
<input type="text" id="hw-OS" />
</div>
<div class="form-group">
<label for="hw-CPU">CPU 사양</label>
<input type="text" id="hw-CPU" />
</div>
<div class="form-group">
<label for="hw-RAM">RAM 용량</label>
<input type="text" id="hw-RAM" />
</div>
<div class="form-group">
<label for="hw-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="hw-SSD1" />
</div>
<div class="form-group">
<label for="hw-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="hw-SSD2" />
</div>
<div class="form-group server-only">
<label for="hw-모니터링">모니터링 여부</label>
<input type="text" id="hw-모니터링" />
</div>
<div class="form-group" id="hw-비품유형-group" style="display:none;">
<label for="hw-비품유형">비품유형</label>
<select id="hw-비품유형">
<option value="노트북">노트북</option><option value="태블릿">태블릿</option><option value="휴대폰">휴대폰</option>
</select>
</div>
<div class="form-group full-width non-server">
<label for="hw-HW사양">H/W 사양 상세</label>
<textarea id="hw-HW사양" rows="2"></textarea>
</div>
<!-- Group 4: 관리 및 운영 -->
<div class="form-section-title">관리 및 운영 (Operation)</div>
<div class="form-group">
<label for="hw-위치">설치위치</label>
<input type="text" id="hw-위치" />
</div>
<div class="form-group">
<label for="hw-담당자_정">담당자 (정)</label>
<input type="text" id="hw-담당자_정" />
</div>
<div class="form-group">
<label for="hw-담당자_부">담당자 (부)</label>
<input type="text" id="hw-담당자_부" />
</div>
<div class="form-group non-server">
<label for="hw-구매일">구매일</label>
<input type="text" id="hw-구매일" />
</div>
<div class="form-group non-server">
<label for="hw-금액">금액</label>
<input type="text" id="hw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group full-width">
<label>품의서 (파일 증빙)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="hw-품의서" />
<span id="hw-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function openHwModal(asset: HardwareAsset) {
currentAsset = asset;
isEditMode = false;
const modal = document.getElementById('hw-asset-modal')!;
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
form.reset();
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
fillHwFormData(asset);
modal.classList.remove('hidden');
createIcons({ icons: { Paperclip } });
}
function fillHwFormData(asset: HardwareAsset) {
(document.getElementById('hw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('hw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('hw-법인') as HTMLInputElement).value = asset.;
(document.getElementById('hw-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('hw-위치') as HTMLInputElement).value = asset.;
(document.getElementById('hw-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-OS') as HTMLInputElement).value = asset.OS || '';
(document.getElementById('hw-CPU') as HTMLInputElement).value = asset.CPU || '';
(document.getElementById('hw-RAM') as HTMLInputElement).value = asset.RAM || '';
(document.getElementById('hw-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
(document.getElementById('hw-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
(document.getElementById('hw-담당자_정') as HTMLInputElement).value = asset._정 || asset. || '';
(document.getElementById('hw-담당자_부') as HTMLInputElement).value = asset._부 || '';
(document.getElementById('hw-품의서명') as HTMLElement).textContent = asset. || '';
const serverOnly = document.querySelectorAll('.server-only');
const nonServer = document.querySelectorAll('.non-server');
const equipGroup = document.getElementById('hw-비품유형-group')!;
if (asset.type === '서버') {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'flex');
nonServer.forEach(el => (el as HTMLElement).style.display = 'none');
equipGroup.style.display = 'none';
(document.getElementById('hw-용도') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-상세') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-비고') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('hw-IP2') as HTMLInputElement).value = (asset as any).IP2 || '';
(document.getElementById('hw-원격접속') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-서버ID') as HTMLInputElement).value = (asset as any).ID || '';
(document.getElementById('hw-서버PW') as HTMLInputElement).value = (asset as any).PW || '';
(document.getElementById('hw-모니터링') as HTMLInputElement).value = asset. || '';
} else {
serverOnly.forEach(el => (el as HTMLElement).style.display = 'none');
nonServer.forEach(el => (el as HTMLElement).style.display = 'flex');
const id = (document.getElementById('hw-asset-id') as HTMLInputElement).value; (document.getElementById('hw-명칭') as HTMLInputElement).value = asset. || '';
const fileInput = document.getElementById('hw-품의서') as HTMLInputElement; (document.getElementById('hw-구매일') as HTMLInputElement).value = asset. || '';
const = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('hw-품의서명') as HTMLElement).innerText.replace('📎', ''); (document.getElementById('hw-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-HW사양') as HTMLTextAreaElement).value = asset.HW사양 || '';
(document.getElementById('hw-IP주소-non-server') as HTMLInputElement).value = asset.IP주소 || '';
const newAsset: HardwareAsset = { if (asset.type === '전산비품') {
id: id || Math.random().toString(36).substring(2, 9), equipGroup.style.display = 'flex';
type: (document.getElementById('hw-asset-type') as HTMLInputElement).value, (document.getElementById('hw-비품유형') as HTMLSelectElement).value = asset. || '노트북';
} else {
equipGroup.style.display = 'none';
}
}
}
export function initHwModal() {
// HTML 주입
if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
}
const modal = document.getElementById('hw-asset-modal')!;
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const closeBtn = document.getElementById('btn-close-hw-modal')!;
const cancelBtn = document.getElementById('btn-cancel-hw-modal')!;
const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const closeModal = () => {
modal.classList.add('hidden');
isEditMode = false;
};
const switchToViewMode = () => {
isEditMode = false;
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentAsset) fillHwFormData(currentAsset);
};
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
revertBtn.addEventListener('click', () => { switchToViewMode(); });
saveBtn.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
isEditMode = true;
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.remove('hidden');
return;
}
const assetId = (document.getElementById('hw-asset-id') as HTMLInputElement).value;
const type = (document.getElementById('hw-asset-type') as HTMLInputElement).value;
const updated: HardwareAsset = {
...currentAsset,
: (document.getElementById('hw-법인') as HTMLInputElement).value, : (document.getElementById('hw-법인') as HTMLInputElement).value,
: (document.getElementById('hw-자산코드') as HTMLInputElement).value, : (document.getElementById('hw-자산코드') as HTMLInputElement).value,
: (document.getElementById('hw-명칭') as HTMLInputElement).value,
: (document.getElementById('hw-위치') as HTMLInputElement).value, : (document.getElementById('hw-위치') as HTMLInputElement).value,
: (document.getElementById('hw-관리자') as HTMLInputElement).value, : (document.getElementById('hw-모델명') as HTMLInputElement).value,
IP주소: (document.getElementById('hw-IP주소') as HTMLInputElement).value,
MACaddress: (document.getElementById('hw-MACaddress') as HTMLInputElement).value,
OS: (document.getElementById('hw-OS') as HTMLInputElement).value, OS: (document.getElementById('hw-OS') as HTMLInputElement).value,
HW사양: (document.getElementById('hw-HW사양') as HTMLTextAreaElement).value, CPU: (document.getElementById('hw-CPU') as HTMLInputElement).value,
: (document.getElementById('hw-구매일') as HTMLInputElement).value, RAM: (document.getElementById('hw-RAM') as HTMLInputElement).value,
: (document.getElementById('hw-금액') as HTMLInputElement).value, SSD1: (document.getElementById('hw-SSD1') as HTMLInputElement).value,
: (document.getElementById('hw-납품업체') as HTMLInputElement).value, SSD2: (document.getElementById('hw-SSD2') as HTMLInputElement).value,
, _정: (document.getElementById('hw-담당자_정') as HTMLInputElement).value,
: (document.getElementById('hw-asset-type') as HTMLInputElement).value === '전산비품' : (document.getElementById('hw-담당자_정') as HTMLInputElement).value,
? (document.getElementById('hw-비품유형') as HTMLSelectElement).value : undefined _부: (document.getElementById('hw-담당자_부') as HTMLInputElement).value,
}; };
if (id) { if (type === '서버') {
const idx = state.masterData.hw.findIndex(a => a.id === id); updated. = (document.getElementById('hw-용도') as HTMLInputElement).value;
if(idx !== -1) state.masterData.hw[idx] = newAsset; updated. = (document.getElementById('hw-상세') as HTMLInputElement).value;
updated. = (document.getElementById('hw-비고') as HTMLInputElement).value;
updated.IP주소 = (document.getElementById('hw-IP주소') as HTMLInputElement).value;
(updated as any).IP2 = (document.getElementById('hw-IP2') as HTMLInputElement).value;
updated. = (document.getElementById('hw-원격접속') as HTMLInputElement).value;
(updated as any).ID = (document.getElementById('hw-서버ID') as HTMLInputElement).value;
(updated as any).PW = (document.getElementById('hw-서버PW') as HTMLInputElement).value;
updated. = (document.getElementById('hw-모니터링') as HTMLInputElement).value;
} else { } else {
state.masterData.hw.push(newAsset); updated. = (document.getElementById('hw-명칭') as HTMLInputElement).value;
updated. = (document.getElementById('hw-구매일') as HTMLInputElement).value;
updated. = (document.getElementById('hw-금액') as HTMLInputElement).value;
updated.HW사양 = (document.getElementById('hw-HW사양') as HTMLTextAreaElement).value;
updated.IP주소 = (document.getElementById('hw-IP주소-non-server') as HTMLInputElement).value;
if (type === '전산비품') {
updated. = (document.getElementById('hw-비품유형') as HTMLSelectElement).value;
}
}
const idx = state.masterData.hw.findIndex(a => a.id === assetId);
if (idx > -1) {
state.masterData.hw[idx] = updated;
renderTable(document.getElementById('main-content')!);
switchToViewMode();
} }
closeModals();
renderContent();
}); });
// 삭제 버튼 이벤트 deleteBtn.addEventListener('click', () => {
btnDeleteHw?.addEventListener('click', (e) => { if (!currentAsset) return;
e.preventDefault(); if (confirm('정말로 이 자산을 삭제하시겠습니까?')) {
const id = (document.getElementById('hw-asset-id') as HTMLInputElement).value; state.masterData.hw = state.masterData.hw.filter(a => a.id !== currentAsset!.id);
if (confirm('삭제하시겠습니까?')) { renderTable(document.getElementById('main-content')!);
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id); closeModal();
closeModals();
renderContent();
} }
}); });
} }
/**
* 하드웨어 상세 모달 열기
* @param asset 수정 시 자산 데이터, 신규 시 undefined
*/
export function openHwModal(asset?: HardwareAsset) {
const hwModal = document.getElementById('hw-asset-modal') as HTMLDivElement;
const hwForm = document.getElementById('hw-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
openModal('hw-asset-modal');
hwForm.reset();
if (asset) {
document.getElementById('hw-modal-title')!.textContent = '자산 상세 정보 수정';
deleteBtn.style.display = 'block';
(document.getElementById('hw-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('hw-asset-type') as HTMLInputElement).value = asset.type;
(document.getElementById('hw-법인') as HTMLInputElement).value = asset.;
(document.getElementById('hw-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('hw-명칭') as HTMLInputElement).value = asset.;
(document.getElementById('hw-위치') as HTMLInputElement).value = asset.;
(document.getElementById('hw-관리자') as HTMLInputElement).value = asset.;
(document.getElementById('hw-IP주소') as HTMLInputElement).value = asset.IP주소;
(document.getElementById('hw-MACaddress') as HTMLInputElement).value = asset.MACaddress;
(document.getElementById('hw-OS') as HTMLInputElement).value = asset.OS;
(document.getElementById('hw-HW사양') as HTMLTextAreaElement).value = asset.HW사양;
(document.getElementById('hw-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-금액') as HTMLInputElement).value = asset. ? Number(asset..replace(/,/g, '')).toLocaleString() : '';
(document.getElementById('hw-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('hw-품의서명') as HTMLElement).innerText = asset. ? `📎${asset.}` : '';
(document.getElementById('hw-비품유형') as HTMLSelectElement).value = asset. || '노트북';
} else {
document.getElementById('hw-modal-title')!.textContent = `${state.activeSubTab} 자산 추가`;
deleteBtn.style.display = 'none';
(document.getElementById('hw-asset-id') as HTMLInputElement).value = '';
(document.getElementById('hw-asset-type') as HTMLInputElement).value = state.activeSubTab;
(document.getElementById('hw-품의서명') as HTMLElement).innerText = '';
(document.getElementById('hw-비품유형') as HTMLSelectElement).value = '노트북';
}
// 전산비품일 경우 유형 선택 필드 노출
if (state.activeSubTab === '전산비품') {
document.getElementById('hw-비품유형-group')!.style.display = 'block';
} else {
document.getElementById('hw-비품유형-group')!.style.display = 'none';
}
}

View File

@@ -1,24 +1,143 @@
import { state } from '../../state'; import { state } from '../../core/state';
import { HardwareAsset } from '../../excelHandler'; import { HardwareAsset, HardwareLog } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { openModal } from './BaseModal';
/** const PC_MODAL_HTML = `
* 개인PC 모달 초기화 및 로직 제어 <div id="pc-asset-modal" class="modal-overlay hidden">
*/ <div class="modal-content wide">
export function initPCModal(renderContent: () => void, closeModals: () => void) { <div class="modal-header">
const pcModal = document.getElementById('pc-asset-modal') as HTMLDivElement; <h2 id="pc-modal-title">개인PC 상세 정보</h2>
<button id="btn-close-pc-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="pc-asset-form" class="grid-form">
<input type="hidden" id="pc-asset-id" />
<input type="hidden" id="pc-asset-type" value="개인PC" />
<div class="form-group">
<label for="pc-법인">법인</label>
<select id="pc-법인" required>
<option value="한맥">한맥 (HM)</option><option value="삼안">삼안 (SM)</option><option value="바론">바론 (BR)</option>
</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산코드</label>
<input type="text" id="pc-자산코드" placeholder="ex) HM-PC-2018-001" required />
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-위치">위치</label>
<input type="text" id="pc-위치" />
</div>
<div class="form-group">
<label for="pc-CPU">CPU</label>
<input type="text" id="pc-CPU" />
</div>
<div class="form-group">
<label for="pc-GPU">GPU</label>
<input type="text" id="pc-GPU" />
</div>
<div class="form-group">
<label for="pc-RAM">RAM</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">SSD1</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">SSD2</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-group">
<label for="pc-HDD1">HDD1</label>
<input type="text" id="pc-HDD1" />
</div>
<div class="form-group">
<label for="pc-HDD2">HDD2</label>
<input type="text" id="pc-HDD2" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" placeholder="ex) 2024-01-01" />
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" placeholder="ex) 1,000,000" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label>품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3>
</div>
<div id="pc-history-list" class="history-timeline">
<div class="empty-history">이력이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-pc-modal" class="btn btn-outline">취소</button>
<button id="btn-save-pc-asset" class="btn btn-primary">저장</button>
</div>
</div>
</div>
</div>
`;
export function initPcModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('pc-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', PC_MODAL_HTML);
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement; const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement; const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement;
const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement; const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement;
const btnCancelPc = document.getElementById('btn-cancel-pc-modal') as HTMLButtonElement;
const btnClosePc = document.getElementById('btn-close-pc-modal') as HTMLButtonElement;
btnCancelPc?.addEventListener('click', closeModals);
btnClosePc?.addEventListener('click', closeModals);
// 저장 버튼 이벤트
btnSavePc?.addEventListener('click', (e) => { btnSavePc?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; } if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value; const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
const fileInput = document.getElementById('pc-품의서') as HTMLInputElement; const fileInput = document.getElementById('pc-품의서') as HTMLInputElement;
const = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('pc-품의서명') as HTMLElement).innerText.replace('📎', ''); const = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('pc-품의서명') as HTMLElement).innerText.replace('첨부: ', '');
const newAsset: HardwareAsset = { const newAsset: HardwareAsset = {
id: id || Math.random().toString(36).substring(2, 9), id: id || Math.random().toString(36).substring(2, 9),
@@ -27,11 +146,7 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
: (document.getElementById('pc-자산코드') as HTMLInputElement).value, : (document.getElementById('pc-자산코드') as HTMLInputElement).value,
: '', : '',
: (document.getElementById('pc-위치') as HTMLInputElement).value, : (document.getElementById('pc-위치') as HTMLInputElement).value,
: '', : '', IP주소: '', MACaddress: '', HW사양: '', OS: '', : (document.getElementById('pc-납품업체') as HTMLInputElement).value,
IP주소: '',
MACaddress: '',
HW사양: '',
OS: '',
: (document.getElementById('pc-사용자') as HTMLInputElement).value, : (document.getElementById('pc-사용자') as HTMLInputElement).value,
CPU: (document.getElementById('pc-CPU') as HTMLInputElement).value, CPU: (document.getElementById('pc-CPU') as HTMLInputElement).value,
GPU: (document.getElementById('pc-GPU') as HTMLInputElement).value, GPU: (document.getElementById('pc-GPU') as HTMLInputElement).value,
@@ -42,13 +157,25 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
HDD2: (document.getElementById('pc-HDD2') as HTMLInputElement).value, HDD2: (document.getElementById('pc-HDD2') as HTMLInputElement).value,
: (document.getElementById('pc-구매일') as HTMLInputElement).value, : (document.getElementById('pc-구매일') as HTMLInputElement).value,
: (document.getElementById('pc-금액') as HTMLInputElement).value, : (document.getElementById('pc-금액') as HTMLInputElement).value,
: (document.getElementById('pc-납품업체') as HTMLInputElement).value,
}; };
if (id) { if (id) {
const idx = state.masterData.hw.findIndex(a => a.id === id); const idx = state.masterData.hw.findIndex(a => a.id === id);
if(idx !== -1) state.masterData.hw[idx] = newAsset; if(idx !== -1) {
const oldAsset = state.masterData.hw[idx];
const changes = getChangeDetails(oldAsset, newAsset);
if (changes) {
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: id,
date: new Date().toLocaleString(),
details: changes,
user: '관리자'
});
}
state.masterData.hw[idx] = newAsset;
}
} else { } else {
state.masterData.hw.push(newAsset); state.masterData.hw.push(newAsset);
} }
@@ -57,7 +184,6 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
renderContent(); renderContent();
}); });
// 삭제 버튼 이벤트
btnDeletePc?.addEventListener('click', (e) => { btnDeletePc?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value; const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
@@ -69,14 +195,10 @@ export function initPCModal(renderContent: () => void, closeModals: () => void)
}); });
} }
/**
* 개인PC 상세 모달 열기
* @param asset 수정 시 자산 데이터, 신규 시 undefined
*/
export function openPcModal(asset?: HardwareAsset) { export function openPcModal(asset?: HardwareAsset) {
const pcModal = document.getElementById('pc-asset-modal') as HTMLDivElement;
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement; const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-pc-asset')!; const deleteBtn = document.getElementById('btn-delete-pc-asset')!;
const historyArea = document.querySelector('.modal-history-area') as HTMLElement;
openModal('pc-asset-modal'); openModal('pc-asset-modal');
pcForm.reset(); pcForm.reset();
@@ -84,6 +206,7 @@ export function openPcModal(asset?: HardwareAsset) {
if (asset) { if (asset) {
document.getElementById('pc-modal-title')!.textContent = '개인PC 상세 정보 수정'; document.getElementById('pc-modal-title')!.textContent = '개인PC 상세 정보 수정';
deleteBtn.style.display = 'block'; deleteBtn.style.display = 'block';
if (historyArea) historyArea.style.display = 'flex';
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id; (document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.; (document.getElementById('pc-법인') as HTMLSelectElement).value = asset.;
@@ -98,14 +221,70 @@ export function openPcModal(asset?: HardwareAsset) {
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || ''; (document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || ''; (document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset. || ''; (document.getElementById('pc-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-금액') as HTMLInputElement).value = asset. ? Number(asset..replace(/,/g, '')).toLocaleString() : ''; (document.getElementById('pc-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset. || ''; (document.getElementById('pc-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset. ? `📎${asset.}` : ''; (document.getElementById('pc-품의서명') as HTMLElement).innerText = asset. ? `첨부: ${asset.}` : '';
renderHistory(asset.id);
} else { } else {
document.getElementById('pc-modal-title')!.textContent = ' 개인PC 자산 추가'; document.getElementById('pc-modal-title')!.textContent = '신규 개인PC 자산 추가';
deleteBtn.style.display = 'none'; deleteBtn.style.display = 'none';
if (historyArea) historyArea.style.display = 'none';
(document.getElementById('pc-asset-id') as HTMLInputElement).value = ''; (document.getElementById('pc-asset-id') as HTMLInputElement).value = '';
(document.getElementById('pc-법인') as HTMLSelectElement).value = '한맥'; (document.getElementById('pc-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('pc-품의서명') as HTMLElement).innerText = ''; (document.getElementById('pc-품의서명') as HTMLElement).innerText = '';
} }
} }
function getChangeDetails(oldAsset: HardwareAsset, newAsset: HardwareAsset): string {
const changes: string[] = [];
const fields = [
{ key: '법인', label: '법인' },
{ key: '자산코드', label: '자산코드' },
{ key: '사용자', label: '사용자' },
{ key: '위치', label: '위치' },
{ key: 'CPU', label: 'CPU' },
{ key: 'GPU', label: 'GPU' },
{ key: 'RAM', label: 'RAM' },
{ key: 'SSD1', label: 'SSD1' },
{ key: 'SSD2', label: 'SSD2' },
{ key: 'HDD1', label: 'HDD1' },
{ key: 'HDD2', label: 'HDD2' },
{ key: '구매일', label: '구매일' },
{ key: '금액', label: '금액' },
{ key: '납품업체', label: '납품업체' },
{ key: '품의서명', label: '품의서' },
];
fields.forEach(field => {
const oldVal = (oldAsset as any)[field.key] || '';
const newVal = (newAsset as any)[field.key] || '';
if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
}
});
return changes.join('\n');
}
function renderHistory(assetId: string) {
const historyList = document.getElementById('pc-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('');
}

View File

@@ -1,16 +1,104 @@
import { state } from '../../state'; import { state } from '../../core/state';
import { SoftwareAsset } from '../../excelHandler'; import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { openModal } from './BaseModal';
/** const SW_MODAL_HTML = `
* 소프트웨어 모달 초기화 및 로직 제어 <div id="sw-asset-modal" class="modal-overlay hidden">
*/ <div class="modal-content">
export function initSWModal(renderContent: () => void, closeModals: () => void) { <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">
<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>
</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>
</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" />
</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>
<label style="display:flex; align-items:center; gap:0.5rem; height: 38px; cursor: pointer;">
<input type="checkbox" id="sw-유지보수여부" /> 대상 여부
</label>
</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, ',')" />
</div>
<div class="form-group">
<label for="sw-수량">수량 (보유량)</label>
<input type="number" id="sw-수량" min="1" value="1" />
</div>
<div class="form-group">
<label for="sw-계정명">계정명</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group">
<label for="sw-비고">비고</label>
<input type="text" id="sw-비고" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" 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);
}
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement; const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
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 btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
const btnCancelSw = document.getElementById('btn-cancel-sw-modal') as HTMLButtonElement;
const btnCloseSw = document.getElementById('btn-close-sw-modal') as HTMLButtonElement;
btnCancelSw?.addEventListener('click', closeModals);
btnCloseSw?.addEventListener('click', closeModals);
// 저장 버튼 이벤트
btnSaveSw?.addEventListener('click', (e) => { btnSaveSw?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (!swForm.checkValidity()) { swForm.reportValidity(); return; } if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
@@ -19,7 +107,9 @@ export function initSWModal(renderContent: () => void, closeModals: () => void)
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,
: (document.getElementById('sw-분야') as HTMLSelectElement).value,
: (document.getElementById('sw-법인') as HTMLSelectElement).value, : (document.getElementById('sw-법인') as HTMLSelectElement).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, : (document.getElementById('sw-구독일') as HTMLInputElement).value,
@@ -42,7 +132,6 @@ export function initSWModal(renderContent: () => void, closeModals: () => void)
renderContent(); renderContent();
}); });
// 삭제 버튼 이벤트
btnDeleteSw?.addEventListener('click', (e) => { btnDeleteSw?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value; const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
@@ -54,12 +143,7 @@ export function initSWModal(renderContent: () => void, closeModals: () => void)
}); });
} }
/**
* 소프트웨어 상세 모달 열기
* @param asset 수정 시 자산 데이터, 신규 시 undefined
*/
export function openSwModal(asset?: SoftwareAsset) { export function openSwModal(asset?: SoftwareAsset) {
const swModal = document.getElementById('sw-asset-modal') as HTMLDivElement;
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement; const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
@@ -69,11 +153,11 @@ export function openSwModal(asset?: SoftwareAsset) {
const subGroup = document.getElementById('sw-구독일-group')!; const subGroup = document.getElementById('sw-구독일-group')!;
const permGroup = document.getElementById('sw-유지보수-group')!; const permGroup = document.getElementById('sw-유지보수-group')!;
if (state.activeSubTab === '구독SW') { if (state.activeSubTab === '구독SW') {
subGroup.style.display = 'block'; subGroup.style.display = 'flex';
permGroup.style.display = 'none'; permGroup.style.display = 'none';
} else { } else {
subGroup.style.display = 'none'; subGroup.style.display = 'none';
permGroup.style.display = 'block'; permGroup.style.display = 'flex';
} }
if (asset) { if (asset) {
@@ -82,21 +166,25 @@ export function openSwModal(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.; (document.getElementById('sw-법인') as HTMLSelectElement).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. || ''; (document.getElementById('sw-구독일') as HTMLInputElement).value = asset. || '';
(document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.; (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked = !!asset.;
(document.getElementById('sw-금액') as HTMLInputElement).value = asset. ? Number(asset..replace(/,/g, '')).toLocaleString() : ''; (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. || '';
} else { } else {
document.getElementById('sw-modal-title')!.textContent = ` ${state.activeSubTab} 자산 추가`; document.getElementById('sw-modal-title')!.textContent = `신규 ${state.activeSubTab} 자산 추가`;
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('sw-분야') as HTMLSelectElement).value = '업무공통';
(document.getElementById('sw-법인') as HTMLSelectElement).value = '한맥'; (document.getElementById('sw-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('sw-부서') as HTMLInputElement).value = '';
} }
} }

View File

@@ -1,73 +1,154 @@
import { state } from '../../state'; import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../excelHandler'; import { SoftwareAsset, SWUser } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { openModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip } from 'lucide'; import { createIcons, Edit2, X, Paperclip } from 'lucide';
let currentSwUserAssetId: string = ''; let currentSwUserAssetId: string = '';
let tempSwUsers: SWUser[] = []; let tempSwUsers: SWUser[] = [];
/** const SW_USER_MODAL_HTML = `
* 소프트웨어 사용자 할당 모달 초기화 <!-- S/W 할당 사용자 목록 모달 -->
*/ <div id="sw-user-modal" class="modal-overlay hidden">
export function initSWUserModal(renderContent: () => void, closeModals: () => void) { <div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2 id="sw-user-modal-title">S/W 할당 사용자 목록</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="sw-user-asset-id" />
<div style="text-align: right; margin-bottom: 0.75rem;">
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table style="width:100%;">
<thead>
<tr>
<th>법인</th>
<th>부서/팀</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>증빙</th>
<th style="text-align:center;">관리</th>
</tr>
</thead>
<tbody id="user-list-body"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-save-sw-user-mapping" class="btn btn-primary">변경사항 저장</button>
<button id="btn-cancel-sw-user-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2 id="sw-user-edit-modal-title">사용자 정보</h2>
<button id="btn-close-sw-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-user-idx" />
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>법인</label>
<select id="new-user-법인">
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>팀</label>
<input type="text" id="new-user-팀" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용기간</label>
<input type="text" id="new-user-사용기간" placeholder="ex) 2024.01 ~ 2024.12" />
</div>
<div class="form-group">
<label>신청서 (증빙파일)</label>
<input type="file" id="new-user-신청서" />
<span id="new-user-신청서명" style="font-size:0.75rem; color:var(--text-muted);"></span>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user-edit" class="btn btn-outline">취소</button>
<button id="btn-save-edit-user" class="btn btn-primary">확인</button>
</div>
</div>
</div>
`;
export function initSwUserModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
}
const btnOpenAddUser = document.getElementById('btn-open-add-user'); const btnOpenAddUser = document.getElementById('btn-open-add-user');
const btnSaveEditUser = document.getElementById('btn-save-edit-user'); const btnSaveEditUser = document.getElementById('btn-save-edit-user');
const btnSaveSwUserMapping = document.getElementById('btn-save-sw-user-mapping'); const btnSaveSwUserMapping = document.getElementById('btn-save-sw-user-mapping');
const btnCancelUserEdit = document.getElementById('btn-cancel-sw-user-edit');
const btnCloseUserEdit = document.getElementById('btn-close-sw-user-edit');
const btnCancelUserModal = document.getElementById('btn-cancel-sw-user-modal');
const btnCloseUserModal = document.getElementById('btn-close-sw-user-modal');
btnOpenAddUser?.addEventListener('click', () => { btnOpenAddUser?.addEventListener('click', () => openUserEditModal(-1));
openUserEditModal(-1); btnSaveEditUser?.addEventListener('click', () => saveUserEdit());
});
btnSaveEditUser?.addEventListener('click', () => {
saveUserEdit();
});
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'); document.getElementById('sw-user-modal')?.classList.add('hidden');
renderContent(); renderContent();
}); });
// 취소 버튼들 btnCancelUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
document.getElementById('btn-cancel-sw-user-edit')?.addEventListener('click', () => { btnCloseUserEdit?.addEventListener('click', () => document.getElementById('sw-user-edit-modal')?.classList.add('hidden'));
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'));
document.getElementById('btn-cancel-sw-user-modal')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
} }
/**
* 소프트웨어 사용자 목록 렌더링
*/
function renderUserList() { function renderUserList() {
const tbody = document.getElementById('user-list-body')!; const tbody = document.getElementById('user-list-body')!;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (tempSwUsers.length === 0) { if (tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="padding: 1rem; text-align: center; color: var(--text-muted);">할당된 사용자가 없습니다.</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" style="padding: 2rem; text-align: center; color: var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return; return;
} }
tempSwUsers.forEach((user, idx) => { tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cssText = 'border-bottom: 1px solid var(--border); transition: background-color 0.2s;';
const deptTeam = [user., user.].filter(Boolean).join(' / ') || '-'; const deptTeam = [user., user.].filter(Boolean).join(' / ') || '-';
const attachIcon = user. ? `<i data-lucide="paperclip" class="text-primary" style="width:16px; height:16px;" title="${user.}"></i>` : '-'; const attachIcon = user. ? `<i data-lucide="paperclip" class="text-primary" style="width:16px; height:16px;" title="${user.}"></i>` : '-';
tr.innerHTML = ` tr.innerHTML = `
<td style="padding:0.5rem; text-align:left;">${user.}</td> <td>${user.}</td>
<td style="padding:0.5rem; text-align:left;">${deptTeam}</td> <td>${deptTeam}</td>
<td style="padding:0.5rem; text-align:left;">${user. || '-'}</td> <td>${user. || '-'}</td>
<td style="padding:0.5rem; text-align:left;"><strong>${user.}</strong></td> <td><strong>${user.}</strong></td>
<td style="padding:0.5rem; text-align:center;">${user. || '-'}</td> <td style="text-align:center;">${user. || '-'}</td>
<td style="padding:0.5rem; text-align:center; color: var(--text-light);">${attachIcon}</td> <td style="text-align:center;">${attachIcon}</td>
<td style="padding:0.5rem; text-align:center; display:flex; justify-content:center; gap:0.25rem;"> <td style="text-align:center;">
<button type="button" class="btn-icon btn-edit-user" data-idx="${idx}" style="color: var(--primary);" title="수정"><i data-lucide="edit-2" style="width:14px; height:14px;"></i></button> <button type="button" class="btn-icon btn-edit-user" data-idx="${idx}" style="color: var(--primary-color);"><i data-lucide="edit-2" style="width:14px; height:14px;"></i></button>
<button type="button" class="btn-icon btn-remove-user" data-idx="${idx}" style="color: var(--danger);" title="삭제"><i data-lucide="x" style="width:14px; height:14px;"></i></button> <button type="button" class="btn-icon btn-remove-user" data-idx="${idx}" style="color: var(--danger);"><i data-lucide="x" style="width:14px; height:14px;"></i></button>
</td> </td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
@@ -84,7 +165,6 @@ function renderUserList() {
tbody.querySelectorAll('.btn-remove-user').forEach(btn => { tbody.querySelectorAll('.btn-remove-user').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt((e.currentTarget as HTMLButtonElement).getAttribute('data-idx')!); const idx = parseInt((e.currentTarget as HTMLButtonElement).getAttribute('data-idx')!);
tempSwUsers.splice(idx, 1); tempSwUsers.splice(idx, 1);
renderUserList(); renderUserList();
@@ -92,9 +172,6 @@ function renderUserList() {
}); });
} }
/**
* 사용자 할당 모달 열기
*/
export function openSwUserModal(asset: SoftwareAsset) { export function openSwUserModal(asset: SoftwareAsset) {
openModal('sw-user-modal'); openModal('sw-user-modal');
currentSwUserAssetId = asset.id; currentSwUserAssetId = asset.id;
@@ -102,9 +179,6 @@ export function openSwUserModal(asset: SoftwareAsset) {
renderUserList(); renderUserList();
} }
/**
* 사용자 추가/수정 모달 열기
*/
function openUserEditModal(idx: number) { function openUserEditModal(idx: number) {
const editModal = document.getElementById('sw-user-edit-modal')!; const editModal = document.getElementById('sw-user-edit-modal')!;
editModal.classList.remove('hidden'); editModal.classList.remove('hidden');
@@ -121,7 +195,7 @@ function openUserEditModal(idx: number) {
(document.getElementById('new-user-신청서') as HTMLInputElement).value = ''; (document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
document.getElementById('new-user-신청서명')!.innerText = ''; document.getElementById('new-user-신청서명')!.innerText = '';
} else { } else {
document.getElementById('sw-user-edit-modal-title')!.innerText = '사용자 수정'; document.getElementById('sw-user-edit-modal-title')!.innerText = '사용자 정보 수정';
const u = tempSwUsers[idx]; const u = tempSwUsers[idx];
(document.getElementById('new-user-법인') as HTMLSelectElement).value = u.; (document.getElementById('new-user-법인') as HTMLSelectElement).value = u.;
(document.getElementById('new-user-부서') as HTMLInputElement).value = u.; (document.getElementById('new-user-부서') as HTMLInputElement).value = u.;
@@ -130,22 +204,15 @@ function openUserEditModal(idx: number) {
(document.getElementById('new-user-이름') as HTMLInputElement).value = u.; (document.getElementById('new-user-이름') as HTMLInputElement).value = u.;
(document.getElementById('new-user-사용기간') as HTMLInputElement).value = u.; (document.getElementById('new-user-사용기간') as HTMLInputElement).value = u.;
(document.getElementById('new-user-신청서') as HTMLInputElement).value = ''; (document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
document.getElementById('new-user-신청서명')!.innerText = u. ? `📎${u.}` : ''; document.getElementById('new-user-신청서명')!.innerText = u. ? `첨부: ${u.}` : '';
} }
} }
/**
* 사용자 추가/수정 내용 저장 (임시 목록에 반영)
*/
function saveUserEdit() { function saveUserEdit() {
const idx = parseInt((document.getElementById('edit-user-idx') as HTMLInputElement).value); const idx = parseInt((document.getElementById('edit-user-idx') as HTMLInputElement).value);
const = (document.getElementById('new-user-법인') as HTMLSelectElement).value;
const = (document.getElementById('new-user-부서') as HTMLInputElement).value;
const = (document.getElementById('new-user-팀') as HTMLInputElement).value;
const = (document.getElementById('new-user-직위') as HTMLInputElement).value;
const = (document.getElementById('new-user-이름') as HTMLInputElement).value.trim(); const = (document.getElementById('new-user-이름') as HTMLInputElement).value.trim();
const = (document.getElementById('new-user-사용기간') as HTMLInputElement).value; if (!) { alert('이름을 입력해주세요.'); return; }
const fileInput = document.getElementById('new-user-신청서') as HTMLInputElement; const fileInput = document.getElementById('new-user-신청서') as HTMLInputElement;
let = ''; let = '';
if (fileInput.files && fileInput.files.length > 0) { if (fileInput.files && fileInput.files.length > 0) {
@@ -154,17 +221,20 @@ function saveUserEdit() {
= tempSwUsers[idx].; = tempSwUsers[idx].;
} }
if (!) { alert('이름을 입력해주세요.'); return; } const userData: SWUser = {
id: idx === -1 ? Math.random().toString(36).substring(2, 9) : tempSwUsers[idx].id,
swId: currentSwUserAssetId,
: (document.getElementById('new-user-법인') as HTMLSelectElement).value,
: (document.getElementById('new-user-부서') as HTMLInputElement).value,
: (document.getElementById('new-user-팀') as HTMLInputElement).value,
: (document.getElementById('new-user-직위') as HTMLInputElement).value,
,
: (document.getElementById('new-user-사용기간') as HTMLInputElement).value,
};
if (idx === -1) { if (idx === -1) tempSwUsers.push(userData);
tempSwUsers.push({ else tempSwUsers[idx] = userData;
id: Math.random().toString(36).substring(2, 9),
swId: currentSwUserAssetId,
, , , , , ,
});
} else {
tempSwUsers[idx] = { ...tempSwUsers[idx], , , , , , , };
}
document.getElementById('sw-user-edit-modal')?.classList.add('hidden'); document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList(); renderUserList();

View File

@@ -1,45 +1,77 @@
import { state } from '../../state'; import { state } from '../../core/state';
import { HardwareAsset } from '../../excelHandler'; import { HardwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal'; import { openModal } from './BaseModal';
/** const STORAGE_MODAL_HTML = `
* 스토리지 모달 초기화 및 로직 제어 <div id="storage-asset-modal" class="modal-overlay hidden">
*/ <div class="modal-content">
<div class="modal-header">
<h2 id="storage-modal-title">스토리지 상세 정보</h2>
<button id="btn-close-storage-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="storage-asset-form" class="grid-form">
<input type="hidden" id="storage-asset-id" />
<input type="hidden" id="storage-asset-type" value="스토리지" />
<div class="form-group"><label for="storage-법인">법인</label><input type="text" id="storage-법인" required /></div>
<div class="form-group"><label for="storage-유형">유형</label><input type="text" id="storage-유형" required /></div>
<div class="form-group"><label for="storage-자산코드">자산코드</label><input type="text" id="storage-자산코드" required /></div>
<div class="form-group"><label for="storage-명칭">명칭</label><input type="text" id="storage-명칭" required /></div>
<div class="form-group"><label for="storage-위치">위치</label><input type="text" id="storage-위치" /></div>
<div class="form-group"><label for="storage-모델명">모델명</label><input type="text" id="storage-모델명" /></div>
<div class="form-group"><label for="storage-용량">용량</label><input type="text" id="storage-용량" /></div>
<div class="form-group"><label for="storage-담당자_정">담당자(정)</label><input type="text" id="storage-담당자_정" /></div>
<div class="form-group"><label for="storage-IP주소">IP주소</label><input type="text" id="storage-IP주소" /></div>
<div class="form-group"><label for="storage-구매일">구매일</label><input type="text" id="storage-구매일" /></div>
<div class="form-group"><label for="storage-금액">금액</label><input type="text" id="storage-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\d))/g, ',')" /></div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-storage-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-cancel-storage-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-storage-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function initStorageModal(renderContent: () => void, closeModals: () => void) { 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 storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
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 btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
const btnCancelStorage = document.getElementById('btn-cancel-storage-modal') as HTMLButtonElement;
const btnCloseStorage = document.getElementById('btn-close-storage-modal') as HTMLButtonElement;
btnCancelStorage?.addEventListener('click', closeModals);
btnCloseStorage?.addEventListener('click', closeModals);
// 저장 버튼 이벤트
btnSaveStorage?.addEventListener('click', (e) => { btnSaveStorage?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (!storageForm.checkValidity()) { storageForm.reportValidity(); return; } if (!storageForm.checkValidity()) { storageForm.reportValidity(); return; }
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value; const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
const fileInput = document.getElementById('storage-품의서') as HTMLInputElement;
const = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('storage-품의서명') as HTMLElement).innerText.replace('📎', '');
const newAsset: HardwareAsset = { const newAsset: HardwareAsset = {
id: id || Math.random().toString(36).substring(2, 9), id: id || Math.random().toString(36).substring(2, 9),
type: '스토리지', type: '스토리지',
: (document.getElementById('storage-법인') as HTMLSelectElement).value, : (document.getElementById('storage-법인') as HTMLInputElement).value,
storage유형: (document.getElementById('storage-유형') as HTMLSelectElement).value, storage유형: (document.getElementById('storage-유형') as HTMLInputElement).value,
: (document.getElementById('storage-자산코드') as HTMLInputElement).value, : (document.getElementById('storage-자산코드') as HTMLInputElement).value,
: (document.getElementById('storage-명칭') as HTMLInputElement).value, : (document.getElementById('storage-명칭') as HTMLInputElement).value,
: (document.getElementById('storage-위치') as HTMLInputElement).value, : (document.getElementById('storage-위치') as HTMLInputElement).value,
: '',
IP주소: (document.getElementById('storage-IP주소') as HTMLInputElement).value,
MACaddress: (document.getElementById('storage-MAC주소') as HTMLInputElement).value,
HW사양: '',
OS: '',
: (document.getElementById('storage-모델명') as HTMLInputElement).value, : (document.getElementById('storage-모델명') as HTMLInputElement).value,
: (document.getElementById('storage-용량') as HTMLInputElement).value, : (document.getElementById('storage-용량') as HTMLInputElement).value,
_정: (document.getElementById('storage-담당자_정') as HTMLInputElement).value, _정: (document.getElementById('storage-담당자_정') as HTMLInputElement).value,
_부: (document.getElementById('storage-담당자_부') as HTMLInputElement).value, IP주소: (document.getElementById('storage-IP주소') as HTMLInputElement).value,
: (document.getElementById('storage-구매일') as HTMLInputElement).value, : (document.getElementById('storage-구매일') as HTMLInputElement).value,
: (document.getElementById('storage-금액') as HTMLInputElement).value, : (document.getElementById('storage-금액') as HTMLInputElement).value,
: (document.getElementById('storage-납품업체') as HTMLInputElement).value, : '', MACaddress: '', HW사양: '', OS: '', : '', : ''
}; };
if (id) { if (id) {
@@ -53,7 +85,6 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
renderContent(); renderContent();
}); });
// 삭제 버튼 이벤트
btnDeleteStorage?.addEventListener('click', (e) => { btnDeleteStorage?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value; const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
@@ -65,12 +96,7 @@ export function initStorageModal(renderContent: () => void, closeModals: () => v
}); });
} }
/**
* 스토리지 상세 모달 열기
* @param asset 수정 시 자산 데이터, 신규 시 undefined
*/
export function openStorageModal(asset?: HardwareAsset) { export function openStorageModal(asset?: HardwareAsset) {
const storageModal = document.getElementById('storage-asset-modal') as HTMLDivElement;
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement; const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-storage-asset')!; const deleteBtn = document.getElementById('btn-delete-storage-asset')!;
@@ -82,27 +108,20 @@ export function openStorageModal(asset?: HardwareAsset) {
deleteBtn.style.display = 'block'; deleteBtn.style.display = 'block';
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id; (document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('storage-법인') as HTMLSelectElement).value = asset.; (document.getElementById('storage-법인') as HTMLInputElement).value = asset.;
(document.getElementById('storage-유형') as HTMLSelectElement).value = asset.storage유형 || 'NAS'; (document.getElementById('storage-유형') as HTMLInputElement).value = asset.storage유형 || 'NAS';
(document.getElementById('storage-자산코드') as HTMLInputElement).value = asset.; (document.getElementById('storage-자산코드') as HTMLInputElement).value = asset.;
(document.getElementById('storage-명칭') as HTMLInputElement).value = asset.; (document.getElementById('storage-명칭') as HTMLInputElement).value = asset.;
(document.getElementById('storage-위치') as HTMLInputElement).value = asset. || ''; (document.getElementById('storage-위치') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-모델명') as HTMLInputElement).value = asset. || ''; (document.getElementById('storage-모델명') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-용량') as HTMLInputElement).value = asset. || ''; (document.getElementById('storage-용량') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-담당자_정') as HTMLInputElement).value = asset._정 || ''; (document.getElementById('storage-담당자_정') as HTMLInputElement).value = asset._정 || '';
(document.getElementById('storage-담당자_부') as HTMLInputElement).value = asset._부 || '';
(document.getElementById('storage-IP주소') as HTMLInputElement).value = asset.IP주소 || ''; (document.getElementById('storage-IP주소') as HTMLInputElement).value = asset.IP주소 || '';
(document.getElementById('storage-MAC주소') as HTMLInputElement).value = asset.MACaddress || '';
(document.getElementById('storage-구매일') as HTMLInputElement).value = asset. || ''; (document.getElementById('storage-구매일') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-금액') as HTMLInputElement).value = asset. ? Number(asset..replace(/,/g, '')).toLocaleString() : ''; (document.getElementById('storage-금액') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-납품업체') as HTMLInputElement).value = asset. || '';
(document.getElementById('storage-품의서명') as HTMLElement).innerText = asset. ? `📎${asset.}` : '';
} else { } else {
document.getElementById('storage-modal-title')!.textContent = ' 스토리지 자산 추가'; document.getElementById('storage-modal-title')!.textContent = '신규 스토리지 자산 추가';
deleteBtn.style.display = 'none'; deleteBtn.style.display = 'none';
(document.getElementById('storage-asset-id') as HTMLInputElement).value = ''; (document.getElementById('storage-asset-id') as HTMLInputElement).value = '';
(document.getElementById('storage-법인') as HTMLSelectElement).value = '한맥';
(document.getElementById('storage-유형') as HTMLSelectElement).value = 'NAS';
(document.getElementById('storage-품의서명') as HTMLElement).innerText = '';
} }
} }

View File

@@ -1,6 +1,6 @@
import { state } from '../state'; import { state } from '../core/state';
export function initSidebar(renderContent: () => void) { export function renderSidebar(onTabChange: (tab: string) => void) {
const navItems = document.querySelectorAll('.nav-list li'); const navItems = document.querySelectorAll('.nav-list li');
const titleElement = document.getElementById('current-tab-title') as HTMLHeadingElement; const titleElement = document.getElementById('current-tab-title') as HTMLHeadingElement;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement; const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
@@ -15,13 +15,13 @@ export function initSidebar(renderContent: () => void) {
state.activeCategory = item.getAttribute('data-category') as 'hw' | 'sw'; state.activeCategory = item.getAttribute('data-category') as 'hw' | 'sw';
state.activeSubTab = item.getAttribute('data-tab') || '대시보드'; state.activeSubTab = item.getAttribute('data-tab') || '대시보드';
// 타이틀 업데이트 (Deep Green 포인트 컬러 유지) // 타이틀 업데이트
const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어'; const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어';
if (titleElement) { if (titleElement) {
titleElement.textContent = `${catName} / ${state.activeSubTab}`; titleElement.textContent = `${catName} / ${state.activeSubTab}`;
} }
// 추가 버튼 노출 여부 (대시보드에서는 숨김) // 추가 버튼 노출 여부
if (btnAddAsset) { if (btnAddAsset) {
if (state.activeSubTab === '대시보드') { if (state.activeSubTab === '대시보드') {
btnAddAsset.classList.add('hidden'); btnAddAsset.classList.add('hidden');
@@ -30,8 +30,8 @@ export function initSidebar(renderContent: () => void) {
} }
} }
// 화면 리렌더링 // 탭 변경 콜백 실행
renderContent(); onTabChange(state.activeSubTab);
}); });
}); });
} }

View File

@@ -26,7 +26,7 @@ export function generateDummyData(): MasterAssetData {
// 1. 개인PC 50개 // 1. 개인PC 50개
for (let i = 1; i <= 50; i++) { for (let i = 1; i <= 50; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024 const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({ hw.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '개인PC', type: '개인PC',
@@ -43,7 +43,7 @@ export function generateDummyData(): MasterAssetData {
HDD1: rand(['-', '1TB', '2TB']), HDD1: rand(['-', '1TB', '2TB']),
HDD2: '', HDD2: '',
구매일: randDate(purchaseYear, purchaseYear), 구매일: randDate(purchaseYear, purchaseYear),
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','), 금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
납품업체: rand(['다나와', '컴퓨존', '오피스디포']), 납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
: '', : '',
: '', IP주소: '', MACaddress: '', OS: '', HW사양: '' : '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
@@ -52,18 +52,32 @@ export function generateDummyData(): MasterAssetData {
// 2. 서버 20개 // 2. 서버 20개
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024 const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({ hw.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '서버', type: '서버',
법인: rand(corps), 법인: rand(corps),
: `HM-SV-${purchaseYear}-${String(i).padStart(3, '0')}`, : `HM-SV-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `웹/DB 서버 #${i}`, : `웹/DB 서버 #${i}`,
: 'IDC / 전산실', 용도: rand(['웹 서버', 'DB 서버', '백업 서버', '개발 서버']),
관리자: randUser(), storage유형: rand(['물리', 'VM']),
위치: rand(['IDC 1센터', 'IDC 2센터', '본사 전산실']),
관리자: rand(users),
담당자_정: rand(users),
담당자_부: rand(users),
IP주소: `192.168.10.${i}`, IP주소: `192.168.10.${i}`,
: `ssh://192.168.10.${i}:22`,
MACaddress: '00:11:22:33:44:' + String(i).padStart(2, '0'), MACaddress: '00:11:22:33:44:' + String(i).padStart(2, '0'),
OS: rand(['Windows Server 2019', 'Ubuntu 22.04 LTS', 'CentOS 7']), OS: rand(['Windows Server 2019', 'Ubuntu 22.04 LTS', 'CentOS 7']),
모델명: rand(['Dell PowerEdge R740', 'HP ProLiant DL380', 'Lenovo ThinkSystem']),
CPU: rand(['Xeon Silver 4210', 'Xeon Gold 6248', 'EPYC 7702']),
RAM: rand(['64GB', '128GB', '256GB']),
GPU: rand(['-', 'RTX A4000', 'Tesla V100']),
SSD1: rand(['512GB SSD', '1TB NVMe']),
SSD2: rand(['-', '1TB SSD', '2TB SSD']),
HDD1: rand(['-', '4TB HDD', '8TB HDD']),
모니터링: rand(['Zabbix', 'Grafana', 'PRTG']),
비고: i % 5 === 0 ? '정기 점검 대상' : '-',
HW사양: 'Xeon 16Core, 64GB RAM', HW사양: 'Xeon 16Core, 64GB RAM',
구매일: randDate(purchaseYear, purchaseYear), 구매일: randDate(purchaseYear, purchaseYear),
: '5,000,000', : '5,000,000',
@@ -74,7 +88,7 @@ export function generateDummyData(): MasterAssetData {
// 3. 스토리지 20개 // 3. 스토리지 20개
for (let i = 1; i <= 20; i++) { for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024 const purchaseYear = Math.floor(Math.random() * 10) + 2017; // 2017~2026
hw.push({ hw.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '스토리지', type: '스토리지',
@@ -105,7 +119,7 @@ export function generateDummyData(): MasterAssetData {
]; ];
equips.forEach((eq) => { equips.forEach((eq) => {
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const purchaseYear = Math.floor(Math.random() * 6) + 2019; // 2019~2024 const purchaseYear = Math.floor(Math.random() * 8) + 2019; // 2019~2026
hw.push({ hw.push({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
type: '전산비품', type: '전산비품',
@@ -127,6 +141,7 @@ export function generateDummyData(): MasterAssetData {
// 5. 구독형 S/W 40개 // 5. 구독형 S/W 40개
for (let i = 1; i <= 40; i++) { for (let i = 1; i <= 40; i++) {
const swId = Math.random().toString(36).substring(2, 9); const swId = Math.random().toString(36).substring(2, 9);
const purchaseYear = Math.random() < 0.3 ? 2026 : 2024;
let isExpiring = Math.random() < 0.25; let isExpiring = Math.random() < 0.25;
let endDt = new Date(); let endDt = new Date();
@@ -140,16 +155,19 @@ export function generateDummyData(): MasterAssetData {
sw.push({ sw.push({
id: swId, id: swId,
type: '구독SW', type: '구독SW',
분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']),
법인: rand(corps), 법인: rand(corps),
부서: rand(depts),
제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']), 제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']),
: '2024-01-01', : `${purchaseYear}-01-01`,
: `2024.01.01 ~ ${endStr}`, : `${purchaseYear}.01.01 ~ ${endStr}`,
: '600,000', 금액: String(Math.floor(Math.random() * 100 + 10) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
수량: Math.floor(Math.random() * 5) + 3, // 3~7 수량: Math.floor(Math.random() * 5) + 3, // 3~7
: `user${i}@hm.com`, : `user${i}@hm.com`,
: '총판', : '총판',
: '연간구독' : '연간구독'
}); });
const assignCount = Math.floor(Math.random() * 2) + 1; const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) { for (let j=0; j<assignCount; j++) {
swUsers.push({ swUsers.push({
@@ -182,7 +200,9 @@ export function generateDummyData(): MasterAssetData {
sw.push({ sw.push({
id: swId, id: swId,
type: '영구SW', type: '영구SW',
분야: rand(['업무공통', '개발S/W', '디자인', '설계S/W']),
법인: rand(corps), 법인: rand(corps),
부서: rand(depts),
제품명: rand(['AutoCAD 2024', 'Windows 10 Pro', '한컴오피스 2022', 'Visual Studio 2022']), 제품명: rand(['AutoCAD 2024', 'Windows 10 Pro', '한컴오피스 2022', 'Visual Studio 2022']),
: '2020-05-15', : '2020-05-15',
유지보수여부: true, 유지보수여부: true,
@@ -208,5 +228,5 @@ export function generateDummyData(): MasterAssetData {
} }
} }
return { hw, sw, swUsers }; return { hw, sw, swUsers, logs: [] };
} }

344
src/core/excelHandler.ts Normal file
View File

@@ -0,0 +1,344 @@
import * as XLSX from 'xlsx';
export interface HardwareAsset {
id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품'
법인: string;
자산코드: string;
명칭: string;
위치: string;
관리자: string;
IP주소: string;
IP2?: string;
MACaddress: string;
HW사양: string;
OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매일?: string;
금액?: string;
납품업체: string;
품의서명: string;
용도?: string;
상세?: string;
원격접속?: string;
서버ID?: string;
서버PW?: string;
모니터링?: string;
비고?: string;
}
export interface SoftwareAsset {
id: string;
type: string; // '구독SW', '영구SW'
분야?: string;
법인: string;
부서?: string;
제품명: string;
구매일: string;
구독일?: string;
유지보수여부?: boolean;
금액: string;
수량: number;
계정명: string;
납품업체: string;
비고: string;
}
export interface SWUser {
id: string;
swId: string;
법인: string;
부서: string;
: string;
직위: string;
이름: string;
사용기간: string;
신청서명: string;
}
export interface HardwareLog {
id: string;
assetId: string;
date: string;
details: string;
user: string;
}
export interface MasterAssetData {
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: SWUser[];
logs: HardwareLog[];
}
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 SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
const HISTORY_HEADERS = ['id', 'assetId', 'date', 'details', 'user'];
/**
* 템플릿 엑셀 다중 시트로 다운로드
*/
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
HW_TABS.forEach(tab => {
let hd = HW_HEADERS;
let wscols: any[] = [];
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}];
} 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}];
} 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}];
} 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}];
}
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = wscols;
XLSX.utils.book_append_sheet(wb, ws, tab);
});
SW_TABS.forEach(tab => {
let hd = tab === '구독SW' ? SUB_SW_HEADERS : PERM_SW_HEADERS;
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}];
XLSX.utils.book_append_sheet(wb, ws, tab);
});
const swUserWs = XLSX.utils.aoa_to_sheet([SW_USER_HEADERS]);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
const historyWs = XLSX.utils.aoa_to_sheet([HISTORY_HEADERS]);
historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
XLSX.utils.book_append_sheet(wb, historyWs, 'History');
XLSX.writeFile(wb, 'itam_assets_template.xlsx');
}
/**
* 마스터 데이터를 여러 시트로 쪼개서 내보내기
*/
export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new();
HW_TABS.forEach(tab => {
const targetAssets = masterData.hw.filter(a => a.type === tab);
let wsData;
let colsConfig;
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.])
];
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}];
} 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. || ''])
];
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}];
} 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.])
];
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}];
} else {
wsData = [
HW_HEADERS,
...targetAssets.map(a => [a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, 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}];
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = colsConfig;
XLSX.utils.book_append_sheet(wb, ws, tab);
});
SW_TABS.forEach(tab => {
const targetAssets = masterData.sw.filter(a => a.type === tab);
let wsData;
if (tab === '구독SW') {
wsData = [
SUB_SW_HEADERS,
...targetAssets.map(a => [a.id, a.||'', a., a.||'', a., a., a., a., a., a., a., a.])
];
} else {
wsData = [
PERM_SW_HEADERS,
...targetAssets.map(a => [a.id, a.||'', a., a.||'', a., a., a. ? 'Y' : 'N', a., a., a., a., a.])
];
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}];
XLSX.utils.book_append_sheet(wb, ws, tab);
});
const swUserWsData = [
SW_USER_HEADERS,
...masterData.swUsers.map(u => [u.id, u.swId, u., u., u., u., u., u., u.])
];
const swUserWs = XLSX.utils.aoa_to_sheet(swUserWsData);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
const historyWsData = [
HISTORY_HEADERS,
...masterData.logs.map(l => [l.id, l.assetId, l.date, l.details, l.user])
];
const historyWs = XLSX.utils.aoa_to_sheet(historyWsData);
historyWs['!cols'] = [{wch:15}, {wch:20}, {wch:20}, {wch:50}, {wch:15}];
XLSX.utils.book_append_sheet(wb, historyWs, 'History');
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `itam_assets_master_${dateStr}.xlsx`);
}
export async function parseExcel(file: File): Promise<MasterAssetData> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'binary' });
const hwAssets: HardwareAsset[] = [];
const swAssets: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
const logs: HardwareLog[] = [];
workbook.SheetNames.forEach(sheetName => {
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet) as any[];
if (HW_TABS.includes(sheetName)) {
json.forEach(row => {
if (sheetName === '개인PC') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산코드'] || '',
: '',
위치: row['위치'] || '',
사용자: row['사용자'] || '',
: '', IP주소: '', MACaddress: '', HW사양: '', 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['품의서명'] || '',
});
} else if (sheetName === '서버') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: 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'] || '',
모델명: 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['유형'] || '물리',
MACaddress: '', HW사양: '', : '', : '', : '', : '',
});
} else if (sheetName === '스토리지') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '', 자산코드: row['자산코드'] || '', 명칭: row['명칭'] || '', 위치: row['위치'] || '',
: '', IP주소: row['IP주소'] || '', MACaddress: row['MAC주소'] || '', HW사양: '', OS: '',
storage유형: row['유형'] || '', 모델명: row['모델명'] || '', 용량: row['용량'] || '',
담당자_정: row['담당자(정)'] || '', 담당자_부: row['담당자(부)'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
});
} else {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '', 자산코드: row['자산코드'] || '', 명칭: row['명칭'] || '', 위치: row['위치'] || '',
관리자: row['관리자'] || '', IP주소: row['IP주소'] || '', MACaddress: row['MACaddress'] || '',
HW사양: row['HW사양'] || '', OS: row['OS'] || '',
구매일: row['구매일'] || '', 금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '', 품의서명: row['품의서명'] || '',
});
}
});
}
if (SW_TABS.includes(sheetName)) {
json.forEach(row => {
swAssets.push({
id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9),
type: sheetName, 분야: row['분야'] || '', 법인: row['법인'] || '', 부서: row['부서'] || '', 제품명: row['제품명'] || '',
구매일: row['구매일'] || '', 구독일: row['구독일'] || '', 유지보수여부: row['유지보수여부'] === 'Y' || row['유지보수여부'] === true,
금액: row['금액'] ? String(row['금액']) : '', 수량: parseInt(row['수량'] || '1', 10),
계정명: row['계정명'] || '', 납품업체: row['납품업체'] || '', 비고: row['비고'] || '',
});
});
}
if (sheetName === 'SW_사용자') {
json.forEach(row => {
swUsers.push({
id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
swId: row['swId'] ? String(row['swId']) : '', 법인: row['법인'] || '', 부서: row['부서'] || '',
: row['팀'] || '', 직위: row['직위'] || '', 이름: row['이름'] || '',
사용기간: row['사용기간'] || '', 신청서명: row['신청서명'] || '',
});
});
}
if (sheetName === 'History') {
json.forEach(row => {
logs.push({
id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
assetId: row['assetId'] ? String(row['assetId']) : '',
date: row['date'] || '', details: row['details'] || '', user: row['user'] || '',
});
});
}
});
resolve({ hw: hwAssets, sw: swAssets, swUsers, logs });
} catch (err) {
reject(err);
}
};
reader.onerror = (err) => reject(err);
reader.readAsBinaryString(file);
});
}

1603
src/core/realServerData.ts Normal file

File diff suppressed because it is too large Load Diff

68
src/core/state.ts Normal file
View File

@@ -0,0 +1,68 @@
import { MasterAssetData, HardwareAsset } from './excelHandler';
import { generateDummyData } from './dummyDataGenerator';
import { realServerData } from './realServerData';
// --- State Definitions ---
export interface AppState {
masterData: MasterAssetData;
activeCategory: 'hw' | 'sw';
activeSubTab: string;
activeCharts: any[];
}
const dummy = generateDummyData();
// 서버 데이터만 실제 데이터로 교체
const mergedHw: HardwareAsset[] = [
...dummy.hw.filter(a => a.type !== '서버'),
...realServerData.map(s => ({
id: s.id || Math.random().toString(36).substring(2, 9),
type: '서버',
법인: s.법인,
자산코드: s.자산코드,
명칭: s.용도 || '',
위치: s.위치,
관리자: s.담당자_정 || '홍길동',
담당자_정: s.담당자_정 || '홍길동',
담당자_부: s.담당자_부 || '김철수',
IP주소: s.IP주소,
IP2: s.IP2 || '',
MACaddress: s.MACaddress || '',
HW사양: s.HW사양 || '',
OS: s.OS,
CPU: s.CPU,
RAM: s.RAM,
SSD1: s.SSD1,
SSD2: s.SSD2,
HDD1: s.HDD1,
storage유형: s.storage유형,
모델명: s.모델명,
구매일: s.구매일 || '',
금액: s.금액 || '',
납품업체: s.납품업체 || '',
품의서명: s.품의서명 || '',
용도: s.용도,
상세: s.상세,
원격접속: s.원격접속 || '',
서버ID: s.서버ID || '',
서버PW: s.서버PW || '',
모니터링: s.모니터링 || '',
비고: s.비고 || ''
}))
];
// --- Initial State ---
export const state: AppState = {
masterData: {
...dummy,
hw: mergedHw,
logs: [] // MasterAssetData 인터페이스에 맞게 추가
},
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: []
};
// --- State Helpers ---
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}

View File

@@ -1,324 +0,0 @@
import * as XLSX from 'xlsx';
export interface HardwareAsset {
id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품'
법인: string;
자산코드: string;
명칭: string;
위치: string;
관리자: string;
IP주소: string;
MACaddress: string;
HW사양: string;
OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매일?: string;
금액?: string;
납품업체?: string;
품의서명?: string;
}
export interface SoftwareAsset {
id: string;
type: string; // '구독SW', '영구SW'
법인: string;
제품명: string;
구매일: string;
구독일?: string;
유지보수여부?: boolean;
금액: string;
수량: number;
계정명: string;
납품업체: string;
비고: string;
}
export interface SWUser {
id: string;
swId: string;
법인: string;
부서: string;
: string;
직위: string;
이름: string;
사용기간: string;
신청서명: string;
}
export interface MasterAssetData {
hw: HardwareAsset[];
sw: SoftwareAsset[];
swUsers: SWUser[];
}
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 STORAGE_HEADERS = ['법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명'];
const SUB_SW_HEADERS = ['ID', '법인', '제품명', '구매일', '구독일', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '법인', '제품명', '구매일', '유지보수여부', '금액', '수량', '계정명', '납품업체', '비고'];
const SW_USER_HEADERS = ['id', 'swId', '법인', '부서', '팀', '직위', '이름', '사용기간', '신청서명'];
/**
* 템플릿 엑셀 다중 시트로 다운로드
*/
export function downloadTemplate() {
const wb = XLSX.utils.book_new();
// HW 탭들 생성
HW_TABS.forEach(tab => {
if (tab === '개인PC') {
const ws = XLSX.utils.aoa_to_sheet([PC_HEADERS]);
ws['!cols'] = [{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}];
XLSX.utils.book_append_sheet(wb, ws, tab);
} else if (tab === '스토리지') {
const ws = XLSX.utils.aoa_to_sheet([STORAGE_HEADERS]);
ws['!cols'] = [{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}];
XLSX.utils.book_append_sheet(wb, ws, tab);
} else {
const ws = XLSX.utils.aoa_to_sheet([HW_HEADERS]);
ws['!cols'] = [{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}];
XLSX.utils.book_append_sheet(wb, ws, tab);
}
});
// SW 탭들 생성
SW_TABS.forEach(tab => {
let hd = tab === '구독SW' ? SUB_SW_HEADERS : PERM_SW_HEADERS;
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = [{wch:15}, {wch:15}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}];
XLSX.utils.book_append_sheet(wb, ws, tab);
});
const swUserWs = XLSX.utils.aoa_to_sheet([SW_USER_HEADERS]);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
XLSX.writeFile(wb, 'itam_assets_template.xlsx');
}
/**
* 마스터 데이터를 여러 시트로 쪼개서 내보내기
*/
export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new();
// HW
HW_TABS.forEach(tab => {
const targetAssets = masterData.hw.filter(a => a.type === tab);
let wsData;
let colsConfig;
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.])
];
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}];
} 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.])
];
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}];
} else {
wsData = [
HW_HEADERS,
...targetAssets.map(a => [a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, 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}];
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = colsConfig;
XLSX.utils.book_append_sheet(wb, ws, tab);
});
// SW
SW_TABS.forEach(tab => {
const targetAssets = masterData.sw.filter(a => a.type === tab);
let wsData;
if (tab === '구독SW') {
wsData = [
SUB_SW_HEADERS,
...targetAssets.map(a => [a.id, a., a., a., a., a., a., a., a., a.])
];
} else {
wsData = [
PERM_SW_HEADERS,
...targetAssets.map(a => [a.id, a., a., a., a. ? 'Y' : 'N', a., a., a., a., a.])
];
}
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = [{wch:15}, {wch:15}, {wch:30}, {wch:15}, {wch:20}, {wch:15}, {wch:10}, {wch:20}, {wch:20}, {wch:30}];
XLSX.utils.book_append_sheet(wb, ws, tab);
});
// SW_사용자
const swUserWsData = [
SW_USER_HEADERS,
...masterData.swUsers.map(u => [u.id, u.swId, u., u., u., u., u., u., u.])
];
const swUserWs = XLSX.utils.aoa_to_sheet(swUserWsData);
swUserWs['!cols'] = [{wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:15}, {wch:20}, {wch:25}];
XLSX.utils.book_append_sheet(wb, swUserWs, 'SW_사용자');
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `itam_assets_master_${dateStr}.xlsx`);
}
/**
* 업로드된 다중 시트 엑셀을 파싱하여 Master Data 구성
*/
export async function parseExcel(file: File): Promise<MasterAssetData> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
const workbook = XLSX.read(data, { type: 'binary' });
const hwAssets: HardwareAsset[] = [];
const swAssets: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
workbook.SheetNames.forEach(sheetName => {
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet) as any[];
if (HW_TABS.includes(sheetName)) {
json.forEach(row => {
if (sheetName === '개인PC') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산코드'] || '',
: '', // 개인PC는 명칭 미사용
위치: row['위치'] || '',
사용자: row['사용자'] || '',
: '',
IP주소: '',
MACaddress: '',
HW사양: '',
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['품의서명'] || '',
});
} else if (sheetName === '스토리지') {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산코드'] || '',
명칭: row['명칭'] || '',
위치: row['위치'] || '',
: '',
IP주소: row['IP주소'] || '',
MACaddress: row['MAC주소'] || row['MACaddress'] || row['MAC address'] || '',
HW사양: '',
OS: '',
storage유형: row['유형'] || '',
모델명: row['모델명'] || '',
용량: row['용량'] || '',
담당자_정: row['담당자(정)'] || '',
담당자_부: row['담당자(부)'] || '',
구매일: row['구매일'] || '',
금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '',
품의서명: row['품의서명'] || '',
});
} else {
hwAssets.push({
id: Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
자산코드: row['자산코드'] || '',
명칭: row['명칭'] || '',
위치: row['위치'] || '',
관리자: row['관리자'] || '',
IP주소: row['IP주소'] || '',
MACaddress: row['MACaddress'] || row['MAC address'] || '',
HW사양: row['HW사양'] || row['H/W 사양'] || '',
OS: row['OS'] || '',
구매일: row['구매일'] || '',
금액: row['금액'] ? String(row['금액']) : '',
납품업체: row['납품업체'] || '',
품의서명: row['품의서명'] || '',
});
}
});
}
if (SW_TABS.includes(sheetName)) {
json.forEach(row => {
swAssets.push({
id: row['ID'] ? String(row['ID']) : Math.random().toString(36).substring(2, 9),
type: sheetName,
법인: row['법인'] || '',
제품명: row['제품명'] || '',
구매일: row['구매일'] || '',
구독일: row['구독일'] || '',
유지보수여부: row['유지보수여부'] === 'Y' || row['유지보수여부'] === true,
금액: row['금액'] ? String(row['금액']) : '',
수량: parseInt(row['수량'] || '1', 10),
계정명: row['계정명'] || '',
납품업체: row['납품업체'] || '',
비고: row['비고'] || '',
});
});
}
if (sheetName === 'SW_사용자') {
json.forEach(row => {
swUsers.push({
id: row['id'] ? String(row['id']) : Math.random().toString(36).substring(2, 9),
swId: row['swId'] ? String(row['swId']) : '',
법인: row['법인'] || '',
부서: row['부서'] || '',
: row['팀'] || '',
직위: row['직위'] || '',
이름: row['이름'] || '',
사용기간: row['사용기간'] || '',
신청서명: row['신청서명'] || '',
});
});
}
});
resolve({ hw: hwAssets, sw: swAssets, swUsers });
} catch (err) {
reject(err);
}
};
reader.onerror = (err) => reject(err);
reader.readAsBinaryString(file);
});
}

View File

@@ -1,88 +1,89 @@
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 } from 'lucide'; import { state } from './core/state';
import { downloadTemplate, exportToExcel, parseExcel } from './excelHandler'; import { renderSidebar } from './components/Sidebar';
import { state } from './state';
import { initSidebar } from './components/Sidebar';
import { initBaseModal } from './components/Modal/BaseModal';
import { initPCModal, openPcModal } from './components/Modal/PCModal';
import { initHWModal, openHwModal } from './components/Modal/HWModal';
import { initStorageModal, openStorageModal } from './components/Modal/StorageModal';
import { initSWModal, openSwModal } from './components/Modal/SWModal';
import { initSWUserModal, openSwUserModal } from './components/Modal/SWUserModal';
import { renderDashboard } from './views/DashboardView'; import { renderDashboard } from './views/DashboardView';
import { renderTable } from './views/AssetTableView'; import { renderTable } from './views/AssetTableView';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset } from './core/excelHandler';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initStorageModal } from './components/Modal/StorageModal';
import { initSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw } from 'lucide';
declare var Chart: any; // --- App Initialization ---
function initApp() {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
// --- DOM Elements --- // 1. 초기 뷰 렌더링 (대시보드)
const mainContent = document.getElementById('main-content') as HTMLElement; renderDashboard(mainContent);
const uploadInput = document.getElementById('excel-upload') as HTMLInputElement;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
const btnDownloadTemp = document.getElementById('btn-download-template') as HTMLButtonElement;
const btnExport = document.getElementById('btn-export-excel') as HTMLButtonElement;
// Initialize Icons
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 }
});
// Initialize Components
const { closeAllModals } = initBaseModal();
initSidebar(renderContent);
initPCModal(renderContent, closeAllModals);
initHWModal(renderContent, closeAllModals);
initStorageModal(renderContent, closeAllModals);
initSWModal(renderContent, closeAllModals);
initSWUserModal(renderContent, closeAllModals);
// Dashboard Detail 닫기 버튼 (공통 처리용)
const btnCloseDashboardDetail = document.getElementById('btn-close-dashboard-detail') as HTMLButtonElement;
btnCloseDashboardDetail?.addEventListener('click', () => {
document.getElementById('dashboard-detail-modal')?.classList.add('hidden');
});
// Add Element Button
btnAddAsset?.addEventListener('click', () => {
if (state.activeSubTab === '대시보드') return;
if (state.activeCategory === 'hw') {
if (state.activeSubTab === '개인PC') openPcModal();
else if (state.activeSubTab === '스토리지') openStorageModal();
else openHwModal();
} else {
openSwModal();
}
});
// --- Excel Controls ---
btnDownloadTemp?.addEventListener('click', () => downloadTemplate());
btnExport?.addEventListener('click', () => exportToExcel(state.masterData));
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
state.masterData = await parseExcel(file);
renderContent();
alert('모든 시트 데이터 연동 완료');
} catch (error) {
console.error(error);
alert('엑셀 파싱 오류: 템플릿 양식이 다르거나 파일이 손상되었습니다.');
} finally {
uploadInput.value = '';
}
});
/**
* 전역 렌더링 함수 - 각 컴포넌트에서 호출하여 화면을 갱신합니다.
*/
function renderContent() {
mainContent.innerHTML = ''; // 화면 초기화
if (state.activeSubTab === '대시보드') { // 2. 사이드바 초기화
renderDashboard(mainContent); renderSidebar((tab) => {
} else { if (tab === '대시보드') {
renderTable(mainContent); renderDashboard(mainContent);
} document.getElementById('btn-add-asset')?.classList.add('hidden');
} else {
renderTable(mainContent);
document.getElementById('btn-add-asset')?.classList.remove('hidden');
}
// 상단 타이틀 업데이트
const titleEl = document.getElementById('current-tab-title')!;
if (titleEl) {
const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어';
titleEl.textContent = `${catName} / ${state.activeSubTab}`;
}
});
// 3. 모달 초기화 (HTML 주입 및 이벤트 바인딩)
initPcModal(() => renderTable(mainContent), () => {});
initHwModal();
initStorageModal(() => renderTable(mainContent), () => {});
initSwModal(() => renderTable(mainContent), () => {});
initSwUserModal(() => renderTable(mainContent), () => {});
initDashboardDetailModal();
// 4. 전역 버튼 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
const uploadInput = document.getElementById('excel-upload') as HTMLInputElement;
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const data = await parseExcel(file);
state.masterData = data;
renderTable(mainContent);
}
});
document.getElementById('btn-add-asset')?.addEventListener('click', () => {
if (state.activeSubTab === '서버' || state.activeSubTab === '전산비품' || state.activeSubTab === '스토리지') {
const newAsset: HardwareAsset = {
id: Math.random().toString(36).substring(2, 9),
type: state.activeSubTab,
: '한맥',
: '',
: '',
: '',
: '',
IP주소: '',
MACaddress: '',
HW사양: '',
OS: '',
: '',
: ''
};
openHwModal(newAsset);
}
});
// 전역 아이콘 초기화
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw }
});
} }
// Initial Render // Start the app
renderContent(); document.addEventListener('DOMContentLoaded', initApp);

13
src/server_data.json Normal file
View File

@@ -0,0 +1,13 @@
[
{
"법인": "(주)회사1",
"자산코드": "ASSET-100",
"명칭": "서버 모델A",
"위치": "본사 1층",
"관리자": "관리자A",
"IP주소": "192.168.0.1",
"MACaddress": "00:00:00:00:00:01",
"HW사양": "Core i7, 16GB RAM",
"OS": "Windows 10"
}
]

View File

@@ -1,23 +0,0 @@
import { MasterAssetData } from './excelHandler';
import { generateDummyData } from './dummyDataGenerator';
// --- State Definitions ---
export interface AppState {
masterData: MasterAssetData;
activeCategory: 'hw' | 'sw';
activeSubTab: string;
activeCharts: any[];
}
// --- Initial State ---
export const state: AppState = {
masterData: generateDummyData(),
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: []
};
// --- State Helpers ---
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}

View File

@@ -215,14 +215,22 @@ body {
.btn { .btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.5rem 1rem; padding: 0.5rem 1.25rem; /* 표준 사이즈로 통일 */
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
line-height: 1; line-height: 1;
min-width: 80px; /* 버튼의 최소 너비 확보 */
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-width: auto;
} }
.btn i { width: 16px; height: 16px; } .btn i { width: 16px; height: 16px; }
@@ -240,12 +248,18 @@ body {
.btn-outline { .btn-outline {
background-color: transparent; background-color: transparent;
color: var(--text-main); color: var(--primary-color);
border: 1px solid var(--border-color); border: 1px solid var(--primary-color);
white-space: nowrap;
} }
.btn-outline:hover { .btn-outline:hover {
background-color: var(--border-color); background-color: var(--primary-light);
border-color: var(--primary-color);
}
.text-nowrap {
white-space: nowrap;
} }
.btn-danger { .btn-danger {
@@ -270,12 +284,15 @@ body {
background-color: var(--white); background-color: var(--white);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
overflow-x: auto; overflow: auto; /* 가로/세로 스크롤 허용 */
max-height: calc(100vh - 180px); /* 화면 높이에 맞춰 제한 (가로 스크롤바 노출용) */
position: relative;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: separate; /* sticky border 유지를 위해 separate 설정 */
border-spacing: 0;
text-align: left; text-align: left;
} }
@@ -290,6 +307,10 @@ th {
font-size: 0.875rem; font-size: 0.875rem;
white-space: nowrap; white-space: nowrap;
background-color: #FAFAFA; background-color: #FAFAFA;
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color); /* sticky 시 경계선 유지 */
} }
td { td {
@@ -300,55 +321,70 @@ tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background-color: var(--bg-color); } tbody tr:hover { background-color: var(--bg-color); }
.empty-row td { text-align: center; padding: 3rem; color: var(--text-muted); } .empty-row td { text-align: center; padding: 3rem; color: var(--text-muted); }
/* Modal */ .sw-table td {
.modal-overlay { text-align: center;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
} }
.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; } /* Search Filter Bar */
.modal-content { .search-bar {
background-color: var(--white);
width: 100%; max-width: 600px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
transform: translateY(20px);
transition: transform 0.2s ease;
}
.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); }
.modal-header {
background-color: var(--primary-color);
color: var(--white);
padding: 1rem 1.5rem;
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
align-items: center; gap: 1rem;
background-color: var(--white);
padding: 1.25rem;
border: 1px solid var(--border-color);
border-radius: 8px;
margin-bottom: 2rem;
align-items: flex-end;
} }
.modal-header h2 { font-size: 1.125rem; font-weight: 500; }
.modal-body { padding: 1.5rem; } .search-item {
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; } display: flex;
.form-group { display: flex; flex-direction: column; gap: 0.375rem; } flex-direction: column;
.form-group.full-width { grid-column: span 2; } gap: 0.5rem;
.form-group label { font-size: 0.875rem; font-weight: 500; } min-width: 180px;
.form-group input, .form-group textarea { }
padding: 0.625rem;
.search-item.flex-1 {
flex: 1;
min-width: 250px;
}
.search-item label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.search-item input,
.search-item select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
font-family: inherit; font-size: 0.875rem; font-size: 0.875rem;
outline: none; transition: border-color 0.2s; outline: none;
transition: all 0.2s;
background-color: var(--white);
} }
.form-group input:focus, .form-group textarea:focus { border-color: var(--primary-color); }
.modal-footer { .search-item input:focus,
padding: 1rem 1.5rem; border-top: 1px solid var(--border-color); .search-item select:focus {
display: flex; justify-content: space-between; align-items: center; border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
}
.btn-reset {
height: 36px;
padding: 0 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.hidden {
display: none !important;
} }
.footer-actions { display: flex; gap: 0.5rem; }

259
src/styles/modal.css Normal file
View File

@@ -0,0 +1,259 @@
/* Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; }
.modal-content {
background-color: var(--white);
width: 100%;
max-width: 600px;
max-height: 90vh;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
transform: translateY(20px);
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
}
.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); }
.modal-header {
background-color: var(--primary-color);
color: var(--white);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.modal-header h2 {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.02em;
}
.modal-header .btn-icon {
color: var(--white);
opacity: 0.8;
transition: opacity 0.2s;
}
.modal-header .btn-icon:hover {
opacity: 1;
}
.modal-body {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
}
.grid-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group.full-width {
grid-column: span 2;
}
/* Section Title for Grouping */
.form-section-title {
grid-column: span 2;
font-size: 0.875rem;
font-weight: 700;
color: var(--primary-color);
padding: 1.5rem 0 0.5rem 0; /* 패딩 조정 */
border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
}
/* Modal Readonly/Edit Mode Interaction */
.grid-form.is-view-mode input,
.grid-form.is-view-mode select,
.grid-form.is-view-mode textarea {
border-color: transparent !important;
background-color: transparent !important;
padding-left: 0;
padding-right: 0;
pointer-events: none;
color: var(--text-main);
font-weight: 500;
}
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00;
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1);
}
.form-section-title:first-child {
padding-top: 0.5rem;
}
.form-group label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
}
.form-group input,
.form-group select,
.form-group textarea {
padding: 0.625rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit;
font-size: 0.875rem;
outline: none;
transition: all 0.2s;
background-color: var(--white);
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: #FAFAFA;
flex-shrink: 0;
}
.footer-actions {
display: flex;
gap: 0.5rem;
}
/* Wide Modal for History/Detail */
.modal-content.wide {
max-width: 950px;
}
.modal-body-split {
display: flex;
gap: 2rem;
min-height: 480px;
}
.modal-form-area {
flex: 1.2;
}
.modal-history-area {
flex: 0.8;
border-left: 1px solid var(--border-color);
padding-left: 1.5rem;
display: flex;
flex-direction: column;
}
.history-header {
margin-bottom: 1rem;
}
.history-header h3 {
font-size: 0.9375rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-main);
}
.history-timeline {
flex: 1;
overflow-y: auto;
max-height: 500px;
padding-right: 0.5rem;
}
.history-item {
position: relative;
padding-left: 1.25rem;
padding-bottom: 1.5rem;
border-left: 2px solid var(--border-color);
}
.history-item::before {
content: '';
position: absolute;
left: -7px;
top: 0;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--white);
border: 2px solid var(--primary-color);
}
.history-item:last-child {
border-left: 2px solid transparent;
}
.history-date {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 0.25rem;
}
.history-user {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color);
margin-bottom: 0.25rem;
}
.history-details {
font-size: 0.8125rem;
color: var(--text-main);
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
}
.empty-history {
padding: 2rem 0;
text-align: center;
color: var(--text-muted);
font-size: 0.8125rem;
}

View File

@@ -1,5 +1,5 @@
import { state } from '../state'; import { state } from '../core/state';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 } from 'lucide'; import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
import { openPcModal } from '../components/Modal/PCModal'; import { openPcModal } from '../components/Modal/PCModal';
import { openHwModal } from '../components/Modal/HWModal'; import { openHwModal } from '../components/Modal/HWModal';
import { openStorageModal } from '../components/Modal/StorageModal'; import { openStorageModal } from '../components/Modal/StorageModal';
@@ -10,8 +10,9 @@ import { openSwUserModal } from '../components/Modal/SWUserModal';
* 자산 목록 테이블 렌더링 메인 함수 * 자산 목록 테이블 렌더링 메인 함수
*/ */
export function renderTable(mainContent: HTMLElement) { export function renderTable(mainContent: HTMLElement) {
mainContent.innerHTML = '';
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'table-container'; container.className = 'view-container';
const table = document.createElement('table'); const table = document.createElement('table');
if (state.activeCategory === 'hw') { if (state.activeCategory === 'hw') {
@@ -20,7 +21,6 @@ export function renderTable(mainContent: HTMLElement) {
renderSwTable(table, container, mainContent); renderSwTable(table, container, mainContent);
} }
// 테이블 내 아이콘 초기화
createIcons({ createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 } icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2 }
}); });
@@ -28,68 +28,149 @@ export function renderTable(mainContent: HTMLElement) {
function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) { function renderHwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const list = state.masterData.hw.filter(a => a.type === state.activeSubTab); const list = state.masterData.hw.filter(a => a.type === state.activeSubTab);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
if (state.activeSubTab === '개인PC') { if (state.activeSubTab === '개인PC') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>GPU</th><th>RAM</th><th>SSD1</th><th>SSD2</th><th>HDD1</th><th>HDD2</th><th>구매일</th><th>금액</th><th>납품업체</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산코드</th><th>사용자</th><th>위치</th><th>CPU</th><th>GPU</th><th>RAM</th><th>SSD1</th><th>SSD2</th><th>HDD1</th><th>HDD2</th><th>구매일</th><th>금액</th><th>납품업체</th><th>품의서</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
container.appendChild(table); tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container); mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!; const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="17">등록된 자산이 없습니다.</td></tr>`; return; } if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="17">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => { list.forEach((asset, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.CPU||''}</td><td>${asset.GPU||''}</td><td>${asset.RAM||''}</td><td>${asset.SSD1||'-'}</td><td>${asset.SSD2||'-'}</td><td>${asset.HDD1||'-'}</td><td>${asset.HDD2||'-'}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td><td><button class="btn-outline btn-edit">수정</button></td>`; tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.CPU||''}</td><td>${asset.GPU||''}</td><td>${asset.RAM||''}</td><td>${asset.SSD1||'-'}</td><td>${asset.SSD2||'-'}</td><td>${asset.HDD1||'-'}</td><td>${asset.HDD2||'-'}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td style="text-align:center;">${asset. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset); }); tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openPcModal(asset); });
tr.querySelector('.btn-edit')?.addEventListener('click', () => openPcModal(asset));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} else if (state.activeSubTab === '스토리지') { } else if (state.activeSubTab === '스토리지') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>모델명</th><th>용량</th><th>담당자(정)</th><th>IP주소</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>유형</th><th>자산코드</th><th>명칭</th><th>위치</th><th>모델명</th><th>용량</th><th>담당자(정)</th><th>IP주소</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
container.appendChild(table); tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container); mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!; const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="13">등록된 자산이 없습니다.</td></tr>`; return; } if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="13">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => { list.forEach((asset, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.storage유형||''}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset._정||''}</td><td>${asset.IP주소||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn-outline btn-edit">수정</button></td>`; tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.storage유형||''}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td>${asset._정||''}</td><td>${asset.IP주소||''}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openStorageModal(asset); }); tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openStorageModal(asset); });
tr.querySelector('.btn-edit')?.addEventListener('click', () => openStorageModal(asset));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} else { } else {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th>${state.activeSubTab === '전산비품' ? '<th>유형</th>' : ''}<th>자산코드</th><th>명칭</th><th>위치</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; // 서버 또는 전산비품
container.appendChild(table); if (state.activeSubTab === '서버') {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>자산번호</th><th>유형</th><th>용도</th><th>상세</th><th>설치위치</th><th>담당자</th><th>IP 주소</th><th>원격접속</th><th>모델명</th><th>OS</th><th>CPU</th><th>RAM</th><th>Storage</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
} else {
table.innerHTML = `<thead><tr><th>No</th><th>법인</th>${state.activeSubTab === '전산비품' ? '<th>유형</th>' : ''}<th>자산코드</th><th>명칭</th><th>위치</th><th>관리자</th><th>구매일</th><th>금액</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`;
}
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container); mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!; const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="10">등록된 자산이 없습니다.</td></tr>`; return; } const colCount = state.activeSubTab === '서버' ? 15 : (state.activeSubTab === '전산비품' ? 11 : 10);
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="${colCount}">등록된 자산이 없습니다.</td></tr>`; return; }
list.forEach((asset, idx) => { list.forEach((asset, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.style.cursor = 'pointer'; tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td>${state.activeSubTab === '전산비품' ? `<td>${asset.||'-'}</td>` : ''}<td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn-outline btn-edit">수정</button></td>`; const formatInline = (v: any) => String(v || '').replace(/\n/g, ' / ').trim();
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); }); const getBadge = (text: string, bgColor: string) => `<span style="background:${bgColor}; color:white; font-size:10px; padding:1px 4px; border-radius:3px; font-weight:700; margin-right:4px; display:inline-block; line-height:1.2;">${text}</span>`;
tr.querySelector('.btn-edit')?.addEventListener('click', () => openHwModal(asset));
if (state.activeSubTab === '서버') {
const mainManager = asset._정 || '';
const subManager = asset._부 || '';
const managerHtml = [mainManager ? `${getBadge('정', '#1E5149')} ${mainManager}` : '', subManager ? `${getBadge('부', '#9CA3AF')} ${subManager}` : ''].filter(v => v !== '').join(' / ');
const tools = (asset. || '').split('\n');
const ids = (asset.ID || '').split('\n');
const pws = (asset.PW || '').split('\n');
const maxLen = Math.max(tools.length, ids.length, pws.length);
let remoteItems = [];
for(let i=0; i<maxLen; i++) {
let toolName = tools[i] || '접속';
let badgeColor = '#3B82F6';
if (toolName.toLowerCase().includes('any')) badgeColor = '#EF4444';
if (toolName.toLowerCase().includes('chrome')) badgeColor = '#F59E0B';
let item = `${getBadge(toolName, badgeColor)}`;
if (ids[i] || pws[i]) item += ` (${ids[i] || '-'}/${pws[i] || '-'})`;
remoteItems.push(item);
}
const remoteHtml = remoteItems.join(' / ');
const ipInfo = [asset.IP주소, asset.IP2].filter(v => v && v !== '').join(' / ');
const storageInfo = [asset.SSD1, asset.SSD2].filter(v => v && v !== '').join(' / ');
tr.innerHTML = `<td>${idx+1}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.storage유형)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${managerHtml}</td><td class="text-nowrap">${formatInline(ipInfo)}</td><td class="text-nowrap">${remoteHtml}</td><td class="text-nowrap">${formatInline(asset.)}</td><td class="text-nowrap">${formatInline(asset.OS)}</td><td class="text-nowrap">${formatInline(asset.CPU)}</td><td class="text-nowrap">${formatInline(asset.RAM)}</td><td class="text-nowrap">${formatInline(storageInfo)}</td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
} else {
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td>${state.activeSubTab === '전산비품' ? `<td>${asset.||'-'}</td>` : ''}<td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.||''}</td><td><button class="btn btn-outline btn-sm btn-edit">수정</button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openHwModal(asset); });
}
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
} }
} }
function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) { function renderSwTable(table: HTMLTableElement, container: HTMLElement, mainContent: HTMLElement) {
const list = state.masterData.sw.filter(a => a.type === state.activeSubTab); const fullList = state.masterData.sw.filter(a => a.type === state.activeSubTab);
table.innerHTML = `<thead><tr><th>No</th><th>법인</th><th>제품명</th><th>구매일</th><th>수량</th><th>사용가능</th><th>관리</th></tr></thead><tbody id="dynamic-tbody"></tbody>`; const isSub = state.activeSubTab === '구독SW';
container.appendChild(table); container.innerHTML = '';
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.innerHTML = `<div class="search-item flex-1"><label>통합 검색 (제품명/부서)</label><input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off"></div><div class="search-item"><label>분야</label><select id="filter-field"><option value="">전체 분야</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="search-item"><label>법인</label><select id="filter-corp"><option value="">전체 법인</option><option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option></select></div><button id="btn-reset-filters" class="btn btn-outline btn-reset" title="검색 조건 초기화"><i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i> 필터 초기화</button>`;
container.appendChild(filterBar);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
table.classList.add('sw-table');
table.innerHTML = `<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><th style="text-align:center;">구매일</th>${isSub ? '<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></tr></thead><tbody id="dynamic-tbody"></tbody>`;
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
mainContent.appendChild(container); mainContent.appendChild(container);
const tbody = document.getElementById('dynamic-tbody')!; const tbody = document.getElementById('dynamic-tbody')!;
if (list.length === 0) { tbody.innerHTML = `<tr><td colspan="7">정보가 없습니다.</td></tr>`; return; } const updateTable = () => {
list.forEach((asset, idx) => { const keyword = (document.getElementById('filter-keyword') as HTMLInputElement).value.toLowerCase().trim();
const assigned = state.masterData.swUsers.filter(u => u.swId === asset.id).length; const field = (document.getElementById('filter-field') as HTMLSelectElement).value;
const avail = asset. - assigned; const corp = (document.getElementById('filter-corp') as HTMLSelectElement).value;
const tr = document.createElement('tr'); const filtered = fullList.filter(asset => {
tr.style.cursor = 'pointer'; const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
tr.innerHTML = `<td>${idx+1}</td><td>${asset.}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.}</td><td><strong style="color: ${avail > 0 ? 'var(--primary)' : 'var(--danger)'}">${avail}</strong></td><td style="display:flex; gap:0.25rem;"><button class="btn-outline btn-edit">수정</button><button class="btn-outline btn-users"><i data-lucide="users" style="width:14px; height:14px;"></i></button></td>`; const matchField = !field || asset. === field;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); }); const matchCorp = !corp || asset. === corp;
tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset)); return matchKeyword && matchField && matchCorp;
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset)); });
tbody.appendChild(tr); 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>`;
return;
}
filtered.forEach((asset, idx) => {
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;
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `<td>${idx+1}</td><td>${asset.||''}</td><td>${asset.}</td><td>${asset.||''}</td><td>${asset.}</td><td>${asset.||''}</td>${isSub ? `<td>${asset.||''}</td>` : ''}<td>${asset.||'0'}</td><td>${qty}</td><td><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td><td style="display:flex; justify-content:center; align-items:center; gap:0.5rem;"><button type="button" class="btn-icon btn-edit" title="수정" style="color: var(--text-muted);"><i data-lucide="edit-2" style="width:18px; height:18px;"></i></button><button type="button" class="btn-icon btn-users" title="사용자 관리" style="color: var(--primary-color);"><i data-lucide="users" style="width:18px; height:18px;"></i></button></td>`;
tr.addEventListener('click', (e) => { if (!(e.target as HTMLElement).closest('button')) openSwModal(asset); });
tr.querySelector('.btn-edit')?.addEventListener('click', () => openSwModal(asset));
tr.querySelector('.btn-users')?.addEventListener('click', () => openSwUserModal(asset));
tbody.appendChild(tr);
});
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};
const keywordInput = document.getElementById('filter-keyword') as HTMLInputElement;
const fieldSelect = document.getElementById('filter-field') as HTMLSelectElement;
const corpSelect = document.getElementById('filter-corp') as HTMLSelectElement;
const resetBtn = document.getElementById('btn-reset-filters') as HTMLButtonElement;
keywordInput.addEventListener('input', updateTable);
fieldSelect.addEventListener('change', updateTable);
corpSelect.addEventListener('change', updateTable);
resetBtn.addEventListener('click', () => {
keywordInput.value = ''; fieldSelect.value = ''; corpSelect.value = '';
updateTable();
}); });
updateTable();
} }

View File

@@ -1,14 +1,22 @@
import { state } from '../state'; import { state } from '../core/state';
import { HardwareAsset, SoftwareAsset } from '../excelHandler'; import { HardwareAsset, SoftwareAsset } from '../core/excelHandler';
import { openDashboardDetail, openSwDashboardDetail, openSwUsageDetail } from '../components/Modal/DashboardDetailModal';
declare var Chart: any;
/** /**
* 대시보드 렌더링 메인 함수 * 대시보드 렌더링 메인 함수
*/ */
export function renderDashboard(mainContent: HTMLElement) { export function renderDashboard(mainContent: HTMLElement) {
if (!mainContent) return;
mainContent.innerHTML = ''; mainContent.innerHTML = '';
// 기존 차트 리소스 해제 // 기존 차트 리소스 해제
state.activeCharts.forEach(c => c.destroy()); if (state.activeCharts) {
state.activeCharts.forEach(c => {
if (c && typeof c.destroy === 'function') c.destroy();
});
}
state.activeCharts = []; state.activeCharts = [];
if (state.activeCategory === 'hw') { if (state.activeCategory === 'hw') {
@@ -37,7 +45,6 @@ function renderHwDashboard(container: HTMLElement) {
else groups[a.type].normal.push(a); else groups[a.type].normal.push(a);
}); });
// 사용현황 카드 생성
let usageCards = ''; let usageCards = '';
types.forEach((t, i) => { types.forEach((t, i) => {
const total = groups[t].idle.length + groups[t].active.length; const total = groups[t].idle.length + groups[t].active.length;
@@ -65,7 +72,6 @@ function renderHwDashboard(container: HTMLElement) {
</div>`; </div>`;
}); });
// 노후화 카드 생성
let agedCards = ''; let agedCards = '';
types.forEach((t, i) => { types.forEach((t, i) => {
const total = groups[t].aged.length + groups[t].normal.length; const total = groups[t].aged.length + groups[t].normal.length;
@@ -100,7 +106,6 @@ function renderHwDashboard(container: HTMLElement) {
<div class="dashboard-layout-2col">${agedCards}</div> <div class="dashboard-layout-2col">${agedCards}</div>
`; `;
// 클릭 이벤트 바인딩
container.querySelectorAll('[data-action="idle"]').forEach(card => { container.querySelectorAll('[data-action="idle"]').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
const t = card.getAttribute('data-type')!; const t = card.getAttribute('data-type')!;
@@ -120,9 +125,20 @@ function renderSwDashboard(container: HTMLElement) {
let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0; let subQty = 0, subUsed = 0, subExp = 0, subTotal = 0;
let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0; let permQty = 0, permUsed = 0, permExp = 0, permTotal = 0;
const currentYear = new Date().getFullYear().toString();
const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
const costByCorp: Record<string, number> = { '한맥': 0, '삼안': 0, '바론': 0 };
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
state.masterData.sw.forEach(sw => { state.masterData.sw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length; const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10); const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0';
const price = parseInt(priceStr, 10) || 0;
if (sw.type === '구독SW') { if (sw.type === '구독SW') {
subQty += qty; subUsed += assigned; subTotal++; subQty += qty; subUsed += assigned; subTotal++;
if (isSWExpiring(sw)) subExp++; if (isSWExpiring(sw)) subExp++;
@@ -130,6 +146,11 @@ function renderSwDashboard(container: HTMLElement) {
permQty += qty; permUsed += assigned; permTotal++; permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++; if (isSWExpiring(sw)) permExp++;
} }
if (sw. && sw..startsWith(currentYear)) {
if (costByCorp[sw.] !== undefined) costByCorp[sw.] += price;
if (sw. && costByCat[sw.] !== undefined) costByCat[sw.] += price;
}
}); });
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0; const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
@@ -160,25 +181,122 @@ function renderSwDashboard(container: HTMLElement) {
</div> </div>
</div> </div>
</div> </div>
<div class="dashboard-layout-2col">
<div class="dashboard-card" data-action="sub-exp" style="padding: 1.25rem 1.5rem; cursor:pointer; display:flex; justify-content:space-between; align-items:center;"> <div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div> <div class="dashboard-card" data-action="sub-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정</span> <div style="flex:1;">
<div style="font-size: 0.8125rem; color:var(--text-muted);">${subExp}개 만료 예정</div> <div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">구독 SW 만료 예정</span>
<span style="font-size:0.75rem; color:#bfbfbf; background:#f9f9f9; padding:2px 6px; border-radius:4px;">30일 이내</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1.25rem;">
전체 ${subTotal}개 제품 중 ${subExp}개 만료 예정
</div>
<div style="font-size: 1.5rem; font-weight:700; color:${subExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'};">${subExp}개</div>
</div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 64px; height: 64px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 1rem; color:var(--text-muted); font-weight:600;">${subExpPer}%</span>
</div>
</div> </div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${subExpPer}%, var(--border-color) 0);"></div>
</div> </div>
<div class="dashboard-card" data-action="perm-exp" style="padding: 1.25rem 1.5rem; cursor:pointer; display:flex; justify-content:space-between; align-items:center;"> <div class="dashboard-card" data-action="perm-exp" style="padding: 1.25rem 1.5rem; flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer;">
<div> <div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정</span> <div style="display:flex; align-items:center; gap: 0.5rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8125rem; color:var(--text-muted);">${permExp} 만료 예정</div> <span style="font-size:1rem; font-weight:700; color:var(--text-main);">유지보수 만료 예정</span>
<span style="font-size:0.75rem; color:#bfbfbf; background:#f9f9f9; padding:2px 6px; border-radius:4px;">30일 이내</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1.25rem;">
전체 ${permTotal}개 제품 중 ${permExp}개 만료 예정
</div>
<div style="font-size: 1.5rem; font-weight:700; color:${permExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'};">${permExp}개</div>
</div> </div>
<div style="width: 60px; height: 60px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0);"></div> <div style="width: 80px; height: 80px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${permExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 64px; height: 64px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 1rem; color:var(--text-muted); font-weight:600;">${permExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 style="margin: 0 0 1rem 0; font-size: 1.125rem; color: var(--text-main);">${currentYear}년 소프트웨어 도입 비용</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card" style="padding: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">법인별 도입 금액 (원)</h4>
<canvas id="chart-cost-corp" style="max-height: 250px;"></canvas>
</div>
<div class="dashboard-card" style="padding: 1.5rem;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9375rem; color: var(--text-main);">분야별 도입 금액 (원)</h4>
<canvas id="chart-cost-cat" style="max-height: 250px;"></canvas>
</div> </div>
</div> </div>
`; `;
// 클릭 이벤트 바인딩 setTimeout(() => {
const ctxCorp = (document.getElementById('chart-cost-corp') as HTMLCanvasElement)?.getContext('2d');
const ctxCat = (document.getElementById('chart-cost-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp && typeof Chart !== 'undefined') {
const chartCorp = new Chart(ctxCorp, {
type: 'bar',
data: {
labels: corps,
datasets: [{
label: '도입 금액',
data: corps.map(c => costByCorp[c]),
backgroundColor: '#3b82f6',
borderRadius: 4,
barThickness: 20
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: (v: any) => v.toLocaleString() },
grid: { display: false }
},
x: { grid: { display: false } }
}
}
});
state.activeCharts.push(chartCorp);
}
if (ctxCat && typeof Chart !== 'undefined') {
const chartCat = new Chart(ctxCat, {
type: 'bar',
data: {
labels: categories,
datasets: [{
label: '도입 금액',
data: categories.map(c => costByCat[c]),
backgroundColor: '#10b981',
borderRadius: 4,
barThickness: 20
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: (v: any) => v.toLocaleString() },
grid: { display: false }
},
x: { grid: { display: false } }
}
}
});
state.activeCharts.push(chartCat);
}
}, 0);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => { container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => {
openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW')); openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW'));
}); });
@@ -193,7 +311,6 @@ function renderSwDashboard(container: HTMLElement) {
}); });
} }
// --- 공통 헬퍼 함수들 ---
function isHwIdle(a: HardwareAsset) { function isHwIdle(a: HardwareAsset) {
if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-'; if (a.type === '개인PC') return !a. || a..trim() === '' || a..trim() === '-';
if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-'; if (a.type === '스토리지') return !a._정 || a._정.trim() === '' || a._정.trim() === '-';
@@ -202,7 +319,11 @@ function isHwIdle(a: HardwareAsset) {
function getHwAgeYears(a: HardwareAsset) { function getHwAgeYears(a: HardwareAsset) {
if (!a.) return 0; if (!a.) return 0;
return (Date.now() - new Date(a.).getTime()) / (1000 * 60 * 60 * 24 * 365.25); try {
const buyDate = new Date(a..replace(/\./g, '-'));
if (isNaN(buyDate.getTime())) return 0;
return (Date.now() - buyDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25);
} catch { return 0; }
} }
function isSWExpiring(sw: SoftwareAsset) { function isSWExpiring(sw: SoftwareAsset) {
@@ -215,73 +336,12 @@ function isSWExpiring(sw: SoftwareAsset) {
return diffDays >= 0 && diffDays <= 30; return diffDays >= 0 && diffDays <= 30;
} }
} else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) { } else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
const endStr = sw..split('~')[1].trim(); try {
const endMs = new Date(endStr.replace(/\./g, '-')).getTime(); const endStr = sw..split('~')[1].trim();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24); const endMs = new Date(endStr.replace(/\./g, '-')).getTime();
return diffDays >= 0 && diffDays <= 30; const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
} catch { return false; }
} }
return false; return false;
} }
// --- 대시보드 상세 모달 제어 (main.ts의 함수 재사용 또는 이동 필요) ---
// 일단 main.ts에 있는 함수를 전역에서 가져와 쓸 수 없으므로, 여기서 직접 정의하거나 main.ts에서 export 해야 합니다.
// 구조 개선을 위해 main.ts에서 이 함수들도 DashboardView로 옮기는 것이 좋습니다.
function openDashboardDetail(title: string, list: HardwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal') as HTMLDivElement;
const titleEl = document.getElementById('dashboard-detail-modal-title') as HTMLHeadingElement;
const tbody = document.getElementById('dashboard-detail-tbody') as HTMLTableSectionElement;
const thead = tbody.closest('table')!.querySelector('thead')!;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset. || asset. || asset._정 || '-';
let name = asset. || asset. || '-';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.type}</td><td>${asset.}</td><td>${name}</td><td>${asset.||'-'}</td><td>${manager}</td><td>${asset.||'-'}</td><td>${asset.||'-'}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
function openSwDashboardDetail(title: string, list: SoftwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal') as HTMLDivElement;
const titleEl = document.getElementById('dashboard-detail-modal-title') as HTMLHeadingElement;
const tbody = document.getElementById('dashboard-detail-tbody') as HTMLTableSectionElement;
const thead = tbody.closest('table')!.querySelector('thead')!;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>법인</th><th>제품명</th><th>수량</th><th>금액</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.type}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
function openSwUsageDetail(title: string, list: SoftwareAsset[]) {
const modal = document.getElementById('dashboard-detail-modal') as HTMLDivElement;
const titleEl = document.getElementById('dashboard-detail-modal-title') as HTMLHeadingElement;
const tbody = document.getElementById('dashboard-detail-tbody') as HTMLTableSectionElement;
const thead = tbody.closest('table')!.querySelector('thead')!;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const avail = (typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10)) - assigned;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${assigned}</td><td>${avail}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}