Compare commits
90 Commits
PC_Table
...
565802f55b
| Author | SHA1 | Date | |
|---|---|---|---|
| 565802f55b | |||
| 525dbd77d4 | |||
| 35c5b1e0fa | |||
| b87ca2854b | |||
| 2f88a0fae7 | |||
| 9a2c35e652 | |||
| 2b9c965c91 | |||
| 4b408b0640 | |||
| 3ab587d342 | |||
| 3b9b2ea598 | |||
| 05c565552a | |||
| 2ec9261c03 | |||
| 06f3baaa58 | |||
| eead43837d | |||
| 46422e8544 | |||
| a30f99f0ad | |||
| 34d99dc4b6 | |||
| bb859dddfc | |||
| 9e8ab11f99 | |||
| 19d4222470 | |||
| db5c7a96a6 | |||
| 7d3d5ef281 | |||
| 9cd5d59bf8 | |||
| 590ddd0e85 | |||
| bf7fb0ffe6 | |||
| 2c67037fc4 | |||
| b2713a142d | |||
| 82bbe85e23 | |||
| d34ebb8500 | |||
| 2af79cdad3 | |||
| 68cb5f9767 | |||
| 8f0508a7d0 | |||
| 171bcc772b | |||
| ab0d25b827 | |||
| d7af75976e | |||
| dde3aefaac | |||
| 1fbd297988 | |||
| 4b5e25fd3f | |||
| 367f72673d | |||
| 9fcecd4bf5 | |||
| d125de1902 | |||
| d8a0c47fb3 | |||
| 4b88ac01a4 | |||
| 5feaa5f170 | |||
| 9365af4522 | |||
| 55e9cd4cd9 | |||
| bb1cc36d01 | |||
| e5b4eb8295 | |||
| b996b18dbc | |||
| e147b1a191 | |||
| 925a55bcc6 | |||
| 809f3fcf3b | |||
| 9d9c482b76 | |||
| 11e2f3b4ca | |||
| e1cdcfd93a | |||
| fdc29b23c1 | |||
| af37df7f2d | |||
| d52c2c4200 | |||
| 4b765aba2e | |||
| 7247737ce0 | |||
| e4d958b5f2 | |||
| ba7ce796d1 | |||
| 5ff991693a | |||
| a576d54a2d | |||
| fca9f5caf8 | |||
| 34baea9143 | |||
| d983ad469f | |||
| 153e422180 | |||
| 213bbe4734 | |||
| d8824ca0e1 | |||
| 1ace678c09 | |||
| 5248b494e9 | |||
| 5372cda59f | |||
| 415727a866 | |||
| c5d7f4cf67 | |||
| 90d94739a2 | |||
| 6904925146 | |||
| 6053c746a3 | |||
| 97478dd9bc | |||
| b3f7920176 | |||
| c5c6acea6a | |||
| 54bfb9d482 | |||
| 7158689fd0 | |||
| fde7ef8439 | |||
| a805d9ce06 | |||
| 7c4ccf6bba | |||
| 3c28c664da | |||
| d94be9a494 | |||
| 157330b06d | |||
| e4914ee66d |
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.gemini/
|
.gemini
|
||||||
.env
|
.env
|
||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
64
ITAM_RMM_Integration_Plan.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# [보고서] IT 자산 실시간 통합 관리 시스템(RMM) 도입 계획서
|
||||||
|
|
||||||
|
## 1. 도입 배경 및 목적
|
||||||
|
- **현황**: 현재 시스템은 수동 입력 기반의 정적 자산 대장으로 운영되어, 실제 장비의 가동 상태나 장애 여부를 실시간으로 파악하는 데 한계가 있음.
|
||||||
|
- **목적**: 전산자산(서버, PC)의 실시간 상태 정보를 자동 수집하고 장애 징후를 사전에 탐지하여, 선제적 유지보수 체계를 구축하고 운영 효율성을 극대화함.
|
||||||
|
|
||||||
|
## 2. 시스템 주요 기능
|
||||||
|
### 2.1 실시간 가동 상태 모니터링
|
||||||
|
- 주요 자원(CPU, Memory, Disk) 사용률 실시간 수집
|
||||||
|
- 운영체제(OS) 및 주요 시스템 서비스의 정상 작동 여부 확인
|
||||||
|
- 자산 리스트 내 상태 인디케이터(정상/주의/장애) 표시
|
||||||
|
|
||||||
|
### 2.2 원격 제어 및 지원 통합
|
||||||
|
- **기술적 구현 방식 (원클릭 자동 연결)**: 웹사이트에서 전화번호를 누르면 전화 앱이 켜지거나, 이메일 주소를 누르면 메일 창이 뜨는 것과 동일한 원리인 'URL 프로토콜 핸들러' 기술을 적용함.
|
||||||
|
- **자동화 프로세스**: 관리자가 화면의 [연결] 버튼을 클릭하면, 시스템이 팀뷰어나 애니데스크에 "A장비로 연결해줘"라는 신호를 직접 보냄.
|
||||||
|
- **편의성**: 관리자가 대상 장비의 ID나 비밀번호를 직접 복사해서 프로그램에 입력할 필요 없이, 클릭 한 번으로 내 PC에 설치된 원격 소프트웨어가 자동 실행되며 즉시 화면이 연결되도록 구현함.
|
||||||
|
- **유연한 접속 모드 지원**:
|
||||||
|
- **무인 접속(Unattended Access)**: 서버 및 공용 장비의 경우, 사전에 등록된 자격 증명을 통해 관리자 승인만으로 즉시 접속하여 야간 또는 긴급 장애에 대응함.
|
||||||
|
- **사용자 승인 접속(Attended Access)**: 개인용 PC의 경우, 사용자의 화면에 접속 요청 팝업을 띄우고 승인 시에만 화면 공유를 시작하여 개인정보 보호 및 보안 규정을 준수함.
|
||||||
|
- **보안 및 감사 로그 자동화**:
|
||||||
|
- 원격 접속이 시작되는 시점에 관리자 정보, 접속 목적, 대상 장비 정보를 DB에 자동 기록함.
|
||||||
|
- 세션 종료 후 총 작업 시간 및 조치 내역을 입력하도록 유도하여 투명한 유지보수 이력을 관리함.
|
||||||
|
|
||||||
|
### 2.3 원격 지원 상세 워크플로우 (Remote Support Workflow)
|
||||||
|
관리자가 장애를 인지하고 조치를 완료하기까지의 표준 프로세스는 다음과 같습니다.
|
||||||
|
|
||||||
|
1. **지원 요청 및 대상 선택**: 관리자가 ITAM 대시보드 또는 리스트에서 장애가 발생한 자산을 선택하고 '원격 지원 시작' 버튼을 클릭함.
|
||||||
|
2. **접속 모드 자동 판별**:
|
||||||
|
- **서버(무인)**: 시스템이 저장된 자격 증명을 확인하고 관리자에게 '즉시 연결' 팝업을 띄움.
|
||||||
|
- **PC(유인)**: 관리자가 '접속 요청' 버튼을 누르면, 대상 PC 화면에 "관리자가 원격 제어를 요청했습니다. 승인하시겠습니까?" 팝업이 전송됨.
|
||||||
|
3. **세션 초기화 및 로그 생성**: 접속 시도가 승인되면 서버는 즉시 [접속 일시, 관리자 ID, 대상 자산 번호]를 포함한 '세션 로그'를 생성하고 상태를 '진행 중'으로 변경함.
|
||||||
|
4. **프로토콜 핸들러 실행**: 브라우저가 관리자 PC의 원격 제어 앱(TeamViewer 등)을 자동으로 실행하며, 대상 장비의 ID와 패스워드 정보를 암호화된 인자로 전달하여 즉시 화면이 연결됨.
|
||||||
|
5. **조치 및 지원 수행**: 관리자가 실시간으로 장비를 제어하여 장애를 복구함.
|
||||||
|
6. **세션 종료 및 결과 기록**:
|
||||||
|
- 관리자가 원격 제어 앱을 종료하면, ITAM 웹 화면에 '조치 결과 입력' 창이 활성화됨.
|
||||||
|
- 관리자가 조치 내용(예: 서비스 재시작, 패치 적용 등)을 입력하고 저장하면 세션 로그가 최종 확정됨.
|
||||||
|
7. **이력 보관**: 완료된 모든 이력은 '자산 상세 정보 > 유지보수 이력' 탭에서 언제든지 열람 및 보고서 출력이 가능함.
|
||||||
|
|
||||||
|
### 3.3 장애 사전 탐지 및 알림
|
||||||
|
- 설정된 임계치(예: 디스크 잔량 10% 미만) 초과 시 즉시 알림 발송
|
||||||
|
- 장기 미접속 또는 점검 누락 장비의 실시간 식별
|
||||||
|
|
||||||
|
## 3. 운영 프로세스 및 메커니즘
|
||||||
|
1. **데이터 수집 (Collection)**: 각 자산에 배치된 에이전트가 시스템 정보를 주기적으로 추출함.
|
||||||
|
2. **분석 및 판별 (Analysis)**: 수집된 데이터를 중앙 서버에서 분석하여 장비의 상태 등급을 판정함.
|
||||||
|
3. **가시화 (Visualization)**: 통합 관리 대시보드를 통해 전체 자산의 헬스 상태를 실시간으로 출력함.
|
||||||
|
4. **대응 (Action)**: 장애 감지 시 원격 제어 기능을 호출하여 즉각적인 기술 지원을 수행함.
|
||||||
|
|
||||||
|
## 4. 핵심 기술 및 도구
|
||||||
|
- **에이전트**: PowerShell 기반의 경량 스크립트를 활용하여 별도의 상용 소프트웨어 설치 없이 시스템 정보 수집.
|
||||||
|
- **백엔드**: Node.js 환경에서 대용량 점검 데이터를 효율적으로 처리하고 데이터베이스화함.
|
||||||
|
- **프론트엔드**: TypeScript를 활용하여 직관적이고 반응성이 뛰어난 관리자 대시보드 구현.
|
||||||
|
- **원격 솔루션**: 보안성이 검증된 TeamViewer/AnyDesk의 프로토콜 연동을 통한 안전한 원격 접속 환경 구축.
|
||||||
|
|
||||||
|
## 5. 기대 효과
|
||||||
|
- **가용성 증대**: 장애 발생 전 사전 조치를 통해 시스템 다운타임을 최소화하고 업무 연속성 확보.
|
||||||
|
- **비용 절감**: 현장 방문 점검 최소화 및 원격 조치를 통한 IT 운영 관리 비용 및 시간 절감.
|
||||||
|
- **데이터 기반 의무**: 객관적인 성능 지표 및 점검 이력을 바탕으로 정밀한 자산 교체 주기 산정 및 감사 대응.
|
||||||
|
- **관리 생산성 향상**: 자산 정보 조회와 실시간 관리를 단일 플랫폼으로 통합하여 업무 프로세스 간소화.
|
||||||
|
|
||||||
|
## 6. 향후 계획
|
||||||
|
- 1단계: 서버 자산 중심의 실시간 모니터링 및 대시보드 구축
|
||||||
|
- 2단계: 전사 PC 대상 원격 지원 및 보안 점검 기능 확대 적용
|
||||||
|
- 3단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화
|
||||||
318
IT_Asset_RMM_System_Report_Detailed.md
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# 전산자산 원격 점검 및 관리 시스템(RMM) 구축 조사 보고서 (상세판)
|
||||||
|
|
||||||
|
## 1. RMM(Remote Monitoring & Management) 개요
|
||||||
|
|
||||||
|
RMM(Remote Monitoring & Management)은 서버, 업무용 PC, 노트북 등 IT 자산에 에이전트를 설치하여
|
||||||
|
중앙 관리 서버에서 상태를 자동 수집하고, 이상 발생 시 경고를 발송하며, 필요 시 원격 접속으로 문제를 해결하는
|
||||||
|
기업용 IT 운영 관리 체계입니다.
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
- CPU, 메모리, 디스크 상태 모니터링
|
||||||
|
- Windows 서비스 및 프로세스 상태 점검
|
||||||
|
- OS 패치 및 백신 상태 확인
|
||||||
|
- 자동 점검 스케줄링 (1일 1~2회 이상)
|
||||||
|
- 이상 발생 시 이메일/메신저 알림
|
||||||
|
- 원격 접속을 통한 장애 조치
|
||||||
|
- 점검 이력 및 감사 로그 보관
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 구축 목표
|
||||||
|
|
||||||
|
### 서버 및 서버용 PC
|
||||||
|
- 하루 1~2회 자동 점검
|
||||||
|
- 주요 시스템 자원 및 서비스 상태 수집
|
||||||
|
- 이상 발생 시 관리자 즉시 통보
|
||||||
|
|
||||||
|
### 업무용 PC
|
||||||
|
- 중앙 관리 서버에서 정기 점검
|
||||||
|
- 패치 및 보안 상태 확인
|
||||||
|
|
||||||
|
### 개인 PC
|
||||||
|
- 사용자가 직접 점검 실행
|
||||||
|
- 결과를 중앙 서버에 업로드
|
||||||
|
|
||||||
|
### 관리자
|
||||||
|
- 마지막 점검 일시 확인
|
||||||
|
- 성공/실패 여부 확인
|
||||||
|
- 미실행 장비 식별
|
||||||
|
- 필요 시 즉시 원격 접속
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 기대 효과
|
||||||
|
|
||||||
|
- 장애 조기 탐지 및 사전 예방
|
||||||
|
- 현장 방문 최소화
|
||||||
|
- 점검 누락 방지
|
||||||
|
- 감사 대응 자료 자동 확보
|
||||||
|
- 자산 운영 현황 실시간 가시화
|
||||||
|
- 사용자 점검 이행 여부 관리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 전체 시스템 아키텍처
|
||||||
|
|
||||||
|
```text
|
||||||
|
[관리자 웹 포털]
|
||||||
|
├─ 대시보드
|
||||||
|
├─ 점검 결과 조회
|
||||||
|
├─ 원격 접속 버튼
|
||||||
|
├─ 알림 관리
|
||||||
|
└─ 사용자 수행 현황
|
||||||
|
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
|
||||||
|
[중앙 관리 서버]
|
||||||
|
├─ 스케줄러
|
||||||
|
├─ 데이터 수집 API
|
||||||
|
├─ 분석 엔진
|
||||||
|
├─ 알림 시스템
|
||||||
|
└─ 데이터베이스
|
||||||
|
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
|
||||||
|
[에이전트 설치 대상]
|
||||||
|
├─ 서버
|
||||||
|
├─ 서버용 PC
|
||||||
|
├─ 업무용 PC
|
||||||
|
└─ 개인 PC
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 주요 구성 요소
|
||||||
|
|
||||||
|
### 5.1 중앙 관리 서버
|
||||||
|
- 스케줄 실행
|
||||||
|
- 상태 분석
|
||||||
|
- 데이터 저장
|
||||||
|
- 알림 전송
|
||||||
|
- 웹 서비스 제공
|
||||||
|
|
||||||
|
### 5.2 에이전트 프로그램
|
||||||
|
- PowerShell 또는 Python 기반
|
||||||
|
- 상태 수집
|
||||||
|
- 중앙 서버 전송
|
||||||
|
|
||||||
|
### 5.3 관리자 웹 대시보드
|
||||||
|
- 실시간 현황 조회
|
||||||
|
- 점검 이력 확인
|
||||||
|
- 원격 접속 실행
|
||||||
|
|
||||||
|
### 5.4 원격 접속 솔루션
|
||||||
|
- TeamViewer Tensor
|
||||||
|
- AnyDesk
|
||||||
|
- Microsoft Remote Help
|
||||||
|
|
||||||
|
### 5.5 데이터베이스
|
||||||
|
- SQL Server 또는 PostgreSQL
|
||||||
|
|
||||||
|
### 5.6 알림 시스템
|
||||||
|
- 이메일
|
||||||
|
- Microsoft Teams
|
||||||
|
- Slack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 점검 항목
|
||||||
|
|
||||||
|
### 공통 점검 항목
|
||||||
|
- CPU 사용률
|
||||||
|
- 메모리 사용률
|
||||||
|
- 디스크 여유 공간
|
||||||
|
- 네트워크 연결 상태
|
||||||
|
- 시스템 부팅 시간
|
||||||
|
- 재부팅 필요 여부
|
||||||
|
|
||||||
|
### 서버 추가 항목
|
||||||
|
- 주요 서비스 실행 여부
|
||||||
|
- 이벤트 로그 오류
|
||||||
|
- 백업 결과
|
||||||
|
- DB 상태
|
||||||
|
|
||||||
|
### PC 추가 항목
|
||||||
|
- 백신 업데이트 여부
|
||||||
|
- Windows Update 상태
|
||||||
|
- BitLocker 상태
|
||||||
|
|
||||||
|
### 개인 PC
|
||||||
|
- 기본 시스템 상태
|
||||||
|
- 점검 수행 여부 및 시간 기록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 운영 프로세스
|
||||||
|
|
||||||
|
### 정상 운영
|
||||||
|
1. 스케줄러가 하루 1~2회 자동 실행
|
||||||
|
2. 에이전트가 점검 수행
|
||||||
|
3. 결과를 중앙 서버로 전송
|
||||||
|
4. 분석 엔진이 정상 여부 판정
|
||||||
|
5. 대시보드에 저장
|
||||||
|
|
||||||
|
### 이상 발생 시
|
||||||
|
1. 임계치 초과 또는 서비스 중지 감지
|
||||||
|
2. 관리자에게 알림 발송
|
||||||
|
3. 관리자가 원격 접속
|
||||||
|
4. 조치 내용 기록
|
||||||
|
|
||||||
|
### 개인 PC
|
||||||
|
1. 사용자가 '점검 실행' 버튼 클릭
|
||||||
|
2. 스크립트 수행
|
||||||
|
3. 결과 업로드
|
||||||
|
4. 관리자가 이행 여부 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 개인 PC 자가 점검 기능
|
||||||
|
|
||||||
|
### 사용자 화면
|
||||||
|
- 점검 실행 버튼
|
||||||
|
- 결과 요약 표시
|
||||||
|
- 마지막 점검 시간 표시
|
||||||
|
|
||||||
|
### 관리자 확인 항목
|
||||||
|
- 마지막 점검 일시
|
||||||
|
- 성공/실패 여부
|
||||||
|
- 미실행 기간
|
||||||
|
- 이상 발생 내역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 관리자 대시보드 구성
|
||||||
|
|
||||||
|
- 전체 자산 현황
|
||||||
|
- 정상/경고/장애 통계
|
||||||
|
- 최근 점검 성공률
|
||||||
|
- 미점검 장비 목록
|
||||||
|
- 개인 PC 수행 현황
|
||||||
|
- 원격 접속 바로가기
|
||||||
|
- 월간 보고서
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 솔루션 비교
|
||||||
|
|
||||||
|
| 솔루션 | 특징 | 적합도 |
|
||||||
|
|------|------|------|
|
||||||
|
| Microsoft Intune | 엔드포인트 관리 및 규정 준수 | 매우 높음 |
|
||||||
|
| TeamViewer Tensor | 기업용 원격 접속 및 RMM 연동 | 매우 높음 |
|
||||||
|
| ManageEngine Endpoint Central | 자산, 패치, 원격 관리 통합 | 매우 높음 |
|
||||||
|
| Zabbix | 오픈소스 모니터링 | 높음 |
|
||||||
|
| Splashtop Remote Support | 원격 지원 + RMM | 높음 |
|
||||||
|
| Power BI | 대시보드 및 보고 | 매우 높음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 권장 구축 방안
|
||||||
|
|
||||||
|
### 권장 아키텍처
|
||||||
|
- Microsoft Intune
|
||||||
|
- TeamViewer Tensor
|
||||||
|
- PowerShell 자동 점검 스크립트
|
||||||
|
- Microsoft SQL Server
|
||||||
|
- Power BI
|
||||||
|
- Microsoft Teams 알림
|
||||||
|
|
||||||
|
### 권장 이유
|
||||||
|
- Windows 환경과 높은 호환성
|
||||||
|
- 보안 및 감사 기능 우수
|
||||||
|
- 사용자 PC까지 통합 관리 가능
|
||||||
|
- 경영진 보고 자동화 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 보안 요구사항
|
||||||
|
|
||||||
|
- MFA(다중 인증)
|
||||||
|
- RBAC(역할 기반 권한 관리)
|
||||||
|
- TLS 암호화
|
||||||
|
- 감사 로그 저장
|
||||||
|
- 승인된 관리자만 원격 접속
|
||||||
|
- 사용자 동의 기반 개인 PC 점검
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 구축 일정 (예시)
|
||||||
|
|
||||||
|
| 단계 | 기간 |
|
||||||
|
|------|------|
|
||||||
|
| 요구사항 분석 | 2주 |
|
||||||
|
| 솔루션 선정 | 2주 |
|
||||||
|
| PoC | 4주 |
|
||||||
|
| 설계 및 개발 | 6주 |
|
||||||
|
| 시범 운영 | 4주 |
|
||||||
|
| 전사 확대 | 4주 |
|
||||||
|
|
||||||
|
총 예상 기간: 약 4~6개월
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 예상 비용 (예시)
|
||||||
|
|
||||||
|
| 항목 | 비용 수준 |
|
||||||
|
|------|----------|
|
||||||
|
| Intune 라이선스 | 사용자당 월 과금 |
|
||||||
|
| TeamViewer Tensor | 동시 세션 기준 |
|
||||||
|
| 개발 비용 | 중~고 |
|
||||||
|
| 운영 비용 | 중간 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 구축 우선순위
|
||||||
|
|
||||||
|
### 1단계
|
||||||
|
- 핵심 서버 모니터링
|
||||||
|
- 관리자 대시보드
|
||||||
|
|
||||||
|
### 2단계
|
||||||
|
- 원격 접속 통합
|
||||||
|
- 자동 알림
|
||||||
|
|
||||||
|
### 3단계
|
||||||
|
- 개인 PC 자가 점검
|
||||||
|
|
||||||
|
### 4단계
|
||||||
|
- Power BI 경영 보고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 최종 권장안
|
||||||
|
|
||||||
|
> Microsoft Intune + TeamViewer Tensor + PowerShell + SQL Server + Power BI
|
||||||
|
|
||||||
|
이 조합은 다음 요구사항을 모두 충족합니다.
|
||||||
|
- 자동 점검
|
||||||
|
- 이상 탐지
|
||||||
|
- 원격 접속
|
||||||
|
- 사용자 자가 점검
|
||||||
|
- 이력 관리
|
||||||
|
- 감사 대응
|
||||||
|
- 경영진 보고
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 공식 출처 및 링크
|
||||||
|
|
||||||
|
- Microsoft Intune: https://intune.microsoft.com
|
||||||
|
- TeamViewer Tensor: https://www.teamviewer.com/en/tensor/
|
||||||
|
- TeamViewer RMM 소개: https://www.teamviewer.com/en/solutions/use-cases/rmm-remote-monitoring-management/
|
||||||
|
- ManageEngine Endpoint Central: https://www.manageengine.com/products/endpoint-central/
|
||||||
|
- Zabbix: https://www.zabbix.com
|
||||||
|
- Power BI: https://powerbi.microsoft.com
|
||||||
|
- Microsoft SQL Server: https://www.microsoft.com/sql-server
|
||||||
|
- Splashtop RMM 설명: https://www.splashtop.com/blog/what-is-remote-monitoring-and-management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. 결론
|
||||||
|
|
||||||
|
본 시스템은 서버, 업무용 PC, 개인 PC를 통합 관리하여
|
||||||
|
정기적인 자동 점검과 이상 탐지, 원격 접속, 사용자 자가 점검, 점검 이력 관리까지 지원하는
|
||||||
|
기업용 IT 운영 플랫폼입니다.
|
||||||
|
|
||||||
|
특히 개인 PC의 자가 점검 기능과 관리자 추적 기능을 포함함으로써
|
||||||
|
규정 준수와 운영 효율성을 동시에 확보할 수 있습니다.
|
||||||
379
PC_사양_개선_기획서.html
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0">
|
||||||
|
<title>PC 사양 대시보드 시각화 개선 기획서</title>
|
||||||
|
<!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #4F46E5;
|
||||||
|
--primary-light: #EEF2FF;
|
||||||
|
--secondary: #10B981;
|
||||||
|
--secondary-light: #D1FAE5;
|
||||||
|
--text-dark: #0F172A;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--border-color: #E2E8F0;
|
||||||
|
--bg-light: #F8FAFC;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: var(--text-dark);
|
||||||
|
background-color: #FFFFFF;
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
header {
|
||||||
|
border-bottom: 2px solid var(--text-dark);
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-category {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.25rem;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--text-dark);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info span strong {
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section Styling */
|
||||||
|
section {
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-dark);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #334155;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List & Card Styling */
|
||||||
|
ul {
|
||||||
|
list-style-position: inside;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-card {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-card h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styling */
|
||||||
|
.table-container {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dark);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
color: var(--primary);
|
||||||
|
background-color: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-secondary {
|
||||||
|
color: var(--secondary);
|
||||||
|
background-color: var(--secondary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight box */
|
||||||
|
.note-box {
|
||||||
|
background-color: #FFFBEB;
|
||||||
|
border: 1px solid #FCD34D;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #92400E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-box strong {
|
||||||
|
color: #78350F;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
margin-top: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="doc-category">기획 명세서 / Product Specification</div>
|
||||||
|
<h1>PC 사양 대시보드 시각화 개선 기획서</h1>
|
||||||
|
<div class="meta-info">
|
||||||
|
<span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span>
|
||||||
|
<span>최종 수정일: <strong>2026. 05. 28</strong></span>
|
||||||
|
<span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 1. 개요 및 목적 -->
|
||||||
|
<section>
|
||||||
|
<h2>기획 개요 및 목적</h2>
|
||||||
|
<p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. 주요 개선 사항 -->
|
||||||
|
<section>
|
||||||
|
<h2>주요 개선 내역</h2>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li>
|
||||||
|
<li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spec-card">
|
||||||
|
<h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li>
|
||||||
|
<li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li>
|
||||||
|
<li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. 데이터 정의 -->
|
||||||
|
<section>
|
||||||
|
<h2>직무별 평균 및 권장 사양 점수 스펙</h2>
|
||||||
|
<p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p>
|
||||||
|
|
||||||
|
<div class="note-box">
|
||||||
|
<strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br>
|
||||||
|
AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>정렬 순위</th>
|
||||||
|
<th>직무명</th>
|
||||||
|
<th>실제 평균 사양 점수 (Bar)</th>
|
||||||
|
<th>기본 권장 사양 점수 (기준)</th>
|
||||||
|
<th>대소 관계 평가</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td>
|
||||||
|
<td><strong>AI 개발자</strong></td>
|
||||||
|
<td>88.0 점</td>
|
||||||
|
<td>95 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>2</td>
|
||||||
|
<td><strong>편집 디자이너</strong></td>
|
||||||
|
<td>80.2 점</td>
|
||||||
|
<td>75 점</td>
|
||||||
|
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>3</td>
|
||||||
|
<td><strong>3D 디자이너</strong></td>
|
||||||
|
<td>78.4 점</td>
|
||||||
|
<td>90 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>4</td>
|
||||||
|
<td><strong>UXUI 디자이너</strong></td>
|
||||||
|
<td>72.7 점</td>
|
||||||
|
<td>70 점</td>
|
||||||
|
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>5</td>
|
||||||
|
<td><strong>3D 개발자</strong></td>
|
||||||
|
<td>67.8 점</td>
|
||||||
|
<td>90 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>6</td>
|
||||||
|
<td><strong>프로그램 개발자</strong></td>
|
||||||
|
<td>67.3 점</td>
|
||||||
|
<td>80 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>7</td>
|
||||||
|
<td><strong>BIM모델러</strong></td>
|
||||||
|
<td>62.1 점</td>
|
||||||
|
<td>75 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>8</td>
|
||||||
|
<td><strong>엔지니어</strong></td>
|
||||||
|
<td>42.9 점</td>
|
||||||
|
<td>60 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>9</td>
|
||||||
|
<td><strong>웹 개발자</strong></td>
|
||||||
|
<td>39.2 점</td>
|
||||||
|
<td>75 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>10</td>
|
||||||
|
<td><strong>기획자</strong></td>
|
||||||
|
<td>38.6 점</td>
|
||||||
|
<td>50 점</td>
|
||||||
|
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>11</td>
|
||||||
|
<td><strong>감리원</strong></td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>40.0 점</td>
|
||||||
|
<td><span class="badge badge-secondary">데이터 없음</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. 기술 구현 세부사항 -->
|
||||||
|
<section>
|
||||||
|
<h2>기술 구현 세부 사양</h2>
|
||||||
|
<div class="spec-card" style="border-left-color: var(--secondary);">
|
||||||
|
<h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3>
|
||||||
|
<p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li>
|
||||||
|
<li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li>
|
||||||
|
<li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>© 2026 HM ITAM Systems. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
429
PC_사양_적정성_분석_기획서.html
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #4F46E5;
|
||||||
|
--primary-light: #EEF2FF;
|
||||||
|
--secondary: #10B981;
|
||||||
|
--secondary-light: #D1FAE5;
|
||||||
|
--danger: #EF4444;
|
||||||
|
--danger-light: #FEE2E2;
|
||||||
|
--warning: #F59E0B;
|
||||||
|
--warning-light: #FEF3C7;
|
||||||
|
--purple: #7C3AED;
|
||||||
|
--purple-light: #EDE9FE;
|
||||||
|
--text-dark: #0F172A;
|
||||||
|
--text-body: #334155;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--border: #E2E8F0;
|
||||||
|
--bg-light: #F8FAFC;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
|
||||||
|
color: var(--text-body);
|
||||||
|
background: #fff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
|
||||||
|
|
||||||
|
/* ─ Header ─ */
|
||||||
|
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
|
||||||
|
.doc-label {
|
||||||
|
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
|
||||||
|
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
|
||||||
|
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
|
||||||
|
margin-left: 0.5rem; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
|
||||||
|
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
|
||||||
|
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
|
||||||
|
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ─ Sections ─ */
|
||||||
|
section { margin-bottom: 3.5rem; }
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
|
||||||
|
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
}
|
||||||
|
h2 .num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; background: var(--primary); color: #fff;
|
||||||
|
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
|
||||||
|
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
|
||||||
|
|
||||||
|
/* ─ Boxes ─ */
|
||||||
|
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
|
||||||
|
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
|
||||||
|
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
|
||||||
|
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
|
||||||
|
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
|
||||||
|
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
|
||||||
|
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
/* ─ Score formula block ─ */
|
||||||
|
.formula {
|
||||||
|
background: #1E293B; color: #E2E8F0; border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
|
||||||
|
}
|
||||||
|
.formula .comment { color: #64748B; }
|
||||||
|
.formula .key { color: #93C5FD; }
|
||||||
|
.formula .val { color: #6EE7B7; }
|
||||||
|
.formula .warn { color: #FCD34D; }
|
||||||
|
|
||||||
|
/* ─ Three-col score grid ─ */
|
||||||
|
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
|
||||||
|
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
|
||||||
|
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.score-card-header {
|
||||||
|
background: var(--bg-light); padding: 0.65rem 1rem;
|
||||||
|
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
|
||||||
|
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
|
||||||
|
.dot-green { background: var(--secondary); }
|
||||||
|
.dot-purple { background: var(--purple); }
|
||||||
|
|
||||||
|
/* ─ Tables ─ */
|
||||||
|
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||||
|
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||||
|
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: var(--bg-light); }
|
||||||
|
|
||||||
|
/* ─ Badges ─ */
|
||||||
|
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.b-primary { color: var(--primary); background: var(--primary-light); }
|
||||||
|
.b-green { color: #065F46; background: var(--secondary-light); }
|
||||||
|
.b-red { color: #991B1B; background: var(--danger-light); }
|
||||||
|
.b-yellow { color: #92400E; background: var(--warning-light); }
|
||||||
|
.b-purple { color: #5B21B6; background: var(--purple-light); }
|
||||||
|
|
||||||
|
/* ─ Flow ─ */
|
||||||
|
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
|
||||||
|
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
|
||||||
|
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
|
||||||
|
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
|
||||||
|
|
||||||
|
/* ─ GPU tier table highlight ─ */
|
||||||
|
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
|
||||||
|
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
|
||||||
|
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
|
||||||
|
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
|
||||||
|
.tier-D td:first-child { color: var(--text-muted); }
|
||||||
|
|
||||||
|
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="doc-header">
|
||||||
|
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
|
||||||
|
<h1>PC 사양 적정성 분석 기획서<br>
|
||||||
|
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
|
||||||
|
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
|
||||||
|
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
|
||||||
|
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
|
||||||
|
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 1. 개요 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
|
||||||
|
<p>
|
||||||
|
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
|
||||||
|
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
|
||||||
|
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
|
||||||
|
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flow">
|
||||||
|
<div class="flow-step">① 기본 100점 만점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">② CPU 등급/세대 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">③ RAM 용량 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step gpu">④ GPU 등급 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">⑤ 연식 노후 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formula">
|
||||||
|
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
|
||||||
|
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. CPU 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
|
||||||
|
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong>과 <strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
|
||||||
|
|
||||||
|
<div class="formula">
|
||||||
|
<span class="comment">// [CPU 등급 감점]</span>
|
||||||
|
i9 / Ryzen 9 → <span class="val">0점 감점</span>
|
||||||
|
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
|
||||||
|
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
|
||||||
|
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
|
||||||
|
기타 → <span class="val">-30점 감점</span>
|
||||||
|
|
||||||
|
<span class="comment">// [CPU 세대 노후 감점]</span>
|
||||||
|
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
|
||||||
|
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
|
||||||
|
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
|
||||||
|
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>CPU 조합별 감점 예시</h3>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
|
||||||
|
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||||
|
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||||
|
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
|
||||||
|
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
|
||||||
|
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
|
||||||
|
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. RAM 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
|
||||||
|
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
|
||||||
|
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
|
||||||
|
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
|
||||||
|
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. GPU 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
|
||||||
|
<p>
|
||||||
|
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
|
||||||
|
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
|
||||||
|
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
|
||||||
|
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
|
||||||
|
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 5. 종합 점수 감점 사례 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 6. 직무별 평균 및 권장 점수 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
|
||||||
|
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
|
||||||
|
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="box box-blue">
|
||||||
|
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
|
||||||
|
AI 개발자(88.0) > 편집 디자이너(80.2) > 3D 디자이너(78.4) > UXUI 디자이너(72.7) > 3D 개발자(67.8) > 프로그램 개발자(67.3) > BIM모델러(62.1) > 엔지니어(42.9) > 웹 개발자(39.2) > 기획자(38.6) ✅
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 7. 적정성 판별 기준 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">7</span>적정성 판별 기준</h2>
|
||||||
|
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
|
||||||
|
<div class="formula">
|
||||||
|
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
|
||||||
|
|
||||||
|
IF <span class="val">개인 실질 점수 < avgScore × 0.80</span> → <span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
|
||||||
|
IF <span class="val">개인 실질 점수 > avgScore × 1.30</span> → <span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
|
||||||
|
ELSE → <span class="key">"적정"</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 < 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
|
||||||
|
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 > 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 8. 신뢰도 검토 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
|
||||||
|
|
||||||
|
<h3>✅ 신뢰 가능한 부분</h3>
|
||||||
|
<div class="box box-green">
|
||||||
|
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
|
||||||
|
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
|
||||||
|
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 > 4080 > 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
|
||||||
|
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
|
||||||
|
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>⚠️ 여전히 남아있는 한계점</h3>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>노트북 TDP 미반영</strong></td>
|
||||||
|
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>SSD 유형 미반영</strong></td>
|
||||||
|
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>GPU 세부 파생 모델 한계</strong></td>
|
||||||
|
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>GPU 세대 보정 미적용</strong></td>
|
||||||
|
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
|
||||||
|
<td><span class="badge b-primary">낮음</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>실측 벤치마크 미연동</strong></td>
|
||||||
|
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box box-blue">
|
||||||
|
<div class="box-title">💡 종합 신뢰도 평가</div>
|
||||||
|
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
|
||||||
|
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
|
||||||
|
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 9. 개선 로드맵 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">9</span>향후 개선 로드맵</h2>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td>하</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td>상</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td>상</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) · 2026. 05. 28</p>
|
||||||
|
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -51,6 +51,6 @@
|
|||||||
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
||||||
* **Modal (모달 공통 규칙)**:
|
* **Modal (모달 공통 규칙)**:
|
||||||
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
||||||
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay)을 클릭하면 모달이 닫히도록 구현합니다.
|
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
|
||||||
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
||||||
|
|
||||||
|
|||||||
BIN
SampleData_PC.xlsx
Normal file
BIN
SampleData_SVR.xlsx
Normal file
BIN
backupDB_20260602.xlsx
Normal file
@@ -1,57 +0,0 @@
|
|||||||
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
|
|
||||||
|
|
||||||
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
|
|
||||||
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
|
|
||||||
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
|
|
||||||
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
|
|
||||||
3. **개선 작업 절차 (Test-First Approach)**:
|
|
||||||
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
|
|
||||||
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
|
||||||
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
|
||||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access)
|
|
||||||
|
|
||||||
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
|
|
||||||
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
|
|
||||||
3. **구동 명령어**:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
* 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
|
|
||||||
4. **IP 확인 방법**:
|
|
||||||
* Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
|
||||||
|
|
||||||
1. **디자인 철학 (Design Philosophy)**
|
|
||||||
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
|
|
||||||
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
|
|
||||||
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
|
|
||||||
|
|
||||||
2. **타이포그래피 (Typography)**
|
|
||||||
* **Font Family**: `Pretendard` (전역 적용)
|
|
||||||
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
|
|
||||||
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
|
|
||||||
* **Date Format (날짜 표기 규칙)**: 시스템 내 모든 날짜는 `YYYY.MM.DD` 형식을 기본으로 사용합니다. (예: 2026.04.10)
|
|
||||||
|
|
||||||
3. **컬러 팔레트 (Color Palette)**
|
|
||||||
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
|
|
||||||
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
|
|
||||||
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
|
|
||||||
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
|
|
||||||
|
|
||||||
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
|
|
||||||
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
|
|
||||||
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
|
|
||||||
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
|
|
||||||
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
|
||||||
* **Modal (모달 공통 규칙)**:
|
|
||||||
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
|
||||||
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay)을 클릭하면 모달이 닫히도록 구현합니다.
|
|
||||||
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 53 KiB |
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css" />
|
|
||||||
<title>ITAM - IT Asset Management</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# 📑 ITAM 시스템 통합 구축 기획서 (plan.md)
|
|
||||||
|
|
||||||
## 1. 프로젝트 목적 (Objective)
|
|
||||||
본 시스템은 **'전산자산번호 부여방안'** 표준 가이드에 따라 사내 모든 전산 자산을 통합 관리하는 것을 목적으로 한다. 분산된 자산 정보를 일원화된 번호 체계로 시스템화하여 직관적인 식별과 정밀한 이력 추적이 가능한 플랫폼을 지향한다.
|
|
||||||
|
|
||||||
## 2. 자산번호 체계 적용 (Standardization)
|
|
||||||
검토안에 따라 모든 자산은 다음과 같은 규칙으로 고유 번호를 부여받으며, 시스템의 핵심 Key로 활용된다.
|
|
||||||
|
|
||||||
* **번호 생성 규칙**: `[구매법인]-[설치위치]-[자산종류]-[일련번호(구매연월+3자리)]`
|
|
||||||
* *예시: 삼안에서 2025년 4월에 구매한 IDC 서버 → `SM-IDC-SVR-202504001`*
|
|
||||||
* **코드 정의**:
|
|
||||||
* **구매법인**: BR(바론), SM(삼안), HM(한맥), JH(장헌), HL(한라), PTC(PTC) 등
|
|
||||||
* **설치위치**: TDC(센터 서버실), HBD(한맥빌딩), IDC(IDC), UBD(유니온 빌딩), NBD(뉴코아 빌딩) 등
|
|
||||||
* **자산종류**: SVR(서버), PC(PC), STO(스토리지), NAS(NAS), DAS(DAS), HDD(하드) 등
|
|
||||||
|
|
||||||
## 3. 기획 의도 및 시스템 가치 (Core Intent)
|
|
||||||
1. **식별 용이성 확보**: 번호만으로도 법인, 위치, 종류, 도입 시기를 즉시 파악할 수 있는 시스템 UI를 제공한다.
|
|
||||||
2. **전사 통합 관리 로드맵**:
|
|
||||||
* **Phase 1**: IDC 서버 및 스토리지 데이터 마이그레이션 (현재 완료 단계)
|
|
||||||
* **Phase 2**: 센터 서버실(TDC) 및 빌딩별 네트워크 장비 확장
|
|
||||||
* **Phase 3**: 전사 개인 PC(PC) 및 하드웨어 부품(HDD 등) 통합
|
|
||||||
* **Phase 4**: 소프트웨어 라이선스와 하드웨어 자산의 매핑 관리
|
|
||||||
3. **데이터 무결성 유지**: 자산 위치 변경 시 기존 번호를 폐기하고 신규 번호를 부여하는 이력 관리 원칙을 시스템상에서 강제 및 기록한다.
|
|
||||||
|
|
||||||
## 4. 시스템 주요 기능 (Key Features)
|
|
||||||
* **자산 자동 번호 부여**: 입력 폼에서 법인/위치/종류 선택 시 가이드에 따른 자산번호 자동 생성 기능.
|
|
||||||
* **상세 이력 카드**: 자산번호를 클릭하면 상세 사양, 취득일, 사용자, 현재 상태 및 과거 이동 이력을 모달로 표시.
|
|
||||||
* **통합 필터링**: 법인별, 위치별, 종류별로 자산을 즉시 분류하여 조회할 수 있는 고성능 테이블 제공.
|
|
||||||
|
|
||||||
## 5. 기술 및 디자인 원칙 (Engineering Standards)
|
|
||||||
* **Design**: `README.md` 가이드를 준수하며, 자산번호가 가장 강조되는 레이아웃을 유지한다.
|
|
||||||
* **Data Structure**: 향후 DB 전환 시 자산번호의 각 코드(SM, IDC 등)를 정규화하여 관리 효율을 극대화한다.
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
:root {
|
|
||||||
--primary-color: #1E5149;
|
|
||||||
--primary-hover: #163d37;
|
|
||||||
--bg-default: #FFFFFF;
|
|
||||||
--bg-muted: #F9FAFB;
|
|
||||||
--info-color: #4B5563; /* 무채색 계열로 변경 */
|
|
||||||
--text-main: #111827;
|
|
||||||
--text-muted: #6B7280;
|
|
||||||
--border-color: #E5E7EB;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--bg-default);
|
|
||||||
color: var(--text-main);
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
height: 50px;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 40px;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar-brand {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-right: 48px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar-menu {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
height: 100%;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item:hover {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item.active {
|
|
||||||
color: white;
|
|
||||||
border-bottom: 3px solid white;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: calc(100vh - 50px);
|
|
||||||
overflow: hidden; /* 전체 페이지 스크롤 방지 */
|
|
||||||
padding: 0; /* 패딩은 내부 요소에서 관리 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-header {
|
|
||||||
padding: 32px 40px 16px 40px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background-color: var(--bg-default);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0 40px 40px 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Common Table Styles */
|
|
||||||
.data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate; /* sticky border 유지를 위해 separate 사용 */
|
|
||||||
border-spacing: 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th, .data-table td {
|
|
||||||
padding: 14px 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
background-color: #F8FAFC; /* 헤더 전용 배경색 */
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-table tr:hover {
|
|
||||||
background-color: var(--bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background-color: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline {
|
|
||||||
background-color: transparent;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline:hover {
|
|
||||||
background-color: var(--bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dashboard Stats - Border based */
|
|
||||||
.dashboard-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
padding: 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal Styles */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
|
||||||
background-color: var(--bg-default);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 800px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.8;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-close-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 24px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item.full-width {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-item label {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-main);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-value {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background-color: var(--bg-default);
|
|
||||||
min-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
background-color: var(--bg-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import './App.css'
|
|
||||||
import AssetManagementView from './components/AssetManagementView'
|
|
||||||
import HardwareManagementView from './components/HardwareManagementView'
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [activeMenu, setActiveMenu] = useState('assets')
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activeMenu) {
|
|
||||||
case 'assets':
|
|
||||||
return <AssetManagementView />
|
|
||||||
case 'hardware':
|
|
||||||
return <HardwareManagementView />
|
|
||||||
default:
|
|
||||||
return <AssetManagementView />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="app-container">
|
|
||||||
<header className="top-bar">
|
|
||||||
<div className="top-bar-brand">
|
|
||||||
ITAM System
|
|
||||||
</div>
|
|
||||||
<nav className="top-bar-menu">
|
|
||||||
<div
|
|
||||||
className={`menu-item ${activeMenu === 'assets' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveMenu('assets')}
|
|
||||||
>
|
|
||||||
전산자산관리대장
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`menu-item ${activeMenu === 'hardware' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveMenu('hardware')}
|
|
||||||
>
|
|
||||||
H/W 사양 정보
|
|
||||||
</div>
|
|
||||||
<div className="menu-item">S/W 설치 현황</div>
|
|
||||||
<div className="menu-item">라이선스 관리</div>
|
|
||||||
<div className="menu-item">실물 자산 관리</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<main className="main-content">
|
|
||||||
{renderContent()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { idcServers, idcStorages, IdcServer } from '../data/idcData'
|
|
||||||
import ServerDetailModal from './ServerDetailModal'
|
|
||||||
|
|
||||||
const AssetManagementView = () => {
|
|
||||||
const [viewMode, setViewMode] = useState<'server' | 'storage'>('server')
|
|
||||||
const [selectedServer, setSelectedServer] = useState<IdcServer | null>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="asset-management" style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
<div className="content-header">
|
|
||||||
<div className="content-title">전산자산관리대장 (IDC)</div>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<button
|
|
||||||
className={`btn ${viewMode === 'server' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setViewMode('server')}
|
|
||||||
>
|
|
||||||
서버 목록
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${viewMode === 'storage' ? 'btn-primary' : 'btn-outline'}`}
|
|
||||||
onClick={() => setViewMode('storage')}
|
|
||||||
>
|
|
||||||
스토리지 목록
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-container">
|
|
||||||
<div style={{ padding: '24px 0 16px 0' }}>
|
|
||||||
<h3 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--primary-color)', margin: 0 }}>
|
|
||||||
{viewMode === 'server' ? 'IDC 서버 상세 정보' : 'IDC 스토리지 상세 정보'}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{viewMode === 'server' ? (
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>회사</th>
|
|
||||||
<th>서버번호</th>
|
|
||||||
<th>구분</th>
|
|
||||||
<th>설치위치</th>
|
|
||||||
<th>관리자</th>
|
|
||||||
<th>IP 주소</th>
|
|
||||||
<th>접속 정보</th>
|
|
||||||
<th>H/W 사양</th>
|
|
||||||
<th>OS</th>
|
|
||||||
<th>구매일</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{idcServers.map((server) => (
|
|
||||||
<tr key={server.serverNo} onClick={() => setSelectedServer(server)} style={{ cursor: 'pointer' }}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{server.company}</td>
|
|
||||||
<td style={{ color: 'var(--primary-color)', fontWeight: 500 }}>{server.serverNo}</td>
|
|
||||||
<td>
|
|
||||||
<div>{server.category}</div>
|
|
||||||
{server.remarks && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{server.remarks}</div>}
|
|
||||||
</td>
|
|
||||||
<td>{server.location}</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ fontWeight: 500 }}>{server.managerPrimary ? `정: ${server.managerPrimary}` : '정: -'}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{server.managerSecondary ? `부: ${server.managerSecondary}` : '부: -'}</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div>{server.ip1}</div>
|
|
||||||
{server.ip2 && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{server.ip2}</div>}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{server.remoteAccess.map((access, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
marginBottom: idx < server.remoteAccess.length - 1 ? '8px' : 0,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '2px'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<span style={{ fontSize: '0.7rem', backgroundColor: '#f3f4f6', padding: '1px 4px', borderRadius: '2px', color: 'var(--text-muted)', fontWeight: 600 }}>{access.tool}</span>
|
|
||||||
<span style={{ fontSize: '0.8125rem', fontWeight: 500 }}>{access.id}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '4px', borderLeft: '1px solid var(--border-color)', marginLeft: '4px' }}>
|
|
||||||
PW: <span style={{ color: 'var(--text-main)' }}>{access.pw}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: '0.8125rem' }}>
|
|
||||||
<div style={{ fontWeight: 500 }}>{server.model}</div>
|
|
||||||
<div style={{ color: 'var(--text-muted)' }}>{server.cpu} / {server.ram}</div>
|
|
||||||
<div style={{ color: 'var(--text-muted)' }}>{server.storage.join(' + ')}</div>
|
|
||||||
</td>
|
|
||||||
<td style={{ fontSize: '0.8125rem' }}>{server.os}</td>
|
|
||||||
<td style={{ fontSize: '0.8125rem' }}>{server.purchaseDate}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>회사</th>
|
|
||||||
<th>서버번호</th>
|
|
||||||
<th>구분</th>
|
|
||||||
<th>설치위치</th>
|
|
||||||
<th>관리자</th>
|
|
||||||
<th>IP 주소</th>
|
|
||||||
<th>접속 정보</th>
|
|
||||||
<th>모델명</th>
|
|
||||||
<th>용량</th>
|
|
||||||
<th>구매일</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{idcStorages.map((storage) => (
|
|
||||||
<tr key={storage.serverNo}>
|
|
||||||
<td style={{ fontWeight: 600 }}>{storage.company}</td>
|
|
||||||
<td style={{ color: 'var(--primary-color)', fontWeight: 500 }}>{storage.serverNo}</td>
|
|
||||||
<td>
|
|
||||||
<div>{storage.category}</div>
|
|
||||||
{storage.remarks && <div style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{storage.remarks}</div>}
|
|
||||||
</td>
|
|
||||||
<td>{storage.location}</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontWeight: 500 }}>정: {storage.managerPrimary}</span>
|
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: '8px' }}>부: {storage.managerSecondary}</span>
|
|
||||||
</td>
|
|
||||||
<td>{storage.ip}</td>
|
|
||||||
<td>
|
|
||||||
{storage.remoteAccess.map((access, idx) => (
|
|
||||||
<div key={idx} style={{
|
|
||||||
marginBottom: idx < storage.remoteAccess.length - 1 ? '8px' : 0,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '2px'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<span style={{ fontSize: '0.7rem', backgroundColor: '#f3f4f6', padding: '1px 4px', borderRadius: '2px', color: 'var(--text-muted)', fontWeight: 600 }}>{access.tool}</span>
|
|
||||||
<span style={{ fontSize: '0.8125rem', fontWeight: 500 }}>{access.id}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', paddingLeft: '4px', borderLeft: '1px solid var(--border-color)', marginLeft: '4px' }}>
|
|
||||||
PW: <span style={{ color: 'var(--text-main)' }}>{access.pw}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</td>
|
|
||||||
<td>{storage.model}</td>
|
|
||||||
<td style={{ fontWeight: 600 }}>{storage.capacity}</td>
|
|
||||||
<td>{storage.purchaseDate}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedServer && (
|
|
||||||
<ServerDetailModal
|
|
||||||
server={selectedServer}
|
|
||||||
onClose={() => setSelectedServer(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AssetManagementView
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { mockCategories } from '../data/mockData'
|
|
||||||
|
|
||||||
const DashboardView = () => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="content-header">
|
|
||||||
<div className="content-title">대시보드</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="dashboard-stats">
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-label">전체 자산</div>
|
|
||||||
<div className="stat-value">8개</div>
|
|
||||||
</div>
|
|
||||||
{mockCategories.map(cat => (
|
|
||||||
<div key={cat.id} className="stat-card">
|
|
||||||
<div className="stat-label">{cat.name}</div>
|
|
||||||
<div className="stat-value">{cat.count}개</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h3>최근 변경 내역</h3>
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>날짜</th>
|
|
||||||
<th>내용</th>
|
|
||||||
<th>사용자</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>2023-04-11</td>
|
|
||||||
<td>PC 신규 등록</td>
|
|
||||||
<td>이관형</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>2023-04-10</td>
|
|
||||||
<td>모니터 부서 할당 변경</td>
|
|
||||||
<td>관리자</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DashboardView
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { mockHardwareSpecs, HardwareSpec } from '../data/mockData'
|
|
||||||
|
|
||||||
const SpecModal = ({ spec, onClose }: { spec: HardwareSpec, onClose: () => void }) => {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', top: 0, left: 0, width: '100%', height: '100%',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
|
||||||
zIndex: 1000
|
|
||||||
}}>
|
|
||||||
<div className="card" style={{ width: '600px', maxWidth: '90%' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '20px' }}>
|
|
||||||
<h2>상세 사양 정보</h2>
|
|
||||||
<button className="btn" onClick={onClose}>×</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '10px' }}>
|
|
||||||
<strong>PC명:</strong> <span>{spec.pcName}</span>
|
|
||||||
<strong>사용자:</strong> <span>{spec.userName}</span>
|
|
||||||
<strong>부서:</strong> <span>{spec.department}</span>
|
|
||||||
<strong>OS:</strong> <span>{spec.os}</span>
|
|
||||||
<strong>CPU:</strong> <span>{spec.cpu}</span>
|
|
||||||
<strong>Memory:</strong> <span>{spec.memory}</span>
|
|
||||||
<strong>Disk:</strong> <span>{spec.disk}</span>
|
|
||||||
<strong>MAC:</strong> <span>{spec.macAddress}</span>
|
|
||||||
<strong>IP:</strong> <span>{spec.ipAddress}</span>
|
|
||||||
<strong>Graphic:</strong> <span>{spec.graphicCard}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'right' }}>
|
|
||||||
<button className="btn btn-primary" onClick={onClose}>닫기</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const HardwareManagementView = () => {
|
|
||||||
const [selectedSpec, setSelectedSpec] = useState<HardwareSpec | null>(null)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="content-header">
|
|
||||||
<div className="content-title">H/W 사양 정보</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>PC명</th>
|
|
||||||
<th>부서</th>
|
|
||||||
<th>사용자</th>
|
|
||||||
<th>OS</th>
|
|
||||||
<th>CPU</th>
|
|
||||||
<th>IP주소</th>
|
|
||||||
<th>상세</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{mockHardwareSpecs.map(spec => (
|
|
||||||
<tr key={spec.id}>
|
|
||||||
<td>{spec.pcName}</td>
|
|
||||||
<td>{spec.department}</td>
|
|
||||||
<td>{spec.userName}</td>
|
|
||||||
<td>{spec.os.split(' ')[2]}</td>
|
|
||||||
<td title={spec.cpu}>{spec.cpu.split('@')[0]}</td>
|
|
||||||
<td>{spec.ipAddress}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ padding: '4px 8px', fontSize: '0.8rem' }}
|
|
||||||
onClick={() => setSelectedSpec(spec)}
|
|
||||||
>
|
|
||||||
보기
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{selectedSpec && (
|
|
||||||
<SpecModal spec={selectedSpec} onClose={() => setSelectedSpec(null)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HardwareManagementView
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { IdcServer } from '../data/idcData';
|
|
||||||
|
|
||||||
interface ServerDetailModalProps {
|
|
||||||
server: IdcServer;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerDetailModal: React.FC<ServerDetailModalProps> = ({ server, onClose }) => {
|
|
||||||
// ESC 키로 모달 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
const handleEsc = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('keydown', handleEsc);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleEsc);
|
|
||||||
};
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="modal-header">
|
|
||||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 600 }}>{server.category} ({server.serverNo})</h2>
|
|
||||||
<button className="modal-close-btn" onClick={onClose} aria-label="Close modal">×</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
<div className="detail-grid">
|
|
||||||
{/* Row 1 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>회사 구분</label>
|
|
||||||
<div className="detail-value">{server.company}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>서버 번호</label>
|
|
||||||
<div className="detail-value" style={{ color: 'var(--primary-color)', fontWeight: 600 }}>{server.serverNo}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>자산명(용도)</label>
|
|
||||||
<div className="detail-value">{server.category}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>설치 위치</label>
|
|
||||||
<div className="detail-value">{server.location}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 3: 관리자 추가 */}
|
|
||||||
<div className="detail-item full-width">
|
|
||||||
<label>관리 담당자</label>
|
|
||||||
<div className="detail-value">
|
|
||||||
<span style={{ fontWeight: 600, color: 'var(--text-main)' }}>정: {server.managerPrimary}</span>
|
|
||||||
<span style={{ marginLeft: '16px', color: 'var(--text-muted)' }}>부: {server.managerSecondary}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 4 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>IP 주소 1</label>
|
|
||||||
<div className="detail-value">{server.ip1 || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>IP 주소 2</label>
|
|
||||||
<div className="detail-value">{server.ip2 || '-'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 5 */}
|
|
||||||
<div className="detail-item full-width">
|
|
||||||
<label>원격 접속 정보</label>
|
|
||||||
<div className="detail-value">
|
|
||||||
{server.remoteAccess.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
|
|
||||||
{server.remoteAccess.map((access, idx) => (
|
|
||||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '4px 8px', backgroundColor: 'var(--bg-muted)', borderRadius: '4px', border: '1px solid var(--border-color)' }}>
|
|
||||||
<span style={{ fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-muted)' }}>{access.tool}</span>
|
|
||||||
<span style={{ fontWeight: 500 }}>{access.id}</span>
|
|
||||||
<span style={{ color: 'var(--border-color)' }}>|</span>
|
|
||||||
<span>PW: <span style={{ color: 'var(--text-main)' }}>{access.pw}</span></span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 6 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>제조사 및 모델명</label>
|
|
||||||
<div className="detail-value">{server.model || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>OS</label>
|
|
||||||
<div className="detail-value">{server.os || '-'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 7 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>CPU</label>
|
|
||||||
<div className="detail-value">{server.cpu || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>RAM</label>
|
|
||||||
<div className="detail-value">{server.ram || '-'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 8 */}
|
|
||||||
<div className="detail-item full-width">
|
|
||||||
<label>Storage (디스크 구성)</label>
|
|
||||||
<div className="detail-value">{server.storage.length > 0 ? server.storage.join(' + ') : '-'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 9 */}
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>구매일자</label>
|
|
||||||
<div className="detail-value">{server.purchaseDate || '-'}</div>
|
|
||||||
</div>
|
|
||||||
<div className="detail-item">
|
|
||||||
<label>모니터링 여부</label>
|
|
||||||
<div className="detail-value">{server.monitoring || '-'}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 10 */}
|
|
||||||
<div className="detail-item full-width">
|
|
||||||
<label>비고 및 특이사항</label>
|
|
||||||
<div className="detail-value" style={{ minHeight: '40px' }}>{server.remarks || '-'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-footer">
|
|
||||||
<button className="btn btn-outline" onClick={onClose} style={{ marginRight: '8px' }}>닫기</button>
|
|
||||||
<button className="btn btn-primary" onClick={onClose}>수정(저장)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServerDetailModal;
|
|
||||||
@@ -1,608 +0,0 @@
|
|||||||
export interface RemoteAccess {
|
|
||||||
tool: string;
|
|
||||||
ip?: string;
|
|
||||||
id: string;
|
|
||||||
pw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IdcServer {
|
|
||||||
company: string;
|
|
||||||
serverNo: string;
|
|
||||||
category: string;
|
|
||||||
remarks: string;
|
|
||||||
location: string;
|
|
||||||
managerPrimary: string;
|
|
||||||
managerSecondary: string;
|
|
||||||
ip1: string;
|
|
||||||
ip2: string;
|
|
||||||
remoteAccess: RemoteAccess[];
|
|
||||||
monitoring: string;
|
|
||||||
serverIdMatch: string;
|
|
||||||
model: string;
|
|
||||||
os: string;
|
|
||||||
cpu: string;
|
|
||||||
ram: string;
|
|
||||||
storage: string[];
|
|
||||||
purchaseDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IdcStorage {
|
|
||||||
company: string;
|
|
||||||
serverNo: string;
|
|
||||||
category: string;
|
|
||||||
remarks: string;
|
|
||||||
location: string;
|
|
||||||
managerPrimary: string;
|
|
||||||
managerSecondary: string;
|
|
||||||
ip: string;
|
|
||||||
managementIp?: string;
|
|
||||||
remoteAccess: RemoteAccess[];
|
|
||||||
model: string;
|
|
||||||
capacity: string;
|
|
||||||
purchaseDate: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const idcServers: IdcServer[] = [
|
|
||||||
{
|
|
||||||
company: "한맥",
|
|
||||||
serverNo: "hm-idc-001",
|
|
||||||
category: "한맥 인트라넷",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.70",
|
|
||||||
ip2: "192.168.10.5",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "samanerp1!" },
|
|
||||||
{ tool: "Remote Util", id: "211.206.127.70", pw: "1234아이티!" }
|
|
||||||
],
|
|
||||||
monitoring: "win exp, raid X",
|
|
||||||
serverIdMatch: "srv07d330084",
|
|
||||||
model: "HPE ProLiant DL360 Gen10",
|
|
||||||
os: "Windows Server 2016",
|
|
||||||
cpu: "intel xeon silver4110 CPU @2.10GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "2.7TB"],
|
|
||||||
purchaseDate: "2020.12.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "한맥",
|
|
||||||
serverNo: "hm-idc-002",
|
|
||||||
category: "한맥 인트라넷 예비",
|
|
||||||
remarks: "단가, 입사자지원 서버 (스마트 건설 용도 구매)",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.78",
|
|
||||||
ip2: "192.168.10.13",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "Hanmac2141!" }
|
|
||||||
],
|
|
||||||
monitoring: "win exp, raid X",
|
|
||||||
serverIdMatch: "srcff5294c84",
|
|
||||||
model: "HPE ProLiant DL360 Gen10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "intel xeon silver4214R CPU @2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "2.7TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-001",
|
|
||||||
category: "삼안 인트라넷",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.237",
|
|
||||||
ip2: "erp.samaneng.com",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "samanerp1!" },
|
|
||||||
{ tool: "Remote Util", id: "118.220.172.237", pw: "1234아이티!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "newSmintranet",
|
|
||||||
model: "HPE ProLiant DL360 Gen10",
|
|
||||||
os: "Windows Server 2016",
|
|
||||||
cpu: "intel xeon silver4214R CPU @2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "3.27TB"],
|
|
||||||
purchaseDate: "2019.12.20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-002",
|
|
||||||
category: "삼안 인트라넷 예비",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.249",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "samanerp1!" },
|
|
||||||
{ tool: "Remote Util", id: "678-605-383-130", pw: "1234아이티!" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "INTRANET",
|
|
||||||
model: "HPE ProLiant DL360 GEN9",
|
|
||||||
os: "Windows Server 2008 R2",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["279GB", "2.72TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-003",
|
|
||||||
category: "SATIS 01",
|
|
||||||
remarks: "구 SATIS 서버, 세금계산서 발행(회계)",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.228",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "satissg11707808" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "satis01",
|
|
||||||
model: "HPE ProLiant DL380p GEN8",
|
|
||||||
os: "Windows Server 2008 R2",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2643 0 @ 3.30GHz",
|
|
||||||
ram: "20GB",
|
|
||||||
storage: ["100GB", "458GB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-004",
|
|
||||||
category: "SATIS 02",
|
|
||||||
remarks: "SATIS 리뉴얼 버전 (ERP 서버)",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.229",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "satissg11707808" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "satis02",
|
|
||||||
model: "HPE ProLiant DL380p GEN8",
|
|
||||||
os: "Windows Server 2008 R2",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2643 0 @ 3.30GHz",
|
|
||||||
ram: "20GB",
|
|
||||||
storage: ["100GB", "458GB", "18.1TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-005",
|
|
||||||
category: "웹 서버",
|
|
||||||
remarks: "남양주 테스트 서버 (도메인 관리 기능 제거 2026.03.11)",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "samanweb.cafe24.com",
|
|
||||||
ip2: "118.220.172.195",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "saman+2013+web" }
|
|
||||||
],
|
|
||||||
monitoring: "win exp, 포트 안열림",
|
|
||||||
serverIdMatch: "www",
|
|
||||||
model: "HPE ProLiant DL380p GEN8",
|
|
||||||
os: "Windwos Server 2012",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2609 0 @ 2.40GHz",
|
|
||||||
ram: "16GB",
|
|
||||||
storage: ["100GB", "230GB", "230GB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-006",
|
|
||||||
category: "PQ DB 서버",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.231",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "7013ddj10235!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "src5dd67f2ed",
|
|
||||||
model: "HPE ProLiant DL360 Gen10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "intel xeon silver4210R CPU @2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["278GB", "2.18TB"],
|
|
||||||
purchaseDate: "2024.12.16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-007",
|
|
||||||
category: "Oracle DB 서버",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 202번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.225",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "7013ddj10235!" }
|
|
||||||
],
|
|
||||||
monitoring: "win exp, raid X",
|
|
||||||
serverIdMatch: "SAMAN-DB",
|
|
||||||
model: "HPE ProLiant DL380 GEN9",
|
|
||||||
os: "Windows Server 2012",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz",
|
|
||||||
ram: "64GB",
|
|
||||||
storage: ["558GB", "1.09TB", "1.09TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-008",
|
|
||||||
category: "안전관리",
|
|
||||||
remarks: "삼안 개발서버2 - AI, SSL, 장헌TBM, 노드",
|
|
||||||
location: "서관 202번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "1.234.37.171",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "samanerp1!" }
|
|
||||||
],
|
|
||||||
monitoring: "연결 X",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL380 GEN10",
|
|
||||||
os: "Windwos Server 2022",
|
|
||||||
cpu: "Intel Xeon(R) Silver 4210R CPU @ 2.40GHz",
|
|
||||||
ram: "128GB",
|
|
||||||
storage: ["278GB", "3.27TB"],
|
|
||||||
purchaseDate: "2025.04.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-idc-009",
|
|
||||||
category: "가족사 공통메뉴",
|
|
||||||
remarks: "삼안 개발서버1 - QNA, 급여명세서",
|
|
||||||
location: "서관 202번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "118.220.172.233",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "samanerp1!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "srcc9ac928ee",
|
|
||||||
model: "HPE ProLiant DL380 GEN10",
|
|
||||||
os: "Windwos Server 2022",
|
|
||||||
cpu: "Intel Xeon(R) Silver 4210R CPU @ 2.40GHz",
|
|
||||||
ram: "128GB",
|
|
||||||
storage: ["278GB", "3.27TB"],
|
|
||||||
purchaseDate: "2025.04.10"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "한라",
|
|
||||||
serverNo: "hl-idc-001",
|
|
||||||
category: "한라 인트라넷",
|
|
||||||
remarks: "인트라넷,안전, 운영, MISO 서버로 운영 중(win 2008)",
|
|
||||||
location: "동관 54번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "1.234.37.143",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "1.234.37.143", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL360 GEN9",
|
|
||||||
os: "Windows Server 2008 R2",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2603 v4 @ 1.70GHz",
|
|
||||||
ram: "8GB",
|
|
||||||
storage: ["299GB", "631GB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "한라",
|
|
||||||
serverNo: "hl-idc-002",
|
|
||||||
category: "안전전산화 서버 (디자인팀 웹)",
|
|
||||||
remarks: "인트라넷 서버 다운 시 백업용 대기",
|
|
||||||
location: "동관 54번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "1.234.37.144",
|
|
||||||
ip2: "192.168.20.49",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "1.234.37.144", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL360 GEN9",
|
|
||||||
os: "Windows Server 2012",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5-2603 v4 @ 1.70GHz",
|
|
||||||
ram: "8GB",
|
|
||||||
storage: ["299GB", "631GB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "한라",
|
|
||||||
serverNo: "hl-idc-003",
|
|
||||||
category: "개발서버2",
|
|
||||||
remarks: "PTC 연구비로 구매한 예비서버2",
|
|
||||||
location: "동관 53번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "192.168.20.171",
|
|
||||||
ip2: "1.234.37.171",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "1.234.37.171", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL380 Gen10",
|
|
||||||
os: "Windows Server 2019 Standard",
|
|
||||||
cpu: "Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "1TB"],
|
|
||||||
purchaseDate: "2022.09.21"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "장헌",
|
|
||||||
serverNo: "jh-idc-001",
|
|
||||||
category: "장헌인트라넷",
|
|
||||||
remarks: "BEPs",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.71",
|
|
||||||
ip2: "192.168.10.6",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "211.206.127.71", pw: "1234dkdlxl!" },
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "Hanmac2141!%" }
|
|
||||||
],
|
|
||||||
monitoring: "잠금 걸려있음",
|
|
||||||
serverIdMatch: "src775d3e5df",
|
|
||||||
model: "HPE ProLiant DL380 GEN10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "1TB"],
|
|
||||||
purchaseDate: "2022.09.21"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "장헌",
|
|
||||||
serverNo: "jh-idc-002",
|
|
||||||
category: "장헌 인트라넷 예비",
|
|
||||||
remarks: "",
|
|
||||||
location: "동관 53번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "1.234.37.170",
|
|
||||||
ip2: "192.168.20.170",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "1.234.37.170", pw: "1234dkdlxl!" },
|
|
||||||
{ tool: "원격데스크탑", id: "Administrator", pw: "Hanmac2141!" }
|
|
||||||
],
|
|
||||||
monitoring: "원격 X, O",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL360 Gen10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "1TB"],
|
|
||||||
purchaseDate: "2022.04.01"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "장헌",
|
|
||||||
serverNo: "jh-idc-003",
|
|
||||||
category: "인트라넷(구)",
|
|
||||||
remarks: "현재는 GIT 백업 으로 사용",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.110",
|
|
||||||
ip2: "192.168.10.40",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "211.206.127.110", pw: "1234dkdlxl!" },
|
|
||||||
{ tool: "원격데스크탑", id: "User", pw: "Hanmac2141!" }
|
|
||||||
],
|
|
||||||
monitoring: "",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz",
|
|
||||||
ram: "",
|
|
||||||
storage: [],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "(주)장헌",
|
|
||||||
serverNo: "jh-idc-004",
|
|
||||||
category: "(주) 장헌 인트라넷",
|
|
||||||
remarks: "2025.12.23 IDC 이전 설치",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.76",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "User", pw: "Hanmac2141!%" }
|
|
||||||
],
|
|
||||||
monitoring: "win exp, raid X",
|
|
||||||
serverIdMatch: "DESKTOP-5IL75B7",
|
|
||||||
model: "",
|
|
||||||
os: "Windows 10",
|
|
||||||
cpu: "12th Gen Intel(R) Core(TM) i7-12700F",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["465GB", "1.81TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "PTC",
|
|
||||||
serverNo: "ptc-idc-001",
|
|
||||||
category: "PTC인트라넷",
|
|
||||||
remarks: "2024.05.22 인트라넷서버로 교체",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.72",
|
|
||||||
ip2: "192.168.10.7",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "211.206.127.72", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "SYSTEM X3650 M2",
|
|
||||||
os: "Windows Server 2008 R2",
|
|
||||||
cpu: "Intel(R) Xeon(R) CPU E5520 @ 2.27GHz",
|
|
||||||
ram: "16GB",
|
|
||||||
storage: ["556GB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "PTC",
|
|
||||||
serverNo: "ptc-idc-002",
|
|
||||||
category: "예비서버",
|
|
||||||
remarks: "PTC 인트라넷 예비서버",
|
|
||||||
location: "서관 204번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "192.168.10.8",
|
|
||||||
ip2: "",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "HPE ProLiant DL360 GEN10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "Intel Xeon(R) Silver 4210R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["278GB", "1.09TB"],
|
|
||||||
purchaseDate: "2022.04.01"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "PTC",
|
|
||||||
serverNo: "ptc-idc-003",
|
|
||||||
category: "DB 백업 서버",
|
|
||||||
remarks: "2024.05.22 변경 (데스크탑)",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.74",
|
|
||||||
ip2: "192.168.10.9",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "Remote Util", id: "211.206.127.74", pw: "1234dkdlxl!" }
|
|
||||||
],
|
|
||||||
monitoring: "설치 X",
|
|
||||||
serverIdMatch: "",
|
|
||||||
model: "",
|
|
||||||
os: "Window 7",
|
|
||||||
cpu: "Intel(R) Core(TM)2 CPU 6400 @ 2.13GHz",
|
|
||||||
ram: "4GB",
|
|
||||||
storage: ["593GB", "1.23TB"],
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "바론",
|
|
||||||
serverNo: "br-idc-001",
|
|
||||||
category: "인트라넷",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "211.206.127.75",
|
|
||||||
ip2: "192.168.10.10",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "Hanmac2141!%" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "srcf0136042d",
|
|
||||||
model: "HPE ProLiant DL360 GEN10",
|
|
||||||
os: "Windows Server 2022",
|
|
||||||
cpu: "Intel Xeon(R) Silver 4210R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "2.18TB"],
|
|
||||||
purchaseDate: "2025.04.14"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "현타",
|
|
||||||
serverNo: "ht-idc-001",
|
|
||||||
category: "인트라넷",
|
|
||||||
remarks: "",
|
|
||||||
location: "동관 53번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip1: "1.234.37.172",
|
|
||||||
ip2: "192.168.20.172",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격데스크탑", id: "administrator", pw: "Hanmac2141!" }
|
|
||||||
],
|
|
||||||
monitoring: "O",
|
|
||||||
serverIdMatch: "src901e49933",
|
|
||||||
model: "HPE ProLiant DL380 GEN10",
|
|
||||||
os: "Windows Server 2019",
|
|
||||||
cpu: "Intel Xeon Silver 4210R CPU @ 2.40GHz",
|
|
||||||
ram: "32GB",
|
|
||||||
storage: ["280GB", "1TB"],
|
|
||||||
purchaseDate: "2022.09.21"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const idcStorages: IdcStorage[] = [
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-nas-001",
|
|
||||||
category: "인트라넷 백업 스토리지",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 203번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip: "118.220.172.246",
|
|
||||||
remoteAccess: [{ tool: "원격", id: "administrator", pw: "sg11707808" }],
|
|
||||||
model: "Promiss R Series",
|
|
||||||
capacity: "36TB",
|
|
||||||
purchaseDate: ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-nas-002",
|
|
||||||
category: "성과품 스토리지",
|
|
||||||
remarks: "매니지먼트 접속 확인 불가",
|
|
||||||
location: "서관 205번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip: "118.220.172.248",
|
|
||||||
managementIp: "118.220.172.247",
|
|
||||||
remoteAccess: [{ tool: "원격", id: "administrator", pw: "sg11707808" }],
|
|
||||||
model: "ENC_3U_16BAY_D",
|
|
||||||
capacity: "23TB",
|
|
||||||
purchaseDate: "2019.06.03"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
company: "삼안",
|
|
||||||
serverNo: "sa-nas-003",
|
|
||||||
category: "성과품 백업 스토리지",
|
|
||||||
remarks: "",
|
|
||||||
location: "서관 202번",
|
|
||||||
managerPrimary: "김철수",
|
|
||||||
managerSecondary: "홍길동",
|
|
||||||
ip: "118.220.172.241",
|
|
||||||
managementIp: "118.220.172.240",
|
|
||||||
remoteAccess: [
|
|
||||||
{ tool: "원격", id: "administrator", pw: "saman1!" },
|
|
||||||
{ tool: "원격", id: "admin0", pw: "Root1234" }
|
|
||||||
],
|
|
||||||
model: "Promiss R Series",
|
|
||||||
capacity: "48TB",
|
|
||||||
purchaseDate: "2025.03.13"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
export interface Category {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Asset {
|
|
||||||
id: string;
|
|
||||||
categoryName: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
internalCode: string;
|
|
||||||
serialNumber: string;
|
|
||||||
department: string;
|
|
||||||
user: string;
|
|
||||||
acquisitionDate: string;
|
|
||||||
status: string;
|
|
||||||
location: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HardwareSpec {
|
|
||||||
id: string;
|
|
||||||
pcName: string;
|
|
||||||
department: string;
|
|
||||||
userName: string;
|
|
||||||
os: string;
|
|
||||||
cpu: string;
|
|
||||||
memory: string;
|
|
||||||
disk: string;
|
|
||||||
macAddress: string;
|
|
||||||
ipAddress: string;
|
|
||||||
graphicCard: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mockCategories: Category[] = [
|
|
||||||
{ id: '1', name: 'PC', count: 1 },
|
|
||||||
{ id: '2', name: '모니터', count: 5 },
|
|
||||||
{ id: '3', name: '노트북', count: 2 },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockAssets: Asset[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
categoryName: 'PC',
|
|
||||||
name: 'PC',
|
|
||||||
quantity: 1,
|
|
||||||
internalCode: 'PC20230411001',
|
|
||||||
serialNumber: 'SN-001-A1',
|
|
||||||
department: '한맥기술',
|
|
||||||
user: '이관형',
|
|
||||||
acquisitionDate: '2023-04-11',
|
|
||||||
status: '정상',
|
|
||||||
location: '본사 3층',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const mockHardwareSpecs: HardwareSpec[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
pcName: 'DESKTOP-G1DVL26',
|
|
||||||
department: '한맥기술',
|
|
||||||
userName: '이준권',
|
|
||||||
os: 'Microsoft Windows 10 Pro 10.0.19044',
|
|
||||||
cpu: 'Intel(R) Core(TM) i5-4590 CPU @ 3.30GHz',
|
|
||||||
memory: 'Samsung 4 GB / Samsung 4 GB',
|
|
||||||
disk: 'ST2000DM001-1CH164 1.82 TB / Samsung SSD 850 EVO 120GB',
|
|
||||||
macAddress: '0862664B98A3',
|
|
||||||
ipAddress: '172.16.9.68',
|
|
||||||
graphicCard: 'NVIDIA GeForce GTX 750',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
pcName: 'DESKTOP-BNBPOUP',
|
|
||||||
department: '한맥기술',
|
|
||||||
userName: '주완기 연구원',
|
|
||||||
os: 'Microsoft Windows 10 Pro 10.0.19045',
|
|
||||||
cpu: 'Intel(R) Core(TM) i5-4570 CPU @ 3.20GHz',
|
|
||||||
memory: 'Samsung 8 GB / Samsung 8 GB',
|
|
||||||
disk: 'ST1000DM003-1CH162 931.51 GB / Samsung SSD 840 EVO 120GB',
|
|
||||||
macAddress: 'E03F4948ECC6',
|
|
||||||
ipAddress: '172.16.9.23',
|
|
||||||
graphicCard: 'Intel(R) HD Graphics 4600',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
:root {
|
|
||||||
font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
|
|
||||||
color-scheme: light;
|
|
||||||
color: #111827;
|
|
||||||
background-color: #F9FAFB;
|
|
||||||
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
min-width: 320px;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App.tsx'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
IDC,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
서버목록,,,,,,,,,,,,,,,,,,,,,,,,,,한맥,LMS 서버,mdf,
|
|
||||||
회사,서버번호,구분,비고,설치위치,담당자,,IP 주소,,원격접속,,,모니터링 여부,"서버 이름 ID
|
|
||||||
일치 여부, 전 서버 이름",제조사 및 모델명,OS,CPU,RAM,Storage1,Storage2,Storage3,계산서,,,,,삼안,XR플래그십 스토리지,,20250414
|
|
||||||
,,,,,정,부,IP,IP2,접속도구,아이디,비빌번호,,,,,,,,,,,,,,,장헌,"스토리지, 서버",mdf 추정,
|
|
||||||
한맥,hm-idc-001,한맥 인트라넷,,서관 204번,,,211.206.127.70,192.168.10.5,원격데스크탑,administrator,samanerp1!,"win exp, raid X",srv07d330084,HPE ProLiant DL360 Gen10,Windows Server 2016,intel xeon silver4110 CPU @2.10GHz 2.10GHZ,32GB,280GB,2.7TB,,20201210 추정,한맥,,,,한라,백업서버,mdf,
|
|
||||||
,,,,,,,,,Remote Util,211.206.127.70,1234아이티!,,,,,,,,,,,,,,,,,,
|
|
||||||
,hm-idc-002,한맥 인트라넷 예비,"단가, 입사자지원 서버 (4/1 장헌산업 이름으로 스마트 건설 용도 구매)",서관 205번,,,211.206.127.78,192.168.10.13,원격데스크탑,administrator,Hanmac2141!,"win exp, raid X",srcff5294c84,HPE ProLiant DL360 Gen10,Windows Server 2019,intel xeon silver4214R CPU @2.40GHz 2.39GHZ,32GB,280GB,2.7TB,,,,,,,,,,
|
|
||||||
삼안,sa-idc-001,삼안 인트라넷,,서관 204번,,,118.220.172.237,erp.samaneng.com,원격데스크탑,administrator,samanerp1!,O,newSmintranet,HPE ProLiant DL360 Gen10,Windows Server 2016,intel xeon silver4214R CPU @2.40GHz 2.39GHZ,32GB,280GB,3.27TB,,20191220 추정,삼안,,,,,,,
|
|
||||||
,,,,,,,,,Remote Util,118.220.172.237,1234아이티!,,,,,,,,,,,,,,,,,,
|
|
||||||
,sa-idc-002,삼안 인트라넷 예비,,서관 204번,,,118.220.172.249,,원격데스크탑,administrator,samanerp1!,설치 X,INTRANET,HPE ProLiant DL360 GEN9,Windows Server 2008 R2,Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz 2.40GHz,32GB,279GB,2.72TB,,,,,,,,,,
|
|
||||||
,,,,,,,,,Remote Util,678-605-383-130,1234아이티!,,,,,,,,,,,,,,,,,,
|
|
||||||
,sa-idc-003,SATIS 01,"구 SATIS 서버, 세금계산서 발행(회계)",서관 204번,,,118.220.172.228,,원격데스크탑,administrator,satissg11707808,설치 X,satis01,HPE ProLiant DL380p GEN8,Windows Server 2008 R2,Intel(R) Xeon(R) CPU E5-2643 0 @ 3.30GHz 3.30GHz,20GB,100GB,458GB,,,,,,,,,,
|
|
||||||
,sa-idc-004,SATIS 02,SATIS 리뉴얼 버전 (ERP 서버),서관 204번,,,118.220.172.229,,원격데스크탑,administrator,satissg11707808,설치 X,satis02,HPE ProLiant DL380p GEN8,Windows Server 2008 R2,Intel(R) Xeon(R) CPU E5-2643 0 @ 3.30GHz 3.30GHz,20GB,100GB,458GB,18.1TB,,,,,,,,,
|
|
||||||
,sa-idc-005,웹 서버,남양주 테스트 서버 (도메인 관리 기능 제거 2026.03.11),서관 204번,,,samanweb.cafe24.com,118.220.172.195,원격데스크탑,administrator,saman+2013+web,"win exp, 포트 안열림",www,HPE ProLiant DL380p GEN8,Windwos Server 2012,Intel(R) Xeon(R) CPU E5-2609 0 @ 2.40GHz 2.40GHz,16GB,100GB,230GB,230GB,,,,,,,,,
|
|
||||||
,sa-idc-006,PQ DB 서버,,서관 204번,,,118.220.172.231,,원격데스크탑,administrator,7013ddj10235!, O,src5dd67f2ed,HPE ProLiant DL360 Gen10,Windows Server 2019,intel xeon silver4210R CPU @2.40GHz 2.39GHZ,32GB,278GB,2.18TB,,20241216 구매교체,삼안,,,,,,,
|
|
||||||
,sa-idc-007,Oracle DB 서버,,서관 202번,,,118.220.172.225,,원격데스크탑,administrator,7013ddj10235!,"win exp, raid X",SAMAN-DB,HPE ProLiant DL380 GEN9,Windows Server 2012,Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz 2.20GHz,64GB,558GB,1.09TB,1.09TB,,,,,,,,,
|
|
||||||
,sa-idc-008,안전관리,"삼안 개발서버2 - AI, SSL, 장헌TBM, 노드",서관 202번,,,1.234.37.171,,원격데스크탑,administrator,samanerp1!,연결 X,,HPE ProLiant DL380 GEN10,Windwos Server 2022,Intel Xeon(R) Silver 4210R CPU @ 2.40GHz,128GB,278GB,3.27TB,,20250410 설치,삼안,,,,,,,
|
|
||||||
,sa-idc-009,가족사 공통메뉴,"삼안 개발서버1 - QNA, 급여명세서",서관 202번,,,118.220.172.233,,원격데스크탑,administrator,samanerp1!,O,srcc9ac928ee,HPE ProLiant DL380 GEN10,Windwos Server 2022,Intel Xeon(R) Silver 4210R CPU @ 2.40GHz,128GB,278GB,3.27TB,,20250410 설치,삼안,,,,,,,
|
|
||||||
한라,hl-idc-001,한라 인트라넷,"인트라넷,안전, 운영, MISO 서버로 운영 중(win 2008)",동관 54번,,,1.234.37.143,,Remote Util,1.234.37.143,1234dkdlxl!,설치 X,,HPE ProLiant DL360 GEN9,Windows Server 2008 R2,Intel(R) Xeon(R) CPU E5-2603 v4 @ 1.70GHz 1.70GHz,8GB,299GB,631GB,,,,,,,,,,
|
|
||||||
,hl-idc-002,안전전산화 서버 (디자인팀 웹),"인트라넷 서버 다운 시 백업용 대기, (임시) 디자인팀 웹 퍼블리싱 서버",동관 54번,,,1.234.37.144,192.168.20.49,Remote Util,1.234.37.144,1234dkdlxl!,O,,HPE ProLiant DL360 GEN9,Windows Server 2012,Intel(R) Xeon(R) CPU E5-2603 v4 @ 1.70GHz 1.70GHz,8GB,299GB,631GB,,,,,,,,,,
|
|
||||||
,hl-idc-003,개발서버2,PTC 연구비로 구매한 예비서버2 ,동관 53번,,,192.168.20.171,1.234.37.171,Remote Util,1.234.37.171,1234dkdlxl!,O,,HPE ProLiant DL380 Gen10,Windows Server 2019 Standard,Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz,32GB,280GB,1TB,,20220921 추정,ptc,,,,,,,
|
|
||||||
,,,"이전 : 하수도자산 소스+프로그램 현재 : 큰길 서비스용 xampp+ PostgreSQL, BEPs",,,,,,원격데스크탑,administrator,Hanmac2141!%,,src775d3e5df,,,,,,,,,,,,,,,,
|
|
||||||
장헌,jh-idc-001,장헌인트라넷,,서관 205번,,,211.206.127.71,192.168.10.6,Remote Util,211.206.127.71,1234dkdlxl!,잠금 걸려있음,,HPE ProLiant DL380 GEN10,Windows Server 2019,Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz 2.39GHz,32GB,280GB,1TB,,20220921 추정,ptc,,,,,,,
|
|
||||||
,jh-idc-002,장헌 인트라넷 예비,,동관 53번,,,1.234.37.170,192.168.20.170,Remote Util,1.234.37.170,1234dkdlxl!,"원격 X, O
|
|
||||||
",,HPE ProLiant DL360 Gen10,Windows Server 2019,Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz 2.39GHz,32GB,280GB,1TB,,20220401 추정,장헌,,,,,,,
|
|
||||||
,,,,,,,,,원격데스크탑,Administrator,Hanmac2141!,,,,,,,,,,,,,,,,,,
|
|
||||||
,jh-idc-003,인트라넷(구),현재는 GIT 백업 으로 사용,서관 205번,,,211.206.127.110,192.168.10.40,Remote Util,211.206.127.110,1234dkdlxl!,,,,Windows Server 2019,Intel(R) Xeon(R) Silver 4214R CPU @ 2.40GHz,,,,,,,,,,,,,
|
|
||||||
,,,,,,,,,원격데스크탑,User,Hanmac2141!,,,,,,,,,,,,,,,,,,
|
|
||||||
(주)장헌,jh-idc-004,(주) 장헌 인트라넷,2025.12.23 (주) 장헌 센터 MDF에서 IDC로 이전 설치,서관 205번,,,211.206.127.76,,원격데스크탑,User,Hanmac2141!%,"win exp, raid X",DESKTOP-5IL75B7,,Windows 10,12th Gen Intel(R) Core(TM) i7-12700F,32GB,465GB,1.81TB,,,,,,,,,,
|
|
||||||
PTC,ptc-idc-001,PTC인트라넷,"구 파일 서버(부서자료 백업용), 2024.05.22 인트라넷서버로 교체",서관 205번,,,211.206.127.72,192.168.10.7,Remote Util,211.206.127.72,1234dkdlxl!,설치 X,,SYSTEM X3650 M2,Windows Server 2008 R2,Intel(R) Xeon(R) CPU E5520 @ 2.27GHz 2.26GHz,16GB,556GB,,,,,,,,,,,
|
|
||||||
,ptc-idc-002,예비서버,PTC 인트라넷 예비서버,서관 204번,,,192.168.10.8,,원격데스크탑,administrator,1234dkdlxl!,O,,HPE ProLiant DL360 GEN10,Windows Server 2019,Intel Xeon(R) Silver 4210R CPU @ 2.40GHz,32GB,278GB,1.09TB,,20220401 추정,장헌,,,,,,,
|
|
||||||
,ptc-idc-003,DB 백업 서버,"구 파일 인트라넷, 2024.05.22에 DB 백업 테스트 서버로 변경 (데스크탑)",서관 205번,,,211.206.127.74,192.168.10.9,Remote Util,211.206.127.74,1234dkdlxl!,설치 X,,,Window 7,Intel(R) Core(TM)2 CPU 6400 @ 2.13GHz 2.13GHz,4GB,593GB,1.23TB,,,,,,,,,,
|
|
||||||
바론,br-idc-001,인트라넷,,서관 205번,,,211.206.127.75,192.168.10.10,원격데스크탑,administrator,Hanmac2141!%,O,srcf0136042d,HPE ProLiant DL360 GEN10,Windows Server 2022,Intel Xeon(R) Silver 4210R CPU @ 2.40GHz,32GB,280GB,2.18TB,,20250414 추정,바론,,,,,,,
|
|
||||||
현타,ht-idc-001,인트라넷,,동관 53번,,,1.234.37.172,192.168.20.172,원격데스크탑,administrator,Hanmac2141!,O,src901e49933,HPE ProLiant DL380 GEN10,Windows Server 2019,Intel Xeon Silver 4210R CPU @ 2.40GHz 2.39GHz,32GB,280GB,1TB,,20220921 추정,ptc,,,,,,,
|
|
||||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
스토리지 목록,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
회사,서버번호,구분,비고,설치위치,담당자,,IP 주소,,원격접속,,,,,,모델명,용량,,,,,,,,,,,,,
|
|
||||||
,,,,,정,부,,,접속도구,아이디,비빌번호,,,,,,,,,,,,,,,,,,
|
|
||||||
삼안,sa-das-001,,"Satis01, Satis02 광케이블 연결 (물리연결)",서관 205번,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
,sa-nas-001,인트라넷 백업 스토리지,,서관 203번,,,(IDC) 118.220.172.246,,원격,administrator,sg11707808,,,,Promiss R Series,36TB,,,,,,,,,,,,,
|
|
||||||
,sa-nas-002,성과품 스토리지,,서관 205번,,,(IDC) 118.220.172.248,,원격,administrator,sg11707808,,,,ENC_3U_16BAY_D // SEAGATE ST2000NM0045,23TB,,,,,20190603 추정,삼안,,,,,,,
|
|
||||||
,,,매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요),,,,(매니지먼트) 118.220.172.247,,원격,-,-,,,,,,,,,,,,,,,,,,
|
|
||||||
,sa-nas-003,성과품 백업 스토리지,,서관 202번,,,(IDC) 118.220.172.241,,원격,administrator,saman1!,,,,Promiss R Series,48TB,,,,,20250313,삼안,,,,,,,
|
|
||||||
,,,,,,,(매지니먼트) 118.220.172.240,,원격,admin0,Root1234,,,,,,,,,,,,,,,,,,
|
|
||||||
한라,hl-das-001,,파일서버 정보 없음(접속 불가),동관 54번,,,,,,,,,,,,,,,,,20190701 추정,한라,(한라 파일서버도 동일일자 추정),,,,,,
|
|
||||||
,hl-das-002,,,동관 54번,,,,,,,,,,,,,,,,,,,,,,,,,
|
|
||||||
|
1834
build/pc_agent/Analysis-00.toc
Normal file
211
build/pc_agent/EXE-00.toc
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
('D:\\이태훈\\22전산자산조사\\ITAM\\dist\\pc_agent.exe',
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-console.ico',
|
||||||
|
None,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<assembly xmlns='
|
||||||
|
b'"urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">\n <trustInfo x'
|
||||||
|
b'mlns="urn:schemas-microsoft-com:asm.v3">\n <security>\n <requested'
|
||||||
|
b'Privileges>\n <requestedExecutionLevel level="asInvoker" uiAccess='
|
||||||
|
b'"false"/>\n </requestedPrivileges>\n </security>\n </trustInfo>\n '
|
||||||
|
b'<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">\n <'
|
||||||
|
b'application>\n <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f'
|
||||||
|
b'0}"/>\n <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>\n '
|
||||||
|
b' <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>\n <s'
|
||||||
|
b'upportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>\n <supporte'
|
||||||
|
b'dOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>\n </application>\n <'
|
||||||
|
b'/compatibility>\n <application xmlns="urn:schemas-microsoft-com:asm.v3">'
|
||||||
|
b'\n <windowsSettings>\n <longPathAware xmlns="http://schemas.micros'
|
||||||
|
b'oft.com/SMI/2016/WindowsSettings">true</longPathAware>\n </windowsSett'
|
||||||
|
b'ings>\n </application>\n <dependency>\n <dependentAssembly>\n <ass'
|
||||||
|
b'emblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version='
|
||||||
|
b'"6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" langua'
|
||||||
|
b'ge="*"/>\n </dependentAssembly>\n </dependency>\n</assembly>',
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
|
||||||
|
[('pyi-contents-directory _internal', '', 'OPTION'),
|
||||||
|
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
|
||||||
|
('struct',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod01_archive',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod02_importers',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod03_ctypes',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod04_pywin32',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyiboot01_bootstrap',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_inspect',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pkgutil',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_multiprocessing',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_cryptography_openssl',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pywintypes',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pythoncom',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
|
||||||
|
('python312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('pywin32_system32\\pywintypes312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('pywin32_system32\\pythoncom312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('select.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_multiprocessing.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('pyexpat.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_ssl.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_hashlib.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('unicodedata.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_decimal.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_lzma.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_bz2.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_ctypes.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_queue.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_wmi.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_socket.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_overlapped.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_asyncio.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_cffi_backend.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('cryptography\\hazmat\\bindings\\_rust.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('charset_normalizer\\md.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\_win32sysloader.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32api.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('Pythonwin\\win32ui.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32event.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32trace.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('VCRUNTIME140.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('VCRUNTIME140_1.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libssl-3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libcrypto-3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libffi-8.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('python3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('Pythonwin\\mfc140u.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('certifi\\cacert.pem',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
|
||||||
|
'DATA'),
|
||||||
|
('certifi\\py.typed',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\RECORD',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\METADATA',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\WHEEL',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\INSTALLER',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
|
||||||
|
'DATA'),
|
||||||
|
('base_library.zip',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
|
||||||
|
'DATA')],
|
||||||
|
[],
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
1779102721,
|
||||||
|
[('run.exe',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\Windows-64bit-intel\\run.exe',
|
||||||
|
'EXECUTABLE')],
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll')
|
||||||
189
build/pc_agent/PKG-00.toc
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
('D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
|
||||||
|
{'BINARY': True,
|
||||||
|
'DATA': True,
|
||||||
|
'EXECUTABLE': True,
|
||||||
|
'EXTENSION': True,
|
||||||
|
'PYMODULE': True,
|
||||||
|
'PYSOURCE': True,
|
||||||
|
'PYZ': False,
|
||||||
|
'SPLASH': True,
|
||||||
|
'SYMLINK': False},
|
||||||
|
[('pyi-contents-directory _internal', '', 'OPTION'),
|
||||||
|
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
|
||||||
|
('struct',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod01_archive',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod02_importers',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod03_ctypes',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyimod04_pywin32',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
|
||||||
|
'PYMODULE'),
|
||||||
|
('pyiboot01_bootstrap',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_inspect',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pkgutil',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_multiprocessing',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_cryptography_openssl',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pywintypes',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pyi_rth_pythoncom',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
|
||||||
|
'PYSOURCE'),
|
||||||
|
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
|
||||||
|
('python312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('pywin32_system32\\pywintypes312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('pywin32_system32\\pythoncom312.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('select.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_multiprocessing.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('pyexpat.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_ssl.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_hashlib.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('unicodedata.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_decimal.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_lzma.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_bz2.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_ctypes.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_queue.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_wmi.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_socket.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_overlapped.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_asyncio.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('_cffi_backend.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('cryptography\\hazmat\\bindings\\_rust.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('charset_normalizer\\md.cp312-win_amd64.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\_win32sysloader.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32api.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('Pythonwin\\win32ui.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32event.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('win32\\win32trace.pyd',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
|
||||||
|
'EXTENSION'),
|
||||||
|
('VCRUNTIME140.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('VCRUNTIME140_1.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libssl-3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libcrypto-3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('libffi-8.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('python3.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('Pythonwin\\mfc140u.dll',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
|
||||||
|
'BINARY'),
|
||||||
|
('certifi\\cacert.pem',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
|
||||||
|
'DATA'),
|
||||||
|
('certifi\\py.typed',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\RECORD',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\METADATA',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\WHEEL',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\INSTALLER',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
|
||||||
|
'DATA'),
|
||||||
|
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
|
||||||
|
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
|
||||||
|
'DATA'),
|
||||||
|
('base_library.zip',
|
||||||
|
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
|
||||||
|
'DATA')],
|
||||||
|
'python312.dll',
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None)
|
||||||
BIN
build/pc_agent/PYZ-00.pyz
Normal file
1193
build/pc_agent/PYZ-00.toc
Normal file
BIN
build/pc_agent/base_library.zip
Normal file
BIN
build/pc_agent/localpycs/pyimod01_archive.pyc
Normal file
BIN
build/pc_agent/localpycs/pyimod02_importers.pyc
Normal file
BIN
build/pc_agent/localpycs/pyimod03_ctypes.pyc
Normal file
BIN
build/pc_agent/localpycs/pyimod04_pywin32.pyc
Normal file
BIN
build/pc_agent/localpycs/struct.pyc
Normal file
BIN
build/pc_agent/pc_agent.pkg
Normal file
58
build/pc_agent/warn-pc_agent.txt
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
|
||||||
|
This file lists modules PyInstaller was not able to find. This does not
|
||||||
|
necessarily mean these modules are required for running your program. Both
|
||||||
|
Python's standard library and 3rd-party Python packages often conditionally
|
||||||
|
import optional modules, some of which may be available only on certain
|
||||||
|
platforms.
|
||||||
|
|
||||||
|
Types of import:
|
||||||
|
* top-level: imported at the top-level - look at these first
|
||||||
|
* conditional: imported within an if-statement
|
||||||
|
* delayed: imported within a function
|
||||||
|
* optional: imported within a try-except-statement
|
||||||
|
|
||||||
|
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
|
||||||
|
tracking down the missing module yourself. Thanks!
|
||||||
|
|
||||||
|
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
|
||||||
|
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
|
||||||
|
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
|
||||||
|
missing module named fcntl - imported by subprocess (optional)
|
||||||
|
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
|
||||||
|
missing module named _scproxy - imported by urllib.request (conditional)
|
||||||
|
missing module named termios - imported by getpass (optional)
|
||||||
|
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
|
||||||
|
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
|
||||||
|
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
|
||||||
|
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
|
||||||
|
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
|
||||||
|
missing module named resource - imported by posix (top-level)
|
||||||
|
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
|
||||||
|
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
|
||||||
|
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
|
||||||
|
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
|
||||||
|
missing module named pyimod02_importers - imported by C:\Users\User\AppData\Local\Programs\Python\Python312\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
|
||||||
|
missing module named collections.Callable - imported by collections (optional), socks (optional)
|
||||||
|
missing module named vms_lib - imported by platform (delayed, optional)
|
||||||
|
missing module named 'java.lang' - imported by platform (delayed, optional)
|
||||||
|
missing module named java - imported by platform (delayed)
|
||||||
|
missing module named _winreg - imported by platform (delayed, optional)
|
||||||
|
missing module named simplejson - imported by requests.compat (conditional, optional)
|
||||||
|
missing module named dummy_threading - imported by requests.cookies (optional)
|
||||||
|
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
|
||||||
|
missing module named annotationlib - imported by typing_extensions (conditional)
|
||||||
|
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
|
||||||
|
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
|
||||||
|
missing module named h2 - imported by urllib3.http2.connection (top-level)
|
||||||
|
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||||
|
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||||
|
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
|
||||||
|
missing module named win_inet_pton - imported by socks (conditional, optional)
|
||||||
|
missing module named bcrypt - imported by cryptography.hazmat.primitives.serialization.ssh (optional)
|
||||||
|
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
|
||||||
|
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
|
||||||
|
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
|
||||||
|
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
|
||||||
|
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
|
||||||
|
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
|
||||||
|
missing module named 'win32com.gen_py' - imported by win32com (conditional, optional)
|
||||||
16542
build/pc_agent/xref-pc_agent.html
Normal file
24
create_db.js
@@ -1,24 +0,0 @@
|
|||||||
import * as XLSX from 'xlsx';
|
|
||||||
|
|
||||||
const hwTabs = ['개인PC', '서버', '스토리지', '전산비품'];
|
|
||||||
const swTabs = ['구독SW', '영구SW'];
|
|
||||||
|
|
||||||
const hwHeaders = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS'];
|
|
||||||
const swHeaders = ['법인', 'SW명', '라이선스키', '할당자', '사용기간', '비고'];
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
|
||||||
|
|
||||||
hwTabs.forEach((tab, i) => {
|
|
||||||
const wsData = [hwHeaders, [`(주)회사${i}`, `ASSET-${i}00`, `${tab} 모델A`, '본사 1층', '관리자A', '192.168.0.1', '00:00:00:00:00:01', 'Core i7, 16GB RAM', 'Windows 10']];
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, tab);
|
|
||||||
});
|
|
||||||
|
|
||||||
swTabs.forEach((tab, i) => {
|
|
||||||
const wsData = [swHeaders, [`(주)회사${i}`, `${tab} Adobe CC`, '1234-5678-ABCD', '홍길동,김철수', '2024.01~2024.12', '본사 전용']];
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, tab);
|
|
||||||
});
|
|
||||||
|
|
||||||
XLSX.writeFile(wb, 'temp_db.xlsx');
|
|
||||||
console.log('temp_db.xlsx created!');
|
|
||||||
176
db_init.js
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||||
|
|
||||||
|
async function initDB() {
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: DB_HOST,
|
||||||
|
user: DB_USER,
|
||||||
|
password: DB_PASS,
|
||||||
|
database: DB_NAME,
|
||||||
|
port: parseInt(DB_PORT || '3306'),
|
||||||
|
multipleStatements: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔄 DB 초기화 시작 (영문 표준 스키마 적용)...');
|
||||||
|
|
||||||
|
const tablesToDrop = [
|
||||||
|
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
|
||||||
|
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
|
||||||
|
];
|
||||||
|
for (const table of tablesToDrop) {
|
||||||
|
await connection.query(`DROP TABLE IF EXISTS ${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createHardwareTable = (tableName, comment) => `
|
||||||
|
CREATE TABLE ${tableName} (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
corp VARCHAR(100),
|
||||||
|
asset_code VARCHAR(100),
|
||||||
|
purchase_date VARCHAR(50),
|
||||||
|
type VARCHAR(50),
|
||||||
|
detail_purpose VARCHAR(50),
|
||||||
|
purpose VARCHAR(255),
|
||||||
|
details TEXT,
|
||||||
|
current_org VARCHAR(255),
|
||||||
|
prev_org VARCHAR(255),
|
||||||
|
location VARCHAR(255),
|
||||||
|
manager_main VARCHAR(100),
|
||||||
|
manager_sub VARCHAR(100),
|
||||||
|
ip_address VARCHAR(100),
|
||||||
|
remote_tool VARCHAR(100),
|
||||||
|
server_id VARCHAR(100),
|
||||||
|
server_pw VARCHAR(100),
|
||||||
|
model_name VARCHAR(255),
|
||||||
|
mainboard VARCHAR(255) COMMENT '메인보드',
|
||||||
|
os VARCHAR(100),
|
||||||
|
cpu VARCHAR(255),
|
||||||
|
ram VARCHAR(100),
|
||||||
|
gpu VARCHAR(100),
|
||||||
|
storage1 VARCHAR(255),
|
||||||
|
storage2 VARCHAR(255),
|
||||||
|
storage3 VARCHAR(255),
|
||||||
|
monitoring VARCHAR(100),
|
||||||
|
price VARCHAR(100),
|
||||||
|
remarks TEXT,
|
||||||
|
storage_location VARCHAR(255),
|
||||||
|
status VARCHAR(50),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
await connection.query(createHardwareTable('pc_assets', 'PC'));
|
||||||
|
await connection.query(createHardwareTable('server_assets', 'Server'));
|
||||||
|
await connection.query(createHardwareTable('storage_assets', 'Storage'));
|
||||||
|
await connection.query(createHardwareTable('equip_assets', 'Equipment'));
|
||||||
|
await connection.query(createHardwareTable('mobile_assets', 'Mobile'));
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE sw_sub_assets (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
corp VARCHAR(100) COMMENT '구매법인',
|
||||||
|
category VARCHAR(100) COMMENT '분야',
|
||||||
|
dept VARCHAR(100) COMMENT '부서',
|
||||||
|
product_name VARCHAR(255) COMMENT '제품명',
|
||||||
|
license_type VARCHAR(100) COMMENT '라이선스 유형',
|
||||||
|
quantity INT COMMENT '수량',
|
||||||
|
price VARCHAR(100) COMMENT '금액',
|
||||||
|
purchase_date VARCHAR(50) COMMENT '구매일',
|
||||||
|
start_date VARCHAR(50) COMMENT '시작일',
|
||||||
|
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||||
|
vendor VARCHAR(255) COMMENT '구매업체',
|
||||||
|
remarks TEXT COMMENT '비고',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE sw_perm_assets (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
corp VARCHAR(100) COMMENT '구매법인',
|
||||||
|
category VARCHAR(100) COMMENT '분야',
|
||||||
|
dept VARCHAR(100) COMMENT '부서',
|
||||||
|
product_name VARCHAR(255) COMMENT '제품명',
|
||||||
|
license_key VARCHAR(255) COMMENT '라이선스 키',
|
||||||
|
quantity INT COMMENT '수량',
|
||||||
|
price VARCHAR(100) COMMENT '금액',
|
||||||
|
purchase_date VARCHAR(50) COMMENT '구매일',
|
||||||
|
start_date VARCHAR(50) COMMENT '시작일',
|
||||||
|
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||||
|
vendor VARCHAR(255) COMMENT '구매업체',
|
||||||
|
remarks TEXT COMMENT '비고',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE cloud_assets (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
platform_name VARCHAR(100),
|
||||||
|
corp VARCHAR(100),
|
||||||
|
dept VARCHAR(100),
|
||||||
|
product_name VARCHAR(255),
|
||||||
|
account_name VARCHAR(255),
|
||||||
|
pay_method VARCHAR(100),
|
||||||
|
pay_day VARCHAR(50),
|
||||||
|
card_num VARCHAR(100),
|
||||||
|
monthly_fee VARCHAR(100),
|
||||||
|
remarks TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE sw_users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
sw_id VARCHAR(50),
|
||||||
|
corp VARCHAR(100),
|
||||||
|
dept VARCHAR(100),
|
||||||
|
position VARCHAR(50),
|
||||||
|
user_name VARCHAR(100),
|
||||||
|
usage_period VARCHAR(100),
|
||||||
|
doc_name VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE asset_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
asset_id VARCHAR(50),
|
||||||
|
log_date VARCHAR(50),
|
||||||
|
log_user VARCHAR(100),
|
||||||
|
details TEXT,
|
||||||
|
cost DECIMAL(15,2) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE ops_domain_assets (
|
||||||
|
id VARCHAR(50) PRIMARY KEY,
|
||||||
|
type VARCHAR(50) COMMENT '유형',
|
||||||
|
corp VARCHAR(100) COMMENT '법인',
|
||||||
|
service_name VARCHAR(255) COMMENT '서비스명',
|
||||||
|
domain_name VARCHAR(255) COMMENT '관리도메인',
|
||||||
|
start_date VARCHAR(50) COMMENT '시작일',
|
||||||
|
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||||
|
price VARCHAR(100) COMMENT '금액',
|
||||||
|
manager_main VARCHAR(100) COMMENT '담당자',
|
||||||
|
manager_sub VARCHAR(100) COMMENT '담당자(부)',
|
||||||
|
remarks TEXT COMMENT '비고',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
initDB().catch(err => {
|
||||||
|
console.error('❌ DB 초기화 실패:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
45
docs/issues/issue_dashboard_and_modal_optimization.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 주요 작업 내용
|
||||||
|
|
||||||
|
### 📂 소프트웨어 관리 프레임워크 재구조화
|
||||||
|
- **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임.
|
||||||
|
1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW
|
||||||
|
2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리)
|
||||||
|
3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스
|
||||||
|
- **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함.
|
||||||
|
|
||||||
|
### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설
|
||||||
|
- **전용 리스트 뷰 (`CloudListView.ts`)**:
|
||||||
|
- 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함.
|
||||||
|
- **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함.
|
||||||
|
- **클라우드 전문 모달 (`CloudModal.ts`)**:
|
||||||
|
- 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함.
|
||||||
|
- **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함.
|
||||||
|
|
||||||
|
### 📊 대시보드(Dashboard) 리팩토링 및 고도화
|
||||||
|
- **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함.
|
||||||
|
- **데이터 시각화**:
|
||||||
|
- **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함.
|
||||||
|
- **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함.
|
||||||
|
- **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함.
|
||||||
|
|
||||||
|
### 🪟 UX 및 데이터 정합성 강화
|
||||||
|
- **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함.
|
||||||
|
- **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함.
|
||||||
|
- **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 향후 과제
|
||||||
|
- 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토
|
||||||
|
- 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토
|
||||||
|
|
||||||
|
---
|
||||||
|
**작업자**: Antigravity (AI Assistant)
|
||||||
|
**상태**: 완료 (2026-04-17)
|
||||||
33
docs/issues/issue_sw_modal_refactor.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다.
|
||||||
|
|
||||||
|
## 2. 작업 상세 내용
|
||||||
|
|
||||||
|
### A. S/W 목록(Table) 개선
|
||||||
|
- **상태 자동 계산 시스템 도입**:
|
||||||
|
- 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시.
|
||||||
|
- 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시.
|
||||||
|
- **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임.
|
||||||
|
|
||||||
|
### B. 상세 정보 모달 개편 (`SWModal.ts`)
|
||||||
|
- **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계.
|
||||||
|
- **날짜 입력 필드 개선**:
|
||||||
|
- '구매일' 필드에 캘린더 피커(Calendar Picker) 적용.
|
||||||
|
- '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용.
|
||||||
|
- 직접 입력("yyyy-mm-dd") 형식도 동시 지원.
|
||||||
|
|
||||||
|
### C. 계약 업데이트(갱신) 관리 기능
|
||||||
|
- **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현.
|
||||||
|
- 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적.
|
||||||
|
- 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동.
|
||||||
|
|
||||||
|
## 3. 관련 파일
|
||||||
|
- `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링.
|
||||||
|
- `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직.
|
||||||
|
- `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일.
|
||||||
|
|
||||||
|
## 4. 확인 사항
|
||||||
|
- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
|
||||||
|
- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.
|
||||||
BIN
image 92.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
img/location_photo/IDC/동관53.png
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
img/location_photo/IDC/동관54.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
img/location_photo/IDC/서관202.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
img/location_photo/IDC/서관203.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
img/location_photo/IDC/서관204.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
img/location_photo/IDC/서관205.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
img/location_photo/기술개발센터/서버실/서버실_1.png
Normal file
|
After Width: | Height: | Size: 11 MiB |
BIN
img/location_photo/기술개발센터/서버실/서버실_2.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_1.png
Normal file
|
After Width: | Height: | Size: 9.5 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_2.png
Normal file
|
After Width: | Height: | Size: 9.8 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_3.png
Normal file
|
After Width: | Height: | Size: 8.1 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_4.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
667
index.html
@@ -1,614 +1,99 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<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="/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/chartjs-plugin-datalabels@2.0.0"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="app-layout">
|
|
||||||
<!-- Sidebar Navigation -->
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h1>HM <span>ITAM</span></h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-section">
|
<head>
|
||||||
<h3><i data-lucide="cpu"></i> 하드웨어</h3>
|
<meta charset="UTF-8" />
|
||||||
<ul id="nav-hw" class="nav-list">
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<li class="active" data-category="hw" data-tab="대시보드"><i data-lucide="layout-dashboard"></i> 대시보드</li>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<li data-category="hw" data-tab="개인PC"><i data-lucide="monitor"></i> 개인PC</li>
|
<title>ITAM 자산관리 ERP</title>
|
||||||
<li data-category="hw" data-tab="서버"><i data-lucide="server"></i> 서버</li>
|
<link rel="stylesheet"
|
||||||
<li data-category="hw" data-tab="스토리지"><i data-lucide="database"></i> 스토리지</li>
|
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||||
<li data-category="hw" data-tab="전산비품"><i data-lucide="laptop"></i> 전산비품</li>
|
<link rel="stylesheet" href="/src/styles/common.css" />
|
||||||
</ul>
|
<link rel="stylesheet" href="/src/styles/login.css" />
|
||||||
</div>
|
<link rel="stylesheet" href="/src/styles/guide.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/modal.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/dashboard.css" />
|
||||||
|
<link rel="stylesheet" href="/src/styles/table.css" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
<div class="nav-section">
|
<body>
|
||||||
<h3><i data-lucide="layers"></i> 소프트웨어</h3>
|
<!-- Login Screen -->
|
||||||
<ul id="nav-sw" class="nav-list">
|
<div id="login-container" class="login-layout">
|
||||||
<li data-category="sw" data-tab="대시보드"><i data-lucide="layout-dashboard"></i> 대시보드</li>
|
<div class="login-card">
|
||||||
<li data-category="sw" data-tab="구독SW"><i data-lucide="calendar-clock"></i> 구독 소프트웨어</li>
|
<div class="login-header">
|
||||||
<li data-category="sw" data-tab="영구SW"><i data-lucide="key"></i> 영구 소프트웨어</li>
|
<img src="/image 92.png" alt="Logo" class="login-logo" />
|
||||||
</ul>
|
<h2>ITAM 시스템</h2>
|
||||||
</div>
|
<p>자산 관리 포털에 오신 것을 환영합니다</p>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<div class="main-wrapper">
|
|
||||||
<header class="top-header">
|
|
||||||
<div class="header-title">
|
|
||||||
<h2 id="current-tab-title">하드웨어 / 대시보드</h2>
|
|
||||||
</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<!-- 엑셀 컨트롤러 묶음 -->
|
|
||||||
<button id="btn-download-template" class="btn btn-outline" title="초기 데이터 입력을 위한 전체 엑셀 템플릿 다운로드">
|
|
||||||
<i data-lucide="download"></i> 통합 양식 다운로드
|
|
||||||
</button>
|
|
||||||
<label for="excel-upload" class="btn btn-outline" title="작성된 엑셀 파일 일괄 업로드">
|
|
||||||
<i data-lucide="upload"></i> 엑셀 업로드
|
|
||||||
</label>
|
|
||||||
<input type="file" id="excel-upload" accept=".xlsx, .xls" style="display: none;" />
|
|
||||||
<button id="btn-export-excel" class="btn btn-primary" title="마스터 데이터 전체를 엑셀로 저장">
|
|
||||||
<i data-lucide="file-spreadsheet"></i> 일괄 엑셀 저장
|
|
||||||
</button>
|
|
||||||
<button id="btn-add-asset" class="btn btn-primary hidden">
|
|
||||||
<i data-lucide="plus"></i> 자산 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="content-area" id="main-content">
|
|
||||||
<!-- 대시보드 뷰, 또는 데이터 테이블 뷰가 JavaScript로 주입됩니다 -->
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="login-selection" class="login-selection">
|
||||||
|
<div class="role-card" data-role="admin">
|
||||||
<!-- HW Asset Modal -->
|
<div class="role-icon">
|
||||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
<i data-lucide="settings"></i>
|
||||||
<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>
|
||||||
|
<h3>관리자</h3>
|
||||||
|
<p>시스템 설정 및 자산 마스터 관리</p>
|
||||||
|
</div>
|
||||||
|
<div class="role-card" data-role="user">
|
||||||
|
<div class="role-icon">
|
||||||
|
<i data-lucide="monitor"></i>
|
||||||
|
</div>
|
||||||
|
<h3>실무자</h3>
|
||||||
|
<p>자산 조회 및 현황 확인</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="login-footer">
|
||||||
|
<p>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
||||||
<!-- PC Asset Modal -->
|
|
||||||
<div id="pc-asset-modal" class="modal-overlay hidden">
|
|
||||||
<div class="modal-content wide">
|
|
||||||
<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">
|
|
||||||
<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 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-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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Storage Asset Modal -->
|
<div class="app-layout" id="app-layout" style="display: none;">
|
||||||
<div id="storage-asset-modal" class="modal-overlay hidden">
|
<!-- Single-Line Integrated Header -->
|
||||||
<div class="modal-content">
|
<header class="main-header">
|
||||||
<div class="modal-header">
|
<div class="header-container" id="nav-container">
|
||||||
<h2 id="storage-modal-title">스토리지 상세 정보</h2>
|
<div class="brand">
|
||||||
<button id="btn-close-storage-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
<img src="/image 92.png" alt="Logo" class="main-logo" />
|
||||||
|
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
|
||||||
</div>
|
</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">
|
<!-- Navigation (GNB + LNB in same row) -->
|
||||||
<label for="storage-법인">법인</label>
|
<nav class="integrated-nav" id="main-nav">
|
||||||
<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;">
|
<!-- JS will render main items and sub items here side-by-side -->
|
||||||
<option value="한맥">한맥 (HM)</option>
|
</nav>
|
||||||
<option value="삼안">삼안 (SM)</option>
|
|
||||||
<option value="바론">바론 (BR)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="header-actions">
|
||||||
<label for="storage-유형">유형</label>
|
<div class="role-switcher" id="role-switcher">
|
||||||
<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;">
|
<span class="role-label user active">실무자</span>
|
||||||
<option value="NAS">NAS</option>
|
<label class="switch">
|
||||||
<option value="DAS">DAS</option>
|
<input type="checkbox" id="role-toggle-checkbox">
|
||||||
</select>
|
<span class="slider round"></span>
|
||||||
</div>
|
</label>
|
||||||
|
<span class="role-label admin">관리자</span>
|
||||||
<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>
|
||||||
|
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
|
||||||
|
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
|
||||||
|
<i data-lucide="book-open"></i> 가이드
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- SW Asset Modal -->
|
<!-- Main Content Area -->
|
||||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
<main class="content-area" id="main-content">
|
||||||
<div class="modal-content">
|
<!-- Components inject views here -->
|
||||||
<div class="modal-header">
|
</main>
|
||||||
<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">
|
<!-- Footer -->
|
||||||
<label for="sw-분야">분야</label>
|
<footer class="main-footer">
|
||||||
<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;">
|
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
|
||||||
<option value="업무공통">업무공통</option>
|
<p>Powered by BARON Consultant Co,Ltd</p>
|
||||||
<option value="개발S/W">개발S/W</option>
|
</footer>
|
||||||
<option value="디자인">디자인</option>
|
</div>
|
||||||
<option value="설계S/W">설계S/W</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<!-- All modals are injected dynamically -->
|
||||||
<label for="sw-법인">법인</label>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<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;">
|
</body>
|
||||||
<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-부서" 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;">
|
|
||||||
<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>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
768
map_config.json
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
{
|
||||||
|
"img/location_photo/IDC/서관205.png": [
|
||||||
|
{
|
||||||
|
"x": "50.78",
|
||||||
|
"y": "1.53",
|
||||||
|
"w": "45.83",
|
||||||
|
"h": "6.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.67",
|
||||||
|
"y": "10.35",
|
||||||
|
"w": "45.95",
|
||||||
|
"h": "5.99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.78",
|
||||||
|
"y": "19.06",
|
||||||
|
"w": "45.83",
|
||||||
|
"h": "6.32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.67",
|
||||||
|
"y": "27.89",
|
||||||
|
"w": "46.06",
|
||||||
|
"h": "6.32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.78",
|
||||||
|
"y": "36.71",
|
||||||
|
"w": "45.95",
|
||||||
|
"h": "6.21"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.78",
|
||||||
|
"y": "45.64",
|
||||||
|
"w": "45.83",
|
||||||
|
"h": "6.32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.67",
|
||||||
|
"y": "54.25",
|
||||||
|
"w": "46.06",
|
||||||
|
"h": "6.54"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.90",
|
||||||
|
"y": "63.29",
|
||||||
|
"w": "45.72",
|
||||||
|
"h": "5.99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.90",
|
||||||
|
"y": "72.00",
|
||||||
|
"w": "45.72",
|
||||||
|
"h": "6.32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "50.78",
|
||||||
|
"y": "81.92",
|
||||||
|
"w": "18.40",
|
||||||
|
"h": "15.58"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "78.67",
|
||||||
|
"y": "82.03",
|
||||||
|
"w": "17.94",
|
||||||
|
"h": "15.25"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/IDC/서관202.png": [
|
||||||
|
{
|
||||||
|
"x": "56.35",
|
||||||
|
"y": "64.02",
|
||||||
|
"w": "40.41",
|
||||||
|
"h": "5.89"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.35",
|
||||||
|
"y": "71.57",
|
||||||
|
"w": "40.66",
|
||||||
|
"h": "5.89"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.23",
|
||||||
|
"y": "79.25",
|
||||||
|
"w": "40.53",
|
||||||
|
"h": "5.76"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "55.98",
|
||||||
|
"y": "86.42",
|
||||||
|
"w": "41.15",
|
||||||
|
"h": "6.27"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/IDC/서관203.png": [
|
||||||
|
{
|
||||||
|
"x": "56.07",
|
||||||
|
"y": "2.44",
|
||||||
|
"w": "40.91",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.07",
|
||||||
|
"y": "10.12",
|
||||||
|
"w": "40.79",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "55.95",
|
||||||
|
"y": "17.80",
|
||||||
|
"w": "41.04",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "55.95",
|
||||||
|
"y": "63.51",
|
||||||
|
"w": "40.91",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "55.95",
|
||||||
|
"y": "71.19",
|
||||||
|
"w": "41.04",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.07",
|
||||||
|
"y": "87.70",
|
||||||
|
"w": "40.91",
|
||||||
|
"h": "6.02"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/IDC/서관204.png": [
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "2.57",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.01",
|
||||||
|
"y": "10.38",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "5.89"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "17.93",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "5.89"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.73",
|
||||||
|
"y": "25.49",
|
||||||
|
"w": "47.69",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "33.17",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "6.02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "40.59",
|
||||||
|
"w": "47.54",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "48.40",
|
||||||
|
"w": "47.54",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.73",
|
||||||
|
"y": "55.95",
|
||||||
|
"w": "47.69",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.01",
|
||||||
|
"y": "63.63",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.73",
|
||||||
|
"y": "71.06",
|
||||||
|
"w": "47.54",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.87",
|
||||||
|
"y": "78.74",
|
||||||
|
"w": "47.40",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.01",
|
||||||
|
"y": "86.68",
|
||||||
|
"w": "18.76",
|
||||||
|
"h": "12.29"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/IDC/동관53.png": [
|
||||||
|
{
|
||||||
|
"x": "61.62",
|
||||||
|
"y": "3.08",
|
||||||
|
"w": "35.63",
|
||||||
|
"h": "7.55"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "61.53",
|
||||||
|
"y": "12.68",
|
||||||
|
"w": "35.80",
|
||||||
|
"h": "7.30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "61.70",
|
||||||
|
"y": "21.65",
|
||||||
|
"w": "35.63",
|
||||||
|
"h": "7.68"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/IDC/동관54.png": [
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "2.57",
|
||||||
|
"w": "42.21",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "10.38",
|
||||||
|
"w": "42.21",
|
||||||
|
"h": "6.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "27.15",
|
||||||
|
"w": "41.97",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "43.54",
|
||||||
|
"w": "42.09",
|
||||||
|
"h": "6.02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "54.93",
|
||||||
|
"w": "42.09",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.83",
|
||||||
|
"y": "70.16",
|
||||||
|
"w": "42.09",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "54.71",
|
||||||
|
"y": "79.51",
|
||||||
|
"w": "42.09",
|
||||||
|
"h": "6.14"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/기술개발센터/서버실_1.png": [
|
||||||
|
{
|
||||||
|
"x": "69.45",
|
||||||
|
"y": "1.10",
|
||||||
|
"w": "8.58",
|
||||||
|
"h": "11.45"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "79.21",
|
||||||
|
"y": "1.10",
|
||||||
|
"w": "11.65",
|
||||||
|
"h": "11.45"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.16",
|
||||||
|
"y": "23.23",
|
||||||
|
"w": "8.43",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "52.91",
|
||||||
|
"y": "53.35",
|
||||||
|
"w": "8.66",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "62.36",
|
||||||
|
"y": "53.47",
|
||||||
|
"w": "8.43",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "71.65",
|
||||||
|
"y": "53.47",
|
||||||
|
"w": "8.50",
|
||||||
|
"h": "20.98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "80.87",
|
||||||
|
"y": "53.35",
|
||||||
|
"w": "8.35",
|
||||||
|
"h": "21.23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.08",
|
||||||
|
"y": "53.35",
|
||||||
|
"w": "8.58",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "43.78",
|
||||||
|
"y": "76.38",
|
||||||
|
"w": "8.50",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "53.15",
|
||||||
|
"y": "76.38",
|
||||||
|
"w": "8.43",
|
||||||
|
"h": "21.23"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "62.44",
|
||||||
|
"y": "76.51",
|
||||||
|
"w": "8.35",
|
||||||
|
"h": "20.98"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "71.57",
|
||||||
|
"y": "76.25",
|
||||||
|
"w": "8.43",
|
||||||
|
"h": "21.11"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "81.02",
|
||||||
|
"y": "76.64",
|
||||||
|
"w": "8.27",
|
||||||
|
"h": "20.85"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.24",
|
||||||
|
"y": "76.64",
|
||||||
|
"w": "8.50",
|
||||||
|
"h": "20.98"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/기술개발센터/서버실_2.png": [
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "1.93",
|
||||||
|
"w": "46.96",
|
||||||
|
"h": "6.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.34",
|
||||||
|
"y": "11.92",
|
||||||
|
"w": "47.09",
|
||||||
|
"h": "6.66"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.34",
|
||||||
|
"y": "21.39",
|
||||||
|
"w": "47.35",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "30.73",
|
||||||
|
"w": "47.22",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.34",
|
||||||
|
"y": "39.82",
|
||||||
|
"w": "47.22",
|
||||||
|
"h": "6.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "49.68",
|
||||||
|
"w": "47.09",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "59.28",
|
||||||
|
"w": "46.82",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.34",
|
||||||
|
"y": "68.63",
|
||||||
|
"w": "47.35",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "77.84",
|
||||||
|
"w": "46.82",
|
||||||
|
"h": "6.40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "86.93",
|
||||||
|
"w": "46.82",
|
||||||
|
"h": "6.53"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/한맥빌딩/MDF실/MDF_1.png": [
|
||||||
|
{
|
||||||
|
"x": "49.33",
|
||||||
|
"y": "14.99",
|
||||||
|
"w": "7.13",
|
||||||
|
"h": "11.01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "59.23",
|
||||||
|
"y": "14.73",
|
||||||
|
"w": "7.13",
|
||||||
|
"h": "11.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "69.22",
|
||||||
|
"y": "14.86",
|
||||||
|
"w": "7.13",
|
||||||
|
"h": "11.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "78.96",
|
||||||
|
"y": "14.99",
|
||||||
|
"w": "7.30",
|
||||||
|
"h": "11.01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "89.03",
|
||||||
|
"y": "14.99",
|
||||||
|
"w": "7.05",
|
||||||
|
"h": "11.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.57",
|
||||||
|
"y": "34.19",
|
||||||
|
"w": "7.39",
|
||||||
|
"h": "11.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.80",
|
||||||
|
"y": "34.06",
|
||||||
|
"w": "7.22",
|
||||||
|
"h": "11.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "64.94",
|
||||||
|
"y": "34.19",
|
||||||
|
"w": "7.30",
|
||||||
|
"h": "11.01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "72.83",
|
||||||
|
"y": "34.19",
|
||||||
|
"w": "7.47",
|
||||||
|
"h": "10.88"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "81.22",
|
||||||
|
"y": "34.06",
|
||||||
|
"w": "7.22",
|
||||||
|
"h": "11.14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "89.36",
|
||||||
|
"y": "34.19",
|
||||||
|
"w": "7.13",
|
||||||
|
"h": "11.01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.66",
|
||||||
|
"y": "53.52",
|
||||||
|
"w": "9.06",
|
||||||
|
"h": "20.99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "58.48",
|
||||||
|
"y": "53.27",
|
||||||
|
"w": "9.15",
|
||||||
|
"h": "21.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "68.55",
|
||||||
|
"y": "53.27",
|
||||||
|
"w": "9.06",
|
||||||
|
"h": "21.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "78.54",
|
||||||
|
"y": "53.39",
|
||||||
|
"w": "8.90",
|
||||||
|
"h": "21.25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "89.36",
|
||||||
|
"y": "53.27",
|
||||||
|
"w": "7.39",
|
||||||
|
"h": "9.99"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "89.36",
|
||||||
|
"y": "64.92",
|
||||||
|
"w": "7.39",
|
||||||
|
"h": "9.60"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "48.57",
|
||||||
|
"y": "77.08",
|
||||||
|
"w": "9.40",
|
||||||
|
"h": "21.38"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "58.56",
|
||||||
|
"y": "77.20",
|
||||||
|
"w": "9.23",
|
||||||
|
"h": "21.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "68.63",
|
||||||
|
"y": "77.33",
|
||||||
|
"w": "9.06",
|
||||||
|
"h": "21.12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "78.71",
|
||||||
|
"y": "77.46",
|
||||||
|
"w": "8.98",
|
||||||
|
"h": "20.99"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/한맥빌딩/MDF실/MDF_2.png": [
|
||||||
|
{
|
||||||
|
"x": "56.59",
|
||||||
|
"y": "44.43",
|
||||||
|
"w": "40.35",
|
||||||
|
"h": "6.78"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.71",
|
||||||
|
"y": "54.80",
|
||||||
|
"w": "40.24",
|
||||||
|
"h": "6.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.71",
|
||||||
|
"y": "65.94",
|
||||||
|
"w": "40.24",
|
||||||
|
"h": "6.40"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/한맥빌딩/MDF실/MDF_3.png": [
|
||||||
|
{
|
||||||
|
"x": "56.71",
|
||||||
|
"y": "13.20",
|
||||||
|
"w": "40.24",
|
||||||
|
"h": "6.78"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.48",
|
||||||
|
"y": "23.57",
|
||||||
|
"w": "40.58",
|
||||||
|
"h": "6.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.59",
|
||||||
|
"y": "34.57",
|
||||||
|
"w": "40.58",
|
||||||
|
"h": "6.27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.59",
|
||||||
|
"y": "44.69",
|
||||||
|
"w": "40.46",
|
||||||
|
"h": "6.66"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.71",
|
||||||
|
"y": "54.80",
|
||||||
|
"w": "40.24",
|
||||||
|
"h": "6.66"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.71",
|
||||||
|
"y": "65.81",
|
||||||
|
"w": "40.24",
|
||||||
|
"h": "6.53"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "56.59",
|
||||||
|
"y": "76.05",
|
||||||
|
"w": "40.35",
|
||||||
|
"h": "6.53"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/한맥빌딩/MDF실/MDF_4.png": [
|
||||||
|
{
|
||||||
|
"x": "52.36",
|
||||||
|
"y": "64.02",
|
||||||
|
"w": "44.38",
|
||||||
|
"h": "6.53"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/기술개발센터/서버실/서버실_1.png": [
|
||||||
|
{
|
||||||
|
"x": "69.53",
|
||||||
|
"y": "1.42",
|
||||||
|
"w": "8.58",
|
||||||
|
"h": "11.45"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "79.21",
|
||||||
|
"y": "1.55",
|
||||||
|
"w": "11.97",
|
||||||
|
"h": "11.32"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.24",
|
||||||
|
"y": "23.30",
|
||||||
|
"w": "8.50",
|
||||||
|
"h": "21.49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "53.07",
|
||||||
|
"y": "53.28",
|
||||||
|
"w": "8.74",
|
||||||
|
"h": "21.62"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "62.28",
|
||||||
|
"y": "53.41",
|
||||||
|
"w": "8.82",
|
||||||
|
"h": "21.49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "71.50",
|
||||||
|
"y": "53.28",
|
||||||
|
"w": "8.90",
|
||||||
|
"h": "21.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "80.87",
|
||||||
|
"y": "53.15",
|
||||||
|
"w": "8.66",
|
||||||
|
"h": "21.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.08",
|
||||||
|
"y": "53.54",
|
||||||
|
"w": "8.90",
|
||||||
|
"h": "21.49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "43.86",
|
||||||
|
"y": "76.32",
|
||||||
|
"w": "8.82",
|
||||||
|
"h": "21.75"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "53.15",
|
||||||
|
"y": "76.45",
|
||||||
|
"w": "8.66",
|
||||||
|
"h": "21.49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "62.52",
|
||||||
|
"y": "76.57",
|
||||||
|
"w": "8.58",
|
||||||
|
"h": "21.62"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "71.65",
|
||||||
|
"y": "76.45",
|
||||||
|
"w": "8.66",
|
||||||
|
"h": "21.62"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "80.94",
|
||||||
|
"y": "76.57",
|
||||||
|
"w": "8.74",
|
||||||
|
"h": "21.49"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "90.24",
|
||||||
|
"y": "76.57",
|
||||||
|
"w": "8.50",
|
||||||
|
"h": "21.36"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img/location_photo/기술개발센터/서버실/서버실_2.png": [
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "1.80",
|
||||||
|
"w": "47.49",
|
||||||
|
"h": "7.04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "12.04",
|
||||||
|
"w": "47.49",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "21.52",
|
||||||
|
"w": "47.35",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "30.48",
|
||||||
|
"w": "47.49",
|
||||||
|
"h": "7.04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "39.82",
|
||||||
|
"w": "47.49",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.47",
|
||||||
|
"y": "50.06",
|
||||||
|
"w": "47.62",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.74",
|
||||||
|
"y": "59.28",
|
||||||
|
"w": "47.22",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.34",
|
||||||
|
"y": "68.37",
|
||||||
|
"w": "47.75",
|
||||||
|
"h": "7.04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "77.97",
|
||||||
|
"w": "47.22",
|
||||||
|
"h": "6.91"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"x": "49.60",
|
||||||
|
"y": "86.93",
|
||||||
|
"w": "47.35",
|
||||||
|
"h": "7.17"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
map_editor.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ITAM Map Coordinate Editor v3.0</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; display: flex; height: 100vh; overflow: hidden; font-family: sans-serif;">
|
||||||
|
|
||||||
|
<!-- Left: File Selector -->
|
||||||
|
<div class="file-sidebar" id="file-sidebar">
|
||||||
|
<!-- Rendered by MapEditor.ts -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Main Editor -->
|
||||||
|
<div class="editor-container" id="container">
|
||||||
|
<div class="img-wrapper" id="wrapper">
|
||||||
|
<img src="" id="target-img" alt="Map Image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Control Panel -->
|
||||||
|
<div class="sidebar">
|
||||||
|
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
|
||||||
|
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
||||||
|
<p>
|
||||||
|
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="box-list" id="box-list"></div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-clear-all" class="btn btn-outline" style="height:38px;">전체 삭제</button>
|
||||||
|
<button id="btn-save-server" class="btn btn-primary" style="height:38px;">서버에 즉시 저장</button>
|
||||||
|
<div id="save-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/map-editor-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
974
package-lock.json
generated
@@ -6,14 +6,21 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"server": "node server.js",
|
||||||
|
"db-init": "node db_init.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide": "^0.364.0",
|
"lucide": "^0.364.0",
|
||||||
|
"mysql2": "^3.22.1",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
pc_agent.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import wmi
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
def collect_specs():
|
||||||
|
try:
|
||||||
|
c = wmi.WMI()
|
||||||
|
computer = c.Win32_ComputerSystem()[0]
|
||||||
|
os_info = c.Win32_OperatingSystem()[0]
|
||||||
|
proc = c.Win32_Processor()[0]
|
||||||
|
board = c.Win32_BaseBoard()[0]
|
||||||
|
|
||||||
|
# 1. 상세 GPU 정보 수집 (모든 그래픽 카드)
|
||||||
|
gpu_list = []
|
||||||
|
for g in c.Win32_VideoController():
|
||||||
|
gpu_list.append(g.Name)
|
||||||
|
gpu_info = ", ".join(gpu_list) if gpu_list else "N/A"
|
||||||
|
|
||||||
|
# 2. 모든 저장장치 정보 수집 및 SSD/HDD 구분
|
||||||
|
storage_list = []
|
||||||
|
|
||||||
|
# Windows 8 이상에서 작동하는 상세 저장소 정보 수집 시도
|
||||||
|
physical_disks = {}
|
||||||
|
try:
|
||||||
|
storage_c = wmi.WMI(namespace="Root\\Microsoft\\Windows\\Storage")
|
||||||
|
for d in storage_c.MSFT_PhysicalDisk():
|
||||||
|
# MediaType: 3(HDD), 4(SSD), 0(Unspecified)
|
||||||
|
physical_disks[d.DeviceId] = d.MediaType
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for d in c.Win32_DiskDrive():
|
||||||
|
size_gb = round(float(d.Size) / (1024**3)) if d.Size else 0
|
||||||
|
|
||||||
|
# 미디어 타입 판단
|
||||||
|
media_type = physical_disks.get(str(d.Index), 0)
|
||||||
|
prefix = ""
|
||||||
|
if media_type == 4:
|
||||||
|
prefix = "[SSD] "
|
||||||
|
elif media_type == 3:
|
||||||
|
prefix = "[HDD] "
|
||||||
|
else:
|
||||||
|
# 힌트가 없을 경우 모델명으로 추측
|
||||||
|
cap = d.Caption.upper()
|
||||||
|
if "SSD" in cap or "NVME" in cap or "FLASH" in cap:
|
||||||
|
prefix = "[SSD] "
|
||||||
|
else:
|
||||||
|
prefix = "[HDD] "
|
||||||
|
|
||||||
|
storage_list.append(f"{prefix}{d.Caption} ({size_gb}GB)")
|
||||||
|
|
||||||
|
# DB 필드(SSD1, SSD2, SSD3)에 나눠 담기
|
||||||
|
storage1 = storage_list[0] if len(storage_list) > 0 else "N/A"
|
||||||
|
storage2 = storage_list[1] if len(storage_list) > 1 else ""
|
||||||
|
storage3 = storage_list[2] if len(storage_list) > 2 else ""
|
||||||
|
|
||||||
|
# 실시간 데이터 추출
|
||||||
|
specs = {
|
||||||
|
"메인보드": f"{board.Manufacturer} {board.Product}".strip(),
|
||||||
|
"CPU": proc.Name.strip(),
|
||||||
|
"RAM": f"{round(float(computer.TotalPhysicalMemory) / (1024**3))}GB",
|
||||||
|
"OS": os_info.Caption,
|
||||||
|
"GPU": gpu_info,
|
||||||
|
"SSD1": storage1,
|
||||||
|
"SSD2": storage2,
|
||||||
|
"SSD3": storage3,
|
||||||
|
"비고": "실시간 에이전트(EXE) 자동 수집"
|
||||||
|
}
|
||||||
|
return specs
|
||||||
|
except Exception as e:
|
||||||
|
print(f"데이터 수집 중 오류 발생: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_data(specs, server_url, asset_code):
|
||||||
|
try:
|
||||||
|
# 전송 데이터에 자산코드 추가 (식별용)
|
||||||
|
specs["자산코드"] = asset_code
|
||||||
|
print(f"\n📡 서버로 전송 중... ({server_url})")
|
||||||
|
response = requests.post(server_url, json=specs, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("✅ 전송 성공! ITAM 시스템에서 확인하세요.")
|
||||||
|
else:
|
||||||
|
print(f"❌ 전송 실패: 서버 응답 코드 {response.status_code}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 서버 연결 오류: {e}")
|
||||||
|
print("서버가 켜져 있는지, URL이 맞는지 확인해주세요.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("========================================")
|
||||||
|
print(" ITAM PC 실시간 사양 수집 에이전트 (v1.1)")
|
||||||
|
print("========================================\n")
|
||||||
|
|
||||||
|
# 1. 정보 수집
|
||||||
|
print("🔍 하드웨어 정보를 읽어오는 중...")
|
||||||
|
data = collect_specs()
|
||||||
|
|
||||||
|
if data:
|
||||||
|
print("\n[수집된 실제 사양]")
|
||||||
|
display_map = {
|
||||||
|
"메인보드": "메인보드",
|
||||||
|
"CPU": "CPU",
|
||||||
|
"RAM": "RAM",
|
||||||
|
"OS": "OS",
|
||||||
|
"GPU": "GPU",
|
||||||
|
"SSD1": "Storage 1",
|
||||||
|
"SSD2": "Storage 2",
|
||||||
|
"SSD3": "Storage 3",
|
||||||
|
"비고": "비고"
|
||||||
|
}
|
||||||
|
for key, value in data.items():
|
||||||
|
if value: # 값이 있는 경우만 표시
|
||||||
|
label = display_map.get(key, key)
|
||||||
|
print(f" - {label}: {value}")
|
||||||
|
|
||||||
|
print("\n" + "="*40)
|
||||||
|
asset_code = input("등록할 자산번호를 입력하세요 (예: PC-001): ").strip()
|
||||||
|
if not asset_code:
|
||||||
|
print("❌ 자산번호 없이는 전송할 수 없습니다.")
|
||||||
|
else:
|
||||||
|
server_ip = input("서버 IP를 입력하세요 (기본값 localhost): ").strip()
|
||||||
|
if not server_ip: server_ip = "localhost"
|
||||||
|
|
||||||
|
target_url = f"http://{server_ip}:3000/api/agent/collect"
|
||||||
|
|
||||||
|
confirm = input("\n위 정보를 서버로 전송할까요? (y/n): ")
|
||||||
|
if confirm.lower() == 'y':
|
||||||
|
send_data(data, target_url, asset_code)
|
||||||
|
|
||||||
|
print("\n5초 후 프로그램이 종료됩니다...")
|
||||||
|
time.sleep(5)
|
||||||
38
pc_agent.spec
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['pc_agent.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='pc_agent',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
429
public/PC_사양_적정성_분석_기획서.html
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #4F46E5;
|
||||||
|
--primary-light: #EEF2FF;
|
||||||
|
--secondary: #10B981;
|
||||||
|
--secondary-light: #D1FAE5;
|
||||||
|
--danger: #EF4444;
|
||||||
|
--danger-light: #FEE2E2;
|
||||||
|
--warning: #F59E0B;
|
||||||
|
--warning-light: #FEF3C7;
|
||||||
|
--purple: #7C3AED;
|
||||||
|
--purple-light: #EDE9FE;
|
||||||
|
--text-dark: #0F172A;
|
||||||
|
--text-body: #334155;
|
||||||
|
--text-muted: #64748B;
|
||||||
|
--border: #E2E8F0;
|
||||||
|
--bg-light: #F8FAFC;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
|
||||||
|
color: var(--text-body);
|
||||||
|
background: #fff;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
|
||||||
|
|
||||||
|
/* ─ Header ─ */
|
||||||
|
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
|
||||||
|
.doc-label {
|
||||||
|
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
|
||||||
|
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.version-badge {
|
||||||
|
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
|
||||||
|
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
|
||||||
|
margin-left: 0.5rem; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
|
||||||
|
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
|
||||||
|
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
|
||||||
|
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
|
||||||
|
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
|
||||||
|
|
||||||
|
/* ─ Sections ─ */
|
||||||
|
section { margin-bottom: 3.5rem; }
|
||||||
|
h2 {
|
||||||
|
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
|
||||||
|
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
|
||||||
|
}
|
||||||
|
h2 .num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; background: var(--primary); color: #fff;
|
||||||
|
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
|
||||||
|
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
|
||||||
|
|
||||||
|
/* ─ Boxes ─ */
|
||||||
|
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
|
||||||
|
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
|
||||||
|
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
|
||||||
|
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
|
||||||
|
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
|
||||||
|
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
|
||||||
|
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
|
||||||
|
|
||||||
|
/* ─ Score formula block ─ */
|
||||||
|
.formula {
|
||||||
|
background: #1E293B; color: #E2E8F0; border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
|
||||||
|
}
|
||||||
|
.formula .comment { color: #64748B; }
|
||||||
|
.formula .key { color: #93C5FD; }
|
||||||
|
.formula .val { color: #6EE7B7; }
|
||||||
|
.formula .warn { color: #FCD34D; }
|
||||||
|
|
||||||
|
/* ─ Three-col score grid ─ */
|
||||||
|
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
|
||||||
|
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
|
||||||
|
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||||
|
.score-card-header {
|
||||||
|
background: var(--bg-light); padding: 0.65rem 1rem;
|
||||||
|
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
|
||||||
|
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
|
||||||
|
.dot-green { background: var(--secondary); }
|
||||||
|
.dot-purple { background: var(--purple); }
|
||||||
|
|
||||||
|
/* ─ Tables ─ */
|
||||||
|
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||||
|
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||||
|
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
|
tr:hover td { background: var(--bg-light); }
|
||||||
|
|
||||||
|
/* ─ Badges ─ */
|
||||||
|
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
|
||||||
|
.b-primary { color: var(--primary); background: var(--primary-light); }
|
||||||
|
.b-green { color: #065F46; background: var(--secondary-light); }
|
||||||
|
.b-red { color: #991B1B; background: var(--danger-light); }
|
||||||
|
.b-yellow { color: #92400E; background: var(--warning-light); }
|
||||||
|
.b-purple { color: #5B21B6; background: var(--purple-light); }
|
||||||
|
|
||||||
|
/* ─ Flow ─ */
|
||||||
|
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
|
||||||
|
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
|
||||||
|
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
|
||||||
|
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
|
||||||
|
|
||||||
|
/* ─ GPU tier table highlight ─ */
|
||||||
|
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
|
||||||
|
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
|
||||||
|
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
|
||||||
|
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
|
||||||
|
.tier-D td:first-child { color: var(--text-muted); }
|
||||||
|
|
||||||
|
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header class="doc-header">
|
||||||
|
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
|
||||||
|
<h1>PC 사양 적정성 분석 기획서<br>
|
||||||
|
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
|
||||||
|
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
|
||||||
|
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
|
||||||
|
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
|
||||||
|
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 1. 개요 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
|
||||||
|
<p>
|
||||||
|
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
|
||||||
|
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
|
||||||
|
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
|
||||||
|
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flow">
|
||||||
|
<div class="flow-step">① 기본 100점 만점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">② CPU 등급/세대 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">③ RAM 용량 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step gpu">④ GPU 등급 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">⑤ 연식 노후 감점</div>
|
||||||
|
<div class="flow-arrow">→</div>
|
||||||
|
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="formula">
|
||||||
|
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
|
||||||
|
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 2. CPU 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
|
||||||
|
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong>과 <strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
|
||||||
|
|
||||||
|
<div class="formula">
|
||||||
|
<span class="comment">// [CPU 등급 감점]</span>
|
||||||
|
i9 / Ryzen 9 → <span class="val">0점 감점</span>
|
||||||
|
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
|
||||||
|
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
|
||||||
|
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
|
||||||
|
기타 → <span class="val">-30점 감점</span>
|
||||||
|
|
||||||
|
<span class="comment">// [CPU 세대 노후 감점]</span>
|
||||||
|
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
|
||||||
|
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
|
||||||
|
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
|
||||||
|
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>CPU 조합별 감점 예시</h3>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
|
||||||
|
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||||
|
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||||
|
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
|
||||||
|
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
|
||||||
|
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
|
||||||
|
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 3. RAM 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
|
||||||
|
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
|
||||||
|
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
|
||||||
|
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
|
||||||
|
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 4. GPU 감점 룰 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
|
||||||
|
<p>
|
||||||
|
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
|
||||||
|
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
|
||||||
|
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
|
||||||
|
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
|
||||||
|
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 5. 종합 점수 감점 사례 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 6. 직무별 평균 및 권장 점수 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
|
||||||
|
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||||
|
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||||
|
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
|
||||||
|
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="box box-blue">
|
||||||
|
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
|
||||||
|
AI 개발자(88.0) > 편집 디자이너(80.2) > 3D 디자이너(78.4) > UXUI 디자이너(72.7) > 3D 개발자(67.8) > 프로그램 개발자(67.3) > BIM모델러(62.1) > 엔지니어(42.9) > 웹 개발자(39.2) > 기획자(38.6) ✅
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 7. 적정성 판별 기준 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">7</span>적정성 판별 기준</h2>
|
||||||
|
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
|
||||||
|
<div class="formula">
|
||||||
|
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
|
||||||
|
|
||||||
|
IF <span class="val">개인 실질 점수 < avgScore × 0.80</span> → <span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
|
||||||
|
IF <span class="val">개인 실질 점수 > avgScore × 1.30</span> → <span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
|
||||||
|
ELSE → <span class="key">"적정"</span>
|
||||||
|
</div>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 < 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
|
||||||
|
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 > 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 8. 신뢰도 검토 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
|
||||||
|
|
||||||
|
<h3>✅ 신뢰 가능한 부분</h3>
|
||||||
|
<div class="box box-green">
|
||||||
|
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
|
||||||
|
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
|
||||||
|
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 > 4080 > 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
|
||||||
|
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
|
||||||
|
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>⚠️ 여전히 남아있는 한계점</h3>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><strong>노트북 TDP 미반영</strong></td>
|
||||||
|
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>SSD 유형 미반영</strong></td>
|
||||||
|
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>GPU 세부 파생 모델 한계</strong></td>
|
||||||
|
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>GPU 세대 보정 미적용</strong></td>
|
||||||
|
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
|
||||||
|
<td><span class="badge b-primary">낮음</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><strong>실측 벤치마크 미연동</strong></td>
|
||||||
|
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
|
||||||
|
<td><span class="badge b-yellow">중간</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box box-blue">
|
||||||
|
<div class="box-title">💡 종합 신뢰도 평가</div>
|
||||||
|
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
|
||||||
|
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
|
||||||
|
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 9. 개선 로드맵 -->
|
||||||
|
<section>
|
||||||
|
<h2><span class="num">9</span>향후 개선 로드맵</h2>
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td>하</td></tr>
|
||||||
|
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td>상</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td>중</td></tr>
|
||||||
|
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td>상</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) · 2026. 05. 28</p>
|
||||||
|
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
scratch/calculate_job_averages.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
// dummyData.ts를 읽어와서 dummyPCs 파싱
|
||||||
|
const content = fs.readFileSync('c:/Project/HM ITAM/src/core/dummyData.ts', 'utf-8');
|
||||||
|
|
||||||
|
// export const dummyPCs: any[] = [ ... ]; 패턴 추출
|
||||||
|
const match = content.match(/export const dummyPCs: any\[\] = (\[[\s\S]*?\]);/);
|
||||||
|
if (!match) {
|
||||||
|
console.error('Failed to parse dummyPCs from dummyData.ts');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyPCs = JSON.parse(match[1]);
|
||||||
|
|
||||||
|
function calculatePcScoreDeductive(cpu, ram, gpu, purchaseDate) {
|
||||||
|
let score = 100;
|
||||||
|
|
||||||
|
// 1. CPU 등급 감점
|
||||||
|
const cpuUpper = (cpu || '').toUpperCase();
|
||||||
|
let cpuDeduction = 0;
|
||||||
|
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||||
|
cpuDeduction = 0;
|
||||||
|
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||||
|
cpuDeduction = 5;
|
||||||
|
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||||
|
cpuDeduction = 15;
|
||||||
|
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||||
|
cpuDeduction = 25;
|
||||||
|
} else {
|
||||||
|
cpuDeduction = 30;
|
||||||
|
}
|
||||||
|
score -= cpuDeduction;
|
||||||
|
|
||||||
|
// 2. CPU 세대 감점
|
||||||
|
let genDeduction = 0;
|
||||||
|
let intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||||
|
let gen = 0;
|
||||||
|
if (intelMatch && intelMatch[1]) {
|
||||||
|
const numStr = intelMatch[1];
|
||||||
|
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||||
|
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
let amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||||
|
let amdGen = 0;
|
||||||
|
if (amdMatch && amdMatch[1] && !intelMatch) {
|
||||||
|
const numStr = amdMatch[1];
|
||||||
|
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intelMatch) {
|
||||||
|
if (gen >= 12) genDeduction = 0;
|
||||||
|
else if (gen >= 10) genDeduction = 5;
|
||||||
|
else if (gen >= 8) genDeduction = 10;
|
||||||
|
else genDeduction = 15;
|
||||||
|
} else if (amdMatch) {
|
||||||
|
if (amdGen >= 5) genDeduction = 0;
|
||||||
|
else if (amdGen >= 3) genDeduction = 5;
|
||||||
|
else genDeduction = 10;
|
||||||
|
} else {
|
||||||
|
genDeduction = 15;
|
||||||
|
}
|
||||||
|
score -= genDeduction;
|
||||||
|
|
||||||
|
// 3. RAM 용량 감점
|
||||||
|
const ramUpper = (ram || '').toUpperCase();
|
||||||
|
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||||
|
let ramDeduction = 25;
|
||||||
|
if (ramMatch && ramMatch[1]) {
|
||||||
|
const ramVal = parseInt(ramMatch[1], 10);
|
||||||
|
if (ramVal >= 32) ramDeduction = 0;
|
||||||
|
else if (ramVal >= 16) ramDeduction = 10;
|
||||||
|
else if (ramVal >= 8) ramDeduction = 20;
|
||||||
|
else ramDeduction = 25;
|
||||||
|
}
|
||||||
|
score -= ramDeduction;
|
||||||
|
|
||||||
|
// 4. GPU 성능 감점
|
||||||
|
const gpuUpper = (gpu || '').toUpperCase();
|
||||||
|
let gpuDeduction = 25;
|
||||||
|
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
|
||||||
|
gpuDeduction = 25;
|
||||||
|
} else if (
|
||||||
|
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||||
|
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||||
|
) {
|
||||||
|
gpuDeduction = 0;
|
||||||
|
} else if (
|
||||||
|
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
|
||||||
|
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
|
||||||
|
) {
|
||||||
|
gpuDeduction = 5;
|
||||||
|
} else if (
|
||||||
|
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
|
||||||
|
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
|
||||||
|
) {
|
||||||
|
gpuDeduction = 15;
|
||||||
|
} else {
|
||||||
|
gpuDeduction = 25;
|
||||||
|
}
|
||||||
|
score -= gpuDeduction;
|
||||||
|
|
||||||
|
// 5. 연식(노후도) 감점
|
||||||
|
let age = 0;
|
||||||
|
if (purchaseDate && purchaseDate !== '-') {
|
||||||
|
let normalized = purchaseDate.replace(/\./g, '-').trim();
|
||||||
|
if (/^\d{6}$/.test(normalized)) {
|
||||||
|
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
|
||||||
|
}
|
||||||
|
const purchase = new Date(normalized);
|
||||||
|
if (!isNaN(purchase.getTime())) {
|
||||||
|
const mockToday = new Date('2026-05-31');
|
||||||
|
const diffMs = mockToday.getTime() - purchase.getTime();
|
||||||
|
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||||
|
age = Math.max(0, parseFloat(age.toFixed(1)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ageDeduction = 0;
|
||||||
|
if (age < 1) ageDeduction = 0;
|
||||||
|
else if (age < 2) ageDeduction = 3;
|
||||||
|
else if (age < 3) ageDeduction = 6;
|
||||||
|
else if (age < 4) ageDeduction = 9;
|
||||||
|
else if (age < 5) ageDeduction = 12;
|
||||||
|
else ageDeduction = 15;
|
||||||
|
|
||||||
|
score -= ageDeduction;
|
||||||
|
|
||||||
|
return Math.max(10, score);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobScores = {};
|
||||||
|
let totalPcs = 0;
|
||||||
|
|
||||||
|
const filteredPCs = dummyPCs.filter(pc => pc.user_position !== '재고PC');
|
||||||
|
|
||||||
|
filteredPCs.forEach(pc => {
|
||||||
|
const job = pc.user_position || '미분류';
|
||||||
|
const score = calculatePcScoreDeductive(pc.cpu, pc.ram, pc.gpu, pc.purchase_date);
|
||||||
|
|
||||||
|
if (!jobScores[job]) {
|
||||||
|
jobScores[job] = { total: 0, count: 0 };
|
||||||
|
}
|
||||||
|
jobScores[job].total += score;
|
||||||
|
jobScores[job].count += 1;
|
||||||
|
totalPcs++;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('--- Job Averages (Deductive 100-point) ---');
|
||||||
|
const sortedJobs = Object.keys(jobScores).map(job => {
|
||||||
|
const avg = jobScores[job].total / jobScores[job].count;
|
||||||
|
return {
|
||||||
|
job,
|
||||||
|
avg: parseFloat(avg.toFixed(1)),
|
||||||
|
count: jobScores[job].count
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.avg - a.avg);
|
||||||
|
|
||||||
|
sortedJobs.forEach((item, index) => {
|
||||||
|
console.log(`${index + 1}. ${item.job}: Avg=${item.avg}점, Count=${item.count}대`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Total PCs (excluding Stock):', totalPcs);
|
||||||
30
scratch/parse_excel.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pkg from 'xlsx';
|
||||||
|
const { readFile, utils } = pkg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = readFile('c:/Project/HM ITAM/SampleData_PC.xlsx');
|
||||||
|
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||||
|
|
||||||
|
const corps = new Set();
|
||||||
|
|
||||||
|
// 첫 번째 행(헤더) 제외하고 C열(인덱스 2) 데이터 추출
|
||||||
|
rawRows.slice(1).forEach(row => {
|
||||||
|
if (row[2] !== undefined && row[2] !== null) {
|
||||||
|
corps.add(String(row[2]).trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobs = new Map();
|
||||||
|
rawRows.slice(1).forEach(row => {
|
||||||
|
const job = String(row[3] || '').trim();
|
||||||
|
jobs.set(job, (jobs.get(job) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('--- Unique Jobs in D column ---');
|
||||||
|
Array.from(jobs.entries()).forEach(([key, val]) => {
|
||||||
|
console.log(`${key}: ${val}대`);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
27
scratch/parse_svr_excel.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import pkg from 'xlsx';
|
||||||
|
const { readFile, utils } = pkg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = readFile('c:/Project/HM ITAM/SampleData_SVR.xlsx');
|
||||||
|
|
||||||
|
for (const sheetName of workbook.SheetNames) {
|
||||||
|
console.log(`\n================= Sheet: ${sheetName} =================`);
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||||
|
const validRows = rawRows.filter(row => {
|
||||||
|
return row.some(val => val !== undefined && val !== null && String(val).trim() !== '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = validRows[0];
|
||||||
|
const assetNameIdx = header.indexOf('자산명');
|
||||||
|
const typeIdx = header.indexOf('유형');
|
||||||
|
const detailIdx = header.indexOf('상세');
|
||||||
|
const teamIdx = header.indexOf('팀명');
|
||||||
|
|
||||||
|
validRows.slice(1).forEach((row, idx) => {
|
||||||
|
console.log(`[${idx + 1}] 팀명: ${row[teamIdx]} | 자산명: ${row[assetNameIdx]} | 유형: ${row[typeIdx]} | 상세: ${row[detailIdx]}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
447
scratch/update_dummy_pcs.js
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
import pkg from 'xlsx';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const { readFile, utils } = pkg;
|
||||||
|
|
||||||
|
// 임시 ID 생성 및 도우미 함수
|
||||||
|
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||||
|
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||||
|
|
||||||
|
function cleanValue(val) {
|
||||||
|
if (val === undefined || val === null) return '-';
|
||||||
|
const str = String(val).trim();
|
||||||
|
return str === '' ? '-' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workbook = readFile('c:/Project/HM ITAM/SampleData_PC.xlsx');
|
||||||
|
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
|
||||||
|
// header: 1로 읽어 2차원 배열을 획득
|
||||||
|
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||||
|
|
||||||
|
// 첫 번째 행은 헤더이므로 제외
|
||||||
|
const dataRows = rawRows.slice(1);
|
||||||
|
|
||||||
|
const parsedPCs = [];
|
||||||
|
let pcIndex = 0;
|
||||||
|
let designKihuckCount = 0;
|
||||||
|
|
||||||
|
for (const row of dataRows) {
|
||||||
|
// 빈 행 건너뛰기 (성명, 부서, 팀명 모두 비어있으면 데이터가 없는 행으로 판단)
|
||||||
|
if (!row[0] && !row[1] && !row[2] && !row[3] && !row[4]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deptRaw = cleanValue(row[0]);
|
||||||
|
const teamRaw = cleanValue(row[1]);
|
||||||
|
const corpRaw = cleanValue(row[2]); // C열: 소속 (NEW)
|
||||||
|
const jobRaw = cleanValue(row[3]); // D열: 직무 (밀림)
|
||||||
|
const nameRaw = cleanValue(row[4]); // E열: 성명 (밀림)
|
||||||
|
|
||||||
|
// 특정 사용자 제외 필터
|
||||||
|
if (nameRaw === '한치영' || nameRaw === '공용') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posRaw = cleanValue(row[5]); // F열: 직급 (밀림)
|
||||||
|
const mainboardRaw = cleanValue(row[6]); // G열: 메인보드 (밀림)
|
||||||
|
const cpuRaw = cleanValue(row[7]); // H열: CPU (밀림)
|
||||||
|
const cpuYearRaw = row[8]; // I열: CPU 출시연도 (밀림)
|
||||||
|
const gpuRaw = cleanValue(row[9]); // J열: GPU (밀림)
|
||||||
|
const gpuYearRaw = row[10]; // K열: GPU 출시연도 (밀림)
|
||||||
|
const ramRaw = cleanValue(row[11]); // L열: RAM (밀림)
|
||||||
|
const ssd1Raw = cleanValue(row[12]);// M열: SDD1 (밀림)
|
||||||
|
const ssd2Raw = cleanValue(row[13]);// N열: SDD2 (밀림)
|
||||||
|
const hdd1Raw = cleanValue(row[14]);// O열: HDD1 (밀림)
|
||||||
|
const hdd2Raw = cleanValue(row[15]);// P열: HDD2 (밀림)
|
||||||
|
const hdd3Raw = cleanValue(row[16]);// Q열: HDD3 (밀림)
|
||||||
|
const hdd4Raw = cleanValue(row[17]);// R열: HDD4 (밀림)
|
||||||
|
|
||||||
|
// W열(22번째 인덱스) -> 구매일자
|
||||||
|
const dateRaw = cleanValue(row[22]);
|
||||||
|
// X열(23번째 인덱스) -> 비고
|
||||||
|
const memoRaw = cleanValue(row[23]);
|
||||||
|
|
||||||
|
// 1. 법인 매핑 (엑셀 C열의 실제 소속 우선 사용, 없을 시 순환 지정)
|
||||||
|
const purchase_corp = corpRaw !== '-' ? corpRaw : CORPS[pcIndex % CORPS.length];
|
||||||
|
|
||||||
|
// 2. 재고PC 판단 및 상태 설정
|
||||||
|
const isStock = teamRaw === '재고PC';
|
||||||
|
const hw_status = isStock ? '창고보관' : '운영중';
|
||||||
|
|
||||||
|
// 3. 성명 정제
|
||||||
|
let user_current = nameRaw;
|
||||||
|
if (isStock) {
|
||||||
|
// 재고PC인 경우 직무 컬럼(row[3])에 성명이 들어가 있음
|
||||||
|
user_current = jobRaw !== '-' ? jobRaw : '재고장비';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 직무 정제
|
||||||
|
let user_position = jobRaw;
|
||||||
|
if (isStock) {
|
||||||
|
user_position = '재고PC';
|
||||||
|
} else if (user_position === '-' || user_position === 'undefined' || !user_position || ['안용주', '김민수', '심영표', '이수창A', '조병철', '윤진호', '김대영', '박정웅', '김유식'].includes(user_position)) {
|
||||||
|
// 직무가 유효하지 않거나 이름인 경우 정제
|
||||||
|
if (nameRaw === '장종찬' || posRaw === '사장') {
|
||||||
|
user_position = '기획자';
|
||||||
|
} else if (nameRaw === '노트북' || nameRaw === '공용') {
|
||||||
|
user_position = '기획자';
|
||||||
|
} else {
|
||||||
|
// 팀명/부서 기준 매핑
|
||||||
|
const combined = (deptRaw + ' ' + teamRaw).toUpperCase();
|
||||||
|
if (combined.includes('개발') || combined.includes('SOLUTION') || combined.includes('WEB') || combined.includes('ERP')) {
|
||||||
|
user_position = '개발자';
|
||||||
|
} else if (combined.includes('BIM') || combined.includes('구조') || combined.includes('설계') || combined.includes('터널') || combined.includes('상하수도') || combined.includes('수자원') || combined.includes('건설') || combined.includes('CM')) {
|
||||||
|
user_position = '엔지니어';
|
||||||
|
} else if (combined.includes('디자인') || combined.includes('GRAPHICS')) {
|
||||||
|
user_position = '디자이너';
|
||||||
|
} else {
|
||||||
|
user_position = '기획자';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 만약 직무가 'BIM모델러' 인 경우, 그대로 유지
|
||||||
|
if (jobRaw === 'BIM모델러') {
|
||||||
|
user_position = 'BIM모델러';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개발자/디자이너 세부 직무 분리 로직 적용
|
||||||
|
if (user_position === '개발자') {
|
||||||
|
const nameUpper = nameRaw.trim();
|
||||||
|
const teamUpper = teamRaw.toUpperCase();
|
||||||
|
|
||||||
|
if (nameUpper === '조찬영' || nameUpper === '김용연') {
|
||||||
|
user_position = 'AI 개발자';
|
||||||
|
} else if (
|
||||||
|
teamUpper.includes('그래픽스') ||
|
||||||
|
teamUpper.includes('MODELER') ||
|
||||||
|
teamUpper.includes('HMEG') ||
|
||||||
|
teamUpper.includes('EG-BIM') ||
|
||||||
|
teamUpper.includes('GSIM') ||
|
||||||
|
teamUpper.includes('STRANA')
|
||||||
|
) {
|
||||||
|
user_position = '3D 개발자';
|
||||||
|
} else if (
|
||||||
|
teamUpper.includes('WEB') ||
|
||||||
|
teamUpper.includes('솔루션개발') ||
|
||||||
|
teamUpper.includes('ERP') ||
|
||||||
|
teamUpper.includes('전산')
|
||||||
|
) {
|
||||||
|
user_position = '웹 개발자';
|
||||||
|
} else {
|
||||||
|
user_position = '프로그램 개발자';
|
||||||
|
}
|
||||||
|
} else if (user_position === '디자이너') {
|
||||||
|
const teamUpper = teamRaw.toUpperCase();
|
||||||
|
if (teamUpper.includes('디자인셀')) {
|
||||||
|
user_position = 'UXUI 디자이너';
|
||||||
|
} else if (teamUpper.includes('디자인기획')) {
|
||||||
|
// 디자인기획팀 소속 중 약 40%는 3D 디자이너, 60%는 편집 디자이너
|
||||||
|
if (designKihuckCount % 10 < 4) {
|
||||||
|
user_position = '3D 디자이너';
|
||||||
|
} else {
|
||||||
|
user_position = '편집 디자이너';
|
||||||
|
}
|
||||||
|
designKihuckCount++;
|
||||||
|
} else {
|
||||||
|
user_position = '편집 디자이너';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 구매일자 포맷 가공 (YYYY-MM)
|
||||||
|
let purchase_date = '2022-01'; // 기본값
|
||||||
|
if (dateRaw !== '-') {
|
||||||
|
if (dateRaw.length === 6 && !isNaN(dateRaw)) {
|
||||||
|
purchase_date = `${dateRaw.substring(0, 4)}-${dateRaw.substring(4, 6)}`;
|
||||||
|
} else if (dateRaw.length === 4 && !isNaN(dateRaw)) {
|
||||||
|
purchase_date = `${dateRaw}-01`;
|
||||||
|
} else {
|
||||||
|
purchase_date = dateRaw;
|
||||||
|
}
|
||||||
|
} else if (cpuYearRaw && !isNaN(cpuYearRaw)) {
|
||||||
|
purchase_date = `${cpuYearRaw}-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 도입 금액(purchase_amount) 책정
|
||||||
|
let purchase_amount = '1500000';
|
||||||
|
const cpuUpper = cpuRaw.toUpperCase();
|
||||||
|
const gpuUpper = gpuRaw.toUpperCase();
|
||||||
|
|
||||||
|
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9') || gpuUpper.includes('4080') || gpuUpper.includes('4090')) {
|
||||||
|
purchase_amount = '3500000';
|
||||||
|
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7') || gpuUpper.includes('3070') || gpuUpper.includes('4070') || gpuUpper.includes('A2000')) {
|
||||||
|
purchase_amount = '2200000';
|
||||||
|
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5') || gpuUpper.includes('3060') || gpuUpper.includes('2060')) {
|
||||||
|
purchase_amount = '1500000';
|
||||||
|
} else if (cpuYearRaw && parseInt(cpuYearRaw) < 2020) {
|
||||||
|
purchase_amount = '800000';
|
||||||
|
} else {
|
||||||
|
purchase_amount = '950000';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. MAC 주소 생성 (16진수 포맷)
|
||||||
|
const mac_address = `00:1A:2B:3C:4D:${pcIndex.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
parsedPCs.push({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '개인PC',
|
||||||
|
purchase_corp,
|
||||||
|
asset_code: 'PC-24' + String(pcIndex).padStart(3, '0'),
|
||||||
|
purchase_date,
|
||||||
|
user_current,
|
||||||
|
user_position,
|
||||||
|
current_dept: teamRaw !== '-' ? teamRaw : deptRaw,
|
||||||
|
previous_dept: pcIndex % 8 === 0 ? '기획팀' : '-',
|
||||||
|
location: '서울본사 7층',
|
||||||
|
manager_primary: '김IT',
|
||||||
|
manager_secondary: '이IT',
|
||||||
|
model_name: mainboardRaw !== '-' ? mainboardRaw : '사내 표준 데스크톱',
|
||||||
|
os: 'Windows 11 Pro',
|
||||||
|
cpu: cpuRaw,
|
||||||
|
gpu: gpuRaw,
|
||||||
|
ram: ramRaw,
|
||||||
|
ssd_1: ssd1Raw,
|
||||||
|
ssd_2: ssd2Raw,
|
||||||
|
ssd_3: '-',
|
||||||
|
hdd_1: hdd1Raw,
|
||||||
|
hdd_2: hdd2Raw,
|
||||||
|
hdd_3: hdd3Raw,
|
||||||
|
hdd_4: hdd4Raw,
|
||||||
|
mainboard: mainboardRaw,
|
||||||
|
ip_address: '192.168.0.' + (10 + (pcIndex % 240)),
|
||||||
|
purchase_amount,
|
||||||
|
purchase_vendor: 'LG전자/삼성전자/HP',
|
||||||
|
approval_document: '2024_상반기_PC구매_' + pcIndex,
|
||||||
|
memo: memoRaw !== '-' ? memoRaw : (isStock ? '재고 보유 분' : '임직원 지급용'),
|
||||||
|
asset_name: `개인PC ${pcIndex + 1}`,
|
||||||
|
mac_address,
|
||||||
|
hw_status
|
||||||
|
});
|
||||||
|
|
||||||
|
pcIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully parsed ${parsedPCs.length} PCs from excel file.`);
|
||||||
|
|
||||||
|
// dummyData.ts 의 나머지 데이터(dummyServers 등)를 포함하여 전체 파일을 새로 씁니다.
|
||||||
|
const newDummyDataFileContent = `import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 문자열
|
||||||
|
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 년월 (YYYY-MM) (최근 10년)
|
||||||
|
const randomPurchaseYM = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const year = currentYear - Math.floor(Math.random() * 10);
|
||||||
|
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||||
|
return \`\${year}-\${month}\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 YYYY-MM-DD
|
||||||
|
const randomDateStr = (maxYearsAgo = 10) => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const year = currentYear - Math.floor(Math.random() * maxYearsAgo);
|
||||||
|
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||||
|
const day = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
|
||||||
|
return \`\${year}-\${month}-\${day}\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||||
|
const getRandomCorp = () => CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
// 1. SampleData_PC.xlsx 에서 파싱된 PC 데이터 주입
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
export const dummyPCs: any[] = ${JSON.stringify(parsedPCs, null, 2)};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
// 2. 기타 자산 더미 데이터 (서버, 스토리지, 소프트웨어 등)
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const dummyServers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '서버',
|
||||||
|
type2: i % 2 === 0 ? '물리' : '가상',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`SRV-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
asset_purpose: i % 2 === 0 ? '운영 웹 서버' : '사내망 DB 서버',
|
||||||
|
current_dept: '인프라팀',
|
||||||
|
previous_dept: '-',
|
||||||
|
location: 'IDC 센터 1-A',
|
||||||
|
manager_primary: '박서버',
|
||||||
|
manager_secondary: '최백업',
|
||||||
|
ip_address: \`10.0.0.\${10 + i}\`,
|
||||||
|
ip_address_2: \`192.168.100.\${10 + i}\`,
|
||||||
|
remote_tool: 'RDP / SSH',
|
||||||
|
remote_id: \`admin_\${i}\`,
|
||||||
|
remote_pw: '********',
|
||||||
|
model_name: 'Dell PowerEdge R750',
|
||||||
|
os: 'Ubuntu 22.04 LTS',
|
||||||
|
cpu: 'Intel Xeon Gold 6330',
|
||||||
|
ram: '128GB',
|
||||||
|
gpu: i % 3 === 0 ? 'NVIDIA A100' : '-',
|
||||||
|
ssd_1: '1TB NVMe',
|
||||||
|
ssd_2: '1TB NVMe',
|
||||||
|
hdd_1: '4TB HDD',
|
||||||
|
monitoring: 'Zabbix Agent',
|
||||||
|
purchase_amount: '8500000',
|
||||||
|
purchase_vendor: '델테크놀로지스',
|
||||||
|
approval_document: \`2024_IDC_확장품의_\sign\${i}\`,
|
||||||
|
memo: '서버 랙 3번 위치',
|
||||||
|
asset_name: \`운영 서버 \${i+1}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:4E:\${String(i).padStart(2, '0')}\`,
|
||||||
|
hw_status: '운영중'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyStorages: any[] = Array.from({ length: 8 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '스토리지',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`STR-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
asset_name: \`공용 스토리지 \${i+1}\`,
|
||||||
|
location: 'IDC 센터 1-A',
|
||||||
|
model_name: 'Synology RS4021xs+',
|
||||||
|
volume: '100TB',
|
||||||
|
manager_primary: '박서버',
|
||||||
|
manager_secondary: '최백업',
|
||||||
|
ip_address: \`10.0.0.\${50 + i}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:4F:\${String(i).padStart(2, '0')}\`,
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
purchase_amount: '12000000',
|
||||||
|
purchase_vendor: '시놀로지코리아',
|
||||||
|
approval_document: \`2024_스토리지구매_\${i}\`,
|
||||||
|
memo: '부서별 백업본 저장용',
|
||||||
|
os: 'Synology DSM',
|
||||||
|
asset_purpose: '데이터 백업',
|
||||||
|
hw_status: '운영중'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyEquips: any[] = Array.from({ length: 12 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '전산비품',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`EQ-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
asset_name: \`네트워크 스위치 \${i+1}\`,
|
||||||
|
location: '전산실 랙 1',
|
||||||
|
manager_primary: '네트워크담당자',
|
||||||
|
ip_address: \`192.168.10.\${200 + i}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:51:\${String(i).padStart(2, '0')}\`,
|
||||||
|
os: 'Cisco IOS',
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
purchase_amount: '150000',
|
||||||
|
purchase_vendor: '다나와',
|
||||||
|
approval_document: \`2024_비품구매_\${i}\`,
|
||||||
|
memo: '사내망 확장용',
|
||||||
|
asset_purpose: '네트워크 분배'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyMobiles: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '모바일기기',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`MOB-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
asset_name: \`테스트용 단말기 \${i+1}\`,
|
||||||
|
location: '개발2팀',
|
||||||
|
manager_primary: '테스터',
|
||||||
|
os: i % 2 === 0 ? 'Android 14' : 'iOS 17',
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
purchase_amount: '900000',
|
||||||
|
purchase_vendor: '삼성전자/애플',
|
||||||
|
approval_document: \`2024_모바일구매_\${i}\`,
|
||||||
|
memo: '앱 호환성 테스트 전용',
|
||||||
|
asset_purpose: 'QA 테스트',
|
||||||
|
ip_address: \`192.168.1.\${10 + i}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:50:\${String(i).padStart(2, '0')}\`
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummySubSw: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '구독SW',
|
||||||
|
sw_field: '업무용/협업',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '전사',
|
||||||
|
product_name: \`Microsoft 365 E\${3 + (i%2)}\`,
|
||||||
|
purchase_date: randomDateStr(3),
|
||||||
|
start_date: randomDateStr(1),
|
||||||
|
expired_date: randomDateStr(0),
|
||||||
|
purchase_amount: '150000',
|
||||||
|
asset_count: 50 + i * 5,
|
||||||
|
email_account: \`admin\${i}@hmcorp.com\`,
|
||||||
|
purchase_vendor: '소프트웨어인라이프',
|
||||||
|
memo: '연간 계약 갱신 필요'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyPermSw: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '영구SW',
|
||||||
|
sw_field: '디자인/설계',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '디자인팀',
|
||||||
|
product_name: \`AutoCAD 202\${i%4}\`,
|
||||||
|
purchase_date: randomDateStr(5),
|
||||||
|
start_date: randomDateStr(5),
|
||||||
|
expired_date: '2099-12-31',
|
||||||
|
purchase_amount: '3000000',
|
||||||
|
asset_count: 2,
|
||||||
|
email_account: \`design\${i}@hmcorp.com\`,
|
||||||
|
purchase_vendor: '오토데스크 파트너',
|
||||||
|
memo: 'USB 동글키 보관중'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyCloud: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '클라우드',
|
||||||
|
asset_mfr: i % 2 === 0 ? 'AWS' : 'GCP',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '개발팀',
|
||||||
|
product_name: \`컴퓨팅 인스턴스 Type \${i}\`,
|
||||||
|
email_account: \`awsadmin\${i}@hmcorp.com\`,
|
||||||
|
purchase_method: '법인카드(신한 1234)',
|
||||||
|
purchase_amount: \`\${500000 + i * 100000}\`,
|
||||||
|
asset_count: 1,
|
||||||
|
purchase_vendor: 'AWS/GCP',
|
||||||
|
memo: '환율 변동에 따라 매월 상이함'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyDomain: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '도메인',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
product_name: \`사내 운영 서비스 \${i+1}\`,
|
||||||
|
domain_address: \`service\${i+1}.hmcorp.com\`,
|
||||||
|
start_date: randomDateStr(4),
|
||||||
|
expired_date: randomDateStr(0),
|
||||||
|
purchase_amount: '22000',
|
||||||
|
manager_primary: '인프라팀장',
|
||||||
|
manager_secondary: '인프라담당자',
|
||||||
|
memo: '가비아 자동갱신 설정 완료'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummySwUsers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_id: dummySubSw[0]?.id || randomId(),
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '경영지원팀',
|
||||||
|
user_current: \`홍길동\${i}\`,
|
||||||
|
memo: \`SW신청서_2400\${i}\`
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyLogs: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
assetId: dummyPCs[0]?.id || randomId(),
|
||||||
|
date: randomDateStr(1),
|
||||||
|
details: i % 2 === 0 ? '메모리 추가 증설 (16GB -> 32GB)' : '디스플레이 파손 수리',
|
||||||
|
user: 'IT지원팀',
|
||||||
|
cost: i % 2 === 0 ? 80000 : 150000,
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync('c:/Project/HM ITAM/src/core/dummyData.ts', newDummyDataFileContent, 'utf-8');
|
||||||
|
console.log('✅ dummyData.ts file updated successfully.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Failed to update dummy data:', e);
|
||||||
|
}
|
||||||
442
scratch/update_dummy_servers.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import pkg from 'xlsx';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const { readFile, utils } = pkg;
|
||||||
|
|
||||||
|
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||||
|
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||||
|
|
||||||
|
function cleanValue(val) {
|
||||||
|
if (val === undefined || val === null) return '-';
|
||||||
|
const str = String(val).trim();
|
||||||
|
return str === '' ? '-' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 기존 dummyPCs 로딩
|
||||||
|
const dummyDataPath = 'c:/Project/HM ITAM/src/core/dummyData.ts';
|
||||||
|
const content = fs.readFileSync(dummyDataPath, 'utf-8');
|
||||||
|
const matchPCs = content.match(/export const dummyPCs: any\[\] = (\[[\s\S]*?\]);/);
|
||||||
|
if (!matchPCs) {
|
||||||
|
console.error('Failed to parse dummyPCs from dummyData.ts');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const dummyPCs = JSON.parse(matchPCs[1]);
|
||||||
|
console.log(`Loaded ${dummyPCs.length} existing PCs from dummyData.ts`);
|
||||||
|
|
||||||
|
// 2. SampleData_SVR.xlsx 파싱
|
||||||
|
const workbook = readFile('c:/Project/HM ITAM/SampleData_SVR.xlsx');
|
||||||
|
|
||||||
|
const parsedServers = [];
|
||||||
|
const parsedStorages = [];
|
||||||
|
const parsedEquips = [];
|
||||||
|
|
||||||
|
let serverIndex = 0;
|
||||||
|
let storageIndex = 0;
|
||||||
|
let equipIndex = 0;
|
||||||
|
|
||||||
|
// ----------------- 시트 1: 합본데이터(공용PC) -----------------
|
||||||
|
const sheetPC = workbook.Sheets['합본데이터(공용PC)'];
|
||||||
|
const rawPC = utils.sheet_to_json(sheetPC, { header: 1 });
|
||||||
|
const rowsPC = rawPC.slice(1).filter(row => row.some(val => val !== undefined && val !== null && String(val).trim() !== ''));
|
||||||
|
|
||||||
|
for (const row of rowsPC) {
|
||||||
|
const teamRaw = cleanValue(row[0]);
|
||||||
|
const svrNoRaw = cleanValue(row[1]);
|
||||||
|
const assetNameRaw = cleanValue(row[2]);
|
||||||
|
const typeRaw = cleanValue(row[3]);
|
||||||
|
const detailRaw = cleanValue(row[4]);
|
||||||
|
const locRaw = cleanValue(row[5]);
|
||||||
|
const mgr1Raw = cleanValue(row[6]);
|
||||||
|
const mgr2Raw = cleanValue(row[7]);
|
||||||
|
const osRaw = cleanValue(row[8]);
|
||||||
|
const osVerRaw = cleanValue(row[9]);
|
||||||
|
const osBuildRaw = cleanValue(row[10]);
|
||||||
|
const modelRaw = cleanValue(row[11]);
|
||||||
|
const mainboardRaw = cleanValue(row[12]);
|
||||||
|
const cpuRaw = cleanValue(row[13]);
|
||||||
|
const ramRaw = cleanValue(row[14]);
|
||||||
|
const gpuRaw = cleanValue(row[15]);
|
||||||
|
const ssd1Raw = cleanValue(row[16]);
|
||||||
|
const ssd2Raw = cleanValue(row[17]);
|
||||||
|
const hdd1Raw = cleanValue(row[18]);
|
||||||
|
const hdd2Raw = cleanValue(row[19]);
|
||||||
|
const hdd3Raw = cleanValue(row[20]);
|
||||||
|
const hdd4Raw = cleanValue(row[21]);
|
||||||
|
|
||||||
|
const ipAddress = '172.16.10.' + (50 + (serverIndex % 150));
|
||||||
|
const randomCorp = CORPS[serverIndex % CORPS.length];
|
||||||
|
|
||||||
|
// 서비스 분류 판단
|
||||||
|
let service_type = '내부서비스';
|
||||||
|
const detailUpper = detailRaw.toUpperCase();
|
||||||
|
const assetUpper = assetNameRaw.toUpperCase();
|
||||||
|
const teamUpper = teamRaw.toUpperCase();
|
||||||
|
|
||||||
|
if (teamUpper.includes('회의실') || assetUpper.includes('회의실') || assetUpper.includes('사이니지')) {
|
||||||
|
service_type = '회의용/공용';
|
||||||
|
} else if (
|
||||||
|
detailUpper.includes('SAAS') || detailUpper.includes('웹서비스') ||
|
||||||
|
detailUpper.includes('운영') || detailUpper.includes('WAS') ||
|
||||||
|
detailUpper.includes('MYSTATION') || detailUpper.includes('CLOUD') ||
|
||||||
|
detailUpper.includes('홈페이지') || detailUpper.includes('WEB') ||
|
||||||
|
detailUpper.includes('외주') || assetUpper.includes('CLOUD') ||
|
||||||
|
assetUpper.includes('웹서비스') || assetUpper.includes('운영')
|
||||||
|
) {
|
||||||
|
service_type = '외부서비스';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 방치 의심 판단
|
||||||
|
const is_inactive = (
|
||||||
|
detailUpper.includes('원격 및 로컬접근 불가') ||
|
||||||
|
detailUpper.includes('철수예정') ||
|
||||||
|
detailUpper.includes('미사용') ||
|
||||||
|
detailUpper.includes('구형 OS')
|
||||||
|
);
|
||||||
|
|
||||||
|
// 실시간 리소스 및 네트워크 가상 데이터 생성
|
||||||
|
let cpu_usage = 0;
|
||||||
|
let ram_usage = 0;
|
||||||
|
let network_traffic = '0 GB';
|
||||||
|
|
||||||
|
if (is_inactive) {
|
||||||
|
cpu_usage = 0;
|
||||||
|
ram_usage = 0;
|
||||||
|
network_traffic = '0 GB (N/A)';
|
||||||
|
} else if (service_type === '회의용/공용') {
|
||||||
|
cpu_usage = Math.floor(Math.random() * 10) + 2; // 2%~12%
|
||||||
|
ram_usage = Math.floor(Math.random() * 15) + 5; // 5%~20%
|
||||||
|
network_traffic = (Math.random() * 1.5 + 0.1).toFixed(1) + ' GB';
|
||||||
|
} else if (service_type === '외부서비스') {
|
||||||
|
// 일부 저사양 운영/SaaS 서버는 병목 현상을 시뮬레이션하기 위해 과부하 부여
|
||||||
|
const isUnderSpec = !gpuRaw.toUpperCase().includes('RTX 30') && !gpuRaw.toUpperCase().includes('RTX 40') && (cpuRaw.toUpperCase().includes('I5') || ramRaw.toUpperCase().includes('16GB') || cpuRaw === '-');
|
||||||
|
if (isUnderSpec) {
|
||||||
|
cpu_usage = Math.floor(Math.random() * 15) + 81; // 81%~95% (과부하)
|
||||||
|
ram_usage = Math.floor(Math.random() * 10) + 86; // 86%~95%
|
||||||
|
} else {
|
||||||
|
cpu_usage = Math.floor(Math.random() * 30) + 40; // 40%~70%
|
||||||
|
ram_usage = Math.floor(Math.random() * 20) + 60; // 60%~80%
|
||||||
|
}
|
||||||
|
network_traffic = (Math.random() * 1500 + 300).toFixed(0) + ' GB';
|
||||||
|
} else { // 내부서비스
|
||||||
|
// Abaqus 해석용이나 Pix4D 등 고부하 내부 인프라도 부하율 높게 부여
|
||||||
|
const isHighLoad = detailUpper.includes('ABAQUS') || detailUpper.includes('PIX4D') || detailUpper.includes('영상 렌더링') || detailUpper.includes('TERRA');
|
||||||
|
if (isHighLoad) {
|
||||||
|
cpu_usage = Math.floor(Math.random() * 20) + 70; // 70%~90%
|
||||||
|
ram_usage = Math.floor(Math.random() * 20) + 75; // 75%~95%
|
||||||
|
} else {
|
||||||
|
cpu_usage = Math.floor(Math.random() * 35) + 15; // 15%~50%
|
||||||
|
ram_usage = Math.floor(Math.random() * 30) + 20; // 20%~50%
|
||||||
|
}
|
||||||
|
network_traffic = (Math.random() * 300 + 10).toFixed(0) + ' GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetItem = {
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: typeRaw !== '-' ? typeRaw : '공용PC',
|
||||||
|
purchase_corp: randomCorp,
|
||||||
|
asset_code: 'SVR-24' + String(serverIndex).padStart(3, '0'),
|
||||||
|
purchase_date: '2023-03',
|
||||||
|
asset_purpose: detailRaw,
|
||||||
|
current_dept: teamRaw,
|
||||||
|
previous_dept: '-',
|
||||||
|
location: locRaw,
|
||||||
|
manager_primary: mgr1Raw,
|
||||||
|
manager_secondary: mgr2Raw,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
remote_tool: 'RDP / VNC',
|
||||||
|
model_name: modelRaw !== '-' ? modelRaw : (mainboardRaw !== '-' ? mainboardRaw : '사내 표준 공용PC'),
|
||||||
|
os: osRaw !== '-' ? `${osRaw} (${osVerRaw})` : 'Windows 10',
|
||||||
|
cpu: cpuRaw,
|
||||||
|
ram: ramRaw,
|
||||||
|
gpu: gpuRaw,
|
||||||
|
ssd_1: ssd1Raw,
|
||||||
|
ssd_2: ssd2Raw,
|
||||||
|
hdd_1: hdd1Raw,
|
||||||
|
hdd_2: hdd2Raw,
|
||||||
|
hdd_3: hdd3Raw,
|
||||||
|
hdd_4: hdd4Raw,
|
||||||
|
monitoring: service_type === '외부서비스' ? '대상' : '비대상',
|
||||||
|
purchase_amount: gpuRaw.toUpperCase().includes('RTX 4080') || gpuRaw.toUpperCase().includes('RTX 3090') ? '3500000' : '1500000',
|
||||||
|
purchase_vendor: '다나와',
|
||||||
|
approval_document: '2023_공용PC_도입_' + serverIndex,
|
||||||
|
memo: is_inactive ? '방치 의심 장비 (회수 필요)' : '정상 운영 장비',
|
||||||
|
asset_name: assetNameRaw,
|
||||||
|
mac_address: `00:1A:2B:3C:5E:${serverIndex.toString(16).toUpperCase().padStart(2, '0')}`,
|
||||||
|
hw_status: is_inactive ? '수리/대기' : '운영중',
|
||||||
|
service_type: service_type,
|
||||||
|
is_inactive: is_inactive,
|
||||||
|
cpu_usage: cpu_usage,
|
||||||
|
ram_usage: ram_usage,
|
||||||
|
network_traffic: network_traffic
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스토리지로 보낼 자산들 (유형이 NAS/DAS이거나 자산명에 NAS가 들어가면)
|
||||||
|
if (typeRaw.toUpperCase().includes('NAS') || typeRaw.toUpperCase().includes('DAS') || assetUpper.includes('NAS') || assetUpper.includes('DAS')) {
|
||||||
|
assetItem.asset_code = 'STO-24' + String(storageIndex).padStart(3, '0');
|
||||||
|
assetItem.volume = hdd1Raw !== '-' ? hdd1Raw : '10TB';
|
||||||
|
parsedStorages.push(assetItem);
|
||||||
|
storageIndex++;
|
||||||
|
} else {
|
||||||
|
parsedServers.push(assetItem);
|
||||||
|
serverIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- 시트 2: 합본데이터(NAS) -----------------
|
||||||
|
const sheetNAS = workbook.Sheets['합본데이터(NAS)'];
|
||||||
|
const rawNAS = utils.sheet_to_json(sheetNAS, { header: 1 });
|
||||||
|
const rowsNAS = rawNAS.slice(1).filter(row => row.some(val => val !== undefined && val !== null && String(val).trim() !== ''));
|
||||||
|
|
||||||
|
for (const row of rowsNAS) {
|
||||||
|
const teamRaw = cleanValue(row[0]);
|
||||||
|
const svrNoRaw = cleanValue(row[1]);
|
||||||
|
const assetNameRaw = cleanValue(row[2]);
|
||||||
|
const typeRaw = cleanValue(row[3]);
|
||||||
|
const detailRaw = cleanValue(row[4]);
|
||||||
|
const locRaw = cleanValue(row[5]);
|
||||||
|
const mgr1Raw = cleanValue(row[6]);
|
||||||
|
const mgr2Raw = cleanValue(row[7]);
|
||||||
|
const toolRaw = cleanValue(row[8]);
|
||||||
|
const ipRaw = cleanValue(row[9]);
|
||||||
|
const ip2Raw = cleanValue(row[10]);
|
||||||
|
const idRaw = cleanValue(row[11]);
|
||||||
|
const pwRaw = cleanValue(row[12]);
|
||||||
|
const osRaw = cleanValue(row[15]);
|
||||||
|
const osVerRaw = cleanValue(row[16]);
|
||||||
|
const osBuildRaw = cleanValue(row[17]);
|
||||||
|
const modelRaw = cleanValue(row[18]);
|
||||||
|
const cpuRaw = cleanValue(row[19]);
|
||||||
|
const ramRaw = cleanValue(row[20]);
|
||||||
|
const gpuRaw = cleanValue(row[21]);
|
||||||
|
const ssd1Raw = cleanValue(row[22]);
|
||||||
|
const ssd2Raw = cleanValue(row[23]);
|
||||||
|
const hdd1Raw = cleanValue(row[24]);
|
||||||
|
const hdd2Raw = cleanValue(row[25]);
|
||||||
|
const hdd3Raw = cleanValue(row[26]);
|
||||||
|
const hdd4Raw = cleanValue(row[27]);
|
||||||
|
|
||||||
|
const randomCorp = CORPS[storageIndex % CORPS.length];
|
||||||
|
|
||||||
|
// NAS는 기본적으로 내부 백업/공유용 인프라
|
||||||
|
const service_type = '내부서비스';
|
||||||
|
const is_inactive = false;
|
||||||
|
|
||||||
|
// NAS 실시간 리소스 가상 데이터
|
||||||
|
const cpu_usage = Math.floor(Math.random() * 25) + 15; // 15%~40%
|
||||||
|
const ram_usage = Math.floor(Math.random() * 35) + 30; // 30%~65%
|
||||||
|
const network_traffic = (Math.random() * 600 + 50).toFixed(0) + ' GB';
|
||||||
|
|
||||||
|
const assetItem = {
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: typeRaw !== '-' ? typeRaw : '공용 NAS',
|
||||||
|
purchase_corp: randomCorp,
|
||||||
|
asset_code: 'STO-24' + String(storageIndex).padStart(3, '0'),
|
||||||
|
purchase_date: '2022-08',
|
||||||
|
asset_purpose: detailRaw,
|
||||||
|
current_dept: teamRaw !== '-' ? teamRaw : '디자인팀',
|
||||||
|
previous_dept: '-',
|
||||||
|
location: locRaw,
|
||||||
|
manager_primary: mgr1Raw,
|
||||||
|
manager_secondary: mgr2Raw,
|
||||||
|
ip_address: ipRaw !== '-' ? ipRaw : '172.16.42.' + (100 + storageIndex),
|
||||||
|
remote_tool: toolRaw !== '-' ? toolRaw : 'Web GUI',
|
||||||
|
model_name: modelRaw !== '-' ? modelRaw : 'Synology 공용 NAS',
|
||||||
|
os: osRaw !== '-' ? `${osRaw} ${osVerRaw}` : 'DSM 7.x',
|
||||||
|
cpu: cpuRaw,
|
||||||
|
ram: ramRaw,
|
||||||
|
gpu: gpuRaw,
|
||||||
|
ssd_1: ssd1Raw,
|
||||||
|
ssd_2: ssd2Raw,
|
||||||
|
hdd_1: hdd1Raw,
|
||||||
|
hdd_2: hdd2Raw,
|
||||||
|
hdd_3: hdd3Raw,
|
||||||
|
hdd_4: hdd4Raw,
|
||||||
|
monitoring: '비대상',
|
||||||
|
purchase_amount: '4500000',
|
||||||
|
purchase_vendor: '시놀로지 총판',
|
||||||
|
approval_document: '2022_스토리지_도입_' + storageIndex,
|
||||||
|
memo: '스토리지 서버 공유 자산',
|
||||||
|
asset_name: assetNameRaw,
|
||||||
|
mac_address: `00:1A:2B:3C:5F:${storageIndex.toString(16).toUpperCase().padStart(2, '0')}`,
|
||||||
|
hw_status: '운영중',
|
||||||
|
service_type: service_type,
|
||||||
|
is_inactive: is_inactive,
|
||||||
|
volume: hdd1Raw !== '-' ? hdd1Raw : '24TB',
|
||||||
|
cpu_usage: cpu_usage,
|
||||||
|
ram_usage: ram_usage,
|
||||||
|
network_traffic: network_traffic
|
||||||
|
};
|
||||||
|
|
||||||
|
parsedStorages.push(assetItem);
|
||||||
|
storageIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Parsed Servers: ${parsedServers.length} units`);
|
||||||
|
console.log(`Parsed Storages: ${parsedStorages.length} units`);
|
||||||
|
|
||||||
|
// 3. 파일 다시 쓰기
|
||||||
|
const newDummyDataFileContent = `import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 문자열
|
||||||
|
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 년월 (YYYY-MM) (최근 10년)
|
||||||
|
const randomPurchaseYM = () => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const year = currentYear - Math.floor(Math.random() * 10);
|
||||||
|
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||||
|
return \`\${year}-\${month}\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유틸리티: 랜덤 YYYY-MM-DD
|
||||||
|
const randomDateStr = (maxYearsAgo = 10) => {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const year = currentYear - Math.floor(Math.random() * maxYearsAgo);
|
||||||
|
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||||
|
const day = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
|
||||||
|
return \`\${year}-\${month}-\${day}\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||||
|
const getRandomCorp = () => CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
// 1. SampleData_PC.xlsx 에서 파싱된 PC 데이터 주입
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
export const dummyPCs: any[] = ${JSON.stringify(dummyPCs, null, 2)};
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
// 2. 기타 자산 더미 데이터 (서버, 스토리지, 소프트웨어 등 - 엑셀 파싱 연동)
|
||||||
|
// ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const dummyServers: any[] = ${JSON.stringify(parsedServers, null, 2)};
|
||||||
|
|
||||||
|
export const dummyStorages: any[] = ${JSON.stringify(parsedStorages, null, 2)};
|
||||||
|
|
||||||
|
export const dummyEquips: any[] = Array.from({ length: 12 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '전산비품',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`EQ-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
asset_name: \`네트워크 스위치 \${i+1}\`,
|
||||||
|
location: '전산실 랙 1',
|
||||||
|
manager_primary: '네트워크담당자',
|
||||||
|
ip_address: \`192.168.10.\${200 + i}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:51:\${String(i).padStart(2, '0')}\`,
|
||||||
|
os: 'Cisco IOS',
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
purchase_amount: '150000',
|
||||||
|
purchase_vendor: '다나와',
|
||||||
|
approval_document: \`2024_비품구매_\${i}\`,
|
||||||
|
memo: '사내망 확장용',
|
||||||
|
asset_purpose: '네트워크 분배'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyMobiles: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '모바일기기',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
asset_code: \`MOB-24\${String(i).padStart(3, '0')}\`,
|
||||||
|
asset_name: \`테스트용 단말기 \${i+1}\`,
|
||||||
|
location: '개발2팀',
|
||||||
|
manager_primary: '테스터',
|
||||||
|
os: i % 2 === 0 ? 'Android 14' : 'iOS 17',
|
||||||
|
purchase_date: randomPurchaseYM(),
|
||||||
|
purchase_amount: '900000',
|
||||||
|
purchase_vendor: '삼성전자/애플',
|
||||||
|
approval_document: \`2024_모바일구매_\${i}\`,
|
||||||
|
memo: '앱 호환성 테스트 전용',
|
||||||
|
asset_purpose: 'QA 테스트',
|
||||||
|
ip_address: \`192.168.1.\${10 + i}\`,
|
||||||
|
mac_address: \`00:1A:2B:3C:50:\${String(i).padStart(2, '0')}\`
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummySubSw: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '구독SW',
|
||||||
|
sw_field: '업무용/협업',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '전사',
|
||||||
|
product_name: \`Microsoft 365 E\${3 + (i%2)}\`,
|
||||||
|
purchase_date: randomDateStr(3),
|
||||||
|
start_date: randomDateStr(1),
|
||||||
|
expired_date: randomDateStr(0),
|
||||||
|
purchase_amount: '150000',
|
||||||
|
asset_count: 50 + i * 5,
|
||||||
|
email_account: \`admin\${i}@hmcorp.com\`,
|
||||||
|
purchase_vendor: '소프트웨어인라이프',
|
||||||
|
memo: '연간 계약 갱신 필요'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyPermSw: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '영구SW',
|
||||||
|
sw_field: '디자인/설계',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '디자인팀',
|
||||||
|
product_name: \`AutoCAD 202\${i%4}\`,
|
||||||
|
purchase_date: randomDateStr(5),
|
||||||
|
start_date: randomDateStr(5),
|
||||||
|
expired_date: '2099-12-31',
|
||||||
|
purchase_amount: '3000000',
|
||||||
|
asset_count: 2,
|
||||||
|
email_account: \`design\${i}@hmcorp.com\`,
|
||||||
|
purchase_vendor: '오토데스크 파트너',
|
||||||
|
memo: 'USB 동글키 보관중'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyCloud: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_type: '클라우드',
|
||||||
|
asset_mfr: i % 2 === 0 ? 'AWS' : 'GCP',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '개발팀',
|
||||||
|
product_name: \`컴퓨팅 인스턴스 Type \${i}\`,
|
||||||
|
email_account: \`awsadmin\${i}@hmcorp.com\`,
|
||||||
|
purchase_method: '법인카드(신한 1234)',
|
||||||
|
purchase_amount: \`\${500000 + i * 100000}\`,
|
||||||
|
asset_count: 1,
|
||||||
|
purchase_vendor: 'AWS/GCP',
|
||||||
|
memo: '환율 변동에 따라 매월 상이함'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyDomain: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
asset_type: '도메인',
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
product_name: \`사내 운영 서비스 \${i+1}\`,
|
||||||
|
domain_address: \`service\${i+1}.hmcorp.com\`,
|
||||||
|
start_date: randomDateStr(4),
|
||||||
|
expired_date: randomDateStr(0),
|
||||||
|
purchase_amount: '22000',
|
||||||
|
manager_primary: '인프라팀장',
|
||||||
|
manager_secondary: '인프라담당자',
|
||||||
|
memo: '가비아 자동갱신 설정 완료'
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummySwUsers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
sw_id: dummySubSw[0]?.id || randomId(),
|
||||||
|
purchase_corp: getRandomCorp(),
|
||||||
|
current_dept: '경영지원팀',
|
||||||
|
user_current: \`홍길동\${i}\`,
|
||||||
|
memo: \`SW신청서_2400\${i}\`
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const dummyLogs: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||||
|
id: randomId(),
|
||||||
|
assetId: dummyPCs[0]?.id || randomId(),
|
||||||
|
date: randomDateStr(1),
|
||||||
|
details: i % 2 === 0 ? '메모리 추가 증설 (16GB -> 32GB)' : '디스플레이 파손 수리',
|
||||||
|
user: 'IT지원팀',
|
||||||
|
cost: i % 2 === 0 ? 80000 : 150000,
|
||||||
|
}));
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(dummyDataPath, newDummyDataFileContent, 'utf-8');
|
||||||
|
console.log('✅ dummyData.ts file updated successfully with SVR dataset.');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('❌ Failed to update dummy data:', e);
|
||||||
|
}
|
||||||
424
server.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import cors from 'cors';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||||
|
|
||||||
|
// uploads 폴더가 없으면 생성
|
||||||
|
if (!fs.existsSync('uploads')) {
|
||||||
|
fs.mkdirSync('uploads');
|
||||||
|
}
|
||||||
|
|
||||||
|
// MySQL Pool Configuration
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: parseInt(process.env.DB_PORT || '3306'),
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error Handler
|
||||||
|
const handleError = (res, err, label) => {
|
||||||
|
console.error(`❌ [${label}] Error:`, err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Global Constants ---
|
||||||
|
const CATEGORY_TABLE_MAP = {
|
||||||
|
pc: 'asset_pc',
|
||||||
|
server: 'asset_server',
|
||||||
|
storage: 'asset_storage',
|
||||||
|
network: 'asset_network',
|
||||||
|
equipment: 'asset_equipment',
|
||||||
|
officeSupplies: 'asset_office_supplies',
|
||||||
|
survey: 'asset_survey',
|
||||||
|
vip: 'asset_vip',
|
||||||
|
swInternal: 'sw_internal',
|
||||||
|
swExternal: 'sw_external',
|
||||||
|
cloud: 'asset_cloud',
|
||||||
|
users: 'system_users',
|
||||||
|
swUsers: 'sw_assignment',
|
||||||
|
logs: 'asset_history'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ASSET_TABLES = [
|
||||||
|
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||||
|
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- API Endpoints ---
|
||||||
|
|
||||||
|
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||||
|
app.post('/api/:table/batch', async (req, res) => {
|
||||||
|
const { table } = req.params;
|
||||||
|
const dbTable = CATEGORY_TABLE_MAP[table] || table;
|
||||||
|
const data = req.body;
|
||||||
|
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
|
||||||
|
const validFields = columns.map(c => c.Field);
|
||||||
|
|
||||||
|
await connection.query(`DELETE FROM ${dbTable}`);
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
const placeholders = validFields.map(() => '?').join(', ');
|
||||||
|
const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
const values = validFields.map(field => {
|
||||||
|
const val = item[field];
|
||||||
|
return val === undefined ? null : val;
|
||||||
|
});
|
||||||
|
await connection.query(sql, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
res.json({ success: true, count: data.length });
|
||||||
|
} catch (err) {
|
||||||
|
if (connection) await connection.rollback();
|
||||||
|
handleError(res, err, 'BATCH SAVE');
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||||
|
app.get('/api/assets/master', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
|
||||||
|
const masterData = {
|
||||||
|
pc: [], server: [], storage: [], network: [],
|
||||||
|
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||||
|
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rows] = await connection.query(`
|
||||||
|
SELECT
|
||||||
|
c.*,
|
||||||
|
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
|
||||||
|
s.monitoring, s.price, s.monitor_inch, s.serial_num,
|
||||||
|
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
|
||||||
|
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
|
||||||
|
(
|
||||||
|
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no))
|
||||||
|
FROM asset_volume WHERE asset_id = c.id
|
||||||
|
) as volumes
|
||||||
|
FROM asset_core c
|
||||||
|
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
||||||
|
LEFT JOIN asset_location l ON l.id = (
|
||||||
|
SELECT id FROM asset_location
|
||||||
|
WHERE asset_id = c.id AND is_active = 1
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
)
|
||||||
|
LEFT JOIN asset_network n ON n.id = (
|
||||||
|
SELECT id FROM asset_network
|
||||||
|
WHERE asset_id = c.id AND is_active = 1
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const catMap = {
|
||||||
|
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
|
||||||
|
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
|
||||||
|
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const key = catMap[row.category] || 'pc';
|
||||||
|
masterData[key].push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual');
|
||||||
|
const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription');
|
||||||
|
const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment');
|
||||||
|
const [users] = await connection.query('SELECT * FROM system_users');
|
||||||
|
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
|
||||||
|
|
||||||
|
masterData.swInternal = swInternal;
|
||||||
|
masterData.swExternal = swExternal;
|
||||||
|
masterData.swUsers = swUsers;
|
||||||
|
masterData.users = users;
|
||||||
|
masterData.logs = logs;
|
||||||
|
|
||||||
|
connection.release();
|
||||||
|
res.json(masterData);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err, 'MASTER DATA');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
|
||||||
|
app.post('/api/asset/:category/save', async (req, res) => {
|
||||||
|
const asset = req.body;
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
// 3.1 asset_core
|
||||||
|
const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position'];
|
||||||
|
const coreData = {};
|
||||||
|
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
||||||
|
const coreKeys = Object.keys(coreData);
|
||||||
|
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')}) ON DUPLICATE KEY UPDATE ${coreKeys.map(k => `${k} = VALUES(${k})`).join(', ')}`;
|
||||||
|
await connection.query(coreSql, Object.values(coreData));
|
||||||
|
|
||||||
|
// 3.2 asset_spec
|
||||||
|
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
|
||||||
|
const specData = { asset_id: asset.id };
|
||||||
|
specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; });
|
||||||
|
const specKeys = Object.keys(specData);
|
||||||
|
const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]);
|
||||||
|
if (specExists.length > 0) {
|
||||||
|
const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`;
|
||||||
|
await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]);
|
||||||
|
} else {
|
||||||
|
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.3 asset_volume
|
||||||
|
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
|
||||||
|
if (asset.volumes) {
|
||||||
|
try {
|
||||||
|
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
|
||||||
|
if (Array.isArray(vols)) {
|
||||||
|
for (let i = 0; i < vols.length; i++) {
|
||||||
|
const v = vols[i];
|
||||||
|
if (v.type && v.capacity) {
|
||||||
|
await connection.query(
|
||||||
|
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('Volume parse error', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.4 asset_location
|
||||||
|
if (asset.location || asset.location_detail) {
|
||||||
|
const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||||
|
const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y;
|
||||||
|
if (isChanged) {
|
||||||
|
await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||||
|
await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||||
|
[asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.5 asset_network
|
||||||
|
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||||
|
const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||||
|
const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw;
|
||||||
|
if (isChanged) {
|
||||||
|
await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||||
|
await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||||
|
[asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (connection) await connection.rollback();
|
||||||
|
handleError(res, err, 'ASSET SAVE V3');
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3.6 PC Flow Transaction (Checkout, Return, Move)
|
||||||
|
app.post('/api/pc/flow', async (req, res) => {
|
||||||
|
const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body;
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
connection = await pool.getConnection();
|
||||||
|
await connection.beginTransaction();
|
||||||
|
|
||||||
|
if (action === 'checkout') {
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_core
|
||||||
|
SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[userName, empNo, dept, position, assetId]
|
||||||
|
);
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
|
||||||
|
[assetId]
|
||||||
|
);
|
||||||
|
} else if (action === 'return') {
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_core
|
||||||
|
SET previous_user = user_current, previous_dept = current_dept,
|
||||||
|
user_current = '', emp_no = '', current_dept = '재고창고', user_position = ''
|
||||||
|
WHERE id = ?`,
|
||||||
|
[assetId]
|
||||||
|
);
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_spec SET hw_status = '대기' WHERE asset_id = ?`,
|
||||||
|
[assetId]
|
||||||
|
);
|
||||||
|
} else if (action === 'move') {
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_core
|
||||||
|
SET previous_user = user_current, previous_dept = current_dept,
|
||||||
|
user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[userName, empNo, dept, position, assetId]
|
||||||
|
);
|
||||||
|
await connection.query(
|
||||||
|
`UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
|
||||||
|
[assetId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid action type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into asset_history
|
||||||
|
await connection.query(
|
||||||
|
`INSERT INTO asset_history (asset_id, log_date, log_user, details)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
[assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details]
|
||||||
|
);
|
||||||
|
|
||||||
|
await connection.commit();
|
||||||
|
console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (connection) await connection.rollback();
|
||||||
|
handleError(res, err, 'PC FLOW TRANSACTION');
|
||||||
|
} finally {
|
||||||
|
if (connection) connection.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Asset Delete
|
||||||
|
app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||||
|
const { category, id } = req.params;
|
||||||
|
|
||||||
|
// Define mapping for which base table handles the delete
|
||||||
|
const deleteTableMap = {
|
||||||
|
pc: 'asset_core',
|
||||||
|
server: 'asset_core',
|
||||||
|
storage: 'asset_core',
|
||||||
|
network: 'asset_core',
|
||||||
|
equipment: 'asset_core',
|
||||||
|
officeSupplies: 'asset_core',
|
||||||
|
survey: 'asset_core',
|
||||||
|
vip: 'asset_core',
|
||||||
|
pcParts: 'asset_core',
|
||||||
|
swInternal: 'asset_software_perpetual',
|
||||||
|
swExternal: 'asset_software_subscription',
|
||||||
|
swUsers: 'asset_software_assignment',
|
||||||
|
users: 'system_users'
|
||||||
|
};
|
||||||
|
|
||||||
|
const table = deleteTableMap[category];
|
||||||
|
|
||||||
|
if (!table) return res.status(400).json({ error: 'Invalid category for deletion' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
// For asset_core, ON DELETE CASCADE will handle spec, location, network, volume
|
||||||
|
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
||||||
|
connection.release();
|
||||||
|
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err, 'ASSET DELETE');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Generate Next Asset Code
|
||||||
|
app.get('/api/generate-asset-code', async (req, res) => {
|
||||||
|
const { prefix, purchaseDate } = req.query;
|
||||||
|
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||||
|
try {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
|
||||||
|
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
|
||||||
|
let maxNum = 0;
|
||||||
|
for (const table of ASSET_TABLES) {
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]);
|
||||||
|
rows.forEach(row => {
|
||||||
|
const parts = row.asset_code.split('-');
|
||||||
|
const num = parseInt(parts[parts.length - 1]);
|
||||||
|
if (!isNaN(num) && num > maxNum) maxNum = num;
|
||||||
|
});
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
const nextNum = maxNum + 1;
|
||||||
|
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||||
|
connection.release();
|
||||||
|
res.json({ nextCode });
|
||||||
|
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Map Config API
|
||||||
|
app.get('/api/maps', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync('map_config.json')) return res.json({});
|
||||||
|
const data = fs.readFileSync('map_config.json', 'utf8');
|
||||||
|
res.json(JSON.parse(data || '{}'));
|
||||||
|
} catch (err) { handleError(res, err, 'GET MAPS'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/maps/save', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { path, boxes } = req.body;
|
||||||
|
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||||
|
let config = {};
|
||||||
|
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||||
|
config[path] = boxes;
|
||||||
|
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. File Upload API (Base64)
|
||||||
|
app.post('/api/upload', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { fileName, fileData } = req.body;
|
||||||
|
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
|
||||||
|
|
||||||
|
// base64 데이터에서 실제 바이너리 추출
|
||||||
|
const base64Data = fileData.replace(/^data:.*;base64,/, "");
|
||||||
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
|
||||||
|
// 고유한 파일명 생성 (타임스탬프 결합)
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||||
|
const filePath = `uploads/${safeFileName}`;
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
console.log(`파일 업로드 성공: ${filePath}`);
|
||||||
|
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err, 'FILE UPLOAD');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000, '0.0.0.0', () => {
|
||||||
|
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
||||||
|
});
|
||||||
279
src/components/Guide.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
|
||||||
|
import { state } from '../core/state';
|
||||||
|
|
||||||
|
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
|
||||||
|
interface GuideTabConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GUIDE_TABS: GuideTabConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: '📋 개요',
|
||||||
|
content: `
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>IT 자산관리 시스템 개요</h3>
|
||||||
|
<p class="guide-text">
|
||||||
|
HM IT 자산관리 시스템(ITAM)은 기업의 IT 자산을 <strong>도입부터 폐기까지</strong> 전 과정에서 효율적으로 관리하기 위한 통합 플랫폼입니다.<br>
|
||||||
|
하드웨어(PC, 서버, 스토리지, 전산비품, 모바일기기)와 소프트웨어(구독SW, 영구SW, 클라우드)를 체계적으로 추적하고 유지보수합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>전체 자산관리 프로세스</h3>
|
||||||
|
<div class="flow-container">
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
<div><span class="step-label">도입/구매</span><p class="step-desc">자산 구매 요청 → 승인 → 발주</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
<div><span class="step-label">등록/배정</span><p class="step-desc">자산번호 부여 → 시스템 등록 → 사용자 할당</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">3</span>
|
||||||
|
<div><span class="step-label">운영/유지</span><p class="step-desc">현황 모니터링 → 점검/수리 → 이력 관리</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">4</span>
|
||||||
|
<div><span class="step-label">반납/폐기</span><p class="step-desc">자산 회수 → 데이터 소거 → 폐기 처리</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>시스템 기본 사용방법</h3>
|
||||||
|
<table class="guide-info-table">
|
||||||
|
<thead><tr><th>기능</th><th>방법</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>자산 조회</strong></td><td>상단 카테고리(하드웨어/소프트웨어) 및 하위 탭 선택 후 데이터 조회</td></tr>
|
||||||
|
<tr><td><strong>자산 등록</strong></td><td>[자산 추가] 버튼 클릭 후 상세 정보 입력 및 저장</td></tr>
|
||||||
|
<tr><td><strong>정보 수정</strong></td><td>목록 행 클릭 후 나타나는 모달에서 내용 변경 및 저장</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pc',
|
||||||
|
label: '💻 개인PC',
|
||||||
|
content: `
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>개인PC 관리 가이드</h3>
|
||||||
|
<p class="guide-text">
|
||||||
|
임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>관리 프로세스</h3>
|
||||||
|
<div class="flow-container">
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
<div><span class="step-label">구매 및 입고</span><p class="step-desc">구매 요청 → 발주 → 입고 검수</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
<div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-down" class="flow-arrow"></i>
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">3</span>
|
||||||
|
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 및 설치위치 기록</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">4</span>
|
||||||
|
<div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-down" class="flow-arrow"></i>
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">5</span>
|
||||||
|
<div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 및 데이터 소거</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">6</span>
|
||||||
|
<div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>주요 관리 항목</h3>
|
||||||
|
<table class="guide-info-table">
|
||||||
|
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>구매법인</td><td>자산의 소유 법인</td><td>등록 시</td></tr>
|
||||||
|
<tr><td>사용자/조직</td><td>실제 사용자 및 소속 부서</td><td>변동 시</td></tr>
|
||||||
|
<tr><td>자산번호</td><td>고유 식별 번호 (바코드)</td><td>등록 시</td></tr>
|
||||||
|
<tr><td>모델명/사양</td><td>제조사 모델 및 CPU/RAM 등</td><td>등록 시</td></tr>
|
||||||
|
<tr><td>구매금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr> </tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="guide-tip">
|
||||||
|
<strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'server',
|
||||||
|
label: '🖥️ 서버/스토리지',
|
||||||
|
content: `
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>인프라 자산 관리 가이드</h3>
|
||||||
|
<p class="guide-text">
|
||||||
|
서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>관리 프로세스</h3>
|
||||||
|
<div class="flow-container">
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
<div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
<div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 및 자산번호 부여</p></div>
|
||||||
|
</div>
|
||||||
|
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
|
||||||
|
<div class="flow-step">
|
||||||
|
<span class="step-number">3</span>
|
||||||
|
<div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>필수 입력 항목</h3>
|
||||||
|
<table class="guide-info-table">
|
||||||
|
<thead><tr><th>항목</th><th>중요성</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr>
|
||||||
|
<tr><td><strong>설치위치</strong></td><td>IDC 또는 서버실 내의 정확한 랙 위치</td></tr>
|
||||||
|
<tr><td><strong>담당자(정/부)</strong></td><td>비상 시 연락 가능한 관리 책임자</td></tr>
|
||||||
|
<tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="guide-warn">
|
||||||
|
<strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'software',
|
||||||
|
label: '💾 소프트웨어',
|
||||||
|
content: `
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>소프트웨어 자산 관리 가이드</h3>
|
||||||
|
<p class="guide-text">
|
||||||
|
구독형(SaaS) 및 영구형 라이선스를 관리합니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-section">
|
||||||
|
<h3>라이선스 관리 포인트</h3>
|
||||||
|
<table class="guide-info-table">
|
||||||
|
<thead><tr><th>구분</th><th>관리 내용</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정 및 비용 정산</td></tr>
|
||||||
|
<tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr>
|
||||||
|
<tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="guide-tip">
|
||||||
|
<strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── 가이드 모달 초기화 ───
|
||||||
|
export function initGuide() {
|
||||||
|
const body = document.body;
|
||||||
|
if (document.getElementById('guide-overlay')) return;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay hidden';
|
||||||
|
overlay.id = 'guide-overlay';
|
||||||
|
|
||||||
|
const tabsHtml = GUIDE_TABS.map((tab, i) =>
|
||||||
|
`<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
const panelsHtml = GUIDE_TABS.map((tab, i) =>
|
||||||
|
`<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal-content wide" id="guide-modal" style="height: 90vh;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2>
|
||||||
|
<button class="btn-icon" id="btn-close-guide">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="guide-tabs-container">
|
||||||
|
<div class="guide-tabs">${tabsHtml}</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding-top: 0;">
|
||||||
|
<div class="guide-body">${panelsHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
body.appendChild(overlay);
|
||||||
|
|
||||||
|
const openGuide = () => {
|
||||||
|
console.log('📖 Opening Full Guide Modal...');
|
||||||
|
overlay.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
const closeGuide = () => overlay.classList.add('hidden');
|
||||||
|
|
||||||
|
const triggerBtn = document.getElementById('btn-open-guide-header');
|
||||||
|
if (triggerBtn) {
|
||||||
|
triggerBtn.addEventListener('click', openGuide);
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); });
|
||||||
|
document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide);
|
||||||
|
|
||||||
|
const tabs = overlay.querySelectorAll('.guide-tab');
|
||||||
|
const panels = overlay.querySelectorAll('.guide-tab-panel');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const targetId = tab.getAttribute('data-guide-tab');
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
panels.forEach(p => p.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } });
|
||||||
|
}
|
||||||
@@ -1,43 +1,121 @@
|
|||||||
|
import { createIcons, X } from 'lucide';
|
||||||
|
import { setEditLock } from './ModalUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
|
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
|
||||||
*/
|
*/
|
||||||
export function initBaseModal() {
|
export abstract class BaseModal {
|
||||||
const modals = document.querySelectorAll('.modal-overlay');
|
protected idPrefix: string;
|
||||||
const closeButtons = document.querySelectorAll('.btn-icon, [id^="btn-cancel-"]');
|
protected title: string;
|
||||||
|
protected currentAsset: any | null = null;
|
||||||
|
protected isEditMode: boolean = false;
|
||||||
|
protected modalEl: HTMLElement | null = null;
|
||||||
|
protected formEl: HTMLFormElement | null = null;
|
||||||
|
|
||||||
// 모든 모달 닫기 함수
|
constructor(idPrefix: string, title: string) {
|
||||||
const closeAllModals = () => {
|
this.idPrefix = idPrefix;
|
||||||
modals.forEach(modal => modal.classList.add('hidden'));
|
this.title = title;
|
||||||
// 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 => {
|
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
|
||||||
btn.addEventListener('click', closeAllModals);
|
*/
|
||||||
});
|
public init(onSave: () => void, closeModalsFn: () => void) {
|
||||||
|
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
|
||||||
|
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
|
||||||
|
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
|
||||||
|
}
|
||||||
|
|
||||||
// ESC 키로 닫기
|
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
|
||||||
window.addEventListener('keydown', (e) => {
|
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
|
||||||
if (e.key === 'Escape') closeAllModals();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 배경(Overlay) 클릭 시 닫기
|
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
|
||||||
modals.forEach(modal => {
|
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
|
||||||
modal.addEventListener('click', (e) => {
|
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
|
||||||
if (e.target === modal) closeAllModals();
|
|
||||||
|
const closeAction = () => {
|
||||||
|
this.close();
|
||||||
|
closeModalsFn(); // 전역 모달 상태 해제 콜백
|
||||||
|
};
|
||||||
|
|
||||||
|
btnCloseHeader?.addEventListener('click', closeAction);
|
||||||
|
btnCancelFooter?.addEventListener('click', closeAction);
|
||||||
|
|
||||||
|
// 3. 자식 클래스 전용 초기화 로직 실행
|
||||||
|
this.initChildLogic(onSave, closeModalsFn);
|
||||||
|
|
||||||
|
// 4. 아이콘 초기화
|
||||||
|
createIcons({ icons: { X } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 열기: 데이터 바인딩 및 모드 설정
|
||||||
|
*/
|
||||||
|
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||||
|
this.currentAsset = asset;
|
||||||
|
this.isEditMode = (mode === 'add' || mode === 'edit');
|
||||||
|
|
||||||
|
this.setEditLockMode(mode);
|
||||||
|
this.fillFormData(asset);
|
||||||
|
|
||||||
|
if (this.modalEl) {
|
||||||
|
this.modalEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onAfterOpen(asset, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 닫기: 상태 초기화
|
||||||
|
*/
|
||||||
|
public close() {
|
||||||
|
if (this.modalEl) {
|
||||||
|
this.modalEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
this.isEditMode = false;
|
||||||
|
this.currentAsset = null;
|
||||||
|
this.onAfterClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
|
||||||
|
*/
|
||||||
|
protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
|
||||||
|
setEditLock(`${this.idPrefix}-asset-form`, mode, {
|
||||||
|
saveBtnId: `btn-save-${this.idPrefix}-asset`,
|
||||||
|
revertBtnId: `btn-revert-${this.idPrefix}-edit`,
|
||||||
|
addLogBtnId: `btn-add-${this.idPrefix}-log`
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return { closeAllModals };
|
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
|
||||||
|
protected abstract renderFrameHTML(): string;
|
||||||
|
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
|
||||||
|
protected abstract fillFormData(asset: any): void;
|
||||||
|
protected abstract onAfterOpen(asset: any, mode: string): void;
|
||||||
|
|
||||||
|
// --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
|
||||||
|
protected onAfterClose(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 모달을 엽니다.
|
* --- 레거시 호환성을 위한 함수형 익스포트 ---
|
||||||
* @param modalId 모달 엘리먼트의 ID
|
* 기존 코드들이 참조하고 있는 함수들을 유지합니다.
|
||||||
*/
|
*/
|
||||||
|
export function closeModals() {
|
||||||
|
const modals = document.querySelectorAll('.modal-overlay');
|
||||||
|
modals.forEach(modal => modal.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initBaseModal() {
|
||||||
|
// ESC 키로 모든 모달 닫기
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') closeModals();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { closeAllModals: closeModals };
|
||||||
|
}
|
||||||
|
|
||||||
export function openModal(modalId: string) {
|
export function openModal(modalId: string) {
|
||||||
const modal = document.getElementById(modalId);
|
const modal = document.getElementById(modalId);
|
||||||
if (modal) {
|
if (modal) {
|
||||||
|
|||||||
136
src/components/Modal/DashboardDetailModal.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { state } from '../../core/state';
|
||||||
|
import { ASSET_SCHEMA } from '../../core/schema';
|
||||||
|
import { createIcons, X } from 'lucide';
|
||||||
|
|
||||||
|
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(); });
|
||||||
|
|
||||||
|
createIcons({ icons: { X } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDashboardDetail(title: string, list: any[]) {
|
||||||
|
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></tr>`;
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (list.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
|
||||||
|
} else {
|
||||||
|
list.forEach((asset, idx) => {
|
||||||
|
let manager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || asset.user_current || '-';
|
||||||
|
let name = asset[ASSET_SCHEMA.MODEL_NAME.key] || asset[ASSET_SCHEMA.ASSET_NAME.key] || '-';
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${idx+1}</td><td>${asset.category || asset[ASSET_SCHEMA.ASSET_TYPE.key]}</td><td>${name}</td><td>${asset[ASSET_SCHEMA.LOCATION.key]||'-'}</td><td>${manager}</td><td>${asset[ASSET_SCHEMA.PURCHASE_DATE.key]||'-'}</td><td>${asset[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||'-'}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSwDashboardDetail(title: string, list: any[]) {
|
||||||
|
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.asset_type || sw.type}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${sw[ASSET_SCHEMA.ASSET_COUNT.key]}</td><td>${sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSwUsageDetail(title: string, list: any[]) {
|
||||||
|
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.sw_id === sw.id).length;
|
||||||
|
const qty = Number(sw[ASSET_SCHEMA.ASSET_COUNT.key] || 0);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${qty}</td><td>${assigned}</td><td>${qty - assigned}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCloudDashboardDetail(title: string, list: any[]) {
|
||||||
|
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 = '';
|
||||||
|
if (list.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 2rem;">해당 내역이 없습니다.</td></tr>`;
|
||||||
|
} else {
|
||||||
|
list.forEach((sw, idx) => {
|
||||||
|
const priceStr = sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/[^0-9]/g, '')).toLocaleString() : '0';
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.DEV_OBJ.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]||'-'}</td><td>${sw.pay_day ? sw.pay_day + '일' : '-'}</td><td>₩ ${priceStr}</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
188
src/components/Modal/DomainModal.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||||
|
import { BaseModal } from './BaseModal';
|
||||||
|
import { CORP_LIST } from './SharedData';
|
||||||
|
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||||
|
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
||||||
|
import { formatExcelDate } from '../../core/excelHandler';
|
||||||
|
import { UI_TEXT } from '../../core/schema';
|
||||||
|
|
||||||
|
class DomainAssetModal extends BaseModal {
|
||||||
|
constructor() {
|
||||||
|
super('domain', '도메인 정보');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected renderFrameHTML(): string {
|
||||||
|
return `
|
||||||
|
<div id="domain-asset-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="domain-modal-title">${this.title}</h2>
|
||||||
|
<button id="btn-close-domain-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="domain-asset-form" class="grid-form">
|
||||||
|
<input type="hidden" id="domain-id" name="id" />
|
||||||
|
|
||||||
|
<div class="form-section-title">기본 정보</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>구분</label>
|
||||||
|
<select id="domain-type" name="type">
|
||||||
|
<option value="호스팅">호스팅</option>
|
||||||
|
<option value="도메인">도메인</option>
|
||||||
|
<option value="기타">기타</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>관리법인</label>
|
||||||
|
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>서비스명</label>
|
||||||
|
<input type="text" id="domain-service-name" name="service_name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>관리도메인</label>
|
||||||
|
<input type="text" id="domain-name" name="domain_name" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section-title">계약 및 비용</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>계약시작일</label>
|
||||||
|
<input type="date" id="domain-start-date" name="start_date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>만료예정일</label>
|
||||||
|
<input type="date" id="domain-expiry-date" name="expiry_date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>비용 (연간/월간)</label>
|
||||||
|
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section-title">담당자 및 비고</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>정담당자</label>
|
||||||
|
<input type="text" id="domain-manager-main" name="manager_main" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>부담당자</label>
|
||||||
|
<input type="text" id="domain-manager-sub" name="manager_sub" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>비고</label>
|
||||||
|
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-history-area">
|
||||||
|
<div class="history-header">
|
||||||
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
||||||
|
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
||||||
|
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="domain-history-list" class="history-timeline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
|
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
|
||||||
|
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||||
|
const saveBtn = document.getElementById('btn-save-domain-asset')!;
|
||||||
|
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
|
||||||
|
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset) return;
|
||||||
|
if (!this.isEditMode) {
|
||||||
|
this.setEditLockMode('edit');
|
||||||
|
this.isEditMode = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this.formEl!);
|
||||||
|
const updated = { ...this.currentAsset };
|
||||||
|
formData.forEach((value, key) => { updated[key] = value; });
|
||||||
|
|
||||||
|
if (!updated.service_name || !updated.domain_name) {
|
||||||
|
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await saveAsset('domain', updated)) {
|
||||||
|
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||||
|
onSave(); this.close(); closeModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revertBtn.addEventListener('click', () => {
|
||||||
|
this.setEditLockMode('view');
|
||||||
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||||
|
if (await deleteAsset('domain', this.currentAsset.id)) {
|
||||||
|
alert('성공적으로 삭제되었습니다.');
|
||||||
|
onSave(); this.close(); closeModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fillFormData(asset: any): void {
|
||||||
|
setFieldValue('domain-id', asset.id);
|
||||||
|
setFieldValue('domain-type', asset.type || '호스팅');
|
||||||
|
setFieldValue('domain-corp', asset.corp || '');
|
||||||
|
setFieldValue('domain-service-name', asset.service_name || '');
|
||||||
|
setFieldValue('domain-name', asset.domain_name || '');
|
||||||
|
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
|
||||||
|
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
|
||||||
|
setFieldValue('domain-price', asset.price || '');
|
||||||
|
setFieldValue('domain-manager-main', asset.manager_main || '');
|
||||||
|
setFieldValue('domain-manager-sub', asset.manager_sub || '');
|
||||||
|
setFieldValue('domain-remarks', asset.remarks || '');
|
||||||
|
|
||||||
|
this.renderHistory(asset.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
|
const titleEl = document.getElementById('domain-modal-title');
|
||||||
|
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
|
||||||
|
|
||||||
|
const deleteBtn = document.getElementById('btn-delete-domain-asset');
|
||||||
|
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHistory(assetId: string) {
|
||||||
|
const container = document.getElementById('domain-history-list');
|
||||||
|
if (!container) return;
|
||||||
|
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||||
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||||
|
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const domainModal = new DomainAssetModal();
|
||||||
|
|
||||||
|
export function initDomainModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
domainModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||||
|
domainModal.open(asset, mode);
|
||||||
|
}
|
||||||
@@ -1,112 +1,682 @@
|
|||||||
import { state } from '../../state';
|
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||||
import { HardwareAsset } from '../../excelHandler';
|
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||||
import { openModal } from './BaseModal';
|
import {
|
||||||
|
generateOptionsHTML,
|
||||||
|
setFieldValue,
|
||||||
|
getFieldValue,
|
||||||
|
parseAndSetLocation,
|
||||||
|
bindLocationEvents,
|
||||||
|
applyDateMask
|
||||||
|
} from './ModalUtils';
|
||||||
|
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
|
||||||
|
import { BaseModal } from './BaseModal';
|
||||||
|
|
||||||
/**
|
class HwAssetModal extends BaseModal {
|
||||||
* 하드웨어(서버, 전산비품 등) 모달 초기화 및 로직 제어
|
private dynamicMapConfig: Record<string, any[]> = {};
|
||||||
*/
|
|
||||||
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;
|
|
||||||
|
|
||||||
// 저장 버튼 이벤트
|
constructor() {
|
||||||
btnSaveHw?.addEventListener('click', (e) => {
|
super('hw', '자산 상세 정보');
|
||||||
e.preventDefault();
|
}
|
||||||
if (!hwForm.checkValidity()) { hwForm.reportValidity(); return; }
|
|
||||||
|
|
||||||
const id = (document.getElementById('hw-asset-id') as HTMLInputElement).value;
|
protected renderFrameHTML(): string {
|
||||||
const fileInput = document.getElementById('hw-품의서') as HTMLInputElement;
|
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||||
const 품의서명 = fileInput.files && fileInput.files.length > 0 ? fileInput.files[0].name : (document.getElementById('hw-품의서명') as HTMLElement).innerText.replace('📎', '');
|
const inputStyle = sharedStyle;
|
||||||
|
const btnStyle = `padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; white-space: nowrap; cursor: pointer; ${sharedStyle}`;
|
||||||
|
|
||||||
const newAsset: HardwareAsset = {
|
return `
|
||||||
id: id || Math.random().toString(36).substring(2, 9),
|
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||||
type: (document.getElementById('hw-asset-type') as HTMLInputElement).value,
|
<div class="modal-content wide">
|
||||||
법인: (document.getElementById('hw-법인') as HTMLInputElement).value,
|
<div class="modal-header">
|
||||||
자산코드: (document.getElementById('hw-자산코드') as HTMLInputElement).value,
|
<h2 id="hw-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
||||||
명칭: (document.getElementById('hw-명칭') as HTMLInputElement).value,
|
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||||
위치: (document.getElementById('hw-위치') as HTMLInputElement).value,
|
</div>
|
||||||
관리자: (document.getElementById('hw-관리자') as HTMLInputElement).value,
|
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||||
IP주소: (document.getElementById('hw-IP주소') as HTMLInputElement).value,
|
<div class="modal-body-split">
|
||||||
MACaddress: (document.getElementById('hw-MACaddress') as HTMLInputElement).value,
|
<div class="modal-form-area">
|
||||||
OS: (document.getElementById('hw-OS') as HTMLInputElement).value,
|
<form id="hw-asset-form" class="grid-form">
|
||||||
HW사양: (document.getElementById('hw-HW사양') as HTMLTextAreaElement).value,
|
<input type="hidden" id="hw-id" name="id" />
|
||||||
구매일: (document.getElementById('hw-구매일') as HTMLInputElement).value,
|
|
||||||
금액: (document.getElementById('hw-금액') as HTMLInputElement).value,
|
|
||||||
납품업체: (document.getElementById('hw-납품업체') as HTMLInputElement).value,
|
|
||||||
품의서명,
|
|
||||||
비품유형: (document.getElementById('hw-asset-type') as HTMLInputElement).value === '전산비품'
|
|
||||||
? (document.getElementById('hw-비품유형') as HTMLSelectElement).value : undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id) {
|
<!-- [SECTION 1] 기본 관리 정보 (필수 공통) -->
|
||||||
const idx = state.masterData.hw.findIndex(a => a.id === id);
|
<div class="form-section-title" style="padding-top: 0; margin-bottom: 12px;">기본 관리 정보</div>
|
||||||
if(idx !== -1) state.masterData.hw[idx] = newAsset;
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||||
|
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||||
|
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly style="flex: 1; ${inputStyle}" />
|
||||||
|
<button type="button" id="btn-gen-hw-code" class="btn btn-outline" style="${btnStyle}">생성</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||||
|
<select id="hw-purchase_corp" name="purchase_corp" style="${inputStyle}">${generateOptionsHTML(CORP_LIST)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.CATEGORY.ui}</label>
|
||||||
|
<select id="hw-category" name="category" style="${inputStyle}">
|
||||||
|
<option value="">선택</option>
|
||||||
|
${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
|
||||||
|
<select id="hw-asset_type" name="asset_type" style="${inputStyle}">
|
||||||
|
<option value="">구분을 먼저 선택하세요</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||||
|
<select id="hw-hw_status" name="hw_status" style="${inputStyle}">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group infra-only monitoring-field">
|
||||||
|
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||||
|
<select id="hw-monitoring" name="monitoring" style="${inputStyle}">
|
||||||
|
<option value="비대상">비대상</option>
|
||||||
|
<option value="대상">대상</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||||
|
<div class="form-section-title org-user-section" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</div>
|
||||||
|
<div class="form-group org-user-field">
|
||||||
|
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||||
|
<select id="hw-current_dept" name="current_dept" style="${inputStyle}">${generateOptionsHTML(ORG_LIST)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group org-user-field">
|
||||||
|
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
|
||||||
|
<input type="text" id="hw-manager_primary" name="manager_primary" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group personal-only">
|
||||||
|
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||||
|
<input type="text" id="hw-user_current" name="user_current" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group personal-only">
|
||||||
|
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||||
|
<input type="text" id="hw-user_position" name="user_position" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||||
|
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group personal-only">
|
||||||
|
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||||
|
<input type="text" id="hw-previous_user" name="previous_user" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [SECTION 3] 하드웨어 사양 및 네트워크 -->
|
||||||
|
<div class="form-section-title hardware-section" style="margin-top: 24px; margin-bottom: 12px;">시스템 사양 및 네트워크</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||||
|
<input type="text" id="hw-model_name" name="model_name" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.ASSET_MFR.ui}</label>
|
||||||
|
<input type="text" id="hw-asset_mfr" name="asset_mfr" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sn-only">
|
||||||
|
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
||||||
|
<input type="text" id="hw-serial_num" name="serial_num" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group spec-only">
|
||||||
|
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||||
|
<input type="text" id="hw-os" name="os" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group spec-only">
|
||||||
|
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||||
|
<input type="text" id="hw-cpu" name="cpu" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group spec-only">
|
||||||
|
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||||
|
<input type="text" id="hw-ram" name="ram" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group spec-only">
|
||||||
|
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||||
|
<input type="text" id="hw-gpu" name="gpu" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group spec-only">
|
||||||
|
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||||
|
<input type="text" id="hw-mainboard" name="mainboard" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 동적 디스크 할당 영역 (Plan B) -->
|
||||||
|
<div class="form-group spec-only full-width" style="grid-column: span 2;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">저장장치 (디스크)</label>
|
||||||
|
<button type="button" id="btn-add-volume" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 디스크 추가</button>
|
||||||
|
</div>
|
||||||
|
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
|
||||||
|
<input type="hidden" id="hw-volumes-data" name="volumes" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group net-only">
|
||||||
|
<label>${ASSET_SCHEMA.IP_ADDR.ui}</label>
|
||||||
|
<input type="text" id="hw-ip_address" name="ip_address" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group net-only">
|
||||||
|
<label>${ASSET_SCHEMA.MAC_ADDR.ui}</label>
|
||||||
|
<input type="text" id="hw-mac_address" name="mac_address" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group monitor-only">
|
||||||
|
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
|
||||||
|
<input type="text" id="hw-monitor_inch" name="monitor_inch" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group parts-only">
|
||||||
|
<label>${ASSET_SCHEMA.VOLUME.ui}</label>
|
||||||
|
<input type="text" id="hw-volume" name="volume" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group parts-only">
|
||||||
|
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
|
||||||
|
<input type="text" id="hw-asset_count" name="asset_count" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [SECTION 4] 원격 접속 정보 (서버 전용) -->
|
||||||
|
<div class="form-section-title remote-section" style="margin-top: 24px; margin-bottom: 12px;">원격 접속 정보</div>
|
||||||
|
<div class="form-group remote-field">
|
||||||
|
<label>${ASSET_SCHEMA.IP_ADDR2.ui}</label>
|
||||||
|
<input type="text" id="hw-ip_address_2" name="ip_address_2" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group remote-field">
|
||||||
|
<label>${ASSET_SCHEMA.REMOTE_TOOL.ui}</label>
|
||||||
|
<input type="text" id="hw-remote_tool" name="remote_tool" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group remote-field">
|
||||||
|
<label>${ASSET_SCHEMA.REMOTE_ID.ui}</label>
|
||||||
|
<input type="text" id="hw-remote_id" name="remote_id" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group remote-field">
|
||||||
|
<label>${ASSET_SCHEMA.REMOTE_PW.ui}</label>
|
||||||
|
<input type="text" id="hw-remote_pw" name="remote_pw" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [SECTION 5] 설치 위치 (인프라/실물 장비 전용) -->
|
||||||
|
<div class="form-section-title location-section" style="margin-top: 24px; margin-bottom: 12px;">설치 위치</div>
|
||||||
|
<div class="form-group location-field">
|
||||||
|
<label>건물/위치</label>
|
||||||
|
<select id="hw-bldg-select" name="location" style="${inputStyle}">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group location-field">
|
||||||
|
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||||
|
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||||
|
<select id="hw-location_detail" name="location_detail" style="flex: 1; ${inputStyle}"><option value="">선택</option></select>
|
||||||
|
<button type="button" id="btn-reg-loc-map" class="btn btn-primary" style="${btnStyle} display: none;">위치등록</button>
|
||||||
|
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view" style="${btnStyle} display: none; pointer-events: auto !important; cursor: pointer !important;">위치보기</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- [SECTION 6] 구매 및 증빙 (공통) -->
|
||||||
|
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">구매 및 증빙 정보</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||||
|
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||||
|
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||||
|
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" style="${inputStyle}" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label>
|
||||||
|
<div class="file-upload-wrapper">
|
||||||
|
<input type="file" id="hw-approval_document_file" style="display:none;" />
|
||||||
|
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;">
|
||||||
|
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action" style="${btnStyle} flex: 1; justify-content: flex-start; pointer-events: auto !important; cursor: pointer !important;">
|
||||||
|
<span id="hw-file-name-display">파일 선택...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="hw-approval_document" name="approval_document" />
|
||||||
|
<div id="hw-file-link-container" style="margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
||||||
|
<textarea id="hw-memo" name="memo" rows="3" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none !important; box-sizing: border-box;"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-history-area">
|
||||||
|
<div class="history-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;">
|
||||||
|
<h3 style="margin: 0; font-size: 14px; font-weight: 800;">자산 변동 이력</h3>
|
||||||
|
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm" style="height: 30px; font-size: 11px;">이력 추가</button>
|
||||||
|
</div>
|
||||||
|
<div id="hw-history-list" class="history-timeline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button id="btn-revert-hw-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||||
|
<button id="btn-cancel-hw-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||||
|
<button id="btn-save-hw-asset" class="btn btn-primary" style="height: 42px;">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||||
|
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 categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
||||||
|
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||||
|
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
|
||||||
|
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||||
|
|
||||||
|
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||||
|
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
||||||
|
|
||||||
|
categorySelect.addEventListener('change', () => {
|
||||||
|
const types = CATEGORY_TYPE_MAP[categorySelect.value] || [];
|
||||||
|
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||||
|
this.applyRoleVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', () => {
|
||||||
|
this.applyRoleVisibility();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||||
|
const cat = categorySelect.value;
|
||||||
|
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||||
|
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||||
|
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode);
|
||||||
|
} catch (err) { console.error('코드 생성 실패:', err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
|
||||||
|
detailSelect.addEventListener('change', () => this.updateMapButtonVisibility());
|
||||||
|
|
||||||
|
document.getElementById('btn-reg-loc-map')?.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
await this.fetchMapConfig();
|
||||||
|
const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value);
|
||||||
|
if (images) this.openImagePicker(images, `${detailSelect.value} 위치 등록`);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-view-loc-map')?.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
await this.fetchMapConfig();
|
||||||
|
const x = getFieldValue('hw-loc_x');
|
||||||
|
const y = getFieldValue('hw-loc_y');
|
||||||
|
const savedImg = getFieldValue('hw-location_photo');
|
||||||
|
const bldg = getFieldValue('hw-bldg-select');
|
||||||
|
const detail = getFieldValue('hw-location_detail');
|
||||||
|
const images = this.getImagesForLocation(bldg, detail);
|
||||||
|
if (images) {
|
||||||
|
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
|
||||||
|
this.openImagePreview(imgPath, `${detail} 위치 확인`, x, y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||||
|
if (await deleteAsset(this.getCategoryKey(this.currentAsset), this.currentAsset.id)) {
|
||||||
|
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
revertBtn.addEventListener('click', () => {
|
||||||
|
this.setEditLockMode('view');
|
||||||
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
|
this.updateMapButtonVisibility();
|
||||||
|
this.toggleEditOnlyBtns(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 동적 볼륨 추가 기능 연결
|
||||||
|
const btnAddVolume = document.getElementById('btn-add-volume')!;
|
||||||
|
btnAddVolume.addEventListener('click', () => this.addVolumeRow());
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('hw-approval_document_file') as HTMLInputElement;
|
||||||
|
const fileNameDisplay = document.getElementById('hw-file-name-display');
|
||||||
|
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||||
|
|
||||||
|
fileInput?.addEventListener('change', async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (fileNameDisplay) fileNameDisplay.textContent = file.name;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://${location.hostname}:3000/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fileName: file.name, fileData: reader.result })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setFieldValue('hw-approval_document', data.filePath);
|
||||||
|
if (fileLinkContainer) {
|
||||||
|
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${data.filePath}" target="_blank" class="btn-loc-action" style="color:var(--primary-color); font-size:12px; text-decoration:underline; pointer-events: auto !important;">[업로드 완료: 파일 보기]</a>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); }
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset) return;
|
||||||
|
if (!this.isEditMode) {
|
||||||
|
this.setEditLockMode('edit');
|
||||||
|
this.isEditMode = true;
|
||||||
|
this.updateMapButtonVisibility();
|
||||||
|
this.toggleFileUploadUI(true);
|
||||||
|
this.toggleEditOnlyBtns(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동적 볼륨 데이터 수집 및 배열 생성
|
||||||
|
const vols: any[] = [];
|
||||||
|
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
||||||
|
const type = (row.querySelector('.vol-type') as HTMLSelectElement).value;
|
||||||
|
const cap = (row.querySelector('.vol-cap') as HTMLInputElement).value;
|
||||||
|
const unit = (row.querySelector('.vol-unit') as HTMLSelectElement).value;
|
||||||
|
if (cap) vols.push({ type, capacity: parseFloat(cap), unit, slot: idx + 1 });
|
||||||
|
});
|
||||||
|
setFieldValue('hw-volumes-data', JSON.stringify(vols));
|
||||||
|
|
||||||
|
const formData = new FormData(this.formEl!);
|
||||||
|
const updated = { ...this.currentAsset };
|
||||||
|
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
|
||||||
|
updated.location = getFieldValue('hw-bldg-select');
|
||||||
|
|
||||||
|
if (await saveAsset(this.getCategoryKey(updated), updated)) {
|
||||||
|
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||||
|
onSave(); this.close(); closeModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private addVolumeRow(vol: any = { type: 'SSD', capacity: '', unit: 'GB' }) {
|
||||||
|
const container = document.getElementById('hw-volume-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.style.display = 'flex';
|
||||||
|
row.style.gap = '8px';
|
||||||
|
row.style.alignItems = 'center';
|
||||||
|
row.className = 'volume-row';
|
||||||
|
|
||||||
|
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||||
|
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
|
||||||
|
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
|
||||||
|
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||||
|
<select class="vol-unit" style="${inputStyle} width: 70px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||||
|
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
|
||||||
|
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn btn-outline btn-remove-vol edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
row.querySelector('.btn-remove-vol')?.addEventListener('click', () => row.remove());
|
||||||
|
container.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleEditOnlyBtns(isEdit: boolean) {
|
||||||
|
const addBtn = document.getElementById('btn-add-volume');
|
||||||
|
if (addBtn) addBtn.style.display = isEdit ? 'inline-flex' : 'none';
|
||||||
|
document.querySelectorAll('.edit-only-btn').forEach(btn => {
|
||||||
|
(btn as HTMLElement).style.display = isEdit ? 'inline-flex' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fillFormData(asset: any): void {
|
||||||
|
setFieldValue('hw-id', asset.id);
|
||||||
|
setFieldValue('hw-asset_code', asset.asset_code || '');
|
||||||
|
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
||||||
|
setFieldValue('hw-category', asset.category || '');
|
||||||
|
const types = CATEGORY_TYPE_MAP[asset.category] || [];
|
||||||
|
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||||
|
if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||||
|
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||||
|
setFieldValue('hw-hw_status', asset.hw_status || '운영');
|
||||||
|
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||||
|
setFieldValue('hw-manager_primary', asset.manager_primary || '');
|
||||||
|
setFieldValue('hw-manager_secondary', asset.manager_secondary || '');
|
||||||
|
setFieldValue('hw-user_current', asset.user_current || '');
|
||||||
|
setFieldValue('hw-user_position', asset.user_position || '');
|
||||||
|
setFieldValue('hw-previous_user', asset.previous_user || '');
|
||||||
|
setFieldValue('hw-model_name', asset.model_name || '');
|
||||||
|
setFieldValue('hw-asset_mfr', asset.asset_mfr || '');
|
||||||
|
setFieldValue('hw-os', asset.os || '');
|
||||||
|
setFieldValue('hw-cpu', asset.cpu || '');
|
||||||
|
setFieldValue('hw-ram', asset.ram || '');
|
||||||
|
setFieldValue('hw-gpu', asset.gpu || '');
|
||||||
|
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||||
|
|
||||||
|
// 동적 볼륨 렌더링 초기화 및 생성
|
||||||
|
const volumeContainer = document.getElementById('hw-volume-container');
|
||||||
|
if (volumeContainer) {
|
||||||
|
volumeContainer.innerHTML = '';
|
||||||
|
let vols = [];
|
||||||
|
try {
|
||||||
|
vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : [];
|
||||||
|
} catch(e) {}
|
||||||
|
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldValue('hw-ip_address', asset.ip_address || '');
|
||||||
|
setFieldValue('hw-ip_address_2', asset.ip_address_2 || '');
|
||||||
|
setFieldValue('hw-mac_address', asset.mac_address || '');
|
||||||
|
setFieldValue('hw-remote_tool', asset.remote_tool || '');
|
||||||
|
setFieldValue('hw-remote_id', asset.remote_id || '');
|
||||||
|
setFieldValue('hw-remote_pw', asset.remote_pw || '');
|
||||||
|
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
||||||
|
setFieldValue('hw-serial_num', asset.serial_num || '');
|
||||||
|
setFieldValue('hw-monitor_inch', asset.monitor_inch || '');
|
||||||
|
setFieldValue('hw-volume', asset.volume || '');
|
||||||
|
setFieldValue('hw-asset_count', asset.asset_count || '');
|
||||||
|
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
||||||
|
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||||
|
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||||
|
setFieldValue('hw-approval_document', asset.approval_document || '');
|
||||||
|
const docName = document.getElementById('hw-file-name-display');
|
||||||
|
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
|
||||||
|
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||||
|
if (fileLinkContainer && asset.approval_document) {
|
||||||
|
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${asset.approval_document}" target="_blank" class="btn-loc-action" style="color:var(--primary-color); font-size:12px; text-decoration:underline; pointer-events: auto !important;">[파일 보기]</a>`;
|
||||||
|
} else if (fileLinkContainer) {
|
||||||
|
fileLinkContainer.innerHTML = '';
|
||||||
|
}
|
||||||
|
setFieldValue('hw-memo', asset.memo || '');
|
||||||
|
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||||
|
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||||
|
setFieldValue('hw-loc_y', asset.loc_y || '');
|
||||||
|
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
|
||||||
|
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||||
|
this.renderHistory(asset.id);
|
||||||
|
this.applyRoleVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
|
const genBtn = document.getElementById('btn-gen-hw-code');
|
||||||
|
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
|
||||||
|
this.toggleFileUploadUI(mode !== 'view');
|
||||||
|
this.toggleEditOnlyBtns(mode !== 'view');
|
||||||
|
this.updateMapButtonVisibility(asset);
|
||||||
|
this.applyRoleVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleFileUploadUI(showUpload: boolean) {
|
||||||
|
const fileBtn = document.getElementById('btn-file-select') as HTMLElement;
|
||||||
|
if (fileBtn) fileBtn.style.display = showUpload ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRoleVisibility(): void {
|
||||||
|
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
||||||
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
||||||
|
|
||||||
|
// 인프라 장비 (서버, 저장매체, 네트워크, 보안장비, 공간정보장비, 서버PC)
|
||||||
|
const infraCategories = ['서버', '저장매체', '네트워크', '보안장비', '공간정보장비'];
|
||||||
|
const isInfra = infraCategories.includes(category) || type.includes('서버') || type.includes('저장시스템');
|
||||||
|
|
||||||
|
// 개인 장비 (PC, 노트북, 모바일, 태블릿) - '서버PC'는 제외
|
||||||
|
const personalCategories = ['PC', '노트북', '모바일', '태블릿'];
|
||||||
|
const isPersonal = (personalCategories.includes(category) || type.includes('개인PC') || type.includes('노트북')) && !type.includes('서버PC');
|
||||||
|
|
||||||
|
// 시스템 사양 (PC, 서버 등)
|
||||||
|
const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션'];
|
||||||
|
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||||
|
|
||||||
|
// 네트워크 정보 (IP/MAC)
|
||||||
|
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
||||||
|
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
||||||
|
|
||||||
|
// 시리얼 번호
|
||||||
|
const hasSN = !['사무가구', 'PC부품'].includes(category);
|
||||||
|
|
||||||
|
// 수량/용량 전용 (부품)
|
||||||
|
const isParts = ['PC부품', '사무가구'].includes(category);
|
||||||
|
|
||||||
|
// 원격 접속 (서버 전용)
|
||||||
|
const showRemote = category === '서버' || type.includes('서버');
|
||||||
|
|
||||||
|
// JS에서 display: block 강제 대신 빈 문자열 할당하여 네이티브 CSS flex 활용
|
||||||
|
document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none');
|
||||||
|
document.querySelectorAll('.net-only').forEach(el => (el as HTMLElement).style.display = showNet ? '' : 'none');
|
||||||
|
document.querySelectorAll('.spec-only').forEach(el => (el as HTMLElement).style.display = hasSpec ? '' : 'none');
|
||||||
|
document.querySelectorAll('.location-section, .location-field').forEach(el => (el as HTMLElement).style.display = (isInfra || category === '공간정보장비') ? '' : 'none');
|
||||||
|
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
||||||
|
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
||||||
|
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
||||||
|
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
||||||
|
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateMapButtonVisibility(asset?: any) {
|
||||||
|
const bldg = asset ? (asset.location || '') : getFieldValue('hw-bldg-select');
|
||||||
|
const detail = asset ? (asset.location_detail || '') : getFieldValue('hw-location_detail');
|
||||||
|
const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x');
|
||||||
|
const y = asset ? (asset.loc_y || '') : getFieldValue('hw-loc_y');
|
||||||
|
const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null');
|
||||||
|
const hasImage = !!this.getImagesForLocation(bldg, detail);
|
||||||
|
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
|
||||||
|
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
|
||||||
|
|
||||||
|
if (hasImage && this.isEditMode) regLocBtn.style.display = 'inline-flex'; else regLocBtn.style.display = 'none';
|
||||||
|
if (hasImage && hasCoords) {
|
||||||
|
viewLocBtn.style.display = 'inline-flex';
|
||||||
|
viewLocBtn.style.pointerEvents = 'auto';
|
||||||
|
viewLocBtn.style.opacity = '1';
|
||||||
} else {
|
} else {
|
||||||
state.masterData.hw.push(newAsset);
|
viewLocBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModals();
|
|
||||||
renderContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 삭제 버튼 이벤트
|
|
||||||
btnDeleteHw?.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = (document.getElementById('hw-asset-id') as HTMLInputElement).value;
|
|
||||||
if (confirm('삭제하시겠습니까?')) {
|
|
||||||
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
|
|
||||||
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 = '노트북';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전산비품일 경우 유형 선택 필드 노출
|
private getImagesForLocation(bldg: string, detail: string): string[] | null {
|
||||||
if (state.activeSubTab === '전산비품') {
|
if (!bldg || !detail) return null;
|
||||||
document.getElementById('hw-비품유형-group')!.style.display = 'block';
|
return IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||||
} else {
|
}
|
||||||
document.getElementById('hw-비품유형-group')!.style.display = 'none';
|
|
||||||
|
private async fetchMapConfig() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||||
|
this.dynamicMapConfig = await res.json();
|
||||||
|
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateDynamicSVG(imagePath: string): string {
|
||||||
|
const boxes = this.dynamicMapConfig[imagePath] || [];
|
||||||
|
if (boxes.length === 0) return '';
|
||||||
|
return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%; position:absolute; top:0; left:0; pointer-events:none;"><g>${boxes.map((b) => `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:rgba(30,81,73,0.05); stroke:rgba(30,81,73,0.2); stroke-width:0.2;" />`).join('')}</g></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private openImagePicker(imagePaths: string[], title: string) {
|
||||||
|
let currentIdx = 0;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'image-picker-overlay';
|
||||||
|
const renderContent = () => {
|
||||||
|
const imgPath = imagePaths[currentIdx];
|
||||||
|
const digitalMap = this.generateDynamicSVG(imgPath);
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||||
|
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||||
|
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
||||||
|
let selectedX = ''; let selectedY = '';
|
||||||
|
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||||
|
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||||
|
container.addEventListener('click', (e) => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||||
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||||
|
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
||||||
|
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||||
|
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||||
|
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||||
|
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||||
|
this.updateMapButtonVisibility(); overlay.remove();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
renderContent(); document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'image-picker-overlay';
|
||||||
|
const digitalMap = this.generateDynamicSVG(imagePath);
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||||
|
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||||
|
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
if (digitalMap) {
|
||||||
|
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
||||||
|
overlay.querySelectorAll('rect').forEach(rect => {
|
||||||
|
const sx = parseFloat(rect.getAttribute('x') || '0');
|
||||||
|
const sy = parseFloat(rect.getAttribute('y') || '0');
|
||||||
|
if (Math.abs(sx - curX) < 0.01 && Math.abs(sy - curY) < 0.01) {
|
||||||
|
rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8';
|
||||||
|
const w = parseFloat(rect.getAttribute('width') || '0');
|
||||||
|
const h = parseFloat(rect.getAttribute('height') || '0');
|
||||||
|
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
|
||||||
|
if (marker) { marker.style.left = `${sx + w/2}%`; marker.style.top = `${sy + h/2}%`; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHistory(assetId: string) {
|
||||||
|
const container = document.getElementById('hw-history-list');
|
||||||
|
if (!container) return;
|
||||||
|
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||||
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||||
|
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCategoryKey(asset: any): string {
|
||||||
|
const cat = asset.category;
|
||||||
|
const code = asset.asset_code || '';
|
||||||
|
if (asset.asset_type === '서버PC') return 'pc';
|
||||||
|
if (cat === '서버' || code.startsWith('SVR')) return 'server';
|
||||||
|
if (cat === '스토리지' || code.startsWith('STO')) return 'storage';
|
||||||
|
if (cat === '네트워크' || code.startsWith('NET')) return 'network';
|
||||||
|
if (cat === '업무지원장비' || code.startsWith('EQP')) return 'equipment';
|
||||||
|
if (cat === '공간정보장비') return 'survey';
|
||||||
|
if (cat === 'PC부품') return 'pcParts';
|
||||||
|
return (cat === 'PC' || code.startsWith('PC')) ? 'pc' : 'officeSupplies';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const hwModal = new HwAssetModal();
|
||||||
|
export function initHwModal(onSave: () => void, closeModals: () => void) { hwModal.init(onSave, closeModals); }
|
||||||
|
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { hwModal.open(asset, mode); }
|
||||||
|
|||||||
242
src/components/Modal/ModalUtils.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { LOCATION_DATA } from './SharedData';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모달 조작 및 UI 생성을 위한 공통 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 1. Select 박스의 Option HTML 생성
|
||||||
|
export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string {
|
||||||
|
let html = includeSelectHint ? '<option value="">선택</option>' : '';
|
||||||
|
html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join('');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 안전하게 폼 필드 값 설정 (Null 에러 방지)
|
||||||
|
export function setFieldValue(id: string, value: any) {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
if (el) {
|
||||||
|
el.value = value || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 안전하게 폼 필드 값 읽기
|
||||||
|
export function getFieldValue(id: string): string {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 위치 정보 파싱 및 UI 세팅
|
||||||
|
export function parseAndSetLocation(bldg: string, detail: string, bldgId: string, detailId: string, etcGroupId?: string, etcInputId?: string) {
|
||||||
|
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
|
||||||
|
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
|
||||||
|
const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null;
|
||||||
|
const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null;
|
||||||
|
|
||||||
|
if (!bldgSelect || !detailSelect) return;
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
bldgSelect.value = '';
|
||||||
|
detailSelect.innerHTML = '<option value="">선택</option>';
|
||||||
|
if (etcGroup) etcGroup.style.display = 'none';
|
||||||
|
|
||||||
|
if (!bldg) return;
|
||||||
|
|
||||||
|
if (LOCATION_DATA[bldg]) {
|
||||||
|
bldgSelect.value = bldg;
|
||||||
|
// 상세 목록 갱신
|
||||||
|
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
detailSelect.value = detail;
|
||||||
|
if (detail === '기타' && etcGroup && etcInput) {
|
||||||
|
etcGroup.style.display = 'flex';
|
||||||
|
// 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나
|
||||||
|
// 하위 호환성을 위해 남겨둠
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 위치 종속성(Cascade) 이벤트 바인딩
|
||||||
|
export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
|
||||||
|
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
|
||||||
|
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
|
||||||
|
const etcGroup = document.getElementById(etcGroupId);
|
||||||
|
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
|
||||||
|
|
||||||
|
if (!bldgSelect || !detailSelect) return;
|
||||||
|
|
||||||
|
bldgSelect.addEventListener('change', () => {
|
||||||
|
const bldg = bldgSelect.value;
|
||||||
|
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []);
|
||||||
|
if (etcGroup) etcGroup.style.display = 'none';
|
||||||
|
if (etcInput) etcInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
detailSelect.addEventListener('change', () => {
|
||||||
|
if (etcGroup) {
|
||||||
|
etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 위치 문자열 조합 (저장용)
|
||||||
|
export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string {
|
||||||
|
const bldg = getFieldValue(bldgId);
|
||||||
|
const detail = getFieldValue(detailId);
|
||||||
|
const etc = getFieldValue(etcInputId);
|
||||||
|
|
||||||
|
let combined = bldg;
|
||||||
|
if (detail) combined += ` ${detail}`;
|
||||||
|
if (detail === '기타' && etc) combined += ` ${etc}`;
|
||||||
|
|
||||||
|
return combined.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 조회/수정 모드 UI 통합 제어
|
||||||
|
export function setEditLock(
|
||||||
|
formId: string,
|
||||||
|
mode: 'view' | 'add' | 'edit',
|
||||||
|
options: {
|
||||||
|
saveBtnId: string,
|
||||||
|
revertBtnId: string,
|
||||||
|
generateBtnId?: string,
|
||||||
|
addLogBtnId?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const form = document.getElementById(formId) as HTMLFormElement;
|
||||||
|
const saveBtn = document.getElementById(options.saveBtnId);
|
||||||
|
const revertBtn = document.getElementById(options.revertBtnId);
|
||||||
|
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
|
||||||
|
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
|
||||||
|
|
||||||
|
if (!form || !saveBtn || !revertBtn) return;
|
||||||
|
|
||||||
|
if (mode === 'add' || mode === 'edit') {
|
||||||
|
// 편집 모드 활성화
|
||||||
|
form.classList.remove('is-view-mode');
|
||||||
|
form.classList.add('is-edit-mode');
|
||||||
|
saveBtn.textContent = '저장';
|
||||||
|
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
|
||||||
|
|
||||||
|
// 번호 생성 버튼은 '추가(add)' 시에만 노출
|
||||||
|
if (generateBtn) {
|
||||||
|
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
// 내역 추가 버튼 노출
|
||||||
|
if (addLogBtn) addLogBtn.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
// 조회 모드 (잠금)
|
||||||
|
form.classList.remove('is-edit-mode');
|
||||||
|
form.classList.add('is-view-mode');
|
||||||
|
saveBtn.textContent = '수정';
|
||||||
|
revertBtn.classList.add('hidden');
|
||||||
|
|
||||||
|
// 조회 모드에서는 버튼들 숨김
|
||||||
|
if (generateBtn) generateBtn.style.display = 'none';
|
||||||
|
if (addLogBtn) addLogBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 8. 공통 모달 프레임 템플릿 생성
|
||||||
|
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
|
||||||
|
* @param title 모달 제목
|
||||||
|
* @param formContent 각 모달마다 다른 폼 본문 HTML
|
||||||
|
* @param options 설정 (이력 영역 제목 등)
|
||||||
|
*/
|
||||||
|
export function createModalFrameHTML(
|
||||||
|
idPrefix: string,
|
||||||
|
title: string,
|
||||||
|
formContent: string,
|
||||||
|
options: { historyTitle: string, addLogBtnId: string }
|
||||||
|
): string {
|
||||||
|
return `
|
||||||
|
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="${idPrefix}-modal-title">${title}</h2>
|
||||||
|
<button id="btn-close-${idPrefix}-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="${idPrefix}-asset-form" class="grid-form">
|
||||||
|
<input type="hidden" id="${idPrefix}-asset-id" />
|
||||||
|
<input type="hidden" id="${idPrefix}-asset-type-hidden" />
|
||||||
|
${formContent}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-history-area">
|
||||||
|
<div class="history-header">
|
||||||
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
|
||||||
|
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
|
||||||
|
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
|
<button id="btn-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
|
||||||
|
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
|
||||||
|
*/
|
||||||
|
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
|
||||||
|
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
|
||||||
|
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
|
||||||
|
const result: any = {};
|
||||||
|
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
|
||||||
|
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
|
||||||
|
*/
|
||||||
|
export function applyDateMask(el: HTMLInputElement) {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
el.placeholder = 'YYYY-MM-DD';
|
||||||
|
el.maxLength = 10;
|
||||||
|
|
||||||
|
el.addEventListener('input', (e) => {
|
||||||
|
let value = el.value.replace(/[^0-9]/g, ''); // 숫자만 남김
|
||||||
|
let result = '';
|
||||||
|
|
||||||
|
if (value.length <= 4) {
|
||||||
|
result = value;
|
||||||
|
} else if (value.length <= 6) {
|
||||||
|
result = value.substring(0, 4) + '-' + value.substring(4);
|
||||||
|
} else {
|
||||||
|
result = value.substring(0, 4) + '-' + value.substring(4, 6) + '-' + value.substring(6, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.value = result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 엔터 키나 입력 완료 시 유효성 검사 (선택 사항)
|
||||||
|
el.addEventListener('blur', () => {
|
||||||
|
const val = el.value;
|
||||||
|
if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) {
|
||||||
|
// 형식이 맞지 않으면 경고 효과 등을 줄 수 있음
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
625
src/components/Modal/PCFlowModal.ts
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
import { state, loadMasterDataFromDB } from '../../core/state';
|
||||||
|
import { createIcons, Search, Monitor, RefreshCw } from 'lucide';
|
||||||
|
import { API_BASE_URL } from '../../core/utils';
|
||||||
|
|
||||||
|
export class PCFlowModal {
|
||||||
|
private static instance: PCFlowModal | null = null;
|
||||||
|
|
||||||
|
private modalEl: HTMLElement | null = null;
|
||||||
|
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
|
||||||
|
|
||||||
|
// Selected state
|
||||||
|
private selectedUser: any = null;
|
||||||
|
private selectedTargetUser: any = null;
|
||||||
|
private selectedPC: any = null;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): PCFlowModal {
|
||||||
|
if (!PCFlowModal.instance) {
|
||||||
|
PCFlowModal.instance = new PCFlowModal();
|
||||||
|
}
|
||||||
|
return PCFlowModal.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(onSave: () => void) {
|
||||||
|
if (document.getElementById('pc-flow-modal')) return;
|
||||||
|
|
||||||
|
// Inject HTML
|
||||||
|
document.body.insertAdjacentHTML('beforeend', this.renderHTML());
|
||||||
|
|
||||||
|
this.modalEl = document.getElementById('pc-flow-modal');
|
||||||
|
this.setupEventListeners(onSave);
|
||||||
|
|
||||||
|
// Set default date to today
|
||||||
|
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
createIcons({ icons: { Search, Monitor, RefreshCw } });
|
||||||
|
}
|
||||||
|
|
||||||
|
public open() {
|
||||||
|
this.resetState();
|
||||||
|
if (this.modalEl) {
|
||||||
|
this.modalEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
if (this.modalEl) {
|
||||||
|
this.modalEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetState() {
|
||||||
|
this.selectedUser = null;
|
||||||
|
this.selectedTargetUser = null;
|
||||||
|
this.selectedPC = null;
|
||||||
|
this.currentFlowType = 'checkout';
|
||||||
|
|
||||||
|
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
||||||
|
if (radioCheckout) radioCheckout.checked = true;
|
||||||
|
|
||||||
|
// Reset text fields
|
||||||
|
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||||
|
if (userSearch) userSearch.value = '';
|
||||||
|
|
||||||
|
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||||
|
if (targetUserSearch) targetUserSearch.value = '';
|
||||||
|
|
||||||
|
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||||
|
if (stockSearch) stockSearch.value = '';
|
||||||
|
|
||||||
|
const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement;
|
||||||
|
if (details) details.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners(onSave: () => void) {
|
||||||
|
const btnClose = document.getElementById('btn-close-pc-flow-modal');
|
||||||
|
const btnCancel = document.getElementById('btn-cancel-pc-flow-modal');
|
||||||
|
const btnSubmit = document.getElementById('btn-submit-pc-flow');
|
||||||
|
|
||||||
|
btnClose?.addEventListener('click', () => this.close());
|
||||||
|
btnCancel?.addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// Flow Type Radio Buttons
|
||||||
|
const labels = document.querySelectorAll('.flow-type-label');
|
||||||
|
labels.forEach(label => {
|
||||||
|
const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement;
|
||||||
|
label.addEventListener('click', () => {
|
||||||
|
labels.forEach(l => l.classList.remove('active'));
|
||||||
|
label.classList.add('active');
|
||||||
|
radio.checked = true;
|
||||||
|
this.currentFlowType = radio.value as any;
|
||||||
|
|
||||||
|
// Reset selected PC when switching flow types
|
||||||
|
this.selectedPC = null;
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Source User Autocomplete Search
|
||||||
|
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||||
|
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
|
||||||
|
|
||||||
|
userSearch?.addEventListener('input', () => {
|
||||||
|
const query = userSearch.value.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
userSuggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = state.masterData.users || [];
|
||||||
|
const filtered = users.filter((u: any) =>
|
||||||
|
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||||
|
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||||
|
(u.emp_no && u.emp_no.toString().includes(query))
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueFiltered: any[] = [];
|
||||||
|
const seen = new Set();
|
||||||
|
filtered.forEach((u: any) => {
|
||||||
|
const key = u.emp_no || u.user_name;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueFiltered.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => {
|
||||||
|
this.selectedUser = user;
|
||||||
|
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||||
|
userSuggestions.classList.add('hidden');
|
||||||
|
|
||||||
|
// Automatically populate details if return or move
|
||||||
|
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
|
||||||
|
this.selectedPC = null; // Reset selection
|
||||||
|
}
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close suggestion overlays on clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) {
|
||||||
|
userSuggestions.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) {
|
||||||
|
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions');
|
||||||
|
targetSuggestions?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) {
|
||||||
|
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions');
|
||||||
|
stockSuggestions?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Target User Autocomplete Search (For Moves)
|
||||||
|
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||||
|
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
|
||||||
|
|
||||||
|
targetUserSearch?.addEventListener('input', () => {
|
||||||
|
const query = targetUserSearch.value.trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
targetSuggestions.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = state.masterData.users || [];
|
||||||
|
const filtered = users.filter((u: any) =>
|
||||||
|
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||||
|
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||||
|
(u.emp_no && u.emp_no.toString().includes(query))
|
||||||
|
);
|
||||||
|
|
||||||
|
const uniqueFiltered: any[] = [];
|
||||||
|
const seen = new Set();
|
||||||
|
filtered.forEach((u: any) => {
|
||||||
|
const key = u.emp_no || u.user_name;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
uniqueFiltered.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => {
|
||||||
|
this.selectedTargetUser = user;
|
||||||
|
targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||||
|
targetSuggestions.classList.add('hidden');
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Stock PC Autocomplete Search (For Checkout)
|
||||||
|
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||||
|
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
|
||||||
|
|
||||||
|
const showStockSuggestions = () => {
|
||||||
|
const query = stockSearch.value.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Filter available PCs (category PC, status '대기' or '재고창고')
|
||||||
|
const pcs = state.masterData.pc || [];
|
||||||
|
const filtered = pcs.filter((p: any) => {
|
||||||
|
const status = (p.hw_status || '').trim();
|
||||||
|
const matchesQuery = !query ||
|
||||||
|
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
|
||||||
|
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
|
||||||
|
(p.cpu && p.cpu.toLowerCase().includes(query));
|
||||||
|
|
||||||
|
return (status === '대기' || status === '재고창고' || status === '미할당') && matchesQuery;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderPCSuggestions(filtered, stockSuggestions, (pc) => {
|
||||||
|
this.selectedPC = pc;
|
||||||
|
stockSearch.value = `${pc.asset_code} - ${pc.model_name}`;
|
||||||
|
stockSuggestions.classList.add('hidden');
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
stockSearch?.addEventListener('input', showStockSuggestions);
|
||||||
|
stockSearch?.addEventListener('focus', showStockSuggestions);
|
||||||
|
stockSearch?.addEventListener('click', showStockSuggestions);
|
||||||
|
|
||||||
|
// 4. Submit Transaction
|
||||||
|
btnSubmit?.addEventListener('click', async () => {
|
||||||
|
if (!this.validateInputs()) return;
|
||||||
|
|
||||||
|
const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value;
|
||||||
|
const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim();
|
||||||
|
const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자';
|
||||||
|
|
||||||
|
// Build Details Message as JSON
|
||||||
|
const logData = {
|
||||||
|
type: this.currentFlowType,
|
||||||
|
user: this.selectedUser ? this.selectedUser.user_name : '',
|
||||||
|
dept: this.selectedUser ? this.selectedUser.dept_name : '',
|
||||||
|
targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '',
|
||||||
|
targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '',
|
||||||
|
assetCode: this.selectedPC ? this.selectedPC.asset_code : '',
|
||||||
|
memo: detailsVal
|
||||||
|
};
|
||||||
|
const finalDetails = JSON.stringify(logData);
|
||||||
|
|
||||||
|
const payload: any = {
|
||||||
|
action: this.currentFlowType,
|
||||||
|
assetId: this.selectedPC.id,
|
||||||
|
date: dateVal,
|
||||||
|
details: finalDetails,
|
||||||
|
manager: loginUser
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.currentFlowType === 'checkout') {
|
||||||
|
payload.userName = this.selectedUser.user_name;
|
||||||
|
payload.dept = this.selectedUser.dept_name;
|
||||||
|
payload.empNo = this.selectedUser.emp_no;
|
||||||
|
payload.position = this.selectedUser.position || '사원';
|
||||||
|
} else if (this.currentFlowType === 'move') {
|
||||||
|
payload.userName = this.selectedTargetUser.user_name;
|
||||||
|
payload.dept = this.selectedTargetUser.dept_name;
|
||||||
|
payload.empNo = this.selectedTargetUser.emp_no;
|
||||||
|
payload.position = this.selectedTargetUser.position || '사원';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/pc/flow`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('PC 이동/반납 처리가 완료되었습니다.');
|
||||||
|
this.close();
|
||||||
|
onSave(); // Refresh views
|
||||||
|
} else {
|
||||||
|
const errData = await response.json();
|
||||||
|
alert(`오류 발생: ${errData.error || '처리 실패'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API Error:', err);
|
||||||
|
alert('서버 전송 중 오류가 발생했습니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateInputs(): boolean {
|
||||||
|
if (this.currentFlowType === 'checkout') {
|
||||||
|
if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; }
|
||||||
|
if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; }
|
||||||
|
} else if (this.currentFlowType === 'return') {
|
||||||
|
if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; }
|
||||||
|
if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; }
|
||||||
|
} else if (this.currentFlowType === 'move') {
|
||||||
|
if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; }
|
||||||
|
if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; }
|
||||||
|
if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; }
|
||||||
|
if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) {
|
||||||
|
alert('인계자와 인수자는 동일할 수 없습니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (users.length === 0) {
|
||||||
|
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.forEach(u => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.style.padding = '8px 12px';
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.style.fontSize = '13px';
|
||||||
|
item.style.borderBottom = '1px solid #F3F4F6';
|
||||||
|
item.className = 'suggestion-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
|
||||||
|
<span>부서: ${u.dept_name}</span>
|
||||||
|
<span>|</span>
|
||||||
|
<span>사번: ${u.emp_no || '-'}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => onSelect(u));
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (pcs.length === 0) {
|
||||||
|
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pcs.forEach(p => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.style.padding = '8px 12px';
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.style.fontSize = '13px';
|
||||||
|
item.style.borderBottom = '1px solid #F3F4F6';
|
||||||
|
item.className = 'suggestion-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">
|
||||||
|
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
item.addEventListener('click', () => onSelect(p));
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
container.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateUI() {
|
||||||
|
// 1. Hide/Show dynamic sections based on flow type
|
||||||
|
const stockContainer = document.getElementById('stock-pc-search-container')!;
|
||||||
|
const targetUserContainer = document.getElementById('target-user-search-container')!;
|
||||||
|
const userPcsContainer = document.getElementById('user-pcs-container')!;
|
||||||
|
const labelStep2 = document.getElementById('user-search-label')!;
|
||||||
|
|
||||||
|
if (this.currentFlowType === 'checkout') {
|
||||||
|
stockContainer.classList.remove('hidden');
|
||||||
|
targetUserContainer.classList.add('hidden');
|
||||||
|
userPcsContainer.classList.add('hidden');
|
||||||
|
labelStep2.textContent = '2. 불출 대상 사원 검색';
|
||||||
|
} else if (this.currentFlowType === 'return') {
|
||||||
|
stockContainer.classList.add('hidden');
|
||||||
|
targetUserContainer.classList.add('hidden');
|
||||||
|
userPcsContainer.classList.remove('hidden');
|
||||||
|
labelStep2.textContent = '2. 반납 대상 사원 검색';
|
||||||
|
} else if (this.currentFlowType === 'move') {
|
||||||
|
stockContainer.classList.add('hidden');
|
||||||
|
targetUserContainer.classList.remove('hidden');
|
||||||
|
userPcsContainer.classList.remove('hidden');
|
||||||
|
labelStep2.textContent = '2. 인계 사원 검색';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update summary panels on the right
|
||||||
|
const summaryUserName = document.getElementById('summary-user-name')!;
|
||||||
|
const summaryUserDept = document.getElementById('summary-user-dept')!;
|
||||||
|
if (this.selectedUser) {
|
||||||
|
summaryUserName.textContent = this.selectedUser.user_name;
|
||||||
|
summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`;
|
||||||
|
} else {
|
||||||
|
summaryUserName.textContent = '선택된 사원 없음';
|
||||||
|
summaryUserDept.textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryTargetCard = document.getElementById('summary-target-user-card')!;
|
||||||
|
const summaryTargetUserName = document.getElementById('summary-target-user-name')!;
|
||||||
|
const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!;
|
||||||
|
if (this.currentFlowType === 'move') {
|
||||||
|
summaryTargetCard.classList.remove('hidden');
|
||||||
|
if (this.selectedTargetUser) {
|
||||||
|
summaryTargetUserName.textContent = this.selectedTargetUser.user_name;
|
||||||
|
summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`;
|
||||||
|
} else {
|
||||||
|
summaryTargetUserName.textContent = '선택된 사원 없음';
|
||||||
|
summaryTargetUserDept.textContent = '-';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
summaryTargetCard.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPcCode = document.getElementById('summary-pc-code')!;
|
||||||
|
const summaryPcModel = document.getElementById('summary-pc-model')!;
|
||||||
|
if (this.selectedPC) {
|
||||||
|
summaryPcCode.textContent = this.selectedPC.asset_code;
|
||||||
|
summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`;
|
||||||
|
} else {
|
||||||
|
summaryPcCode.textContent = '선택된 PC 없음';
|
||||||
|
summaryPcModel.textContent = '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Render user's active PCs list on the right (For Return & Move)
|
||||||
|
const userPcsList = document.getElementById('user-pcs-list')!;
|
||||||
|
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
|
||||||
|
const allPcs = state.masterData.pc || [];
|
||||||
|
const userPcs = allPcs.filter((p: any) =>
|
||||||
|
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
|
||||||
|
(p.user_current && p.user_current === this.selectedUser.user_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (userPcs.length === 0) {
|
||||||
|
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
||||||
|
} else {
|
||||||
|
userPcsList.innerHTML = userPcs.map(p => {
|
||||||
|
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
|
||||||
|
return `
|
||||||
|
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
|
||||||
|
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
||||||
|
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Bind clicks to list items
|
||||||
|
userPcsList.querySelectorAll('.user-pc-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const pcId = item.getAttribute('data-id');
|
||||||
|
const foundPC = userPcs.find(p => p.id === pcId);
|
||||||
|
if (foundPC) {
|
||||||
|
this.selectedPC = foundPC;
|
||||||
|
this.updateUI();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userPcsList.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderHTML(): string {
|
||||||
|
const overlayStyle = `
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000; transition: opacity 0.3s;
|
||||||
|
`;
|
||||||
|
const contentStyle = `
|
||||||
|
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
|
||||||
|
`;
|
||||||
|
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
|
||||||
|
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||||
|
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
|
||||||
|
<div class="modal-content" style="${contentStyle}">
|
||||||
|
|
||||||
|
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
|
||||||
|
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||||
|
</h2>
|
||||||
|
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
|
||||||
|
<!-- 왼쪽 영역: 입력 폼 -->
|
||||||
|
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
|
||||||
|
|
||||||
|
<!-- 1. 처리 유형 -->
|
||||||
|
<div>
|
||||||
|
<label style="${labelStyle}">1. 처리 유형 선택</label>
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
|
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
|
||||||
|
불출 (지급)
|
||||||
|
</label>
|
||||||
|
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
|
<input type="radio" name="flow-type" value="return" style="display:none;" />
|
||||||
|
입고 (반납)
|
||||||
|
</label>
|
||||||
|
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||||
|
<input type="radio" name="flow-type" value="move" style="display:none;" />
|
||||||
|
이동 (이관)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. 대상 사용자 검색 -->
|
||||||
|
<div style="position: relative;">
|
||||||
|
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
|
||||||
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
|
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||||
|
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
|
</div>
|
||||||
|
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
||||||
|
<div id="target-user-search-container" class="hidden" style="position: relative;">
|
||||||
|
<label style="${labelStyle}">새 인수 사원 검색</label>
|
||||||
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
|
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||||
|
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
|
</div>
|
||||||
|
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
||||||
|
<div id="stock-pc-search-container" style="position: relative;">
|
||||||
|
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
|
||||||
|
<div style="position: relative; display: flex; align-items: center;">
|
||||||
|
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
|
||||||
|
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||||
|
</div>
|
||||||
|
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. 상세 공통 입력 -->
|
||||||
|
<div style="display: flex; gap: 16px;">
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
|
||||||
|
<input type="date" id="pc-flow-date" style="${inputStyle}" />
|
||||||
|
</div>
|
||||||
|
<div style="flex: 2;">
|
||||||
|
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
|
||||||
|
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
||||||
|
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
|
||||||
|
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
|
||||||
|
|
||||||
|
<!-- 사원 요약 카드 -->
|
||||||
|
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
|
||||||
|
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||||
|
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||||
|
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
|
||||||
|
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||||
|
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 대상 PC 자산 요약 카드 -->
|
||||||
|
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||||
|
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
|
||||||
|
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
|
||||||
|
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||||
|
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
|
||||||
|
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
|
||||||
|
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
|
||||||
|
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
|
||||||
|
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flow-type-label {
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-color: var(--border-color);
|
||||||
|
background: white;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.flow-type-label:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.flow-type-label.active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
.suggestion-item:hover {
|
||||||
|
background-color: var(--primary-light) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pcFlowModal = PCFlowModal.getInstance();
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { state } from '../../state';
|
|
||||||
import { HardwareAsset, HardwareLog } from '../../excelHandler';
|
|
||||||
import { openModal } from './BaseModal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개인PC 모달 초기화 및 로직 제어
|
|
||||||
*/
|
|
||||||
export function initPCModal(renderContent: () => void, closeModals: () => void) {
|
|
||||||
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
|
|
||||||
const btnSavePc = document.getElementById('btn-save-pc-asset') as HTMLButtonElement;
|
|
||||||
const btnDeletePc = document.getElementById('btn-delete-pc-asset') as HTMLButtonElement;
|
|
||||||
|
|
||||||
// 저장 버튼 이벤트
|
|
||||||
btnSavePc?.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!pcForm.checkValidity()) { pcForm.reportValidity(); return; }
|
|
||||||
|
|
||||||
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
|
|
||||||
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 newAsset: HardwareAsset = {
|
|
||||||
id: id || Math.random().toString(36).substring(2, 9),
|
|
||||||
type: '개인PC',
|
|
||||||
법인: (document.getElementById('pc-법인') as HTMLSelectElement).value,
|
|
||||||
자산코드: (document.getElementById('pc-자산코드') as HTMLInputElement).value,
|
|
||||||
명칭: '',
|
|
||||||
위치: (document.getElementById('pc-위치') as HTMLInputElement).value,
|
|
||||||
관리자: '',
|
|
||||||
IP주소: '',
|
|
||||||
MACaddress: '',
|
|
||||||
HW사양: '',
|
|
||||||
OS: '',
|
|
||||||
사용자: (document.getElementById('pc-사용자') as HTMLInputElement).value,
|
|
||||||
CPU: (document.getElementById('pc-CPU') as HTMLInputElement).value,
|
|
||||||
GPU: (document.getElementById('pc-GPU') as HTMLInputElement).value,
|
|
||||||
RAM: (document.getElementById('pc-RAM') as HTMLInputElement).value,
|
|
||||||
SSD1: (document.getElementById('pc-SSD1') as HTMLInputElement).value,
|
|
||||||
SSD2: (document.getElementById('pc-SSD2') as HTMLInputElement).value,
|
|
||||||
HDD1: (document.getElementById('pc-HDD1') 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,
|
|
||||||
품의서명
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
const idx = state.masterData.hw.findIndex(a => a.id === id);
|
|
||||||
if(idx !== -1) {
|
|
||||||
const oldAsset = state.masterData.hw[idx];
|
|
||||||
const changes = getChangeDetails(oldAsset, newAsset);
|
|
||||||
|
|
||||||
if (changes) {
|
|
||||||
// 로그인 기능이 없으므로 '관리자'가 로그인한 것으로 가정
|
|
||||||
const modifier = '관리자';
|
|
||||||
|
|
||||||
const log: HardwareLog = {
|
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
|
||||||
assetId: id,
|
|
||||||
date: new Date().toLocaleString(),
|
|
||||||
details: changes,
|
|
||||||
user: modifier
|
|
||||||
};
|
|
||||||
|
|
||||||
state.masterData.logs.push(log);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.masterData.hw[idx] = newAsset;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.masterData.hw.push(newAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModals();
|
|
||||||
renderContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 삭제 버튼 이벤트
|
|
||||||
btnDeletePc?.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = (document.getElementById('pc-asset-id') as HTMLInputElement).value;
|
|
||||||
if (confirm('삭제하시겠습니까?')) {
|
|
||||||
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
|
|
||||||
// 관련 로그도 삭제할지 여부는 정책에 따라 (여기서는 유지)
|
|
||||||
closeModals();
|
|
||||||
renderContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개인PC 상세 모달 열기
|
|
||||||
*/
|
|
||||||
export function openPcModal(asset?: HardwareAsset) {
|
|
||||||
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-pc-asset')!;
|
|
||||||
const historyArea = document.querySelector('.modal-history-area') as HTMLElement;
|
|
||||||
|
|
||||||
openModal('pc-asset-modal');
|
|
||||||
pcForm.reset();
|
|
||||||
|
|
||||||
if (asset) {
|
|
||||||
document.getElementById('pc-modal-title')!.textContent = '개인PC 상세 정보 수정';
|
|
||||||
deleteBtn.style.display = 'block';
|
|
||||||
if (historyArea) historyArea.style.display = 'flex';
|
|
||||||
|
|
||||||
(document.getElementById('pc-asset-id') as HTMLInputElement).value = asset.id;
|
|
||||||
(document.getElementById('pc-법인') as HTMLSelectElement).value = asset.법인;
|
|
||||||
(document.getElementById('pc-자산코드') as HTMLInputElement).value = asset.자산코드;
|
|
||||||
(document.getElementById('pc-사용자') as HTMLInputElement).value = asset.사용자 || '';
|
|
||||||
(document.getElementById('pc-위치') as HTMLInputElement).value = asset.위치 || '';
|
|
||||||
(document.getElementById('pc-CPU') as HTMLInputElement).value = asset.CPU || '';
|
|
||||||
(document.getElementById('pc-GPU') as HTMLInputElement).value = asset.GPU || '';
|
|
||||||
(document.getElementById('pc-RAM') as HTMLInputElement).value = asset.RAM || '';
|
|
||||||
(document.getElementById('pc-SSD1') as HTMLInputElement).value = asset.SSD1 || '';
|
|
||||||
(document.getElementById('pc-SSD2') as HTMLInputElement).value = asset.SSD2 || '';
|
|
||||||
(document.getElementById('pc-HDD1') as HTMLInputElement).value = asset.HDD1 || '';
|
|
||||||
(document.getElementById('pc-HDD2') as HTMLInputElement).value = asset.HDD2 || '';
|
|
||||||
(document.getElementById('pc-구매일') as HTMLInputElement).value = asset.구매일 || '';
|
|
||||||
(document.getElementById('pc-금액') as HTMLInputElement).value = asset.금액 ? asset.금액.replace(/,/g, '').replace(/\B(?=(\d{3})+(?!\d))/g, ',') : '';
|
|
||||||
(document.getElementById('pc-납품업체') as HTMLInputElement).value = asset.납품업체 || '';
|
|
||||||
(document.getElementById('pc-품의서명') as HTMLElement).innerText = asset.품의서명 ? `📎${asset.품의서명}` : '';
|
|
||||||
|
|
||||||
renderHistory(asset.id);
|
|
||||||
} else {
|
|
||||||
document.getElementById('pc-modal-title')!.textContent = '새 개인PC 자산 추가';
|
|
||||||
deleteBtn.style.display = 'none';
|
|
||||||
if (historyArea) historyArea.style.display = 'none'; // 신규 시 이력 숨김
|
|
||||||
|
|
||||||
(document.getElementById('pc-asset-id') as HTMLInputElement).value = '';
|
|
||||||
(document.getElementById('pc-법인') as HTMLSelectElement).value = '한맥';
|
|
||||||
(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('');
|
|
||||||
}
|
|
||||||
@@ -1,108 +1,371 @@
|
|||||||
import { state } from '../../state';
|
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||||
import { SoftwareAsset } from '../../excelHandler';
|
import { BaseModal } from './BaseModal';
|
||||||
import { openModal } from './BaseModal';
|
import { openSwUserModal } from './SWUserModal';
|
||||||
|
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
||||||
|
import { CORP_LIST } from './SharedData';
|
||||||
|
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||||
|
import { API_BASE_URL } from '../../core/utils';
|
||||||
|
import {
|
||||||
|
generateOptionsHTML,
|
||||||
|
setFieldValue,
|
||||||
|
getFieldValue,
|
||||||
|
applyDateMask
|
||||||
|
} from './ModalUtils';
|
||||||
|
|
||||||
/**
|
class SwAssetModal extends BaseModal {
|
||||||
* 소프트웨어 모달 초기화 및 로직 제어
|
constructor() {
|
||||||
*/
|
super('sw', '소프트웨어 상세 정보');
|
||||||
export function initSWModal(renderContent: () => void, closeModals: () => void) {
|
}
|
||||||
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
|
|
||||||
const btnSaveSw = document.getElementById('btn-save-sw-asset') as HTMLButtonElement;
|
|
||||||
const btnDeleteSw = document.getElementById('btn-delete-sw-asset') as HTMLButtonElement;
|
|
||||||
|
|
||||||
// 저장 버튼 이벤트
|
protected renderFrameHTML(): string {
|
||||||
btnSaveSw?.addEventListener('click', (e) => {
|
return `
|
||||||
e.preventDefault();
|
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||||
if (!swForm.checkValidity()) { swForm.reportValidity(); return; }
|
<div class="modal-content wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="sw-modal-title">${this.title}</h2>
|
||||||
|
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="modal-body-split">
|
||||||
|
<div class="modal-form-area">
|
||||||
|
<form id="sw-asset-form" class="grid-form">
|
||||||
|
<input type="hidden" id="sw-asset-id" name="id" />
|
||||||
|
|
||||||
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
|
<div class="form-section-title">기본 정보 (Identity)</div>
|
||||||
const newAsset: SoftwareAsset = {
|
<div class="form-group">
|
||||||
id: id || Math.random().toString(36).substring(2, 9),
|
<label>자산 유형</label>
|
||||||
type: (document.getElementById('sw-asset-type') as HTMLInputElement).value,
|
<select id="sw-asset-type" name="asset_type" required>
|
||||||
분야: (document.getElementById('sw-분야') as HTMLSelectElement).value,
|
<option value="내부SW">내부SW</option>
|
||||||
법인: (document.getElementById('sw-법인') as HTMLSelectElement).value,
|
<option value="외부SW">외부SW</option>
|
||||||
부서: (document.getElementById('sw-부서') as HTMLInputElement).value,
|
<option value="클라우드">클라우드</option>
|
||||||
제품명: (document.getElementById('sw-제품명') as HTMLInputElement).value,
|
</select>
|
||||||
구매일: (document.getElementById('sw-구매일') as HTMLInputElement).value,
|
</div>
|
||||||
구독일: (document.getElementById('sw-구독일') as HTMLInputElement).value,
|
<div class="form-group">
|
||||||
유지보수여부: (document.getElementById('sw-유지보수여부') as HTMLInputElement).checked,
|
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||||
금액: (document.getElementById('sw-금액') as HTMLInputElement).value,
|
<select id="sw-분야" name="sw_field" required>
|
||||||
수량: parseInt((document.getElementById('sw-수량') as HTMLInputElement).value || '1', 10),
|
<option value="업무공통">업무공통</option>
|
||||||
계정명: (document.getElementById('sw-계정명') as HTMLInputElement).value,
|
<option value="개발S/W">개발S/W</option>
|
||||||
납품업체: (document.getElementById('sw-납품업체') as HTMLInputElement).value,
|
<option value="디자인">디자인</option>
|
||||||
비고: (document.getElementById('sw-비고') as HTMLInputElement).value,
|
<option value="설계S/W">설계S/W</option>
|
||||||
};
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||||
|
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
|
||||||
|
<input type="text" id="sw-제품명" name="product_name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group cloud-only">
|
||||||
|
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
|
||||||
|
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||||
|
<input type="text" id="sw-부서" name="current_dept" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-user-tracking">
|
||||||
|
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||||
|
<input type="text" id="sw-user-current" name="user_current" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-user-tracking">
|
||||||
|
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||||
|
<input type="text" id="sw-previous-user" name="previous_user" />
|
||||||
|
</div>
|
||||||
|
|
||||||
if (id) {
|
<div class="form-section-title">라이선스 및 계약 정보</div>
|
||||||
const idx = state.masterData.sw.findIndex(a => a.id === id);
|
<div class="form-group sw-standard-field">
|
||||||
if(idx !== -1) state.masterData.sw[idx] = newAsset;
|
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
|
||||||
|
<input type="number" id="sw-수량" name="asset_count" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||||
|
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group cloud-only">
|
||||||
|
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
|
||||||
|
<input type="text" id="sw-계정명" name="email_account" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group cloud-only">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
|
||||||
|
<select id="sw-결제수단" name="purchase_method">
|
||||||
|
<option value="">선택안함</option>
|
||||||
|
<option value="법인카드">법인카드</option>
|
||||||
|
<option value="인보이스">인보이스</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-section-title">관리 및 비고</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||||
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
|
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
||||||
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
|
</button>
|
||||||
|
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||||
|
<input type="text" id="sw-납품업체" name="purchase_vendor" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
|
||||||
|
<input type="text" id="sw-개발담당자" name="dev_manager" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
|
||||||
|
<input type="text" id="sw-기획담당자" name="planning_manager" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field">
|
||||||
|
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
|
||||||
|
<input type="text" id="sw-영업담당자" name="sales_manager" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
||||||
|
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
||||||
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
|
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
||||||
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
|
</button>
|
||||||
|
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
||||||
|
<textarea id="sw-비고" name="memo" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||||
|
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
||||||
|
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-history-area">
|
||||||
|
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
||||||
|
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||||
|
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sw-history-list" class="history-timeline"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 계약 업데이트 서브 모달 -->
|
||||||
|
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||||
|
<div class="modal-content" style="max-width: 500px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>계약 업데이트 반영</h2>
|
||||||
|
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="grid-form" style="grid-template-columns: 1fr;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>업데이트 일자</label>
|
||||||
|
<input type="date" id="sw-update-date" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group sub-sw-update">
|
||||||
|
<label>새로운 계약 기간</label>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||||
|
<span>~</span>
|
||||||
|
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>발생 비용</label>
|
||||||
|
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>상세 내용 (메모)</label>
|
||||||
|
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div></div>
|
||||||
|
<div class="footer-actions">
|
||||||
|
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
|
||||||
|
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||||
|
const saveBtn = document.getElementById('btn-save-sw-asset')!;
|
||||||
|
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
|
||||||
|
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
|
||||||
|
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
|
||||||
|
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
|
||||||
|
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
|
||||||
|
|
||||||
|
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
|
||||||
|
|
||||||
|
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
|
||||||
|
const el = document.getElementById(id) as HTMLInputElement;
|
||||||
|
if (el) applyDateMask(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
userAssignBtn.addEventListener('click', () => {
|
||||||
|
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 업데이트 모달 로직
|
||||||
|
const subModal = document.getElementById('sw-update-modal')!;
|
||||||
|
const closeUpdate = () => subModal.classList.add('hidden');
|
||||||
|
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
||||||
|
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
|
||||||
|
|
||||||
|
btnOpenUpdate?.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
|
||||||
|
subModal.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
|
||||||
|
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
|
||||||
|
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
|
||||||
|
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
|
||||||
|
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
|
||||||
|
|
||||||
|
if (start) setFieldValue('sw-시작일', start);
|
||||||
|
if (end) setFieldValue('sw-만료일', end);
|
||||||
|
if (cost) setFieldValue('sw-금액', cost);
|
||||||
|
|
||||||
|
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
|
||||||
|
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify([...state.masterData.logs, log])
|
||||||
|
});
|
||||||
|
|
||||||
|
closeUpdate(); onSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
revertBtn.addEventListener('click', () => {
|
||||||
|
this.setEditLockMode('view');
|
||||||
|
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset) return;
|
||||||
|
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
|
||||||
|
|
||||||
|
const type = getFieldValue('sw-asset-type');
|
||||||
|
const formData = new FormData(this.formEl!);
|
||||||
|
const updated = { ...this.currentAsset };
|
||||||
|
formData.forEach((value, key) => { updated[key] = value; });
|
||||||
|
|
||||||
|
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||||
|
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
|
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||||
|
const type = this.currentAsset.asset_type || this.currentAsset.type;
|
||||||
|
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||||
|
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
|
||||||
|
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fillFormData(asset: any): void {
|
||||||
|
setFieldValue('sw-asset-id', asset.id);
|
||||||
|
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
|
||||||
|
setFieldValue('sw-분야', asset.sw_field || '');
|
||||||
|
setFieldValue('sw-법인', asset.purchase_corp || '');
|
||||||
|
setFieldValue('sw-부서', asset.current_dept || '');
|
||||||
|
setFieldValue('sw-user-current', asset.user_current || '');
|
||||||
|
setFieldValue('sw-previous-user', asset.previous_user || '');
|
||||||
|
setFieldValue('sw-제품명', asset.product_name || '');
|
||||||
|
setFieldValue('sw-수량', asset.asset_count || '');
|
||||||
|
setFieldValue('sw-금액', asset.purchase_amount || '');
|
||||||
|
setFieldValue('sw-구매일', asset.purchase_date || '');
|
||||||
|
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
|
||||||
|
setFieldValue('sw-개발담당자', asset.dev_manager || '');
|
||||||
|
setFieldValue('sw-기획담당자', asset.planning_manager || '');
|
||||||
|
setFieldValue('sw-영업담당자', asset.sales_manager || '');
|
||||||
|
setFieldValue('sw-비고', asset.memo || '');
|
||||||
|
|
||||||
|
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
|
||||||
|
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
|
||||||
|
setFieldValue('sw-계정명', asset.email_account || '');
|
||||||
|
setFieldValue('sw-결제수단', asset.purchase_method || '');
|
||||||
} else {
|
} else {
|
||||||
state.masterData.sw.push(newAsset);
|
setFieldValue('sw-만료일', asset.expiry_date || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModals();
|
this.renderHistory(asset.id);
|
||||||
renderContent();
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// 삭제 버튼 이벤트
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
btnDeleteSw?.addEventListener('click', (e) => {
|
this.applySwTypeUI(asset.asset_type || asset.type);
|
||||||
e.preventDefault();
|
}
|
||||||
const id = (document.getElementById('sw-asset-id') as HTMLInputElement).value;
|
|
||||||
if (confirm('삭제하시겠습니까?')) {
|
private applySwTypeUI(type: string) {
|
||||||
state.masterData.sw = state.masterData.sw.filter(a => a.id !== id);
|
const cloudFields = document.querySelectorAll('.cloud-only');
|
||||||
closeModals();
|
const swFields = document.querySelectorAll('.sw-standard-field');
|
||||||
renderContent();
|
const userSection = document.getElementById('sw-user-section');
|
||||||
|
const expiryGroup = document.getElementById('sw-expiry-group');
|
||||||
|
const userTracking = document.querySelectorAll('.sw-user-tracking');
|
||||||
|
|
||||||
|
if (type === '클라우드') {
|
||||||
|
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||||
|
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||||
|
if (userSection) userSection.style.display = 'none';
|
||||||
|
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||||
|
} else {
|
||||||
|
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||||
|
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||||
|
if (userSection) userSection.style.display = 'block';
|
||||||
|
if (type === '외부SW' || type === '내부SW') {
|
||||||
|
if (expiryGroup) expiryGroup.style.display = 'flex';
|
||||||
|
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 소프트웨어 상세 모달 열기
|
|
||||||
* @param asset 수정 시 자산 데이터, 신규 시 undefined
|
|
||||||
*/
|
|
||||||
export function openSwModal(asset?: SoftwareAsset) {
|
|
||||||
const swModal = document.getElementById('sw-asset-modal') as HTMLDivElement;
|
|
||||||
const swForm = document.getElementById('sw-asset-form') as HTMLFormElement;
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
|
|
||||||
|
|
||||||
openModal('sw-asset-modal');
|
|
||||||
swForm.reset();
|
|
||||||
|
|
||||||
const subGroup = document.getElementById('sw-구독일-group')!;
|
|
||||||
const permGroup = document.getElementById('sw-유지보수-group')!;
|
|
||||||
if (state.activeSubTab === '구독SW') {
|
|
||||||
subGroup.style.display = 'block';
|
|
||||||
permGroup.style.display = 'none';
|
|
||||||
} else {
|
|
||||||
subGroup.style.display = 'none';
|
|
||||||
permGroup.style.display = 'block';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (asset) {
|
private renderHistory(swId: string) {
|
||||||
document.getElementById('sw-modal-title')!.textContent = `${state.activeSubTab} 상세 정보 수정`;
|
const container = document.getElementById('sw-history-list');
|
||||||
deleteBtn.style.display = 'block';
|
if (!container) return;
|
||||||
|
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||||
(document.getElementById('sw-asset-id') as HTMLInputElement).value = asset.id;
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||||
(document.getElementById('sw-asset-type') as HTMLInputElement).value = asset.type;
|
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||||
(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).checked = !!asset.유지보수여부;
|
|
||||||
(document.getElementById('sw-금액') as HTMLInputElement).value = asset.금액 ? Number(asset.금액.replace(/,/g, '')).toLocaleString() : '';
|
|
||||||
(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.비고 || '';
|
|
||||||
} else {
|
|
||||||
document.getElementById('sw-modal-title')!.textContent = `새 ${state.activeSubTab} 자산 추가`;
|
|
||||||
deleteBtn.style.display = 'none';
|
|
||||||
(document.getElementById('sw-asset-id') as HTMLInputElement).value = '';
|
|
||||||
(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 HTMLInputElement).value = '';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const swModal = new SwAssetModal();
|
||||||
|
|
||||||
|
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||||
|
swModal.init(onSave, closeModals);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
|
||||||
|
swModal.open(asset, mode);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,171 +1,267 @@
|
|||||||
import { state } from '../../state';
|
import { state } from '../../core/state';
|
||||||
import { SoftwareAsset, SWUser } from '../../excelHandler';
|
import { BaseModal } from './BaseModal';
|
||||||
import { openModal } from './BaseModal';
|
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
|
||||||
import { createIcons, Edit2, X, Paperclip } from 'lucide';
|
import { ORG_LIST } from './SharedData';
|
||||||
|
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
|
||||||
|
|
||||||
let currentSwUserAssetId: string = '';
|
class SwUserModal extends BaseModal {
|
||||||
let tempSwUsers: SWUser[] = [];
|
private tempSwUsers: any[] = [];
|
||||||
|
|
||||||
/**
|
constructor() {
|
||||||
* 소프트웨어 사용자 할당 모달 초기화
|
super('sw-user', '소프트웨어 사용자 관리');
|
||||||
*/
|
|
||||||
export function initSWUserModal(renderContent: () => void, closeModals: () => void) {
|
|
||||||
const btnOpenAddUser = document.getElementById('btn-open-add-user');
|
|
||||||
const btnSaveEditUser = document.getElementById('btn-save-edit-user');
|
|
||||||
const btnSaveSwUserMapping = document.getElementById('btn-save-sw-user-mapping');
|
|
||||||
|
|
||||||
btnOpenAddUser?.addEventListener('click', () => {
|
|
||||||
openUserEditModal(-1);
|
|
||||||
});
|
|
||||||
|
|
||||||
btnSaveEditUser?.addEventListener('click', () => {
|
|
||||||
saveUserEdit();
|
|
||||||
});
|
|
||||||
|
|
||||||
btnSaveSwUserMapping?.addEventListener('click', () => {
|
|
||||||
// 변경사항 전역 상태에 반영
|
|
||||||
state.masterData.swUsers = state.masterData.swUsers.filter(u => u.swId !== currentSwUserAssetId);
|
|
||||||
state.masterData.swUsers.push(...tempSwUsers);
|
|
||||||
|
|
||||||
document.getElementById('sw-user-modal')?.classList.add('hidden');
|
|
||||||
renderContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 취소 버튼들
|
|
||||||
document.getElementById('btn-cancel-sw-user-edit')?.addEventListener('click', () => {
|
|
||||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
|
||||||
});
|
|
||||||
document.getElementById('btn-cancel-sw-user-modal')?.addEventListener('click', () => {
|
|
||||||
document.getElementById('sw-user-modal')?.classList.add('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 소프트웨어 사용자 목록 렌더링
|
|
||||||
*/
|
|
||||||
function renderUserList() {
|
|
||||||
const tbody = document.getElementById('user-list-body')!;
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
if (tempSwUsers.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="padding: 1rem; text-align: center; color: var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tempSwUsers.forEach((user, idx) => {
|
protected renderFrameHTML(): string {
|
||||||
const tr = document.createElement('tr');
|
return `
|
||||||
tr.style.cssText = 'border-bottom: 1px solid var(--border); transition: background-color 0.2s;';
|
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal-content wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="sw-user-title">${this.title}</h2>
|
||||||
|
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
||||||
|
|
||||||
const deptTeam = [user.부서, user.팀].filter(Boolean).join(' / ') || '-';
|
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
||||||
const attachIcon = user.신청서명 ? `<i data-lucide="paperclip" class="text-primary" style="width:16px; height:16px;" title="${user.신청서명}"></i>` : '-';
|
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
||||||
|
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
tr.innerHTML = `
|
<div class="table-container">
|
||||||
<td style="padding:0.5rem; text-align:left;">${user.법인}</td>
|
<table>
|
||||||
<td style="padding:0.5rem; text-align:left;">${deptTeam}</td>
|
<thead>
|
||||||
<td style="padding:0.5rem; text-align:left;">${user.직위 || '-'}</td>
|
<tr>
|
||||||
<td style="padding:0.5rem; text-align:left;"><strong>${user.이름}</strong></td>
|
<th>조직</th>
|
||||||
<td style="padding:0.5rem; text-align:center;">${user.사용기간 || '-'}</td>
|
<th>부서</th>
|
||||||
<td style="padding:0.5rem; text-align:center; color: var(--text-light);">${attachIcon}</td>
|
<th>직위</th>
|
||||||
<td style="padding:0.5rem; text-align:center; display:flex; justify-content:center; gap:0.25rem;">
|
<th>이름</th>
|
||||||
<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>
|
<th>사용기간</th>
|
||||||
<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>
|
<th>신청서</th>
|
||||||
</td>
|
<th>관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sw-user-table-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
|
||||||
|
<form id="sw-user-asset-form" class="hidden"></form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
|
||||||
|
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용자 추가/수정 서브 모달 -->
|
||||||
|
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||||
|
<div class="modal-content" style="width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
||||||
|
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
||||||
|
<input type="hidden" id="edit-user-index" value="-1" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label>조직</label>
|
||||||
|
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</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-이름" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>사용 시작일</label>
|
||||||
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
|
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||||
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
|
</button>
|
||||||
|
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>사용 종료일</label>
|
||||||
|
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||||
|
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||||
|
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||||
|
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||||
|
</button>
|
||||||
|
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>신청서 (증빙)</label>
|
||||||
|
<input type="file" id="new-user-신청서" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
|
||||||
|
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
}
|
||||||
});
|
|
||||||
|
|
||||||
createIcons({ icons: { Edit2, X, Paperclip } });
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||||
|
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
|
||||||
|
const addUserBtn = document.getElementById('btn-open-add-user')!;
|
||||||
|
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
|
||||||
|
|
||||||
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
|
['new-user-시작일', 'new-user-종료일'].forEach(id => {
|
||||||
btn.addEventListener('click', (e) => {
|
const el = document.getElementById(id) as HTMLInputElement;
|
||||||
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
if (el) applyDateMask(el);
|
||||||
openUserEditModal(idx);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
tbody.querySelectorAll('.btn-remove-user').forEach(btn => {
|
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
|
||||||
btn.addEventListener('click', (e) => {
|
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
|
||||||
e.stopPropagation();
|
|
||||||
const idx = parseInt((e.currentTarget as HTMLButtonElement).getAttribute('data-idx')!);
|
mainSaveBtn.addEventListener('click', () => {
|
||||||
tempSwUsers.splice(idx, 1);
|
if (!this.currentAsset) return;
|
||||||
renderUserList();
|
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
|
||||||
|
const newMapping = {
|
||||||
|
sw_id: this.currentAsset!.id,
|
||||||
|
userData: this.tempSwUsers.map(u => [u.조직, u.부서, u.직위, u.이름, u.사용기간, u.신청서명])
|
||||||
|
};
|
||||||
|
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
|
||||||
|
else state.masterData.swUsers.push(newMapping as any);
|
||||||
|
|
||||||
|
onSave(); this.close(); closeModals();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
|
||||||
* 사용자 할당 모달 열기
|
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
||||||
*/
|
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
||||||
export function openSwUserModal(asset: SoftwareAsset) {
|
|
||||||
openModal('sw-user-modal');
|
|
||||||
currentSwUserAssetId = asset.id;
|
|
||||||
tempSwUsers = state.masterData.swUsers.filter(u => u.swId === asset.id).map(u => ({...u}));
|
|
||||||
renderUserList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||||
* 사용자 추가/수정 모달 열기
|
const closeSub = () => subModal.classList.add('hidden');
|
||||||
*/
|
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
|
||||||
function openUserEditModal(idx: number) {
|
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
|
||||||
const editModal = document.getElementById('sw-user-edit-modal')!;
|
|
||||||
editModal.classList.remove('hidden');
|
|
||||||
(document.getElementById('edit-user-idx') as HTMLInputElement).value = String(idx);
|
|
||||||
|
|
||||||
if (idx === -1) {
|
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
|
||||||
document.getElementById('sw-user-edit-modal-title')!.innerText = '새 사용자 추가';
|
}
|
||||||
(document.getElementById('new-user-법인') as HTMLSelectElement).value = '한맥';
|
|
||||||
(document.getElementById('new-user-부서') as HTMLInputElement).value = '';
|
protected fillFormData(asset: any): void {
|
||||||
(document.getElementById('new-user-팀') as HTMLInputElement).value = '';
|
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||||
(document.getElementById('new-user-직위') as HTMLInputElement).value = '';
|
swInfo.innerHTML = `
|
||||||
(document.getElementById('new-user-이름') as HTMLInputElement).value = '';
|
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||||
(document.getElementById('new-user-사용기간') as HTMLInputElement).value = '';
|
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||||
(document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
|
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
||||||
document.getElementById('new-user-신청서명')!.innerText = '';
|
</div>
|
||||||
} else {
|
`;
|
||||||
document.getElementById('sw-user-edit-modal-title')!.innerText = '사용자 수정';
|
|
||||||
const u = tempSwUsers[idx];
|
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
|
||||||
(document.getElementById('new-user-법인') as HTMLSelectElement).value = u.법인;
|
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
||||||
(document.getElementById('new-user-부서') as HTMLInputElement).value = u.부서;
|
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
||||||
(document.getElementById('new-user-팀') as HTMLInputElement).value = u.팀;
|
})) : [];
|
||||||
(document.getElementById('new-user-직위') as HTMLInputElement).value = u.직위;
|
|
||||||
(document.getElementById('new-user-이름') as HTMLInputElement).value = u.이름;
|
this.renderUserList();
|
||||||
(document.getElementById('new-user-사용기간') as HTMLInputElement).value = u.사용기간;
|
}
|
||||||
(document.getElementById('new-user-신청서') as HTMLInputElement).value = '';
|
|
||||||
document.getElementById('new-user-신청서명')!.innerText = u.신청서명 ? `📎${u.신청서명}` : '';
|
protected onAfterOpen(): void {}
|
||||||
|
|
||||||
|
private renderUserList() {
|
||||||
|
const tbody = document.getElementById('sw-user-table-body')!;
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
if (this.tempSwUsers.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tempSwUsers.forEach((user, idx) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${user.조직 || ''}</td>
|
||||||
|
<td>${user.부서 || ''}</td>
|
||||||
|
<td>${user.직위 || ''}</td>
|
||||||
|
<td>${user.이름 || ''}</td>
|
||||||
|
<td>${user.사용기간 || ''}</td>
|
||||||
|
<td style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex; gap:0.5rem;">
|
||||||
|
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
||||||
|
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||||
|
this.openUserEditSubModal(idx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||||
|
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
|
||||||
|
this.tempSwUsers.splice(idx, 1); this.renderUserList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
createIcons({ icons: { Paperclip } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private openUserEditSubModal(idx: number = -1) {
|
||||||
|
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||||
|
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
|
||||||
|
form.reset();
|
||||||
|
setFieldValue('edit-user-index', idx);
|
||||||
|
if (idx > -1) {
|
||||||
|
const user = this.tempSwUsers[idx];
|
||||||
|
setFieldValue('new-user-조직', user.조직);
|
||||||
|
setFieldValue('new-user-부서', user.부서);
|
||||||
|
setFieldValue('new-user-직위', user.직위);
|
||||||
|
setFieldValue('new-user-이름', user.이름);
|
||||||
|
if (user.사용기간 && user.사용기간.includes('~')) {
|
||||||
|
const parts = user.사용기간.split('~');
|
||||||
|
setFieldValue('new-user-시작일', parts[0].trim());
|
||||||
|
setFieldValue('new-user-종료일', parts[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subModal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveUserDataToList() {
|
||||||
|
const idx = parseInt(getFieldValue('edit-user-index'));
|
||||||
|
const 신청서Input = document.getElementById('new-user-신청서') as HTMLInputElement;
|
||||||
|
const 신청서명 = 신청서Input.files && 신청서Input.files.length > 0 ? 신청서Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx].신청서명 : '');
|
||||||
|
|
||||||
|
const userData: any = {
|
||||||
|
조직: getFieldValue('new-user-조직'),
|
||||||
|
부서: getFieldValue('new-user-부서'),
|
||||||
|
직위: getFieldValue('new-user-직위'),
|
||||||
|
이름: getFieldValue('new-user-이름'),
|
||||||
|
사용기간: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
|
||||||
|
신청서명
|
||||||
|
};
|
||||||
|
if (idx === -1) this.tempSwUsers.push(userData);
|
||||||
|
else this.tempSwUsers[idx] = userData;
|
||||||
|
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
||||||
|
this.renderUserList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export const swUserModal = new SwUserModal();
|
||||||
* 사용자 추가/수정 내용 저장 (임시 목록에 반영)
|
|
||||||
*/
|
|
||||||
function saveUserEdit() {
|
|
||||||
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;
|
|
||||||
|
|
||||||
const fileInput = document.getElementById('new-user-신청서') as HTMLInputElement;
|
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
|
||||||
let 신청서명 = '';
|
swUserModal.init(onSave, closeModals);
|
||||||
if (fileInput.files && fileInput.files.length > 0) {
|
}
|
||||||
신청서명 = fileInput.files[0].name;
|
|
||||||
} else if (idx !== -1) {
|
export function openSwUserModal(asset: any) {
|
||||||
신청서명 = tempSwUsers[idx].신청서명;
|
swUserModal.open(asset);
|
||||||
}
|
|
||||||
|
|
||||||
if (!이름) { alert('이름을 입력해주세요.'); return; }
|
|
||||||
|
|
||||||
if (idx === -1) {
|
|
||||||
tempSwUsers.push({
|
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
|
||||||
swId: currentSwUserAssetId,
|
|
||||||
법인, 부서, 팀, 직위, 이름, 사용기간, 신청서명
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
tempSwUsers[idx] = { ...tempSwUsers[idx], 법인, 부서, 팀, 직위, 이름, 사용기간, 신청서명 };
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
|
||||||
renderUserList();
|
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/components/Modal/SharedData.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 모든 모달에서 공통으로 사용하는 리스트 데이터 및 설정
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 구매법인 목록
|
||||||
|
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
|
||||||
|
|
||||||
|
// 사용조직 목록
|
||||||
|
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
|
||||||
|
|
||||||
|
// 하드웨어 상태 목록
|
||||||
|
export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타'];
|
||||||
|
|
||||||
|
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
|
||||||
|
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||||
|
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
|
||||||
|
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
|
||||||
|
'저장매체': ['SSD', 'HDD', '외장HDD'],
|
||||||
|
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
|
||||||
|
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
|
||||||
|
'공간정보장비': ['드론', '측량장비', '보조기기'],
|
||||||
|
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
|
||||||
|
'외부SW': ['영구', '구독'],
|
||||||
|
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||||
|
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
|
||||||
|
'내빈/외빈': ['선물'],
|
||||||
|
'시설자산': ['사무가구']
|
||||||
|
};
|
||||||
|
|
||||||
|
// 설치위치 종속성 데이터
|
||||||
|
export const LOCATION_DATA: Record<string, string[]> = {
|
||||||
|
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
|
||||||
|
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
|
||||||
|
'유니온빌딩': ['4층', '5층', '6층'],
|
||||||
|
'뉴코아빌딩': ['4층', '6층', '7층'],
|
||||||
|
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유형별 자산번호 접두사(Prefix) 매핑
|
||||||
|
export const TYPE_PREFIX_MAP: Record<string, string> = {
|
||||||
|
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
|
||||||
|
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
|
||||||
|
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
|
||||||
|
'노트북': 'NBK', '태블릿': 'TAB',
|
||||||
|
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
|
||||||
|
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 배치도 이미지 매핑 데이터
|
||||||
|
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
|
||||||
|
'IDC': {
|
||||||
|
'서관202': ['img/location_photo/IDC/서관202.png'],
|
||||||
|
'서관203': ['img/location_photo/IDC/서관203.png'],
|
||||||
|
'서관204': ['img/location_photo/IDC/서관204.png'],
|
||||||
|
'서관205': ['img/location_photo/IDC/서관205.png'],
|
||||||
|
'동관53': ['img/location_photo/IDC/동관53.png'],
|
||||||
|
'동관54': ['img/location_photo/IDC/동관54.png'],
|
||||||
|
},
|
||||||
|
'기술개발센터': {
|
||||||
|
'서버실': [
|
||||||
|
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
||||||
|
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'한맥빌딩': {
|
||||||
|
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
|
||||||
|
'MDF실': [
|
||||||
|
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
||||||
|
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
||||||
|
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
|
||||||
|
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import { state } from '../../state';
|
|
||||||
import { HardwareAsset } from '../../excelHandler';
|
|
||||||
import { openModal } from './BaseModal';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스토리지 모달 초기화 및 로직 제어
|
|
||||||
*/
|
|
||||||
export function initStorageModal(renderContent: () => void, closeModals: () => void) {
|
|
||||||
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
|
|
||||||
const btnSaveStorage = document.getElementById('btn-save-storage-asset') as HTMLButtonElement;
|
|
||||||
const btnDeleteStorage = document.getElementById('btn-delete-storage-asset') as HTMLButtonElement;
|
|
||||||
|
|
||||||
// 저장 버튼 이벤트
|
|
||||||
btnSaveStorage?.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!storageForm.checkValidity()) { storageForm.reportValidity(); return; }
|
|
||||||
|
|
||||||
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 = {
|
|
||||||
id: id || Math.random().toString(36).substring(2, 9),
|
|
||||||
type: '스토리지',
|
|
||||||
법인: (document.getElementById('storage-법인') as HTMLSelectElement).value,
|
|
||||||
storage유형: (document.getElementById('storage-유형') as HTMLSelectElement).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,
|
|
||||||
품의서명
|
|
||||||
};
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
const idx = state.masterData.hw.findIndex(a => a.id === id);
|
|
||||||
if(idx !== -1) state.masterData.hw[idx] = newAsset;
|
|
||||||
} else {
|
|
||||||
state.masterData.hw.push(newAsset);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModals();
|
|
||||||
renderContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 삭제 버튼 이벤트
|
|
||||||
btnDeleteStorage?.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const id = (document.getElementById('storage-asset-id') as HTMLInputElement).value;
|
|
||||||
if (confirm('삭제하시겠습니까?')) {
|
|
||||||
state.masterData.hw = state.masterData.hw.filter(a => a.id !== id);
|
|
||||||
closeModals();
|
|
||||||
renderContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스토리지 상세 모달 열기
|
|
||||||
* @param asset 수정 시 자산 데이터, 신규 시 undefined
|
|
||||||
*/
|
|
||||||
export function openStorageModal(asset?: HardwareAsset) {
|
|
||||||
const storageModal = document.getElementById('storage-asset-modal') as HTMLDivElement;
|
|
||||||
const storageForm = document.getElementById('storage-asset-form') as HTMLFormElement;
|
|
||||||
const deleteBtn = document.getElementById('btn-delete-storage-asset')!;
|
|
||||||
|
|
||||||
openModal('storage-asset-modal');
|
|
||||||
storageForm.reset();
|
|
||||||
|
|
||||||
if (asset) {
|
|
||||||
document.getElementById('storage-modal-title')!.textContent = '스토리지 상세 정보 수정';
|
|
||||||
deleteBtn.style.display = 'block';
|
|
||||||
|
|
||||||
(document.getElementById('storage-asset-id') as HTMLInputElement).value = asset.id;
|
|
||||||
(document.getElementById('storage-법인') as HTMLSelectElement).value = asset.법인;
|
|
||||||
(document.getElementById('storage-유형') as HTMLSelectElement).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-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.금액 ? Number(asset.금액.replace(/,/g, '')).toLocaleString() : '';
|
|
||||||
(document.getElementById('storage-납품업체') as HTMLInputElement).value = asset.납품업체 || '';
|
|
||||||
(document.getElementById('storage-품의서명') as HTMLElement).innerText = asset.품의서명 ? `📎${asset.품의서명}` : '';
|
|
||||||
} else {
|
|
||||||
document.getElementById('storage-modal-title')!.textContent = '새 스토리지 자산 추가';
|
|
||||||
deleteBtn.style.display = 'none';
|
|
||||||
(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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
116
src/components/Navigation.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { state } from '../core/state';
|
||||||
|
|
||||||
|
const MENU_CONFIG: any = {
|
||||||
|
hw: {
|
||||||
|
label: '하드웨어',
|
||||||
|
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||||
|
},
|
||||||
|
sw: {
|
||||||
|
label: '소프트웨어',
|
||||||
|
tabs: ['외부SW', '내부SW']
|
||||||
|
},
|
||||||
|
ops: {
|
||||||
|
label: '운영지원',
|
||||||
|
tabs: ['클라우드', '도메인', '비용관리']
|
||||||
|
},
|
||||||
|
vip: {
|
||||||
|
label: '내빈/외빈',
|
||||||
|
tabs: ['선물']
|
||||||
|
},
|
||||||
|
fac: {
|
||||||
|
label: '시설자산',
|
||||||
|
tabs: ['사무가구']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||||
|
const navContainer = document.getElementById('main-nav')!;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
navContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// 기존 메뉴 렌더링
|
||||||
|
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
||||||
|
const config = MENU_CONFIG[catKey];
|
||||||
|
|
||||||
|
// 역할에 따라 노출할 서브탭 필터링
|
||||||
|
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||||
|
if (state.currentUserRole === 'admin') {
|
||||||
|
// 관리자(admin)일 경우 대시보드 탭만 노출
|
||||||
|
return tab === '대시보드';
|
||||||
|
} else {
|
||||||
|
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
|
||||||
|
return tab !== '대시보드';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
|
||||||
|
if (visibleTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = state.activeCategory === catKey;
|
||||||
|
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
|
||||||
|
|
||||||
|
const trigger = document.createElement('div');
|
||||||
|
trigger.className = 'gnb-trigger';
|
||||||
|
trigger.textContent = config.label;
|
||||||
|
|
||||||
|
trigger.addEventListener('click', () => {
|
||||||
|
if (state.activeCategory !== catKey) {
|
||||||
|
state.activeCategory = catKey as any;
|
||||||
|
const firstTab = visibleTabs[0] || config.tabs[0];
|
||||||
|
state.activeSubTab = firstTab;
|
||||||
|
render();
|
||||||
|
onTabChange(firstTab);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
group.appendChild(trigger);
|
||||||
|
|
||||||
|
const shelf = document.createElement('div');
|
||||||
|
shelf.className = 'lnb-shelf';
|
||||||
|
|
||||||
|
visibleTabs.forEach((tab: string) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
||||||
|
item.textContent = tab;
|
||||||
|
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
state.activeCategory = catKey as any;
|
||||||
|
state.activeSubTab = tab;
|
||||||
|
render();
|
||||||
|
onTabChange(tab);
|
||||||
|
});
|
||||||
|
shelf.appendChild(item);
|
||||||
|
});
|
||||||
|
group.appendChild(shelf);
|
||||||
|
navContainer.appendChild(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
|
||||||
|
if (state.currentUserRole === 'admin') {
|
||||||
|
const adminGroup = document.createElement('div');
|
||||||
|
adminGroup.className = 'nav-group';
|
||||||
|
|
||||||
|
const adminTrigger = document.createElement('div');
|
||||||
|
adminTrigger.className = 'gnb-trigger';
|
||||||
|
adminTrigger.innerHTML = '관리자';
|
||||||
|
adminTrigger.style.color = 'var(--text-muted)';
|
||||||
|
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
|
||||||
|
adminTrigger.style.marginLeft = '1rem';
|
||||||
|
adminTrigger.style.paddingLeft = '1.5rem';
|
||||||
|
|
||||||
|
adminTrigger.addEventListener('click', () => {
|
||||||
|
window.open('/map_editor.html', '_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
adminGroup.appendChild(adminTrigger);
|
||||||
|
navContainer.appendChild(adminGroup);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { state } from '../state';
|
|
||||||
|
|
||||||
export function initSidebar(renderContent: () => void) {
|
|
||||||
const navItems = document.querySelectorAll('.nav-list li');
|
|
||||||
const titleElement = document.getElementById('current-tab-title') as HTMLHeadingElement;
|
|
||||||
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
|
|
||||||
|
|
||||||
navItems.forEach(item => {
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
// 탭 UI 업데이트
|
|
||||||
navItems.forEach(nav => nav.classList.remove('active'));
|
|
||||||
item.classList.add('active');
|
|
||||||
|
|
||||||
// 상태 업데이트
|
|
||||||
state.activeCategory = item.getAttribute('data-category') as 'hw' | 'sw';
|
|
||||||
state.activeSubTab = item.getAttribute('data-tab') || '대시보드';
|
|
||||||
|
|
||||||
// 타이틀 업데이트 (Deep Green 포인트 컬러 유지)
|
|
||||||
const catName = state.activeCategory === 'hw' ? '하드웨어' : '소프트웨어';
|
|
||||||
if (titleElement) {
|
|
||||||
titleElement.textContent = `${catName} / ${state.activeSubTab}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 추가 버튼 노출 여부 (대시보드에서는 숨김)
|
|
||||||
if (btnAddAsset) {
|
|
||||||
if (state.activeSubTab === '대시보드') {
|
|
||||||
btnAddAsset.classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
btnAddAsset.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 화면 리렌더링
|
|
||||||
renderContent();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
10695
src/core/dummyData.ts
Normal file
272
src/core/excelHandler.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { ASSET_SCHEMA } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
|
||||||
|
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 1:1로 일치시킵니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface HardwareAsset {
|
||||||
|
[key: string]: any;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareAsset {
|
||||||
|
[key: string]: any;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SWUser {
|
||||||
|
id: string;
|
||||||
|
sw_id: string;
|
||||||
|
user_name: string;
|
||||||
|
dept: string;
|
||||||
|
corp: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareLog {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
date: string;
|
||||||
|
details: string;
|
||||||
|
user: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MasterAssetData {
|
||||||
|
pc: HardwareAsset[];
|
||||||
|
server: HardwareAsset[];
|
||||||
|
storage: HardwareAsset[];
|
||||||
|
network: HardwareAsset[];
|
||||||
|
equipment: HardwareAsset[];
|
||||||
|
survey: HardwareAsset[];
|
||||||
|
pcParts: HardwareAsset[];
|
||||||
|
swInternal: SoftwareAsset[];
|
||||||
|
swExternal: SoftwareAsset[];
|
||||||
|
cloud: SoftwareAsset[];
|
||||||
|
domain: any[];
|
||||||
|
vip: HardwareAsset[];
|
||||||
|
officeSupplies: HardwareAsset[];
|
||||||
|
cost: any[];
|
||||||
|
swUsers: SWUser[];
|
||||||
|
logs: HardwareLog[];
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB 컬럼 순서 및 구성 정의 (실제 DB 스키마 dump 기준)
|
||||||
|
*/
|
||||||
|
const DB_MAPPING: Record<string, (keyof typeof ASSET_SCHEMA)[]> = {
|
||||||
|
pc: [
|
||||||
|
'ASSET_TYPE', 'HW_STATUS', 'CURRENT_DEPT', 'PREV_DEPT', 'USER_POSITION',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'CPU', 'RAM', 'GPU', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'HDD3', 'HDD4', 'MAC_ADDR',
|
||||||
|
'MANAGER_MAIN', 'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||||
|
'PURCHASE_VENDOR', 'MEMO', 'MAINBOARD'
|
||||||
|
],
|
||||||
|
server: [
|
||||||
|
'ASSET_TYPE', 'MODEL_NAME', 'ASSET_PURPOSE', 'HW_STATUS',
|
||||||
|
'CURRENT_DEPT', 'CPU', 'RAM', 'GPU', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP_ADDR',
|
||||||
|
'REMOTE_TOOL', 'REMOTE_ID', 'REMOTE_PW', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||||
|
'MEMO', 'PREV_DEPT', 'MANAGER_SUB', 'IP_ADDR2', 'MONITORING', 'HDD3', 'HDD4', 'EMP_NO'
|
||||||
|
],
|
||||||
|
storage: [
|
||||||
|
'ASSET_TYPE', 'HW_STATUS', 'VOLUME', 'MODEL_NAME',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'SERIAL_NUM', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||||
|
'MEMO', 'CURRENT_DEPT', 'PREV_DEPT'
|
||||||
|
],
|
||||||
|
network: [
|
||||||
|
'PURCHASE_CORP', 'HW_STATUS', 'CURRENT_DEPT', 'PREV_DEPT',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||||
|
'MANAGER_SUB', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR', 'MEMO'
|
||||||
|
],
|
||||||
|
survey: [ // asset_survey (공간정보장비)
|
||||||
|
'HW_STATUS', 'ASSET_NAME', 'LOCATION', 'LOC_DETAIL',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'MANAGER_MAIN', 'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||||
|
'PURCHASE_VENDOR', 'MEMO'
|
||||||
|
],
|
||||||
|
pcParts: [
|
||||||
|
'HW_STATUS', 'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME', 'VOLUME',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'MONITOR_INCH', 'LOCATION', 'LOC_DETAIL', 'PURCHASE_CORP', 'PURCHASE_DATE',
|
||||||
|
'PURCHASE_AMOUNT', 'PURCHASE_VENDOR', 'MEMO'
|
||||||
|
],
|
||||||
|
equipment: [
|
||||||
|
'HW_STATUS', 'ASSET_STATUS', 'ASSET_TYPE', 'ASSET_MFR',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'MODEL_NAME', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||||
|
'MEMO'
|
||||||
|
],
|
||||||
|
officeSupplies: [ // asset_office_supplies (시설자산)
|
||||||
|
'HW_STATUS', 'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME',
|
||||||
|
'EMP_NO', 'CURRENT_USER',
|
||||||
|
'ASSET_COUNT', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||||
|
'MEMO'
|
||||||
|
],
|
||||||
|
swInternal: [
|
||||||
|
'SW_FIELD', 'DEV_OBJ', 'SW_STATUS', 'SW_TYPE', 'MANAGER_MAIN',
|
||||||
|
'DEV_MGR', 'PLANNING_MGR', 'SALES_MGR', 'PURCHASE_CORP', 'MEMO'
|
||||||
|
],
|
||||||
|
swExternal: [
|
||||||
|
'PRODUCT_NAME', 'SW_TYPE', 'SW_STATUS', 'SW_FIELD', 'CURRENT_DEPT',
|
||||||
|
'PREV_DEPT', 'MANAGER_MAIN', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||||
|
'PURCHASE_VENDOR', 'EMAIL_ACCOUNT', 'MEMO', 'EMP_NO', 'CURRENT_USER'
|
||||||
|
],
|
||||||
|
cloud: [
|
||||||
|
'ASSET_PURPOSE', 'PURCHASE_METHOD', 'PURCHASE_VENDOR', 'PURCHASE_CORP',
|
||||||
|
'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||||
|
'MEMO', 'SW_ID', 'SW_PW'
|
||||||
|
],
|
||||||
|
domain: [
|
||||||
|
'DOMAIN_ADDR', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||||
|
'MEMO'
|
||||||
|
],
|
||||||
|
cost: [
|
||||||
|
'ASSET_TYPE', 'ASSET_PURPOSE', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||||
|
'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||||
|
'EMAIL_ACCOUNT', 'EMAIL_PW', 'MEMO', 'EMP_NO', 'CURRENT_USER'
|
||||||
|
],
|
||||||
|
vip: [ // asset_vip (선물)
|
||||||
|
'ASSET_NAME', 'MODEL_NAME', 'LOCATION', 'LOC_DETAIL',
|
||||||
|
'PURCHASE_CORP', 'PURCHASE_DATE', 'EXPIRED_DATE', 'PURCHASE_VENDOR', 'MEMO'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
export function downloadTemplate() {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
const tabConfigs = [
|
||||||
|
{ name: 'PC', key: 'pc' },
|
||||||
|
{ name: '서버', key: 'server' },
|
||||||
|
{ name: '스토리지', key: 'storage' },
|
||||||
|
{ name: '공간정보장비', key: 'survey' },
|
||||||
|
{ name: 'PC부품', key: 'pcParts' },
|
||||||
|
{ name: '네트워크', key: 'network' },
|
||||||
|
{ name: '업무지원장비', key: 'equipment' },
|
||||||
|
{ name: '내부SW', key: 'swInternal' },
|
||||||
|
{ name: '외부SW', key: 'swExternal' },
|
||||||
|
{ name: '클라우드', key: 'cloud' },
|
||||||
|
{ name: '도메인', key: 'domain' },
|
||||||
|
{ name: '비용관리', key: 'cost' },
|
||||||
|
{ name: '선물', key: 'vip' },
|
||||||
|
{ name: '시설자산', key: 'officeSupplies' }
|
||||||
|
];
|
||||||
|
|
||||||
|
tabConfigs.forEach(config => {
|
||||||
|
const keys = DB_MAPPING[config.key];
|
||||||
|
const headers = keys.map(k => ASSET_SCHEMA[k].ui);
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers]);
|
||||||
|
ws['!cols'] = Array(headers.length).fill({ wch: 20 });
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, config.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
XLSX.writeFile(wb, 'itam_template_db_aligned.xlsx');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToExcel(masterData: MasterAssetData) {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
|
||||||
|
const exportConfigs = [
|
||||||
|
{ name: 'PC', list: masterData.pc, key: 'pc' },
|
||||||
|
{ name: '서버', list: masterData.server, key: 'server' },
|
||||||
|
{ name: '스토리지', list: masterData.storage, key: 'storage' },
|
||||||
|
{ name: '공간정보장비', list: masterData.survey || [], key: 'survey' },
|
||||||
|
{ name: 'PC부품', list: masterData.pcParts || [], key: 'pcParts' },
|
||||||
|
{ name: '네트워크', list: masterData.network || [], key: 'network' },
|
||||||
|
{ name: '업무지원장비', list: masterData.equipment || [], key: 'equipment' },
|
||||||
|
{ name: '내부SW', list: masterData.swInternal, key: 'swInternal' },
|
||||||
|
{ name: '외부SW', list: masterData.swExternal, key: 'swExternal' },
|
||||||
|
{ name: '클라우드', list: masterData.cloud || [], key: 'cloud' },
|
||||||
|
{ name: '도메인', list: masterData.domain || [], key: 'domain' },
|
||||||
|
{ name: '비용관리', list: masterData.cost || [], key: 'cost' },
|
||||||
|
{ name: '선물', list: masterData.vip || [], key: 'vip' },
|
||||||
|
{ name: '시설자산', list: masterData.officeSupplies || [], key: 'officeSupplies' }
|
||||||
|
];
|
||||||
|
|
||||||
|
exportConfigs.forEach(config => {
|
||||||
|
const schemaKeys = DB_MAPPING[config.key];
|
||||||
|
const headers = schemaKeys.map(k => ASSET_SCHEMA[k].ui);
|
||||||
|
const rows = config.list.map(asset =>
|
||||||
|
schemaKeys.map(k => {
|
||||||
|
const dbField = ASSET_SCHEMA[k].db;
|
||||||
|
return asset[dbField] || asset[ASSET_SCHEMA[k].key] || '';
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, config.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
XLSX.writeFile(wb, `itam_export_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatExcelDate(val: any): string {
|
||||||
|
if (!val) return '';
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
return val.replace(/\./g, '-').trim();
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseExcel(file: File): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const workbook = XLSX.read(e.target?.result, { type: 'array' });
|
||||||
|
const parsedData: any = {};
|
||||||
|
|
||||||
|
workbook.SheetNames.forEach(sheetName => {
|
||||||
|
const ws = workbook.Sheets[sheetName];
|
||||||
|
const rows = XLSX.utils.sheet_to_json(ws, { defval: "" }) as any[];
|
||||||
|
const list: any[] = [];
|
||||||
|
|
||||||
|
rows.forEach(r => {
|
||||||
|
const data: any = { id: Math.random().toString(36).substring(2, 9) };
|
||||||
|
|
||||||
|
// Set default category based on sheet name
|
||||||
|
data['category'] = sheetName;
|
||||||
|
|
||||||
|
Object.keys(r).forEach(label => {
|
||||||
|
const schemaEntry = Object.values(ASSET_SCHEMA).find(s => s.ui === label);
|
||||||
|
const key = schemaEntry ? schemaEntry.db : label;
|
||||||
|
let val = r[label];
|
||||||
|
|
||||||
|
if (label.includes('일자') || label.includes('연월') || label.includes('만료일') || label.includes('시작일')) {
|
||||||
|
val = formatExcelDate(val);
|
||||||
|
}
|
||||||
|
data[key] = val;
|
||||||
|
});
|
||||||
|
|
||||||
|
list.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sheet Name Mapping back to state keys
|
||||||
|
const nameMap: Record<string, string> = {
|
||||||
|
'PC': 'pc', '서버': 'server', '스토리지': 'storage', '공간정보장비': 'survey',
|
||||||
|
'PC부품': 'pcParts', '네트워크': 'network', '업무지원장비': 'equipment',
|
||||||
|
'내부SW': 'swInternal', '외부SW': 'swExternal', '클라우드': 'cloud',
|
||||||
|
'도메인': 'domain', '비용관리': 'cost', '선물': 'vip', '시설자산': 'officeSupplies'
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateKey = nameMap[sheetName] || sheetName;
|
||||||
|
if (list.length > 0) parsedData[stateKey] = list;
|
||||||
|
});
|
||||||
|
resolve(parsedData);
|
||||||
|
} catch (err) { reject(err); }
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||