43 Commits

Author SHA1 Message Date
9cd5d59bf8 refactor: complete modal class-based architecture, design system integration, and map editor modularization 2026-06-01 14:57:07 +09:00
590ddd0e85 feat: enhance map editor, refine location view, and update image assets
- Map Editor: Add box numbering (drawing/placed) and set default file
- Location View: Refine mouse interaction in view mode (readonly)
- Assets: Add MDF room support and update server room directory structure
- Backend: Add map configuration API for real-time saving
2026-06-01 14:00:45 +09:00
bf7fb0ffe6 refactor(ui): unify 14 list views into a single ListFactory component
- ListFactory.ts를 생성하여 중복되는 테이블 생성, 정렬, 필터 로직을 공통 컴포넌트화

- 14개의 ListView.ts 파일들을 ListFactory를 호출하는 설정 객체 형태로 리팩토링

- TypeScript 컴파일 에러(타입 불일치 및 누락된 속성) 수정 완료
2026-05-26 19:47:01 +09:00
2c67037fc4 refactor: cleanup temp files, centralize API URL, and dynamic routing
- 불필요한 마이그레이션 스크립트, JSON 덤프, 백업 폴더 일괄 삭제

- 프론트엔드 API_BASE_URL 상수 도입 및 하드코딩된 API 엔드포인트 통합

- 백엔드(server.js) GET/POST 라우팅 구조를 Map 기반 동적 라우팅으로 리팩토링

- 미사용 dummyDataGenerator 제거
2026-05-26 19:37:34 +09:00
b2713a142d feat: enhance HW modal layout and Server list view columns
- 상세 모달 레이아웃 개선: 모델명과 메인보드 동일 행 배치, 중복 메인보드 필드 제거

- OS 컬럼 스키마 매핑 및 상세 모달 입력 폼 추가

- 모든 하드웨어(서버 포함)에서 HDD 1~4 노출되도록 pc-only 속성 제거

- 서버 리스트 뷰 레이아웃 개선: 자산유형(asset_type) 컬럼 추가 및 너비 조정

- 서버 리스트 모델/메인보드 통합 컬럼 노출 로직 개선 (model_name 우선 표시)

- 자산코드 일괄 재부여 스크립트(batch_reformat_codes.js) 추가 및 유니크 제약조건 회피 로직 반영
2026-05-26 19:26:44 +09:00
82bbe85e23 feat: migrate ServerPC data to asset_pc, enhance filters with location, and standardize page headers
- 서버PC 자산을 asset_pc 테이블로 통합 마이그레이션 및 스키마 확장 (위치, IP 정보 복구 완료)

- 하드웨어 자산 페이지의 구매법인 필터를 자산위치 필터로 교체 및 동적 데이터 바인딩 적용

- 모든 자산 리스트 페이지 상단에 설명(Description) 필드 추가 및 헤더 표준화

- 상세 모달 내 삭제 버튼 기능 구현 및 서버PC 용도 필드 노출 오류 수정

- 현 사용조직 필터 리스트가 비어있던 DOM 셀렉터 버그 수정
2026-05-26 17:33:03 +09:00
d34ebb8500 feat: restructure navigation, customize list columns, and move action buttons to search bar
1. Restructured navigation hierarchy (Hardware, Software, Ops Support, etc.).
2. Customized table columns for all asset categories according to new specs.
3. Moved Template/Upload/Export/Add buttons to search bar with layout optimization.
4. Hidden Asset Code and Previous User from list views (Modal only).
5. Added Current/Previous User and detailed PC spec fields (GPU, HDD3/4).
2026-05-20 14:34:07 +09:00
2af79cdad3 refactor: integrate software assets into unified schema and optimize backend API 2026-04-30 09:34:29 +09:00
68cb5f9767 Fix: Excel upload logic, field mapping for servers, and date format synchronization 2026-04-27 11:12:20 +09:00
8f0508a7d0 Implement global table sorting, dashboard UI enhancements, and secret cloud access 2026-04-27 09:30:47 +09:00
171bcc772b feat: setup Filter branch and apply software management fixes 2026-04-27 09:11:22 +09:00
ab0d25b827 docs: Add branch difference summaries for Gitea issues 2026-04-23 20:44:47 +09:00
d7af75976e fix: Use absolute API paths in upload modal and fix cloud endpoint 2026-04-23 20:34:56 +09:00
dde3aefaac fix: Ensure upload preview modal opens and add debug logs 2026-04-23 20:28:05 +09:00
1fbd297988 feat: Add bulk asset code generation in upload review modal 2026-04-23 20:25:58 +09:00
4b5e25fd3f feat: Implement Excel bulk upload with review modal and domain support 2026-04-23 20:14:51 +09:00
367f72673d feat: 운영 서비스 도메인 관리 기능 추가 및 UI 간격 조정 2026-04-23 20:06:56 +09:00
9fcecd4bf5 feat: 소프트웨어 자산 관리 기능 고도화 및 대시보드 누적 비용 분석 기능 추가 2026-04-23 19:47:07 +09:00
d125de1902 fix: SW modal type switching now works dynamically like HW modal (hidden input ID collision resolved) 2026-04-23 18:53:10 +09:00
d8a0c47fb3 fix: restore Cloud tab under SW menu, change date fields to yyyy-mm-dd, add start_date field 2026-04-23 18:40:58 +09:00
4b88ac01a4 fix: resolve all TypeScript build errors after Setting branch merge 2026-04-23 18:36:33 +09:00
5feaa5f170 Merge origin/setting into SW_Table and resolve conflicts 2026-04-23 18:22:34 +09:00
9365af4522 feat: implement unified schema mapper, enhance UI/UX with responsive design, and optimize asset log logic 2026-04-23 18:00:10 +09:00
55e9cd4cd9 Refactor: SW 상세 모달 동적 필드 전환 및 클라우드 통합, 자산 유형 명칭 일원화 2026-04-23 17:22:38 +09:00
bb1cc36d01 feat: update UI title, restore guide functionality, and simplify server list view 2026-04-23 14:48:06 +09:00
e5b4eb8295 feat: restore database from backup and fix date formatting in restore script 2026-04-23 13:37:45 +09:00
b996b18dbc Complete merge with cleaned main branch 2026-04-23 10:22:08 +09:00
e147b1a191 Fix merge conflicts in HWModal.ts and restore optimized structure 2026-04-23 10:21:08 +09:00
925a55bcc6 Merge latest main with optimized multi-branch features into server_dashboard 2026-04-23 10:18:11 +09:00
809f3fcf3b Merge all feature branches into main and optimize core architecture 2026-04-23 10:16:31 +09:00
9d9c482b76 Merge and optimize server modal with setting branch features 2026-04-23 09:47:09 +09:00
11e2f3b4ca Merge latest code from main into server_dashboard and resolve conflicts 2026-04-23 09:35:53 +09:00
e1cdcfd93a feat: 하드웨어 자동 변경 이력 생성 및 자산 관리 프로세스 고도화 2026-04-22 17:15:58 +09:00
fdc29b23c1 feat: 자산 관리 가이드 추가 및 테이블 스타일 개선 2026-04-22 16:32:57 +09:00
af37df7f2d merge: Equip_table 통합 및 자산번호 YYYYMM 체계 확립 2026-04-22 11:28:18 +09:00
d52c2c4200 feat: 구매연월 표준화 및 자산번호 YYYYMM 형식 적용 2026-04-22 11:24:15 +09:00
fca9f5caf8 Merge branch 'main' of https://gitea.hmac.kr/Taehoon/ITAM 2026-04-21 11:40:59 +09:00
34baea9143 fix: 빌드 에러 및 포트 동기화 수정 2026-04-21 11:40:54 +09:00
90d94739a2 merge: main 브랜치의 최신 엑셀 확장 기능 병합 및 충돌 해결 2026-04-17 15:54:44 +09:00
6053c746a3 merge: main 브랜치의 최신 변경 사항 병합 및 충돌 해결 2026-04-17 15:37:57 +09:00
54bfb9d482 feat: update server asset details, ui labels, and excel mapping logic 2026-04-17 10:34:32 +09:00
7158689fd0 feat: 서버 상세 모달 구매일자 필드 추가 및 특정 법인명 제거 2026-04-16 18:07:19 +09:00
fde7ef8439 feat: 서버 리스트 보안 강화 및 위치 정보 포맷팅 개선, 모달 시스템 안정화
주요 변경 사항:
- 리스트 보안 강화: 서버 자산 리스트에서 IP 주소 및 원격접속 컬럼 제거 (상세 모달에서만 노출)
- 보안 배지 가독성 개선: 상세 모달 내 개별 필드 배지를 '네트워크 정보' 섹션 타이틀 옆으로 통합 이동
- 위치 정보 포맷팅: 서버 리스트 내 '서관/동관' 시작 위치에 'IDC' 접두사 자동 추가 (예: IDC(서관 204번))
- 모달 시스템 복구: 이벤트 위임 방식을 통한 전역 ESC 키 및 닫기 버튼 기능 완벽 복구
- 안정성 확보: BaseModal 초기화 로직 보완 및 동적 DOM 요소 대응 강화
2026-04-15 17:52:37 +09:00
123 changed files with 26587 additions and 9684 deletions

View 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단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화

View 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의 자가 점검 기능과 관리자 추적 기능을 포함함으로써
규정 준수와 운영 효율성을 동시에 확보할 수 있습니다.

View File

@@ -51,6 +51,6 @@
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay) 클릭하면 모달이 닫히도록 구현합니다.
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

File diff suppressed because it is too large Load Diff

View 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열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -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>

View File

@@ -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 등)를 정규화하여 관리 효율을 극대화한다.

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}>&times;</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

View File

@@ -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">&times;</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;

View File

@@ -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"
}
];

View File

@@ -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',
},
];

View File

@@ -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%;
}

View File

@@ -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>,
)

View File

@@ -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번,,,,,,,,,,,,,,,,,,,,,,,,,
1 IDC
2 서버목록 한맥 LMS 서버 mdf
3 회사 서버번호 구분 비고 설치위치 담당자 IP 주소 원격접속 모니터링 여부 서버 이름 ID 일치 여부, 전 서버 이름 제조사 및 모델명 OS CPU RAM Storage1 Storage2 Storage3 계산서 삼안 XR플래그십 스토리지 20250414
4 IP IP2 접속도구 아이디 비빌번호 장헌 스토리지, 서버 mdf 추정
5 한맥 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
6 Remote Util 211.206.127.70 1234아이티!
7 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
8 삼안 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 추정 삼안
9 Remote Util 118.220.172.237 1234아이티!
10 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
11 Remote Util 678-605-383-130 1234아이티!
12 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
13 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
14 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
15 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 구매교체 삼안
16 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
17 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 설치 삼안
18 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 설치 삼안
19 한라 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
20 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
21 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
22 이전 : 하수도자산 소스+프로그램 현재 : 큰길 서비스용 xampp+ PostgreSQL, BEPs 원격데스크탑 administrator Hanmac2141!% src775d3e5df
23 장헌 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
24 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 추정 장헌
25 원격데스크탑 Administrator Hanmac2141!
26 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
27 원격데스크탑 User Hanmac2141!
28 (주)장헌 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
29 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
30 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 추정 장헌
31 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
32 바론 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 추정 바론
33 현타 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
34
35
36
37 스토리지 목록
38 회사 서버번호 구분 비고 설치위치 담당자 IP 주소 원격접속 모델명 용량
39 접속도구 아이디 비빌번호
40 삼안 sa-das-001 Satis01, Satis02 광케이블 연결 (물리연결) 서관 205번
41 sa-nas-001 인트라넷 백업 스토리지 서관 203번 (IDC) 118.220.172.246 원격 administrator sg11707808 Promiss R Series 36TB
42 sa-nas-002 성과품 스토리지 서관 205번 (IDC) 118.220.172.248 원격 administrator sg11707808 ENC_3U_16BAY_D // SEAGATE ST2000NM0045 23TB 20190603 추정 삼안
43 매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요) (매니지먼트) 118.220.172.247 원격 - -
44 sa-nas-003 성과품 백업 스토리지 서관 202번 (IDC) 118.220.172.241 원격 administrator saman1! Promiss R Series 48TB 20250313 삼안
45 (매지니먼트) 118.220.172.240 원격 admin0 Root1234
46 한라 hl-das-001 파일서버 정보 없음(접속 불가) 동관 54번 20190701 추정 한라 (한라 파일서버도 동일일자 추정)
47 hl-das-002 동관 54번

File diff suppressed because it is too large Load Diff

211
build/pc_agent/EXE-00.toc Normal file
View 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
View 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

Binary file not shown.

1193
build/pc_agent/PYZ-00.toc Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
build/pc_agent/pc_agent.pkg Normal file

Binary file not shown.

View 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)

File diff suppressed because it is too large Load Diff

View File

@@ -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!');

View File

@@ -15,9 +15,8 @@ async function initDB() {
multipleStatements: true
});
console.log('🔄 DB 초기화 시작 (표준 스키마 적용)...');
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'
@@ -26,27 +25,27 @@ async function initDB() {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
}
// 공통 하드웨어 테이블 생성 함수
const createHardwareTable = (tableName, comment) => `
CREATE TABLE ${tableName} (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
asset_code VARCHAR(100) COMMENT '자산번호',
purchase_date VARCHAR(50) COMMENT '구매일자',
type VARCHAR(50) COMMENT '유형',
detail_purpose VARCHAR(50) COMMENT '상세용도',
purpose VARCHAR(255) COMMENT '용도',
details TEXT COMMENT '상세내용',
current_org VARCHAR(255) COMMENT '현 사용조직',
prev_org VARCHAR(255) COMMENT '이전 사용조직',
location VARCHAR(255) COMMENT '설치위치',
manager_main VARCHAR(100) COMMENT '담당자(정)',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
ip_address VARCHAR(100) COMMENT 'IP 주소 1',
remote_tool VARCHAR(100) COMMENT '원격도구',
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),
@@ -55,54 +54,58 @@ async function initDB() {
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100) COMMENT '금액',
price VARCHAR(100),
remarks TEXT,
storage_location VARCHAR(255),
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='${comment}';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
await connection.query(createHardwareTable('pc_assets', '개인PC 자산'));
await connection.query(createHardwareTable('server_assets', '서버 자산'));
await connection.query(createHardwareTable('storage_assets', '스토리지 자산'));
await connection.query(createHardwareTable('equip_assets', '전산비품 자산'));
await connection.query(createHardwareTable('mobile_assets', '모바일기기 자산'));
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 '구매법인',
asset_code 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 '납품업체',
vendor VARCHAR(255) COMMENT '구매업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 소프트웨어 영구 테이블
await connection.query(`
CREATE TABLE sw_perm_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
asset_code 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 '구매일',
vendor VARCHAR(255) 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 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 클라우드 자산 테이블
await connection.query(`
CREATE TABLE cloud_assets (
id VARCHAR(50) PRIMARY KEY,
@@ -117,37 +120,53 @@ async function initDB() {
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 소프트웨어 사용자 매핑 테이블
await connection.query(`
CREATE TABLE sw_users (
id INT AUTO_INCREMENT PRIMARY KEY,
sw_id VARCHAR(50) COMMENT 'SW 자산 ID',
corp VARCHAR(100) COMMENT '법인',
dept VARCHAR(100) COMMENT '부서',
position VARCHAR(50) COMMENT '직위',
user_name VARCHAR(100) COMMENT '이름',
usage_period VARCHAR(100) COMMENT '사용기간',
doc_name VARCHAR(255) COMMENT '신청서명',
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 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
// 변경 이력 테이블
await connection.query(`
CREATE TABLE asset_logs (
id VARCHAR(50) PRIMARY KEY,
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 COLLATE=utf8mb4_unicode_ci;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 표준화된 스키마로 재생성되었습니다.');
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();
}

View File

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

BIN
image 92.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

@@ -1,57 +1,60 @@
<!DOCTYPE html>
<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" />
<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>
<body>
<div class="app-layout">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<h1>HM <span>ITAM</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<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>
<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/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>
<body>
<div class="app-layout">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<img src="/image 92.png" alt="Logo" class="main-logo" />
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<div class="header-actions">
<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>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
<!-- Footer -->
<footer class="main-footer">
<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>
<p>Powered by BARON Consultant Co,Ltd</p>
</footer>
</div>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

620
map_config.json Normal file
View File

@@ -0,0 +1,620 @@
{
"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"
}
]
}

40
map_editor.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>ITAM Map Coordinate Editor v3.0</title>
</head>
<body>
<!-- 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 class="btn btn-outline" style="height:38px;" onclick="clearAll()">전체 삭제</button>
<button id="btn-save-server" class="btn btn-primary" style="height:38px;" onclick="saveToServer()">서버에 즉시 저장</button>
<div id="save-status"></div>
</div>
</div>
<script type="module" src="/src/map-editor-main.ts"></script>
</body>
</html>

1
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"

View File

@@ -18,6 +18,7 @@
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"

134
pc_agent.py Normal file
View 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
View 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,
)

451
server.js
View File

@@ -2,14 +2,19 @@ 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();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.json({ limit: '100mb' }));
// Request Logger
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
const pool = mysql.createPool({
host: process.env.DB_HOST,
@@ -17,345 +22,165 @@ const pool = mysql.createPool({
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
charset: 'utf8mb4'
});
// 테이블 존재 여부 확인 및 자동 생성
async function ensureTables() {
const connection = await pool.getConnection();
try {
await connection.query(`
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS asset_logs (
id VARCHAR(50) PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ Cloud & Logs tables ensured.');
} finally {
connection.release();
}
}
const handleError = (res, err, context, isGet = false) => {
console.error(`❌ [${context}] Error:`, err.message);
if (isGet) res.json([]);
else res.status(500).json({ error: err.message });
};
// 공통 배치 저장 로직
async function batchSave(tableName, assets, getQuery) {
// --- API Implementation ---
/**
* Generic Fetcher for Asset Tables
*/
const fetchAssets = async (tableName, res, context) => {
try {
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
res.json(rows);
} catch (err) {
handleError(res, err, context, true);
}
};
/**
* Generic Batch Saver for Asset Tables
*/
const saveAssetsBatch = async (tableName, items, res, context) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Get valid columns for this table
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
const validColumns = cols.map(c => c.Field);
// 1. Clear existing
await connection.query(`DELETE FROM ${tableName}`);
if (assets.length > 0) {
const { sql, values } = getQuery(assets);
await connection.query(sql, [values]);
// 2. Insert new items
for (const item of items) {
const filteredRow = {};
validColumns.forEach(col => {
if (col === 'created_at' || col === 'updated_at') return;
if (item[col] !== undefined) filteredRow[col] = item[col];
});
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
}
await connection.commit();
return { success: true, count: assets.length };
res.json({ success: true, count: items.length });
} catch (err) {
await connection.rollback();
throw err;
handleError(res, err, context);
} finally {
connection.release();
}
}
};
// 하드웨어 쿼리 헬퍼
const hardwareInsertSQL = (table) => `
INSERT INTO ${table} (
id, corp, asset_code, purchase_date, type, detail_purpose, purpose, details,
current_org, prev_org, location, manager_main, manager_sub, ip_address,
remote_tool, server_id, server_pw, model_name, os, cpu, ram, gpu,
storage1, storage2, storage3, monitoring, price, remarks,
storage_location, status
) VALUES ?
`;
// --- Routes ---
const getHardwareValues = (a) => [
a.id, a.법인||'', a.자산코드||'', a.구매일||'', a.type||'', a.상세용도||'', a.용도||'', a.상세||'',
a.현사용조직||'', a.이전사용조직||'', a.위치||'', a.담당자_정||'', a.담당자_부||'', a.IP주소||'',
a.원격접속||'', a.서버ID||'', a.서버PW||'', a.모델명||'', a.OS||'', a.CPU||'', a.RAM||'', a.GPU||'',
a.SSD1||'', a.SSD2||'', a.HDD1||'', a.모니터링||'', a.금액||'', a.비고||'',
a.보관위치||'', a.현재상태||''
];
const routeMap = {
'/api/users': { table: 'system_users', context: 'USERS' },
'/api/pc': { table: 'asset_pc', context: 'PC' },
'/api/server': { table: 'asset_server', context: 'SERVER' },
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
'/api/network': { table: 'asset_network', context: 'NETWORK' },
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
'/api/cost': { table: 'asset_cost', context: 'COST' },
'/api/vip': { table: 'asset_vip', context: 'VIP' },
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
};
const mapHardware = (r, defaultType) => ({
id: r.id, 법인: r.corp, 자산코드: r.asset_code, 구매일: r.purchase_date, type: r.type || defaultType,
상세용도: r.detail_purpose, 용도: r.purpose, 상세: r.details, 현사용조직: r.current_org,
이전사용조직: r.prev_org, 위치: r.location, 담당자_정: r.manager_main, 담당자_부: r.manager_sub,
IP주소: r.ip_address, 원격접속: r.remote_tool, 서버ID: r.server_id, 서버PW: r.server_pw,
모델명: r.model_name, OS: r.os, CPU: r.cpu, RAM: r.ram, GPU: r.gpu, SSD1: r.storage1,
SSD2: r.storage2, HDD1: r.storage3, 모니터링: r.monitoring, 금액: r.price, 비고: r.remarks,
보관위치: r.storage_location, 현재상태: r.status
Object.entries(routeMap).forEach(([route, { table, context }]) => {
app.get(route, (req, res) => fetchAssets(table, res, context));
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
});
// --- API 라우트 정의 ---
// PC API
app.get('/api/pc', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM pc_assets');
console.log('🔍 DB Raw Rows (PC):', rows.length, 'items found.');
if (rows.length > 0) console.log('🔍 First row sample:', rows[0]);
res.json(rows.map(r => mapHardware(r, '개인PC')));
} catch (err) {
console.error('❌ DB Query Error (PC):', err.message);
res.status(500).json({ error: err.message });
}
});
app.post('/api/pc/batch', async (req, res) => {
try {
const result = await batchSave('pc_assets', req.body, (assets) => ({
sql: hardwareInsertSQL('pc_assets'),
values: assets.map(getHardwareValues)
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 서버 API
app.get('/api/server', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM server_assets');
res.json(rows.map(r => mapHardware(r, '서버')));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/server/batch', async (req, res) => {
try {
const result = await batchSave('server_assets', req.body, (assets) => ({
sql: hardwareInsertSQL('server_assets'),
values: assets.map(getHardwareValues)
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 스토리지 API
app.get('/api/storage', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM storage_assets');
res.json(rows.map(r => mapHardware(r, '스토리지')));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/storage/batch', async (req, res) => {
try {
const result = await batchSave('storage_assets', req.body, (assets) => ({
sql: hardwareInsertSQL('storage_assets'),
values: assets.map(getHardwareValues)
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 전산비품 API
app.get('/api/equip', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM equip_assets');
res.json(rows.map(r => mapHardware(r, '전산비품')));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/equip/batch', async (req, res) => {
try {
const result = await batchSave('equip_assets', req.body, (assets) => ({
sql: hardwareInsertSQL('equip_assets'),
values: assets.map(getHardwareValues)
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 모바일 API
app.get('/api/mobile', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM mobile_assets');
res.json(rows.map(r => mapHardware(r, '모바일기기')));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/mobile/batch', async (req, res) => {
try {
const result = await batchSave('mobile_assets', req.body, (assets) => ({
sql: hardwareInsertSQL('mobile_assets'),
values: assets.map(getHardwareValues)
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 구독 SW API
app.get('/api/sw/sub', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_sub_assets');
res.json(rows.map(r => ({
id: r.id, type: '구독SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name,
라이선스유형: r.license_type, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date,
만료일: r.expiry_date, 납품업체: r.vendor, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/sw/sub/batch', async (req, res) => {
try {
const result = await batchSave('sw_sub_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_sub_assets (id, corp, asset_code, product_name, license_type, quantity, price, purchase_date, expiry_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스유형||'', a.수량||0, a.금액||'', a.구매일||'', a.만료일||'', a.납품업체||'', a.비고||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 영구 SW API
app.get('/api/sw/perm', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_perm_assets');
res.json(rows.map(r => ({
id: r.id, type: '영구SW', 법인: r.corp, 자산번호: r.asset_code, 제품명: r.product_name,
라이선스키: r.license_key, 수량: r.quantity, 금액: r.price, 구매일: r.purchase_date,
납품업체: r.vendor, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/sw/perm/batch', async (req, res) => {
try {
const result = await batchSave('sw_perm_assets', req.body, (assets) => ({
sql: `INSERT INTO sw_perm_assets (id, corp, asset_code, product_name, license_key, quantity, price, purchase_date, vendor, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.법인||'', a.자산번호||'', a.제품명||'', a.라이선스키||'', a.수량||0, a.금액||'', a.구매일||'', a.납품업체||'', a.비고||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 클라우드 API
app.get('/api/cloud', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM cloud_assets');
res.json(rows.map(r => ({
id: r.id, type: '클라우드', 플랫폼명: r.platform_name, 법인: r.corp, 부서: r.dept,
제품명: r.product_name, 계정명: r.account_name, 결제수단: r.pay_method,
결제일: r.pay_day, 연결카드번호: r.card_num, 당월청구액: r.monthly_fee, 비고: r.remarks
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/cloud/batch', async (req, res) => {
try {
const result = await batchSave('cloud_assets', req.body, (assets) => ({
sql: `INSERT INTO cloud_assets (id, platform_name, corp, dept, product_name, account_name, pay_method, pay_day, card_num, monthly_fee, remarks) VALUES ?`,
values: assets.map(a => [a.id, a.플랫폼명||'', a.법인||'', a.부서||'', a.제품명||'', a.계정명||'', a.결제수단||'', a.결제일||'', a.연결카드번호||'', a.당월청구액||'', a.비고||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// 로그 API
app.get('/api/logs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM asset_logs ORDER BY log_date DESC');
res.json(rows.map(r => ({
id: r.id, assetId: r.asset_id, date: r.log_date, user: r.log_user, details: r.details
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/logs/batch', async (req, res) => {
try {
const result = await batchSave('asset_logs', req.body, (assets) => ({
sql: `INSERT INTO asset_logs (id, asset_id, log_date, log_user, details) VALUES ?`,
values: assets.map(a => [a.id, a.assetId||'', a.date||'', a.user||'', a.details||''])
}));
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// SW 사용자 API
app.get('/api/sw-users', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM sw_users');
const grouped = rows.reduce((acc, u) => {
if (!acc[u.sw_id]) acc[u.sw_id] = [];
acc[u.sw_id].push([u.corp, u.dept, u.position, u.user_name, u.usage_period, u.doc_name]);
return acc;
}, {});
res.json(Object.keys(grouped).map(sw_id => ({ sw_id, userData: grouped[sw_id] })));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/sw-users/batch', async (req, res) => {
try {
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
app.post('/api/asset/history/batch', async (req, res) => {
const connection = await pool.getConnection();
await connection.beginTransaction();
await connection.query('DELETE FROM sw_users');
const allUsers = req.body;
if (allUsers.length > 0) {
const values = allUsers.flatMap(item =>
(item.userData || []).map(u => [item.sw_id, u[0], u[1], u[2], u[3], u[4], u[5]])
);
if (values.length > 0) {
await connection.query('INSERT INTO sw_users (sw_id, corp, dept, position, user_name, usage_period, doc_name) VALUES ?', [values]);
}
}
await connection.commit();
connection.release();
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
try {
await connection.beginTransaction();
await connection.query('DELETE FROM asset_history');
for (const item of req.body) {
const dbRow = {
asset_id: item.assetId,
log_date: item.date,
log_user: item.user,
details: item.details,
cost: item.cost || 0
};
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
}
await connection.commit();
res.json({ success: true });
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
});
// 자산번호 자동 생성 API
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try {
const tables = ['pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets'];
let maxNum = 0;
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
let lastCode = '';
for (const table of tables) {
const [rows] = await pool.query(
`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`,
[`${prefix}%`]
);
rows.forEach(r => {
const numPart = r.asset_code.replace(prefix, '');
const num = parseInt(numPart);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
}
let nextNum = 1;
if (lastCode) {
const lastNum = parseInt(lastCode.split('-').pop() || '0');
nextNum = lastNum + 1;
}
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
});
const nextNum = (maxNum + 1).toString().padStart(3, '0');
res.json({ nextCode: `${prefix}${nextNum}` });
// 6. Map Config API (Real-time Save)
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) {
res.status(500).json({ error: err.message });
handleError(res, err, 'GET MAPS');
}
});
// 초기화 및 서버 기동
ensureTables().then(() => {
app.listen(PORT, () => {
console.log(`📡 ITAM Dedicated API Server running on http://localhost:${PORT}`);
});
}).catch(err => {
console.error('❌ Failed to start server:', err);
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));
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE MAPS');
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
});

279
src/components/Guide.ts Normal file
View 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 } });
}

View File

@@ -1,32 +1,121 @@
import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils';
/**
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
*/
export function initBaseModal() {
const closeAllModals = () => {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
};
export abstract class BaseModal {
protected idPrefix: string;
protected title: string;
protected currentAsset: any | null = null;
protected isEditMode: boolean = false;
protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null;
// ESC 키로 닫기
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllModals();
});
constructor(idPrefix: string, title: string) {
this.idPrefix = idPrefix;
this.title = title;
}
// 배경(Overlay) 클릭 시 닫기 (동적 생성된 모달 대응을 위해 이벤트 위임 고려 가능하나 일단 단순 구현)
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('modal-overlay')) {
closeAllModals();
/**
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
*/
public init(onSave: () => void, closeModalsFn: () => void) {
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
}
});
return { closeAllModals };
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
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`
});
}
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
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) {
const modal = document.getElementById(modalId);
if (modal) {

View File

@@ -1,317 +0,0 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Save, X, Edit2, RotateCcw, History, Plus } from 'lucide';
const CLOUD_MODAL_HTML = `
<div id="cloud-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="cloud-modal-title">클라우드 서비스 상세</h2>
<button id="btn-close-cloud-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="cloud-asset-form" class="grid-form">
<input type="hidden" id="cloud-asset-id" />
<div class="form-group"><label>플랫폼명</label><input type="text" id="cloud-플랫폼명" placeholder="예: AWS, Cafe24" required /></div>
<div class="form-group">
<label>담당법인</label>
<select id="cloud-법인" required>
<option value="한맥">한맥</option><option value="삼안">삼안</option><option value="바론">바론</option>
</select>
</div>
<div class="form-group" style="grid-column: span 2;"><label>사용용도(프로젝트/제품명)</label><input type="text" id="cloud-제품명" required /></div>
<div class="form-group"><label>담당부서</label><input type="text" id="cloud-부서" /></div>
<div class="form-group"><label>계정명(이메일)</label><input type="text" id="cloud-계정명" /></div>
<div class="form-group"><label>결제수단</label>
<select id="cloud-결제수단">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group"><label>연결카드번호(뒷4자리)</label><input type="text" id="cloud-연결카드번호" placeholder="1234" /></div>
<div class="form-group"><label>결제일(기준일)</label><input type="number" min="1" max="31" id="cloud-결제일" placeholder="15" /></div>
<div class="form-group"><label>당월 청구액(원)</label><input type="text" id="cloud-당월청구액" placeholder="0" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" /></div>
<div class="form-group" style="grid-column: span 2;"><label>비고</label><input type="text" id="cloud-비고" /></div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-cloud-update" class="btn btn-outline btn-sm"><i data-lucide="plus" style="width:14px;height:14px;"></i> 내역 추가</button>
</div>
<div id="cloud-history-list" class="history-timeline">
<div class="empty-history">내역이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer" style="justify-content: space-between;">
<button id="btn-delete-cloud-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-cloud-edit" class="btn btn-outline hidden">취소</button>
<button id="btn-close-cloud-footer" class="btn btn-outline">닫기</button>
<button id="btn-save-cloud-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<div id="cloud-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>클라우드 결제/이력 업데이트</h2>
<button id="btn-close-cloud-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="cloud-update-date" />
</div>
<div class="form-group">
<label>청구 금액(원)</label>
<input type="text" id="cloud-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 150,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="cloud-update-note" placeholder="예: 트래픽 초과로 인한 요금 증가" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-cloud-update" class="btn btn-outline">취소</button>
<button id="btn-save-cloud-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
`;
export let currentCloudAsset: SoftwareAsset | null = null;
export let isCloudEditMode = false;
export function setCloudEditMode(edit: boolean) {
isCloudEditMode = edit;
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const btnSave = document.getElementById('btn-save-cloud-asset') as HTMLButtonElement;
const btnRevert = document.getElementById('btn-revert-cloud-edit') as HTMLButtonElement;
const btnClose = document.getElementById('btn-close-cloud-footer') as HTMLButtonElement;
if (edit) {
form.classList.add('is-edit-mode');
form.classList.remove('is-view-mode');
btnSave.textContent = '저장';
btnRevert.classList.remove('hidden');
btnClose.classList.add('hidden');
Array.from(form.elements).forEach((el: any) => el.disabled = false);
} else {
form.classList.add('is-view-mode');
form.classList.remove('is-edit-mode');
btnSave.textContent = '수정';
btnRevert.classList.add('hidden');
btnClose.classList.remove('hidden');
Array.from(form.elements).forEach((el: any) => el.disabled = true);
if (currentCloudAsset) fillCloudFormData(currentCloudAsset);
}
}
export function fillCloudFormData(asset: SoftwareAsset) {
(document.getElementById('cloud-asset-id') as HTMLInputElement).value = asset.id;
(document.getElementById('cloud-플랫폼명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-법인') as HTMLSelectElement).value = asset. || '한맥';
(document.getElementById('cloud-제품명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-부서') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-계정명') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-결제수단') as HTMLSelectElement).value = asset. || '';
(document.getElementById('cloud-연결카드번호') as HTMLInputElement).value = asset. || '';
(document.getElementById('cloud-결제일') as HTMLInputElement).value = asset. || '';
const billing = asset. ? asset..replace(/[^0-9]/g, '') : '';
(document.getElementById('cloud-당월청구액') as HTMLInputElement).value = billing ? Number(billing).toLocaleString() : '';
(document.getElementById('cloud-비고') as HTMLInputElement).value = asset. || '';
document.getElementById('btn-open-cloud-update')!.style.display = 'flex';
renderCloudHistory(asset.id);
}
function renderCloudHistory(assetId: string) {
const historyList = document.getElementById('cloud-history-list');
if (!historyList) return;
if (!state.masterData.logs) state.masterData.logs = [];
const logs = state.masterData.logs
.filter(l => l.assetId === assetId)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (logs.length === 0) {
historyList.innerHTML = '<div class="empty-history">업데이트 내역이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">작업자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
createIcons({ icons: { X, History, Plus } });
}
export function initCloudModal(renderContent: () => void, closeModals: () => void) {
if (!document.getElementById('cloud-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', CLOUD_MODAL_HTML);
}
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const btnRevert = document.getElementById('btn-revert-cloud-edit');
const btnSave = document.getElementById('btn-save-cloud-asset');
const btnDelete = document.getElementById('btn-delete-cloud-asset');
document.getElementById('btn-close-cloud-modal')?.addEventListener('click', closeModals);
document.getElementById('btn-close-cloud-footer')?.addEventListener('click', closeModals);
btnRevert?.addEventListener('click', (e) => {
e.preventDefault();
setCloudEditMode(false);
});
btnSave?.addEventListener('click', (e) => {
e.preventDefault();
if (!isCloudEditMode) {
setCloudEditMode(true);
return;
}
if (!form.checkValidity()) { form.reportValidity(); return; }
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
const billingRaw = (document.getElementById('cloud-당월청구액') as HTMLInputElement).value.replace(/[^0-9]/g, '');
const newAsset: SoftwareAsset = {
id: id || Math.random().toString(36).substring(2, 9),
type: '클라우드',
: (document.getElementById('cloud-플랫폼명') as HTMLInputElement).value,
: (document.getElementById('cloud-법인') as HTMLSelectElement).value,
: (document.getElementById('cloud-제품명') as HTMLInputElement).value,
: (document.getElementById('cloud-부서') as HTMLInputElement).value,
: (document.getElementById('cloud-계정명') as HTMLInputElement).value,
: (document.getElementById('cloud-결제수단') as HTMLSelectElement).value,
: (document.getElementById('cloud-연결카드번호') as HTMLInputElement).value,
: (document.getElementById('cloud-결제일') as HTMLInputElement).value,
당월청구액: billingRaw,
: (document.getElementById('cloud-비고') as HTMLInputElement).value,
: '', : '', 수량: 1, : ''
};
if (id) {
const idx = state.masterData.sw.findIndex(a => a.id === id);
if (idx !== -1) state.masterData.sw[idx] = newAsset;
} else {
state.masterData.sw.push(newAsset);
const now = new Date();
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: newAsset.id,
date: `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`,
user: '관리자',
details: '신규 등록'
});
}
closeModals();
renderContent();
});
btnDelete?.addEventListener('click', (e) => {
e.preventDefault();
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
if (confirm('클라우드 자산을 삭제하시겠습니까?')) {
state.masterData.sw = state.masterData.sw.filter(a => a.id !== id);
closeModals();
renderContent();
}
});
// 클라우드 업데이트 (이력) 모달 로직
const updateModal = document.getElementById('cloud-update-modal')!;
document.getElementById('btn-open-cloud-update')?.addEventListener('click', () => {
updateModal.classList.remove('hidden');
(document.getElementById('cloud-update-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('cloud-update-cost') as HTMLInputElement).value = '';
(document.getElementById('cloud-update-note') as HTMLInputElement).value = '';
});
const closeUpdateModal = () => updateModal.classList.add('hidden');
document.getElementById('btn-close-cloud-update')?.addEventListener('click', closeUpdateModal);
document.getElementById('btn-cancel-cloud-update')?.addEventListener('click', closeUpdateModal);
document.getElementById('btn-save-cloud-update')?.addEventListener('click', () => {
const id = (document.getElementById('cloud-asset-id') as HTMLInputElement).value;
if (!id) return;
const date = (document.getElementById('cloud-update-date') as HTMLInputElement).value;
const costRaw = (document.getElementById('cloud-update-cost') as HTMLInputElement).value.replace(/[^0-9]/g, '');
const note = (document.getElementById('cloud-update-note') as HTMLInputElement).value;
if (!date) return alert('업데이트 일자를 입력하세요.');
let details = '결제/상태 업데이트';
if (costRaw) details += ` (비용: ₩ ${Number(costRaw).toLocaleString()})`;
if (note) details += `\n메모: ${note}`;
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: id,
date,
user: '관리자',
details
});
// 금액 업데이트 반영
if (costRaw) {
const idx = state.masterData.sw.findIndex(a => a.id === id);
if (idx !== -1) {
state.masterData.sw[idx]. = costRaw;
(document.getElementById('cloud-당월청구액') as HTMLInputElement).value = Number(costRaw).toLocaleString();
}
}
closeUpdateModal();
renderCloudHistory(id);
renderContent();
});
createIcons({ icons: { Save, X, Edit2, RotateCcw, History, Plus } });
}
export function openCloudModal(asset?: SoftwareAsset) {
currentCloudAsset = asset || null;
const form = document.getElementById('cloud-asset-form') as HTMLFormElement;
const deleteBtn = document.getElementById('btn-delete-cloud-asset')!;
openModal('cloud-asset-modal');
form.reset();
if (asset) {
document.getElementById('cloud-modal-title')!.textContent = '클라우드 서비스 상세';
deleteBtn.style.display = 'block';
fillCloudFormData(asset);
setCloudEditMode(false);
} else {
document.getElementById('cloud-modal-title')!.textContent = '신규 클라우드 서비스 등록';
deleteBtn.style.display = 'none';
(document.getElementById('cloud-asset-id') as HTMLInputElement).value = '';
document.getElementById('btn-open-cloud-update')!.style.display = 'none';
renderCloudHistory('');
setCloudEditMode(true);
}
createIcons({ icons: { History, Plus } });
}

View File

@@ -1,5 +1,6 @@
import { HardwareAsset, SoftwareAsset } from '../../core/excelHandler';
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">
@@ -37,9 +38,11 @@ export function initDashboardDetailModal() {
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: HardwareAsset[]) {
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');
@@ -49,23 +52,23 @@ export function openDashboardDetail(title: string, list: HardwareAsset[]) {
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>자산코드</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일</th><th>금액</th></tr>`;
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="8" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset. || asset. || asset._정 || '-';
let name = asset. || asset. || '-';
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.type}</td><td>${asset.}</td><td>${name}</td><td>${asset.||'-'}</td><td>${manager}</td><td>${asset.||'-'}</td><td>${asset.||'-'}</td>`;
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: SoftwareAsset[]) {
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');
@@ -79,13 +82,13 @@ export function openSwDashboardDetail(title: string, list: SoftwareAsset[]) {
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.type}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td><td>${sw.}</td>`;
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: SoftwareAsset[]) {
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');
@@ -98,15 +101,16 @@ export function openSwUsageDetail(title: string, list: SoftwareAsset[]) {
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const 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.}</td><td>${sw.}</td><td>${sw.}</td><td>${assigned}</td><td>${Number(sw.) - assigned}</td>`;
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: SoftwareAsset[]) {
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');
@@ -116,15 +120,15 @@ export function openCloudDashboardDetail(title: string, list: SoftwareAsset[]) {
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>플랫폼</th><th>법인</th><th>제품명</th><th>결제일</th><th>당월청구액(원)</th></tr>`;
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. ? Number(sw..replace(/[^0-9]/g, '')).toLocaleString() : '0';
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.||'-'}</td><td>${sw.||'-'}</td><td>${sw.||'-'}</td><td>${sw. ? sw. + '일' : '-'}</td><td>₩ ${priceStr}</td>`;
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);
});
}

View 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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,11 +26,11 @@ export function getFieldValue(id: string): string {
}
// 4. 위치 정보 파싱 및 UI 세팅
export function parseAndSetLocation(locationStr: string, bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
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 = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null;
const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null;
if (!bldgSelect || !detailSelect) return;
@@ -39,22 +39,19 @@ export function parseAndSetLocation(locationStr: string, bldgId: string, detailI
detailSelect.innerHTML = '<option value="">선택</option>';
if (etcGroup) etcGroup.style.display = 'none';
if (!locationStr) return;
if (!bldg) return;
const parts = locationStr.split(' ');
const bldg = parts[0];
if (LOCATION_DATA[bldg]) {
bldgSelect.value = bldg;
// 상세 목록 갱신
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
const detail = parts[1];
if (detail) {
detailSelect.value = detail;
if (detail === '기타' && etcGroup && etcInput) {
etcGroup.style.display = 'flex';
etcInput.value = parts.slice(2).join(' ');
// 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나
// 하위 호환성을 위해 남겨둠
}
}
}
@@ -103,13 +100,15 @@ export function setEditLock(
options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: 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;
@@ -118,10 +117,14 @@ export function setEditLock(
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김 (닫기가 대신함)
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
// 번호 생성 버튼은 '추가' 시에만 노출
if (generateBtn) generateBtn.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');
@@ -129,7 +132,111 @@ export function setEditLock(
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
// 조회 모드에서는 번호 생성 버튼 무조건 숨김
if (generateBtn) generateBtn.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)) {
// 형식이 맞지 않으면 경고 효과 등을 줄 수 있음
}
});
}

View File

@@ -1,362 +0,0 @@
import { state, saveHardwareAsset, deleteHardwareAsset } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { createIcons, History, X, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST, HW_TYPE_LIST, LOCATION_DATA } from './SharedData';
import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
parseAndSetLocation,
bindLocationEvents,
getCombinedLocation
} from './ModalUtils';
let currentAsset: HardwareAsset | null = null;
let isEditMode = false;
const PC_MODAL_HTML = `
<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-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="pc-법인">구매법인</label>
<select id="pc-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-자산코드">자산번호/코드</label>
<input type="text" id="pc-자산코드" readonly placeholder="자동 생성됩니다" required />
</div>
<div class="form-group">
<label for="pc-유형">유형</label>
<select id="pc-유형">${generateOptionsHTML(HW_TYPE_LIST)}</select>
</div>
<div class="form-group">
<label for="pc-상세용도">상세용도</label>
<select id="pc-상세용도">
<option value="개인PC">개인PC</option>
<option value="서버">서버</option>
</select>
</div>
<div class="form-group">
<label for="pc-사용자">사용자</label>
<input type="text" id="pc-사용자" required />
</div>
<div class="form-group">
<label for="pc-현사용조직">현 사용조직</label>
<select id="pc-현사용조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group" id="pc-이전사용조직-group">
<label for="pc-이전사용조직">이전 사용조직</label>
<input type="text" id="pc-이전사용조직" readonly />
</div>
<div class="form-section-title">시스템 사양 (Specifications)</div>
<div class="form-group">
<label for="pc-모델명">모델명</label>
<input type="text" id="pc-모델명" />
</div>
<div class="form-group">
<label for="pc-OS">운영체제 (OS)</label>
<input type="text" id="pc-OS" />
</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-RAM">RAM 용량</label>
<input type="text" id="pc-RAM" />
</div>
<div class="form-group">
<label for="pc-SSD1">Storage 1 (SSD/HDD)</label>
<input type="text" id="pc-SSD1" />
</div>
<div class="form-group">
<label for="pc-SSD2">Storage 2 (SSD/HDD)</label>
<input type="text" id="pc-SSD2" />
</div>
<div class="form-section-title" id="pc-location-title">관리 및 운영 (Operation)</div>
<div class="form-group pc-location-field">
<label for="pc-위치-빌딩">설치위치 (건물)</label>
<select id="pc-위치-빌딩">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div>
<div class="form-group pc-location-field">
<label for="pc-위치-상세">상세 위치</label>
<select id="pc-위치-상세">
<option value="">건물을 먼저 선택하세요</option>
</select>
</div>
<div class="form-group" id="pc-위치-기타-group" style="display:none;">
<label for="pc-위치-기타">직접 입력 (기타)</label>
<input type="text" id="pc-위치-기타" />
</div>
<div class="form-group">
<label for="pc-구매일">구매일</label>
<input type="text" id="pc-구매일" />
</div>
<div class="form-group">
<label for="pc-금액">금액</label>
<input type="text" id="pc-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\d))/g, ',')" />
</div>
<div class="form-group">
<label for="pc-납품업체">납품업체</label>
<input type="text" id="pc-납품업체" />
</div>
<div class="form-group full-width">
<label>품의서 (파일)</label>
<div style="display:flex; align-items:center; gap:0.5rem;">
<input type="file" id="pc-품의서" />
<span id="pc-품의서명" style="font-size:0.75rem; color:var(--text-light)"></span>
</div>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 수정 이력</h3>
</div>
<div id="pc-history-list" class="history-timeline">
<div class="empty-history">이력이 없습니다.</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-pc-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-pc-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-pc-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-pc-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
export function openPcModal(asset: HardwareAsset, mode: 'view' | 'add' = 'view') {
currentAsset = asset;
const modal = document.getElementById('pc-asset-modal');
if (!modal) return;
const form = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset')!;
const revertBtn = document.getElementById('btn-revert-pc-edit')!;
if (form) form.reset();
if (mode === 'add') {
isEditMode = true;
if (form) {
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
}
saveBtn.textContent = '저장';
revertBtn.classList.add('hidden');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'none';
} else {
isEditMode = false;
if (form) {
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
}
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
const prevOrgGroup = document.getElementById('pc-이전사용조직-group');
if (prevOrgGroup) prevOrgGroup.style.display = 'flex';
}
fillFormData(asset);
renderHistory(asset.id);
modal.classList.remove('hidden');
applyPcTypeSpecificUI();
createIcons({ icons: { X, History, Paperclip } });
}
function applyPcTypeSpecificUI() {
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const modelGroup = document.getElementById('pc-모델명')?.closest('.form-group') as HTMLElement;
const osGroup = document.getElementById('pc-OS')?.closest('.form-group') as HTMLElement;
const cpuGroup = document.getElementById('pc-CPU')?.closest('.form-group') as HTMLElement;
const ramGroup = document.getElementById('pc-RAM')?.closest('.form-group') as HTMLElement;
const ssd1Group = document.getElementById('pc-SSD1')?.closest('.form-group') as HTMLElement;
const ssd2Group = document.getElementById('pc-SSD2')?.closest('.form-group') as HTMLElement;
const locationFields = document.querySelectorAll('.pc-location-field');
const etcGroup = document.getElementById('pc-위치-기타-group');
// 초기화 (숨김)
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'none'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (etcGroup) etcGroup.style.display = 'none';
if (type === '서버') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (['스토리지', 'NAS', 'DAS'].includes(type)) {
[modelGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
else if (type === 'PC' || type === '노트북') {
[modelGroup, osGroup, cpuGroup, ramGroup, ssd1Group, ssd2Group].forEach(g => { if(g) g.style.display = 'flex'; });
if (detailPurpose === '서버') {
locationFields.forEach(el => (el as HTMLElement).style.display = 'flex');
}
}
else if (['CPU', 'GPU', '모바일'].includes(type)) {
if (modelGroup) modelGroup.style.display = 'flex';
}
else if (type === 'RAM') {
if (ramGroup) ramGroup.style.display = 'flex';
}
else if (type === 'HDD') {
if (ssd1Group) ssd1Group.style.display = 'flex';
}
else if (type === '태블릿') {
if (modelGroup) modelGroup.style.display = 'flex';
if (ssd1Group) ssd1Group.style.display = 'flex';
}
}
function fillFormData(asset: HardwareAsset) {
setFieldValue('pc-asset-id', asset.id);
setFieldValue('pc-법인', asset.);
setFieldValue('pc-자산코드', asset.);
setFieldValue('pc-유형', asset.type);
setFieldValue('pc-사용자', asset.);
setFieldValue('pc-현사용조직', asset.);
setFieldValue('pc-이전사용조직', asset.);
setFieldValue('pc-상세용도', (asset as any).);
parseAndSetLocation(asset., 'pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
setFieldValue('pc-모델명', asset.);
setFieldValue('pc-OS', asset.OS);
setFieldValue('pc-CPU', asset.CPU);
setFieldValue('pc-RAM', asset.RAM);
setFieldValue('pc-SSD1', asset.SSD1);
setFieldValue('pc-SSD2', asset.SSD2);
setFieldValue('pc-구매일', asset.);
setFieldValue('pc-금액', asset.);
setFieldValue('pc-납품업체', asset.);
setFieldValue('pc-품의서명', asset.);
}
export function initPcModal(onSave: () => void, closeModalsCb: () => void) {
if (!document.getElementById('pc-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', PC_MODAL_HTML);
}
const pcForm = document.getElementById('pc-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-pc-asset');
const revertBtn = document.getElementById('btn-revert-pc-edit');
const deleteBtn = document.getElementById('btn-delete-pc-asset');
// 유형 및 상세용도 리스너
const typeSelect = document.getElementById('pc-유형') as HTMLSelectElement;
const detailPurposeSelect = document.getElementById('pc-상세용도') as HTMLSelectElement;
[typeSelect, detailPurposeSelect].forEach(el => {
el?.addEventListener('change', () => applyPcTypeSpecificUI());
});
bindLocationEvents('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타-group', 'pc-위치-기타');
const handleClose = () => { closeModalsCb(); isEditMode = false; };
document.getElementById('btn-close-pc-modal')?.addEventListener('click', handleClose);
document.getElementById('btn-cancel-pc-modal')?.addEventListener('click', handleClose);
revertBtn?.addEventListener('click', () => {
isEditMode = false;
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
if (currentAsset) fillFormData(currentAsset);
});
saveBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (!isEditMode) {
isEditMode = true;
pcForm.classList.replace('is-view-mode', 'is-edit-mode');
saveBtn.textContent = '저장';
revertBtn?.classList.remove('hidden');
return;
}
const type = getFieldValue('pc-유형');
const detailPurpose = getFieldValue('pc-상세용도');
const updated: any = {
...currentAsset,
법인: getFieldValue('pc-법인'),
자산코드: getFieldValue('pc-자산코드'),
현사용조직: getFieldValue('pc-현사용조직'),
이전사용조직: getFieldValue('pc-이전사용조직'),
사용자: getFieldValue('pc-사용자'),
상세용도: detailPurpose,
위치: getCombinedLocation('pc-위치-빌딩', 'pc-위치-상세', 'pc-위치-기타'),
모델명: getFieldValue('pc-모델명'),
OS: getFieldValue('pc-OS'),
CPU: getFieldValue('pc-CPU'),
RAM: getFieldValue('pc-RAM'),
SSD1: getFieldValue('pc-SSD1'),
SSD2: getFieldValue('pc-SSD2'),
구매일: getFieldValue('pc-구매일'),
금액: getFieldValue('pc-금액'),
납품업체: getFieldValue('pc-납품업체'),
type: type || 'PC'
};
saveHardwareAsset(updated);
onSave();
isEditMode = false;
pcForm.classList.replace('is-edit-mode', 'is-view-mode');
saveBtn.textContent = '수정';
revertBtn?.classList.add('hidden');
});
deleteBtn?.addEventListener('click', () => {
if (!currentAsset) return;
if (confirm('삭제하시겠습니까?')) {
deleteHardwareAsset(currentAsset.id);
onSave();
handleClose();
}
});
}
function renderHistory(assetId: string) {
const historyList = document.getElementById('pc-history-list');
if (!historyList) return;
const logs = state.masterData.logs
.filter(l => l.assetId === assetId)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
if (logs.length === 0) {
historyList.innerHTML = '<div class="empty-history">이력이 없습니다.</div>';
return;
}
historyList.innerHTML = logs.map(log => `
<div class="history-item">
<div class="history-date">${log.date}</div>
<div class="history-user">수정자: ${log.user}</div>
<div class="history-details">${log.details.replace(/\n/g, '<br>')}</div>
</div>
`).join('');
}

View File

@@ -1,424 +1,371 @@
import { state } from '../../core/state';
import { SoftwareAsset } from '../../core/excelHandler';
import { openModal, closeModals } from './BaseModal';
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw } from 'lucide';
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,
setEditLock
applyDateMask
} from './ModalUtils';
let currentSwAsset: SoftwareAsset | null = null;
let isEditMode = false;
class SwAssetModal extends BaseModal {
constructor() {
super('sw', '소프트웨어 상세 정보');
}
const SW_MODAL_HTML = `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
protected renderFrameHTML(): string {
return `
<div id="sw-asset-modal" class="modal-overlay hidden">
<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" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label>${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>
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<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 class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" />
<input type="hidden" id="sw-asset-type" />
<!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</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 for="sw-법인">구매법인</label>
<select id="sw-법인" required>${generateOptionsHTML(CORP_LIST)}</select>
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-자산번호">자산번호</label>
<input type="text" id="sw-자산번호" readonly placeholder="자동 생성" />
<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 full-width">
<label for="sw-제품명">제품명 / 서비스명</label>
<input type="text" id="sw-제품명" required />
<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 cloud-only">
<label for="sw-플랫폼명">플랫폼명</label>
<input type="text" id="sw-플랫폼명" placeholder="예: AWS, Cafe24" />
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
<div class="form-group cloud-only">
<label for="sw-부서">담당부서</label>
<input type="text" id="sw-부서" />
</div>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field" id="sw-license-type-group">
<label for="sw-라이선스유형">라이선스 유형</label>
<input type="text" id="sw-라이선스유형" />
</div>
<div class="form-group sw-standard-field" id="sw-license-key-group">
<label for="sw-라이선스키">라이선스 키</label>
<input type="text" id="sw-라이선스키" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-수량">보유 수량</label>
<input type="number" id="sw-수량" min="0" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-금액">도입 금액</label>
<input type="text" id="sw-금액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only">
<label for="sw-계정명">계정명 (이메일)</label>
<input type="text" id="sw-계정명" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제수단">결제수단</label>
<select id="sw-결제수단">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-group cloud-only">
<label for="sw-연결카드번호">연결카드번호(뒷4자리)</label>
<input type="text" id="sw-연결카드번호" maxlength="4" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제일">결제일 (기준일)</label>
<input type="number" id="sw-결제일" min="1" max="31" />
</div>
<div class="form-group cloud-only">
<label for="sw-당월청구액">당월 청구액(원)</label>
<input type="text" id="sw-당월청구액" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label for="sw-구매일">구매일</label>
<input type="text" id="sw-구매일" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">만료일 (구독)</label>
<input type="text" id="sw-만료일" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-납품업체">납품업체</label>
<input type="text" id="sw-납품업체" />
</div>
<div class="form-group full-width">
<label for="sw-비고">비고</label>
<textarea id="sw-비고" rows="2"></textarea>
</div>
</form>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem;">
<div class="section-header" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
<h3 style="font-size:1rem; font-weight:600;">사용자 할당 현황</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
할당 관리 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="sw-assigned-users-summary" class="user-summary-grid"></div>
</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-add-sw-log" class="btn btn-outline btn-sm cloud-only">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
<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 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-log-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div class="modal-header">
<h2>업데이트 내역 추가</h2>
<button id="btn-close-sw-log" 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="new-log-date" />
</div>
<div class="form-group">
<label>상세 내용</label>
<textarea id="new-log-details" rows="3" placeholder="예: 결제 금액 변동, 담당자 변경 등"></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-log" class="btn btn-outline">취소</button>
<button id="btn-confirm-sw-log" class="btn btn-primary">추가</button>
</div>
</div>
</div>
</div>
`;
function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const keyGroup = document.getElementById('sw-license-key-group');
const typeGroup = document.getElementById('sw-license-type-group');
const expiryGroup = document.getElementById('sw-expiry-group');
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';
} 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') {
if (keyGroup) keyGroup.style.display = 'none';
if (typeGroup) typeGroup.style.display = 'flex';
if (expiryGroup) expiryGroup.style.display = 'flex';
} else {
if (keyGroup) keyGroup.style.display = 'flex';
if (typeGroup) typeGroup.style.display = 'none';
if (expiryGroup) expiryGroup.style.display = 'none';
}
}
}
function fillSwFormData(asset: SoftwareAsset) {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.type);
setFieldValue('sw-법인', asset.);
setFieldValue('sw-자산번호', asset. || '');
setFieldValue('sw-제품명', asset.);
setFieldValue('sw-수량', asset.);
setFieldValue('sw-금액', asset.);
setFieldValue('sw-구매일', asset. || '');
setFieldValue('sw-납품업체', asset. || '');
setFieldValue('sw-비고', asset. || '');
if (asset.type === '클라우드') {
setFieldValue('sw-플랫폼명', (asset as any). || '');
setFieldValue('sw-부서', (asset as any). || '');
setFieldValue('sw-계정명', (asset as any). || '');
setFieldValue('sw-결제수단', (asset as any). || '');
setFieldValue('sw-연결카드번호', (asset as any). || '');
setFieldValue('sw-결제일', (asset as any). || '');
setFieldValue('sw-당월청구액', (asset as any). || '');
} else if (asset.type === '구독SW') {
setFieldValue('sw-라이선스유형', (asset as any). || '');
setFieldValue('sw-만료일', (asset as any). || '');
} else {
setFieldValue('sw-라이선스키', (asset as any). || '');
`;
}
renderUserSummary(asset.id);
renderSwHistory(asset.id);
}
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')!;
function renderUserSummary(swId: string) {
const container = document.getElementById('sw-assigned-users-summary');
if (!container) return;
const userMapping = state.masterData.swUsers.find(u => u.sw_id === swId);
if (!userMapping || !userMapping.userData || userMapping.userData.length === 0) {
container.innerHTML = '<div class="empty-summary">할당된 사용자가 없습니다.</div>';
return;
}
container.innerHTML = userMapping.userData.map(u => `
<div class="user-badge-item">
<span class="u-name">${u[3] || '이름없음'}</span>
<span class="u-dept">${u[1] || '부서없음'}</span>
</div>
`).join('');
}
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
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 function openSwModal(asset: SoftwareAsset, mode: 'view' | 'add' = 'view') {
currentSwAsset = asset;
const modal = document.getElementById('sw-asset-modal')!;
// 수정 잠금 상태 제어
setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = (mode === 'add');
fillSwFormData(asset);
applySwTypeUI(asset.type);
modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } });
}
export function initSwModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
const form = document.getElementById('sw-asset-form') as HTMLFormElement;
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 userUpdateBtn = document.getElementById('btn-open-sw-update')!;
const logAddBtn = document.getElementById('btn-add-sw-log')!;
const closeModalAction = () => { closeModals(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
isEditMode = false;
if (currentSwAsset) fillSwFormData(currentSwAsset);
});
saveBtn.addEventListener('click', () => {
if (!currentSwAsset) return;
if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
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])
});
isEditMode = true;
return;
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 {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
const type = getFieldValue('sw-asset-type');
const updated: any = {
...currentSwAsset,
법인: getFieldValue('sw-법인'),
자산번호: getFieldValue('sw-자산번호'),
제품명: getFieldValue('sw-제품명'),
수량: parseInt(getFieldValue('sw-수량') || '0'),
금액: getFieldValue('sw-금액'),
구매일: getFieldValue('sw-구매일'),
납품업체: getFieldValue('sw-납품업체'),
비고: getFieldValue('sw-비고'),
type: type
};
this.renderHistory(asset.id);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
}
private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
if (type === '클라우드') {
updated. = getFieldValue('sw-플랫폼명');
updated. = getFieldValue('sw-부서');
updated. = getFieldValue('sw-계정명');
updated. = getFieldValue('sw-결제수단');
updated. = getFieldValue('sw-연결카드번호');
updated. = getFieldValue('sw-결제일');
updated. = getFieldValue('sw-당월청구액');
} else if (type === '구독SW') {
updated. = getFieldValue('sw-라이선스유형');
updated. = getFieldValue('sw-만료일');
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 {
updated. = getFieldValue('sw-라이선스키');
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');
}
}
}
// 데이터 저장 로직 (state 업데이트)
let targetList: SoftwareAsset[] = [];
if (type === '구독SW') targetList = state.masterData.subSw;
else if (type === '영구SW') targetList = state.masterData.permSw;
else if (type === '클라우드') targetList = state.masterData.cloud;
const idx = targetList.findIndex(a => a.id === updated.id);
if (idx > -1) targetList[idx] = updated;
else targetList.push(updated);
onSave();
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false;
});
deleteBtn.addEventListener('click', () => {
if (!currentSwAsset) return;
if (confirm('삭제하시겠습니까?')) {
const type = currentSwAsset.type;
if (type === '구독SW') state.masterData.subSw = state.masterData.subSw.filter(a => a.id !== currentSwAsset!.id);
else if (type === '영구SW') state.masterData.permSw = state.masterData.permSw.filter(a => a.id !== currentSwAsset!.id);
else if (type === '클라우드') state.masterData.cloud = state.masterData.cloud.filter(a => a.id !== currentSwAsset!.id);
onSave();
closeModalAction();
}
});
userUpdateBtn.addEventListener('click', () => {
if (currentSwAsset) openSwUserModal(currentSwAsset);
});
// 이력 추가 모달 로직
const logModal = document.getElementById('sw-log-modal')!;
logAddBtn.addEventListener('click', () => {
logModal.classList.remove('hidden');
(document.getElementById('new-log-date') as HTMLInputElement).value = new Date().toISOString().split('T')[0];
(document.getElementById('new-log-details') as HTMLTextAreaElement).value = '';
});
document.getElementById('btn-close-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-cancel-sw-log')?.addEventListener('click', () => logModal.classList.add('hidden'));
document.getElementById('btn-confirm-sw-log')?.addEventListener('click', () => {
if (!currentSwAsset) return;
const date = (document.getElementById('new-log-date') as HTMLInputElement).value;
const details = (document.getElementById('new-log-details') as HTMLTextAreaElement).value;
if (!date || !details) { alert('날짜와 내용을 입력해주세요.'); return; }
state.masterData.logs = state.masterData.logs || [];
state.masterData.logs.push({
id: Math.random().toString(36).substring(2, 9),
assetId: currentSwAsset.id,
date,
user: '관리자',
details
});
logModal.classList.add('hidden');
renderSwHistory(currentSwAsset.id);
});
private renderHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
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 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);
}

View File

@@ -1,249 +1,267 @@
import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip } from 'lucide';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { BaseModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
let currentSwUserAsset: SoftwareAsset | null = null;
let tempSwUsers: SWUser[] = [];
class SwUserModal extends BaseModal {
private tempSwUsers: any[] = [];
const SW_USER_MODAL_HTML = `
<div id="sw-user-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-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>
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
<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>
<div class="table-container">
<table>
<thead>
<tr>
<th>구매법인</th>
<th>부서/팀</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<th>관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
</table>
</div>
</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(CORP_LIST)}</select>
</div>
<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-이름" required />
</div>
<div class="form-group">
<label>사용기간</label>
<input type="text" id="new-user-사용기간" placeholder="ex) 2024-01-01 ~ 2024-12-31" />
</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>
`;
export function openSwUserModal(asset: SoftwareAsset) {
currentSwUserAsset = asset;
const modal = document.getElementById('sw-user-modal')!;
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.} | ${asset.}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.}</div>
</div>
`;
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
tempSwUsers = existingMapping ? JSON.parse(JSON.stringify(existingMapping.userDataList || [])) : [];
renderUserList();
modal.classList.remove('hidden');
createIcons({ icons: { Edit2, X, Paperclip } });
}
function renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
tbody.innerHTML = '';
if (tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return;
constructor() {
super('sw-user', '소프트웨어 사용자 관리');
}
tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || 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>
protected renderFrameHTML(): string {
return `
<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>
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
<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>
<div class="table-container">
<table>
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<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>
`;
}
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')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return;
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());
const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
}
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset. || ''}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset. || ''}</div>
</div>
`;
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
this.renderUserList();
}
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')!);
openUserEditSubModal(idx);
tbody.appendChild(tr);
});
});
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
tempSwUsers.splice(idx, 1);
renderUserList();
}
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
this.openUserEditSubModal(idx);
});
});
});
createIcons({ icons: { Paperclip } });
}
function 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 = tempSwUsers[idx];
setFieldValue('new-user-법인', user. || user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
setFieldValue('new-user-사용기간', user.);
} else {
setFieldValue('new-user-법인', currentSwUserAsset?.);
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 } });
}
subModal.classList.remove('hidden');
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();
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
}
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
addUserBtn.addEventListener('click', () => openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => {
saveUserDataToList();
});
mainSaveBtn.addEventListener('click', () => {
if (!currentSwUserAsset) return;
// 전역 상태 업데이트
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = {
sw_id: currentSwUserAsset!.id,
userDataList: tempSwUsers
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave();
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-edit')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-sub')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
swUserModal.init(onSave, closeModals);
}
function 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 ? tempSwUsers[idx]. : '');
const userData: any = {
구매법인: getFieldValue('new-user-법인'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
사용기간: getFieldValue('new-user-사용기간'),
};
if (idx === -1) tempSwUsers.push(userData);
else tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList();
export function openSwUserModal(asset: any) {
swUserModal.open(asset);
}

View File

@@ -8,17 +8,29 @@ export const CORP_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론
// 사용조직 목록
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
// 하드웨어 자산 유형 목록
export const HW_TYPE_LIST = [
'서버', 'PC', '스토리지', 'NAS', 'DAS',
'CPU', 'HDD', 'RAM', 'GPU',
'모바일', '노트북', '태블릿'
];
// 하드웨어 상태 목록
export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타'];
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', 'NAS', 'DAS', '서버PC', '스토리지 렉'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'스토리지': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부': ['영구', '구독'],
'내부': ['판매용', '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']
@@ -26,8 +38,35 @@ export const LOCATION_DATA: Record<string, string[]> = {
// 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', 'PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'CPU': 'CPU', 'HDD': 'HDD', 'RAM': 'RAM', 'GPU': 'GPU',
'모바일': 'MOB', '노트북': 'PC', '태블릿': 'TAB',
'개인PC': 'PC', '모바일기기': 'MOB'
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
};
// 배치도 이미지 매핑 데이터
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'
]
}
};

View File

@@ -1,27 +1,35 @@
import { state } from '../core/state';
const MENU_CONFIG = {
const MENU_CONFIG: any = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '개인PC', '서버', '스토리지', '전산비품', '모바일기기']
tabs: ['서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
},
sw: {
label: '소프트웨어',
tabs: ['대시보드', '구독SW', '영구SW', '클라우드']
tabs: ['외부', '내부']
},
ops: {
label: '운영 서비스',
tabs: ['대시보드', '서비스현황', '백업관리', '보안점검']
label: '운영지원',
tabs: ['클라우드', '도메인', '비용관리']
},
vip: {
label: '내빈/외빈',
tabs: ['선물']
},
fac: {
label: '시설자산',
tabs: ['사무가구']
}
};
export function renderNavigation(onTabChange: (tab: string) => void) {
const navContainer = document.getElementById('main-nav')!;
const btnAddAsset = document.getElementById('btn-add-asset') as HTMLButtonElement;
const render = () => {
navContainer.innerHTML = '';
// 기존 메뉴 렌더링
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const isActive = state.activeCategory === catKey;
@@ -29,50 +37,60 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
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;
state.activeSubTab = '대시보드';
if (btnAddAsset) btnAddAsset.classList.remove('hidden');
state.activeCategory = catKey as any;
const firstTab = config.tabs[0];
state.activeSubTab = firstTab;
render();
onTabChange('대시보드');
onTabChange(firstTab);
}
});
group.appendChild(trigger);
// 하위 탭 선반 (Shelf)
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
config.tabs.forEach(tab => {
config.tabs.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;
state.activeCategory = catKey as any;
state.activeSubTab = tab;
if (btnAddAsset) {
btnAddAsset.classList.remove('hidden');
}
render();
onTabChange(tab);
});
shelf.appendChild(item);
});
group.appendChild(shelf);
// 마우스 오버 시 다른 그룹의 선반은 가리고 내 것만 보여주는 스타일은 CSS에서 처리함
navContainer.appendChild(group);
});
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
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();

View File

@@ -1,200 +0,0 @@
import { MasterAssetData, HardwareAsset, SoftwareAsset, SWUser } from './excelHandler';
const corps = ['한맥', '삼안', '바론'];
const users = ['홍길동', '김철수', '이영희', '박지훈', '김팀장', '신유진', '윤대웅', '마리아'];
const depts = ['설계팀', '기술팀', '경영지원팀', '영업팀'];
function rand(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randDate(startYear: number, endYear: number) {
const y = Math.floor(Math.random() * (endYear - startYear + 1)) + startYear;
const m = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
const d = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function randUser() { // 25% 확률로 유휴자산 할당
return Math.random() < 0.25 ? '' : rand(users);
}
export function generateDummyData(): MasterAssetData {
const pc: HardwareAsset[] = [];
const server: HardwareAsset[] = [];
const storage: HardwareAsset[] = [];
const equip: HardwareAsset[] = [];
const mobile: HardwareAsset[] = [];
const subSw: SoftwareAsset[] = [];
const permSw: SoftwareAsset[] = [];
const swUsers: any[] = [];
const logs: any[] = [];
// 1. 개인PC 50개
for (let i = 1; i <= 50; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017;
pc.push({
id: Math.random().toString(36).substring(2, 9),
type: '개인PC',
법인: rand(corps),
: `HM-PC-${purchaseYear}-${String(i).padStart(3, '0')}`,
: '',
: `${rand(['본사', '지사'])} ${Math.floor(Math.random()*5)+1}`,
사용자: randUser(),
CPU: rand(['i5-10400', 'i7-12700', 'Ryzen 5', 'Ryzen 7']),
GPU: rand(['-', 'GTX 1660', 'RTX 3060', 'RTX 4070']),
RAM: rand(['16GB', '32GB']),
SSD1: rand(['256GB', '512GB', '1TB']),
SSD2: '',
HDD1: rand(['-', '1TB', '2TB']),
HDD2: '',
구매일: randDate(purchaseYear, purchaseYear),
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\B(?=(\d{3})+(?!\d))/g, ','),
납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
: '',
: '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
// 2. 서버 20개
for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017;
server.push({
id: Math.random().toString(36).substring(2, 9),
type: '서버',
법인: rand(corps),
: `HM-SV-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `웹/DB 서버 #${i}`,
용도: rand(['웹 서버', 'DB 서버', '백업 서버', '개발 서버']),
storage유형: rand(['물리', 'VM']),
위치: rand(['IDC 1센터', 'IDC 2센터', '본사 전산실']),
관리자: rand(users),
담당자_정: rand(users),
담당자_부: rand(users),
IP주소: `192.168.10.${i}`,
: `ssh://192.168.10.${i}:22`,
MACaddress: '00:11:22:33:44:' + String(i).padStart(2, '0'),
OS: rand(['Windows Server 2019', 'Ubuntu 22.04 LTS', 'CentOS 7']),
모델명: rand(['Dell PowerEdge R740', 'HP ProLiant DL380', 'Lenovo ThinkSystem']),
CPU: rand(['Xeon Silver 4210', 'Xeon Gold 6248', 'EPYC 7702']),
RAM: rand(['64GB', '128GB', '256GB']),
GPU: rand(['-', 'RTX A4000', 'Tesla V100']),
SSD1: rand(['512GB SSD', '1TB NVMe']),
SSD2: rand(['-', '1TB SSD', '2TB SSD']),
HDD1: rand(['-', '4TB HDD', '8TB HDD']),
모니터링: rand(['Zabbix', 'Grafana', 'PRTG']),
비고: i % 5 === 0 ? '정기 점검 대상' : '-',
HW사양: 'Xeon 16Core, 64GB RAM',
구매일: randDate(purchaseYear, purchaseYear),
: '5,000,000',
: '서버뱅크',
: ''
});
}
// 3. 스토리지 10개
for (let i = 1; i <= 10; i++) {
const purchaseYear = Math.floor(Math.random() * 10) + 2017;
storage.push({
id: Math.random().toString(36).substring(2, 9),
type: '스토리지',
법인: rand(corps),
storage유형: rand(['NAS', 'DAS']),
: `HM-ST-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `백업 스토리지 #${i}`,
: '전산실',
모델명: rand(['Synology DS920+', 'QNAP TS-453D']),
용량: rand(['16TB', '32TB', '64TB']),
담당자_정: randUser(),
담당자_부: rand(users),
IP주소: `192.168.20.${i}`,
MACaddress: '',
구매일: randDate(purchaseYear, purchaseYear),
: '1,500,000',
: '스토리지넷',
: '',
: '', OS: '', HW사양: ''
});
}
// 4. 전산비품 15개
for (let i = 1; i <= 15; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2019;
equip.push({
id: Math.random().toString(36).substring(2, 9),
type: '전산비품',
법인: rand(corps),
비품유형: rand(['프린터', '모니터', 'UPS']),
: `HM-EQ-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `비품 #${i}`,
위치: rand(['본사', '지사']),
관리자: randUser(),
구매일: randDate(purchaseYear, purchaseYear),
: '300,000',
: '오피스공구',
: '',
IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
// 5. 모바일기기 10개
for (let i = 1; i <= 10; i++) {
const purchaseYear = Math.floor(Math.random() * 5) + 2022;
mobile.push({
id: Math.random().toString(36).substring(2, 9),
type: '모바일기기',
법인: rand(corps),
: `HM-MO-${purchaseYear}-${String(i).padStart(3, '0')}`,
명칭: rand(['아이폰 15', '갤럭시 S24', '아이패드 에어']),
: '개인 지급',
관리자: randUser(),
OS: rand(['iOS', 'Android', 'iPadOS']),
구매일: randDate(purchaseYear, purchaseYear),
: '1,200,000',
: '통신사',
: '',
IP주소: '', MACaddress: '', HW사양: '', : ''
});
}
// 6. 구독 SW 20개
for (let i = 1; i <= 20; i++) {
const swId = Math.random().toString(36).substring(2, 9);
subSw.push({
id: swId,
type: '구독SW',
분야: rand(['업무공통', '개발S/W']),
법인: rand(corps),
제품명: rand(['Adobe CC', 'M365']),
: '2024-01-01',
: '2025-01-01',
: '100,000',
수량: 5,
: `admin${i}@hm.com`,
: '총판',
: ''
});
swUsers.push({ sw_id: swId, userData: [[rand(corps), rand(depts), '사원', rand(users), '2024.01~12', '신청완료']] });
}
// 7. 영구 SW 20개
for (let i = 1; i <= 20; i++) {
const swId = Math.random().toString(36).substring(2, 9);
permSw.push({
id: swId,
type: '영구SW',
분야: rand(['설계S/W']),
법인: rand(corps),
제품명: rand(['AutoCAD', '한컴오피스']),
: '2023-01-01',
: `KEY-${swId}`,
: '500,000',
수량: 2,
: `license${i}`,
: '총판',
: ''
});
}
return { pc, server, storage, equip, mobile, subSw, permSw, swUsers, logs };
}

View File

@@ -1,85 +1,28 @@
import * as XLSX from 'xlsx';
import { ASSET_SCHEMA } from './schema';
/**
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 1:1로 일치시킵니다.
*/
export interface HardwareAsset {
[key: string]: any;
id: string;
type: string; // '개인PC', '서버', '스토리지', '전산비품', '모바일기기'
법인: string;
자산코드: string;
명칭: string;
위치: string;
관리자: string;
IP주소: string;
IP2?: string;
MACaddress: string;
HW사양: string;
OS: string;
사용자?: string;
CPU?: string;
GPU?: string;
RAM?: string;
SSD1?: string;
SSD2?: string;
HDD1?: string;
HDD2?: string;
storage유형?: string;
비품유형?: string;
모델명?: string;
용량?: string;
담당자_정?: string;
담당자_부?: string;
구매일?: string;
금액?: string;
납품업체: string;
품의서명: string;
용도?: string;
상세?: string;
원격접속?: string;
서버ID?: string;
서버PW?: string;
모니터링?: string;
비고?: string;
현사용조직?: string;
이전사용조직?: string;
보관위치?: string;
현재상태?: string;
}
export interface SoftwareAsset {
[key: string]: any;
id: string;
type: string; // '구독SW', '영구SW', '클라우드'
분야?: string;
법인: string;
부서?: string;
제품명: string;
구매일: string;
구독일?: string;
만료일?: string;
라이선스유형?: string;
라이선스키?: string;
유지보수여부?: boolean;
금액: string;
수량: number;
계정명: string;
납품업체: string;
비고: string;
플랫폼명?: string;
결제수단?: string;
결제일?: string;
연결카드번호?: string;
당월청구액?: string;
}
export interface SWUser {
id: string;
sw_id: string;
법인: string;
부서: string;
: string;
직위: string;
이름: string;
사용기간: string;
신청서명: string;
userData?: any[];
user_name: string;
dept: string;
corp: string;
[key: string]: any;
}
export interface HardwareLog {
@@ -94,100 +37,236 @@ export interface MasterAssetData {
pc: HardwareAsset[];
server: HardwareAsset[];
storage: HardwareAsset[];
equip: HardwareAsset[];
mobile: HardwareAsset[];
subSw: SoftwareAsset[];
permSw: SoftwareAsset[];
swUsers: any[]; // { sw_id, userData: [] } 형태로 처리
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;
}
const HW_TABS = ['개인PC', '서버', '스토리지', '전산비품', '모바일기기'];
const SW_TABS = ['구독SW', '영구SW', '클라우드'];
const PC_HEADERS = ['법인', '자산코드', '사용자', '위치', 'CPU', 'GPU', 'RAM', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP주소', 'HW사양', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SERVER_HEADERS = ['구매법인', '자산번호', '구매일자', '유형', '용도', '상세내용', '현사용조직', '이전사용조직', '설치위치', '담당자(정)', '담당자(부)', 'IP 주소 1', 'IP 주소 2', '원격도구', '서버 ID', '서버 PW', '모델명', 'OS', 'CPU', 'RAM', 'GPU', 'Storage 1', 'Storage 2', 'Storage 3', '모니터링', '비고'];
const STORAGE_HEADERS = ['구매법인', '유형', '자산코드', '명칭', '위치', '모델명', '용량', '담당자(정)', '담당자(부)', 'IP주소', 'MAC주소', '구매일', '금액', '납품업체', '품의서명', '비고'];
const EQUIP_HEADERS = ['구매법인', '비품유형', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const MOBILE_HEADERS = ['구매법인', '자산코드', '명칭', '위치', '관리자', '기기유형', 'OS', '구매일', '금액', '납품업체', '품의서명', '비고'];
const SUB_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '만료일', '라이선스유형', '금액', '수량', '계정명', '납품업체', '비고'];
const PERM_SW_HEADERS = ['ID', '분야', '법인', '부서', '제품명', '구매일', '라이선스키', '금액', '수량', '계정명', '납품업체', '비고'];
const CLOUD_HEADERS = ['ID', '플랫폼명', '법인', '부서', '사용용도(제품명)', '계정명', '결제수단', '결제일', '연결카드번호', '당월청구액', '비고'];
/**
* 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', headers: PC_HEADERS },
{ name: '서버', headers: SERVER_HEADERS },
{ name: '스토리지', headers: STORAGE_HEADERS },
{ name: '전산비품', headers: EQUIP_HEADERS },
{ name: '모바일기기', headers: MOBILE_HEADERS }
{ 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 ws = XLSX.utils.aoa_to_sheet([config.headers]);
ws['!cols'] = Array(config.headers.length).fill({ wch: 18 });
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);
});
SW_TABS.forEach(tab => {
let hd = tab === '구독SW' ? SUB_SW_HEADERS : (tab === '클라우드' ? CLOUD_HEADERS : PERM_SW_HEADERS);
const ws = XLSX.utils.aoa_to_sheet([hd]);
ws['!cols'] = Array(hd.length).fill({ wch: 18 });
XLSX.utils.book_append_sheet(wb, ws, tab);
});
XLSX.writeFile(wb, 'itam_assets_template_full.xlsx');
XLSX.writeFile(wb, 'itam_template_db_aligned.xlsx');
}
export function exportToExcel(masterData: MasterAssetData) {
const wb = XLSX.utils.book_new();
const exportMap = [
{ tab: '개인PC', list: masterData.pc, headers: PC_HEADERS, map: (a: any) => [a., a., a., a., a.CPU, a.GPU, a.RAM, a.SSD1, a.SSD2, a.HDD1, a.HDD2, a.IP주소, a.HW사양, a., a., a., a., a.] },
{ tab: '서버', list: masterData.server, headers: SERVER_HEADERS, map: (a: any) => [a., a., a., a.storage유형 || '물리', a., a., a., a., a., a._정, a._부, a.IP주소, a.IP2, a., a.ID, a.PW, a., a.OS, a.CPU, a.RAM, a.GPU, a.SSD1, a.SSD2, a.HDD1, a., a.] },
{ tab: '스토리지', list: masterData.storage, headers: STORAGE_HEADERS, map: (a: any) => [a., a.storage유형, a., a., a., a., a., a._정, a._부, a.IP주소, a.MACaddress, a., a., a., a., a.] },
{ tab: '전산비품', list: masterData.equip, headers: EQUIP_HEADERS, map: (a: any) => [a., a., a., a., a., a., a.IP주소, a.MACaddress, a.HW사양, a.OS, a., a., a., a., a.] },
{ tab: '모바일기기', list: masterData.mobile, headers: MOBILE_HEADERS, map: (a: any) => [a., a., a., a., a., a.type, a.OS, a., a., a., a., a.] },
{ tab: '구독SW', list: masterData.subSw, headers: SUB_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a., a.] },
{ tab: '영구SW', list: masterData.permSw, headers: PERM_SW_HEADERS, map: (a: any) => [a.id, a., a., a., a., a., a., a., a., a., a., a.] }
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' }
];
exportMap.forEach(m => {
const ws = XLSX.utils.aoa_to_sheet([m.headers, ...m.list.map(m.map)]);
XLSX.utils.book_append_sheet(wb, ws, m.tab);
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_master_full_${new Date().toISOString().split('T')[0]}.xlsx`);
XLSX.writeFile(wb, `itam_export_${new Date().toISOString().split('T')[0]}.xlsx`);
}
export async function parseExcel(file: File): Promise<MasterAssetData> {
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: 'binary' });
const data: MasterAssetData = { pc: [], server: [], storage: [], equip: [], mobile: [], subSw: [], permSw: [], swUsers: [], logs: [] };
const workbook = XLSX.read(e.target?.result, { type: 'array' });
const parsedData: any = {};
workbook.SheetNames.forEach(sheetName => {
const rows = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]) as any[];
if (sheetName === '개인PC') {
rows.forEach(r => data.pc.push({ id: Math.random().toString(36).substring(2, 9), type: '개인PC', 법인: r['법인']||'', 자산코드: r['자산코드']||'', 사용자: r['사용자']||'', 위치: r['위치']||'', CPU: r['CPU']||'', GPU: r['GPU']||'', RAM: r['RAM']||'', SSD1: r['SSD1']||'', SSD2: r['SSD2']||'', HDD1: r['HDD1']||'', HDD2: r['HDD2']||'', IP주소: r['IP주소']||'', HW사양: r['HW사양']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', : '', MACaddress: '', OS: '', : '' }));
} else if (sheetName === '서버') {
rows.forEach(r => data.server.push({ id: Math.random().toString(36).substring(2, 9), type: '서버', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산번호']||r['자산코드']||'', 구매일: r['구매일자']||r['구매일']||'', storage유형: r['유형']||'물리', 용도: r['용도']||'', 상세: r['상세내용']||'', 현사용조직: r['현사용조직']||'', 이전사용조직: r['이전사용조직']||'', 위치: r['설치위치']||r['위치']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP 주소 1']||r['IP주소']||'', IP2: r['IP 주소 2']||'', 원격접속: r['원격도구']||r['원격접속']||'', 서버ID: r['서버 ID']||r['서버ID']||'', 서버PW: r['서버 PW']||r['서버PW']||'', 모델명: r['모델명']||'', OS: r['OS']||'', CPU: r['CPU']||'', RAM: r['RAM']||'', GPU: r['GPU']||'', SSD1: r['Storage 1']||r['SSD1']||'', SSD2: r['Storage 2']||r['SSD2']||'', HDD1: r['Storage 3']||r['HDD1']||'', 모니터링: r['모니터링']||'', 비고: r['비고']||'', : '', : '', MACaddress: '', HW사양: '', : '', : '', : '' }));
} else if (sheetName === '스토리지') {
rows.forEach(r => data.storage.push({ id: Math.random().toString(36).substring(2, 9), type: '스토리지', 법인: r['구매법인']||r['법인']||'', storage유형: r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 모델명: r['모델명']||'', 용량: r['용량']||'', 담당자_정: r['담당자(정)']||'', 담당자_부: r['담당자(부)']||'', IP주소: r['IP주소']||'', MACaddress: r['MAC주소']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', HW사양: '', OS: '', : '' }));
} else if (sheetName === '전산비품') {
rows.forEach(r => data.equip.push({ id: Math.random().toString(36).substring(2, 9), type: '전산비품', 법인: r['구매법인']||r['법인']||'', 비품유형: r['비품유형']||r['유형']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', IP주소: r['IP주소']||'', MACaddress: r['MACaddress']||'', HW사양: r['HW사양']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'' }));
} else if (sheetName === '모바일기기') {
rows.forEach(r => data.mobile.push({ id: Math.random().toString(36).substring(2, 9), type: '모바일기기', 법인: r['구매법인']||r['법인']||'', 자산코드: r['자산코드']||'', 명칭: r['명칭']||'', 위치: r['위치']||'', 관리자: r['관리자']||'', OS: r['OS']||'', 구매일: r['구매일']||'', 금액: r['금액']||'', 납품업체: r['납품업체']||'', 품의서명: r['품의서명']||'', 비고: r['비고']||'', IP주소: '', MACaddress: '', HW사양: '' }));
} else if (sheetName === '구독SW') {
rows.forEach(r => data.subSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '구독SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 만료일: r['만료일']||'', 라이선스유형: r['라이선스유형']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
} else if (sheetName === '영구SW') {
rows.forEach(r => data.permSw.push({ id: r['ID']||Math.random().toString(36).substring(2, 9), type: '영구SW', 분야: r['분야']||'', 법인: r['법인']||'', 부서: r['부서']||'', 제품명: r['제품명']||'', 구매일: r['구매일']||'', 라이선스키: r['라이선스키']||'', 금액: r['금액']||'', 수량: parseInt(r['수량']||'1'), 계정명: r['계정명']||'', 납품업체: r['납품업체']||'', 비고: r['비고']||'' }));
}
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(data);
resolve(parsedData);
} catch (err) { reject(err); }
};
reader.readAsBinaryString(file);
reader.readAsArrayBuffer(file);
});
}

115
src/core/filterHandler.ts Normal file
View File

@@ -0,0 +1,115 @@
import { ASSET_SCHEMA, UI_TEXT } from './schema';
import { getActionButtonsHTML } from './utils';
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
import { CORP_LIST } from '../components/Modal/SharedData';
/**
* ITAM Unified Filter Bar Component
* 검색 UI를 표준화하고 한 곳에서 관리합니다.
*/
export interface FilterOptions {
keywordLabel?: string;
showCorp?: boolean;
showDept?: boolean;
showLoc?: boolean;
showField?: boolean;
showType?: boolean;
extraHTML?: string;
onFilterChange: (filters: any) => void;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
</select>
</div>` : ''}
${showField ? `
<div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="filter-field">
<option value="">전체 분야</option>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>` : ''}
${showCorp ? `
<div class="search-item">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, '', true)}</select>
</div>` : ''}
${showLoc ? `
<div class="search-item">
<label>${ASSET_SCHEMA.LOCATION.ui}</label>
<select id="filter-loc"><option value="">전체 위치</option></select>
</div>` : ''}
${showDept ? `
<div class="search-item">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="filter-dept"><option value="">전체 조직</option></select>
</div>` : ''}
${extraHTML}
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
${getActionButtonsHTML()}
`;
// Bind Events
const triggerChange = () => {
const filters = {
keyword: (container.querySelector('#filter-keyword') as HTMLInputElement)?.value.toLowerCase().trim() || '',
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
};
onFilterChange(filters);
};
container.querySelector('#filter-keyword')?.addEventListener('input', triggerChange);
container.querySelector('#filter-corp')?.addEventListener('change', triggerChange);
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
const el = container.querySelector(`#${id}`);
if (el) (el as any).value = '';
});
triggerChange();
});
}
/**
* 공통 필터링 로직
*/
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
return list.filter(item => {
const matchKeyword = !filters.keyword || searchKeys.some(key =>
String(item[ASSET_SCHEMA[key].key] || item[ASSET_SCHEMA[key].db] || '').toLowerCase().includes(filters.keyword)
);
const matchCorp = !filters.corp || (item[ASSET_SCHEMA.PURCHASE_CORP.key] || item[ASSET_SCHEMA.PURCHASE_CORP.db]) === filters.corp;
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
});
}

View File

@@ -609,7 +609,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"용도": "GSIM NAS",
@@ -629,7 +629,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "NAS",
"용도": "그래픽스개발팀 데이터 백업 NAS",
@@ -649,7 +649,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공통 GIT 서버",
@@ -669,7 +669,7 @@ export const realServerData = [
"SSD2": "1TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "BUILD 서버",
@@ -689,7 +689,7 @@ export const realServerData = [
"SSD2": "10TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "HmEG 테스트 서버",
@@ -709,7 +709,7 @@ export const realServerData = [
"SSD2": "1TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "산하 ERP 개발서버",
@@ -729,7 +729,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공간정보 신청",
@@ -749,7 +749,7 @@ export const realServerData = [
"SSD2": "931GB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "AI 관련",
@@ -769,7 +769,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "한종 테스트",
@@ -789,7 +789,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "GSIM 언리얼 서버",
@@ -809,7 +809,7 @@ export const realServerData = [
"SSD2": "8TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "AutoCAD 테스트 서버",
@@ -829,7 +829,7 @@ export const realServerData = [
"SSD2": "2TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "GSIM 테스트 서버",
@@ -849,7 +849,7 @@ export const realServerData = [
"SSD2": "512GB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "공간데이터 서버",
@@ -869,7 +869,7 @@ export const realServerData = [
"SSD2": "8 TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "PC",
"용도": "가평 VM 원격 서버",
@@ -889,7 +889,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM 협업",
@@ -909,7 +909,7 @@ export const realServerData = [
"SSD2": "1.88TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "GSIM 협업 스토리지",
@@ -929,7 +929,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM META 서버",
@@ -949,7 +949,7 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "GSIM 서버",
@@ -969,7 +969,7 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "GSIM 스토리지",
@@ -989,7 +989,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "함양-합천 서버",
@@ -1009,7 +1009,7 @@ export const realServerData = [
"SSD2": "10TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "HM MapService 2.0 서버",
@@ -1029,7 +1029,7 @@ export const realServerData = [
"SSD2": "40 TB"
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "스토리지",
"용도": "HM MapService 2.0 스토리지",
@@ -1049,7 +1049,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "Gitlab Runner",
@@ -1069,7 +1069,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "기술개발센터",
"법인": "",
"자산코드": "",
"storage유형": "서버",
"용도": "전산모사",
@@ -1089,7 +1089,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "1",
"storage유형": "NAS",
"용도": "NAS 2",
@@ -1105,7 +1105,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "2",
"storage유형": "NAS",
"용도": "NAS 1",
@@ -1121,7 +1121,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "3",
"storage유형": "NAS",
"용도": "NAS 4",
@@ -1137,7 +1137,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "4",
"storage유형": "NAS",
"용도": "NAS 5",
@@ -1153,7 +1153,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "5",
"storage유형": "NAS",
"용도": "NAS 6",
@@ -1169,7 +1169,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "6",
"storage유형": "NAS",
"용도": "NAS7",
@@ -1185,7 +1185,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "7",
"storage유형": "NAS",
"용도": "총괄기획실 NAS",
@@ -1201,7 +1201,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "8",
"storage유형": "NAS",
"용도": "한맥 NAS 1",
@@ -1217,7 +1217,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "9",
"storage유형": "NAS",
"용도": "한맥 NAS 2",
@@ -1233,7 +1233,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "10",
"storage유형": "NAS",
"용도": "한맥 NAS 3",
@@ -1249,7 +1249,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "11",
"storage유형": "NAS",
"용도": "NAS 13",
@@ -1265,7 +1265,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "12",
"storage유형": "PC",
"용도": "회계",
@@ -1281,7 +1281,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "13",
"storage유형": "PC",
"용도": "한맥CAD",
@@ -1297,9 +1297,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "14",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "Ai-Cell-Util",
"상세": "깃티, 매터모스트 등 70여종",
"위치": "한맥빌딩(MDF 실)",
@@ -1313,7 +1313,7 @@ export const realServerData = [
"SSD2": "8 TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "15",
"storage유형": "PC",
"용도": "한라CAD",
@@ -1329,7 +1329,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "16",
"storage유형": "NAS",
"용도": "디자인팀1 NAS",
@@ -1345,7 +1345,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "17",
"storage유형": "NAS",
"용도": "디자인팀2 NAS",
@@ -1361,9 +1361,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "18",
"storage유형": "서버(미니워크스테이션)",
"storage유형": "PC",
"용도": "인사정보 서버",
"상세": "인사정보 PM",
"위치": "한맥빌딩(MDF 실)",
@@ -1377,9 +1377,9 @@ export const realServerData = [
"SSD2": "2 TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "19",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "BEPs 서버",
"상세": "BEPs 개발서버, Outline 협업서비스",
"위치": "한맥빌딩(MDF 실)",
@@ -1393,9 +1393,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "20",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "Ai-Cell-A100-1",
"상세": "OCR, Local LLM 등 30여종",
"위치": "한맥빌딩(MDF 실)",
@@ -1409,9 +1409,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "21",
"storage유형": "서버(타워)",
"storage유형": "PC",
"용도": "빌드서버",
"상세": "인스톨 쉴드, 지라",
"위치": "한맥빌딩(MDF 실)",
@@ -1425,9 +1425,9 @@ export const realServerData = [
"SSD2": "4TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "22",
"storage유형": "PC\n서버(랙)",
"storage유형": "PC",
"용도": "저장소 및 전산모사\n구)스마트건설 서버",
"상세": "ParaView, CFDCore\n디지털화설문, 검색WIKI 웹서비스",
"위치": "한맥빌딩(MDF 실)",
@@ -1441,9 +1441,9 @@ export const realServerData = [
"SSD2": "2TB"
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "23",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "IDC 산하ERP서버",
"상세": "XR 가상화 메인 서버 → IDC 산하ERP서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1457,9 +1457,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "24",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "WAS Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1473,9 +1473,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "25",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "한맥 백업 서버",
"상세": "가족사 인트라넷 소스 백업 서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1489,9 +1489,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "26",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "한라 백업 서버",
"상세": "한라 웹 소스 및 Miso DB 백업 서버",
"위치": "한맥빌딩(MDF 실)",
@@ -1505,7 +1505,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "27",
"storage유형": "NAS",
"용도": "기술개발센터 NAS",
@@ -1521,7 +1521,7 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "28",
"storage유형": "NAS",
"용도": "-",
@@ -1537,9 +1537,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "29",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "Backup Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1553,9 +1553,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "30",
"storage유형": "스토리지(랙)",
"storage유형": "스토리지",
"용도": "-",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1569,9 +1569,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "31",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "XR WAS Server",
"상세": "",
"위치": "한맥빌딩(MDF 실)",
@@ -1585,9 +1585,9 @@ export const realServerData = [
"SSD2": ""
},
{
"법인": "한맥빌딩",
"법인": "",
"자산코드": "32",
"storage유형": "서버(랙)",
"storage유형": "서버",
"용도": "WAS Storage",
"상세": "",
"위치": "한맥빌딩(MDF 실)",

179
src/core/schema.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* ITAM 통합 스키마 매퍼 (Unified Schema Mapper)
*
* key: 애플리케이션 내부 로직에서 사용하는 속성명
* db: MySQL 데이터베이스 컬럼명
* ui: 사용자에게 보여지는 UI 레이블
*/
export const ASSET_SCHEMA = {
// ─── 공통 필드 (Common) ───
ID: { key: 'id', db: 'id', ui: 'ID' },
ASSET_CODE: { key: 'asset_code', db: 'asset_code', ui: '자산번호' },
CATEGORY: { key: 'category', db: 'category', ui: '구분' },
ASSET_TYPE: { key: 'asset_type', db: 'asset_type', ui: '유형' },
PURCHASE_CORP: { key: 'purchase_corp',db: 'purchase_corp', ui: '구매법인' },
PURCHASE_DATE: { key: 'purchase_date',db: 'purchase_date', ui: '구매일자' },
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ───
HW_STATUS: { key: 'hw_status', db: 'hw_status', ui: '상태' },
MODEL_NAME: { key: 'model_name', db: 'model_name', ui: '모델명' },
ASSET_NAME: { key: 'asset_name', db: 'asset_name', ui: '자산명' },
ASSET_MFR: { key: 'asset_mfr', db: 'asset_mfr', ui: '제조사' },
CURRENT_DEPT: { key: 'current_dept', db: 'current_dept', ui: '현 사용조직' },
PREV_DEPT: { key: 'previous_dept',db: 'previous_dept', ui: '직전 사용조직' },
CURRENT_USER: { key: 'user_current', db: 'user_current', ui: '현 사용자' },
EMP_NO: { key: 'emp_no', db: 'emp_no', ui: '사번' },
USER_POSITION: { key: 'user_position', db: 'user_position', ui: '직무' },
PREV_USER: { key: 'previous_user',db: 'previous_user', ui: '직전 사용자' },
CPU: { key: 'cpu', db: 'cpu', ui: 'CPU' },
RAM: { key: 'ram', db: 'ram', ui: 'RAM' },
GPU: { key: 'gpu', db: 'gpu', ui: 'GPU' },
SSD1: { key: 'ssd_1', db: 'ssd_1', ui: 'SSD1' },
SSD2: { key: 'ssd_2', db: 'ssd_2', ui: 'SSD2' },
HDD1: { key: 'hdd_1', db: 'hdd_1', ui: 'HDD1' },
HDD2: { key: 'hdd_2', db: 'hdd_2', ui: 'HDD2' },
HDD3: { key: 'hdd_3', db: 'hdd_3', ui: 'HDD3' },
HDD4: { key: 'hdd_4', db: 'hdd_4', ui: 'HDD4' },
MAINBOARD: { key: 'mainboard', db: 'mainboard', ui: '메인보드' },
OS: { key: 'os', db: 'os', ui: 'OS' },
IP_ADDR: { key: 'ip_address', db: 'ip_address', ui: 'IP 주소' },
IP_ADDR2: { key: 'ip_address_2', db: 'ip_address_2', ui: 'IP 주소 2' },
MAC_ADDR: { key: 'mac_address', db: 'mac_address', ui: 'MAC 주소' },
REMOTE_TOOL: { key: 'remote_tool', db: 'remote_tool', ui: '원격도구' },
REMOTE_ID: { key: 'remote_id', db: 'remote_id', ui: '원격 ID' },
REMOTE_PW: { key: 'remote_pw', db: 'remote_pw', ui: '원격 PW' },
MONITORING: { key: 'monitoring', db: 'monitoring', ui: '모니터링' },
VOLUME: { key: 'volume', db: 'volume', ui: '용량' },
MONITOR_INCH: { key: 'monitor_inch', db: 'monitor_inch', ui: '인치' },
ASSET_COUNT: { key: 'asset_count', db: 'asset_count', ui: '수량' },
SERIAL_NUM: { key: 'serial_num', db: 'serial_num', ui: 'S/N' },
// ─── 소프트웨어/클라우드 상세 (SW/Cloud/Domain) ───
SW_STATUS: { key: 'sw_status', db: 'sw_status', ui: '상태' },
SW_FIELD: { key: 'sw_field', db: 'sw_field', ui: '분야' },
SW_TYPE: { key: 'sw_type', db: 'sw_type', ui: '유형' },
DEV_OBJ: { key: 'dev_objective',db: 'dev_objective', ui: '목적' },
DEV_MGR: { key: 'dev_manager', db: 'dev_manager', ui: '개발담당자' },
PLANNING_MGR: { key: 'planning_manager', db: 'planning_manager', ui: '기획담당자' },
SALES_MGR: { key: 'sales_manager',db: 'sales_manager', ui: '영업담당자' },
PRODUCT_NAME: { key: 'product_name', db: 'product_name', ui: '제품명' },
DOMAIN_ADDR: { key: 'domain_address', db: 'domain_address',ui: '도메인주소' },
EMAIL_ACCOUNT: { key: 'email_account', db: 'email_account', ui: '이메일주소' },
EMAIL_PW: { key: 'email_pw', db: 'email_pw', ui: '이메일비밀번호' },
SW_ID: { key: 'sw_id', db: 'sw_id', ui: '계정ID' },
SW_PW: { key: 'sw_pw', db: 'sw_pw', ui: '비밀번호' },
PURCHASE_METHOD:{ key: 'purchase_method', db: 'purchase_method', ui: '결제수단' },
ASSET_PURPOSE: { key: 'asset_purpose', db: 'asset_purpose', ui: '용도' },
ASSET_STATUS: { key: 'asset_status', db: 'asset_status', ui: '상태' },
START_DATE: { key: 'start_date', db: 'start_date', ui: '시작일' },
EXPIRED_DATE: { key: 'expired_date', db: 'expired_date', ui: '만료일' }
};
/**
* 페이지별 헤더 정보 (타이틀, 설명, 아이콘)
*/
export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: string; icon: string }> = {
'PC': {
title: '개인PC 자산 관리',
description: '임직원에게 지급된 데스크톱 및 노트북 자산의 할당 현황과 하드웨어 사양을 통합 관리합니다.',
icon: 'laptop'
},
'서버': {
title: '서버 자산 관리',
description: 'IDC 및 사내 서버실에 운영 중인 물리 서버 장비의 도입, 운영, 폐기 현황을 관리합니다.',
icon: 'server'
},
'스토리지': {
title: '스토리지 자산 관리',
description: '데이터 저장 및 백업을 위한 NAS, DAS 등 스토리지 장비의 용량과 연결 상태를 관리합니다.',
icon: 'database'
},
'네트워크': {
title: '네트워크 장비 관리',
description: '스위치, 방화벽, 공유기 등 사내 네트워크 인프라를 구성하는 주요 장비 현황을 관리합니다.',
icon: 'layers'
},
'업무지원장비': {
title: '업무 지원 장비 관리',
description: '모니터, 프린터, 스캐너 등 원활한 업무 수행을 보조하는 전산 비품들을 관리합니다.',
icon: 'monitor'
},
'PC부품': {
title: 'PC 부품 자산 관리',
description: 'CPU, RAM, GPU 등 PC 조립 및 유지보수를 위해 보유 중인 주요 부품 재고를 관리합니다.',
icon: 'cpu'
},
'공간정보장비': {
title: '공간 정보 장비 관리',
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map'
},
'내부': {
title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code'
},
'외부': {
title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package'
},
'도메인': {
title: '도메인 자산 관리',
description: '운영 중인 서비스 도메인의 등록 정보, 관리 업체 및 갱신 만료일을 관리합니다.',
icon: 'globe'
},
'클라우드': {
title: '클라우드 자산 관리',
description: 'AWS, Azure, GCP 등 클라우드 인프라 자원 및 구독 서비스 이용 현황을 관리합니다.',
icon: 'cloud'
},
'비용관리': {
title: 'IT 비용 집행 관리',
description: '전산 자산 도입 및 유지보수에 소요되는 정기/비정기 지출 비용을 통합 관리합니다.',
icon: 'credit-card'
},
'선물': {
title: '내빈/외빈 선물 관리',
description: '내외빈 방문 시 지급되는 기념품 및 선물용 자산의 재고와 지급 이력을 관리합니다.',
icon: 'gift'
},
'사무가구': {
title: '사무용 가구 관리',
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
icon: 'armchair'
}
};
/**
* 용어 사전 (UI 텍스트 전용)
*/
export const UI_TEXT = {
ACTION: {
ADD: '신규 등록',
EDIT: '수정',
SAVE: '저장',
DELETE: '삭제',
CANCEL: '취소',
CLOSE: '닫기',
HISTORY_ADD: '이력 추가',
RESET_FILTER: '필터 초기화'
},
MESSAGES: {
CONFIRM_DELETE: '정말로 삭제하시겠습니까?',
SAVE_SUCCESS: '성공적으로 저장되었습니다.',
NO_DATA: '검색 결과가 없습니다.'
}
};

View File

@@ -1,190 +1,217 @@
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
import { API_BASE_URL } from './utils';
// --- State Definitions ---
export interface MasterAssetData {
pc: HardwareAsset[];
server: HardwareAsset[];
storage: HardwareAsset[];
equip: HardwareAsset[];
mobile: HardwareAsset[];
subSw: SoftwareAsset[];
permSw: SoftwareAsset[];
cloud: SoftwareAsset[]; // 클라우드 배열 추가
users: any[];
pc: any[];
server: any[];
storage: any[];
network: any[];
survey: any[];
pcParts: any[];
equipment: any[];
officeSupplies: any[];
swInternal: any[];
swExternal: any[];
cloud: any[];
domain: any[];
cost: any[];
vip: any[];
mobile?: any[]; // Legacy mobile support
equip?: any[]; // Backward compat
// Backward compatibility
subSw: any[];
permSw: any[];
swUsers: SWUser[];
logs: HardwareLog[];
// 동료 코드 호환용 통합 배열 (프론트엔드 로직용)
hw: HardwareAsset[];
sw: SoftwareAsset[];
// 통합 배열
hw: any[];
sw: any[];
}
export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw';
activeSubTab: string; // '대시보드', '개인PC', '서버', '스토리지', '전산비품', '구독SW', '영구SW', '클라우드'
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string;
masterData: MasterAssetData;
activeCharts: any[];
}
// 초기 상태
export const state: AppState = {
activeCategory: 'dashboard',
activeSubTab: '대시보드',
activeCategory: 'hw',
activeSubTab: '서버', // 대시보드 제거됨에 따라 기본값 변경
activeCharts: [],
masterData: {
pc: [],
server: [],
storage: [],
equip: [],
mobile: [],
subSw: [],
permSw: [],
cloud: [],
hw: [], // 호환용
sw: [], // 호환용
swUsers: [],
logs: []
users: [],
pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [],
subSw: [], permSw: [],
hw: [], sw: [],
swUsers: [], logs: []
}
};
/**
* 전용 API 엔드포인트들로부터 데이터 로드
* 신규 14개 테이블 구조에 맞춘 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
const endpoints = [
{ key: 'pc', url: 'http://localhost:3000/api/pc' },
{ key: 'server', url: 'http://localhost:3000/api/server' },
{ key: 'storage', url: 'http://localhost:3000/api/storage' },
{ key: 'equip', url: 'http://localhost:3000/api/equip' },
{ key: 'mobile', url: 'http://localhost:3000/api/mobile' },
{ key: 'subSw', url: 'http://localhost:3000/api/sw/sub' },
{ key: 'permSw', url: 'http://localhost:3000/api/sw/perm' },
{ key: 'cloud', url: 'http://localhost:3000/api/cloud' },
{ key: 'swUsers', url: 'http://localhost:3000/api/sw-users' },
{ key: 'logs', url: 'http://localhost:3000/api/logs' }
{ key: 'users', url: '/api/users' },
{ key: 'pc', url: '/api/pc' },
{ key: 'server', url: '/api/server' },
{ key: 'storage', url: '/api/storage' },
{ key: 'network', url: '/api/network' },
{ key: 'survey', url: '/api/survey' },
{ key: 'pcParts', url: '/api/pc-parts' },
{ key: 'equipment', url: '/api/equipment' },
{ key: 'officeSupplies', url: '/api/office-supplies' },
{ key: 'swInternal', url: '/api/sw/internal' },
{ key: 'swExternal', url: '/api/sw/external' },
{ key: 'cloud', url: '/api/cloud' },
{ key: 'domain', url: '/api/domain' },
{ key: 'cost', url: '/api/cost' },
{ key: 'vip', url: '/api/vip' },
{ key: 'swUsers', url: '/api/asset/software/assignment' },
{ key: 'logs', url: '/api/asset/history' }
];
const results = await Promise.all(endpoints.map(e => fetch(e.url)));
// 기존 데이터 초기화 (재분류 전)
state.masterData.pc = [];
state.masterData.server = [];
state.masterData.storage = [];
state.masterData.equip = [];
state.masterData.mobile = [];
const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url)));
for (let i = 0; i < endpoints.length; i++) {
if (results[i].ok) {
const data = await results[i].json();
const key = endpoints[i].key;
if (['pc', 'server', 'storage', 'equip', 'mobile'].includes(key)) {
// 하드웨어 데이터는 자동 재분류 로직 통과
(data as HardwareAsset[]).forEach(asset => saveHardwareAsset(asset));
} else {
(state.masterData as any)[key] = data || [];
}
(state.masterData as any)[key] = Array.isArray(data) ? data : [];
}
}
// 동료 코드 호환을 위한 통합 sw 배열 생성
state.masterData.sw = [
...state.masterData.subSw,
...state.masterData.permSw,
...state.masterData.cloud
];
// Mapping for backward compatibility
state.masterData.equip = state.masterData.equipment;
state.masterData.subSw = state.masterData.swExternal;
state.masterData.permSw = state.masterData.swInternal;
// 하드웨어 통합 배열 생성 (대시보드 등에서 사용)
// 하드웨어 통합 (대시보드 호환용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
...state.masterData.network,
...state.masterData.survey,
...state.masterData.equipment,
...state.masterData.officeSupplies
];
console.log('✅ 모든 DB 데이터 로드 및 통합 완료');
// 소프트웨어 통합
state.masterData.sw = [
...state.masterData.swInternal,
...state.masterData.swExternal,
...state.masterData.cloud
];
console.log('✅ All data (including users) loaded and unified');
return true;
} catch (err) {
console.warn('⚠️ 백엔드 서버 연결 실패. 로컬 데이터를 유지합니다.');
console.warn('⚠️ 서버 연결 실패:', err);
}
return false;
}
// --- State Helpers ---
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}
/**
* 하드웨어 자산 통합 저장 (자동 카테고리 분류)
* 자산 저장 (Generic API)
*/
export function saveHardwareAsset(updatedAsset: HardwareAsset) {
const type = updatedAsset.type || '';
const detailPurpose = (updatedAsset as any). || updatedAsset.detail_purpose || '';
export async function saveAsset(category: string, asset: any) {
try {
const endpointMap: Record<string, string> = {
'users': '/api/users/batch',
'pc': '/api/pc/batch',
'server': '/api/server/batch',
'storage': '/api/storage/batch',
'network': '/api/network/batch',
'survey': '/api/survey/batch',
'pcParts': '/api/pc-parts/batch',
'equipment': '/api/equipment/batch',
'officeSupplies': '/api/office-supplies/batch',
'swInternal': '/api/sw/internal/batch',
'swExternal': '/api/sw/external/batch',
'cloud': '/api/cloud/batch',
'domain': '/api/domain/batch',
'cost': '/api/cost/batch',
'vip': '/api/vip/batch'
};
// 1. 타겟 카테고리 결정 (사용자 정의 그룹 기준)
let targetKey: keyof MasterAssetData = 'equip';
const upperType = type.toUpperCase();
const isServer = type.includes('서버') || detailPurpose.includes('서버');
const isStorage = ['NAS', 'DAS', '스토리지'].some(t => type.includes(t));
const isMobileGroup = ['모바일', '태블릿', '노트북', '휴대폰', '핸드폰'].some(t => type.includes(t));
const isEquipGroup = ['CPU', 'RAM', 'HDD', 'GPU'].some(t => upperType.includes(t));
const isPc = type === 'PC' || type === '개인PC' || detailPurpose === '개인PC';
const url = `${API_BASE_URL}${endpointMap[category]}`;
const currentList = [...(state.masterData as any)[category]];
const idx = currentList.findIndex(a => a.id === asset.id);
if (idx > -1) currentList[idx] = asset;
else currentList.push(asset);
if (isServer) {
targetKey = 'server';
} else if (isStorage) {
targetKey = 'storage';
} else if (isMobileGroup) {
targetKey = 'mobile';
} else if (isPc) {
targetKey = 'pc';
} else if (isEquipGroup) {
targetKey = 'equip';
}
// 2. 모든 카테고리에서 기존 ID 자산 삭제 (중복 방지)
const hwKeys: (keyof MasterAssetData)[] = ['pc', 'server', 'storage', 'equip', 'mobile'];
hwKeys.forEach(key => {
const arr = state.masterData[key] as HardwareAsset[];
if (Array.isArray(arr)) {
const idx = arr.findIndex(a => a.id === updatedAsset.id);
if (idx > -1) arr.splice(idx, 1);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentList)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
});
// 3. 새로운 타겟 카테고리에 추가
(state.masterData[targetKey] as HardwareAsset[]).push(updatedAsset);
// 4. 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
} catch (err) {
console.error('자산 저장 실패:', err);
}
return false;
}
/**
* 하드웨어 자산 통합 삭제
* 자산 삭제 (Generic API - Batch 방식 활용)
*/
export function deleteHardwareAsset(assetId: string) {
const hwKeys: (keyof MasterAssetData)[] = ['pc', 'server', 'storage', 'equip', 'mobile'];
hwKeys.forEach(key => {
const arr = state.masterData[key] as HardwareAsset[];
if (Array.isArray(arr)) {
const idx = arr.findIndex(a => a.id === assetId);
if (idx > -1) arr.splice(idx, 1);
}
});
export async function deleteAsset(category: string, assetId: string) {
try {
const endpointMap: Record<string, string> = {
'users': '/api/users/batch',
'pc': '/api/pc/batch',
'server': '/api/server/batch',
'storage': '/api/storage/batch',
'network': '/api/network/batch',
'survey': '/api/survey/batch',
'pcParts': '/api/pc-parts/batch',
'equipment': '/api/equipment/batch',
'officeSupplies': '/api/office-supplies/batch',
'swInternal': '/api/sw/internal/batch',
'swExternal': '/api/sw/external/batch',
'cloud': '/api/cloud/batch',
'domain': '/api/domain/batch',
'cost': '/api/cost/batch',
'vip': '/api/vip/batch'
};
// 통합 hw 배열 동기화
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.equip,
...state.masterData.mobile
];
const url = `${API_BASE_URL}${endpointMap[category]}`;
const currentList = [...(state.masterData as any)[category]];
const filteredList = currentList.filter(a => a.id !== assetId);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filteredList)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 삭제 실패:', err);
}
return false;
}

46
src/core/tableHandler.ts Normal file
View File

@@ -0,0 +1,46 @@
/**
* 공통 테이블 핸들러
*/
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: string;
direction: SortDirection;
}
/**
* 테이블 헤더에 정렬 이벤트를 바인딩합니다.
* @param table 대상 테이블 요소
* @param currentState 현재 정렬 상태
* @param onSort 정렬 변경 시 호출될 콜백
*/
export function setupTableSorting(
table: HTMLTableElement,
currentState: SortState,
onSort: (key: string, direction: SortDirection) => void
) {
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(th => {
const key = th.getAttribute('data-sort')!;
th.classList.add('sortable');
// 현재 정렬 상태 표시
if (currentState.key === key) {
th.classList.add(currentState.direction);
} else {
th.classList.remove('asc', 'desc');
}
(th as HTMLElement).onclick = () => {
let nextDirection: SortDirection = 'asc';
if (currentState.key === key) {
nextDirection = currentState.direction === 'asc' ? 'desc' : 'asc';
}
onSort(key, nextDirection);
};
});
}

View File

@@ -1,7 +1,29 @@
import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = `http://${location.hostname}:3000`;
/**
* ITAM 공통 유틸리티 함수
*/
/**
* 페이지 헤더(타이틀 및 설명) 렌더링
*/
export function renderPageHeader(container: HTMLElement, pageId: string) {
const config = PAGE_DESCRIPTIONS[pageId];
if (!config) return;
const header = document.createElement('div');
header.className = 'page-header';
header.innerHTML = `
<div class="page-title-group">
<h2 class="page-title"><i data-lucide="${config.icon}"></i> ${config.title}</h2>
<p class="page-description">${config.description}</p>
</div>
`;
container.appendChild(header);
}
/**
* 숫자에 천 단위 콤마 추가 (금액 표시용)
*/
@@ -15,8 +37,8 @@ export function formatPrice(value: string | number): string {
/**
* HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/
export function createBadge(text: string, bgColor: string): string {
return `<span style="background:${bgColor}; color:white; font-size:10px; padding:1px 4px; border-radius:3px; font-weight:700; margin-right:4px; display:inline-block; line-height:1.2;">${text}</span>`;
export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string {
return `<span class="badge badge-${type}">${text}</span>`;
}
/**
@@ -30,7 +52,13 @@ export function formatInline(value: any): string {
* 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD)
*/
export function normalizeDate(dateStr: string): string {
return (dateStr || '').replace(/\./g, '-').trim();
if (!dateStr) return '';
let str = String(dateStr).replace(/\./g, '-').trim();
// YYYYMM 형식 처리 (6자리 숫자)
if (/^\d{6}$/.test(str)) {
return `${str.substring(0, 4)}-${str.substring(4, 6)}`;
}
return str;
}
/**
@@ -71,22 +99,68 @@ export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: stri
}
/**
* 자산 목록 정렬 (방안 C: 구매법인별 -> 자산번호 순)
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순)
*/
export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => {
// 1순위: 구매법인 (한글 가나다순)
const corpA = String(a. || '').trim();
const corpB = String(b. || '').trim();
// 1순위: 법인 (가나다순)
const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1;
if (corpA > corpB) return 1;
// 2순위: 자산번호 (영문/숫자순)
const codeA = String(a. || a. || '').trim();
const codeB = String(b. || b. || '').trim();
// 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1;
if (codeA > codeB) return 1;
return 0;
});
}
/**
* 동적 정렬 함수
* @param list 정렬할 목록
* @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc')
*/
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
// 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA;
}
// 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10);
const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA;
}
// 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}
/**
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가)
*/
export function getActionButtonsHTML(): string {
return `
<div class="search-actions">
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
`;
}

View File

@@ -1,15 +1,15 @@
import { state, loadMasterDataFromDB } from './core/state';
import { state, loadMasterDataFromDB, saveAsset } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { downloadTemplate, exportToExcel, parseExcel, HardwareAsset, SoftwareAsset, SWUser } from './core/excelHandler';
import { initBaseModal } from './components/Modal/BaseModal';
import { initPcModal } from './components/Modal/PCModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { createIcons, Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw } from 'lucide';
import { initGuide } from './components/Guide';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
async function apiBatchSave(url: string, data: any[], label: string) {
@@ -19,52 +19,59 @@ async function apiBatchSave(url: string, data: any[], label: string) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`${label} DB 저장 실패`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`);
}
console.log(`${label} DB 저장 완료`);
} catch (err) {
console.error(`${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
}
}
const savePcToDB = () => apiBatchSave('http://localhost:3000/api/pc/batch', state.masterData.pc, '개인PC');
const saveServerToDB = () => apiBatchSave('http://localhost:3000/api/server/batch', state.masterData.server, '서버');
const saveStorageToDB = () => apiBatchSave('http://localhost:3000/api/storage/batch', state.masterData.storage, '스토리지');
const saveEquipToDB = () => apiBatchSave('http://localhost:3000/api/equip/batch', state.masterData.equip, '전산비품');
const saveMobileToDB = () => apiBatchSave('http://localhost:3000/api/mobile/batch', state.masterData.mobile, '모바일기기');
const saveSubSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/sub/batch', state.masterData.subSw, '구독SW');
const savePermSwToDB = () => apiBatchSave('http://localhost:3000/api/sw/perm/batch', state.masterData.permSw, '영구SW');
const saveCloudToDB = () => apiBatchSave('http://localhost:3000/api/cloud/batch', state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave('http://localhost:3000/api/sw-users/batch', state.masterData.swUsers, 'SW사용자');
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자');
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그');
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
// 모든 하드웨어 DB 동기화
async function saveAllHardwareToDB() {
await Promise.all([
savePcToDB(),
saveServerToDB(),
saveStorageToDB(),
saveEquipToDB(),
saveMobileToDB()
]);
// 화면 갱신 통합 핸들러
function refreshView() {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
if (state.activeSubTab === '대시보드') {
renderDashboard(mainContent);
} else {
renderSWTable(mainContent);
}
}
// 모든 소프트웨어 DB 동기화
async function saveAllSoftwareToDB() {
await Promise.all([
saveSubSwToDB(),
savePermSwToDB(),
saveCloudToDB(),
saveSwUsersToDB()
]);
// 통합 저장 및 갱신
async function saveAllDataToDB() {
await Promise.all([
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
]);
await loadMasterDataFromDB();
refreshView();
}
// --- App Initialization ---
function initApp() {
console.log('🚀 ITAM Dedicated System Initializing...');
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const { closeAllModals } = initBaseModal();
try {
renderNavigation((tab) => {
if (tab === '대시보드') {
@@ -74,79 +81,59 @@ function initApp() {
}
});
// 모달 초기화
initPcModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initHwModal(() => { saveAllHardwareToDB(); renderSWTable(mainContent); }, closeAllModals);
initSwModal(() => {
saveAllSoftwareToDB();
renderSWTable(mainContent);
}, closeAllModals);
initHwModal(() => saveAllDataToDB(), closeAllModals);
initSwModal(() => saveAllDataToDB(), closeAllModals);
initSwUserModal(() => {
saveSwUsersToDB();
renderSWTable(mainContent);
saveSwUsersToDB().then(() => {
loadMasterDataFromDB().then(() => refreshView());
});
}, closeAllModals);
initDomainModal(() => saveAllDataToDB(), closeAllModals);
initDashboardDetailModal();
initGuide();
loadMasterDataFromDB().then((success) => {
if (success) {
refreshView();
}
});
} catch (e) { console.error('❌ Initialization failed:', e); }
// 초기 로드 시 대시보드 렌더링
renderDashboard(mainContent);
console.log('🚀 ITAM App Multi-Table Optimized');
// DB에서 데이터 로드 후 화면 갱신
loadMasterDataFromDB().then((success) => {
if (success) {
if (state.activeSubTab === '대시보드') renderDashboard(mainContent);
else renderSWTable(mainContent);
}
});
// 버튼 이벤트 바인딩
document.getElementById('btn-download-template')?.addEventListener('click', () => downloadTemplate());
document.getElementById('btn-export-excel')?.addEventListener('click', () => exportToExcel(state.masterData));
const uploadInput = document.getElementById('excel-upload') as HTMLInputElement;
uploadInput?.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const data = await parseExcel(file);
state.masterData = data;
await Promise.all([
saveAllHardwareToDB(),
saveAllSoftwareToDB()
]);
renderSWTable(mainContent);
}
});
document.getElementById('btn-add-asset')?.addEventListener('click', () => {
const tab = state.activeSubTab;
const cat = state.activeCategory;
if (cat === 'hw') {
// 하드웨어 대시보드 또는 개별 탭에서 추가
const defaultType = (tab === '대시보드') ? '' : tab;
openHwModal({
id: Math.random().toString(36).substring(2, 9),
type: defaultType,
: '한맥', : '', : '', : '', MACaddress: '', HW사양: '', OS: '', : '', : ''
} as any, 'add');
} else if (cat === 'sw') {
// 소프트웨어 대시보드 또는 개별 탭에서 추가
let defaultType = tab;
if (tab === '대시보드') defaultType = '구독SW'; // SW는 기본 레이아웃을 위해 하나 지정하되 필드는 빈값
// --- 통합 이벤트 위임 (Dynamic Elements 지원) ---
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 자산 추가
if (target.closest('#btn-add-asset')) {
const tab = state.activeSubTab;
const cat = state.activeCategory;
const newId = Math.random().toString(36).substring(2, 9);
openSwModal({
id: Math.random().toString(36).substring(2, 9),
type: defaultType, : '', : '', 수량: 1, : '', : '', : '', : '한맥'
} as any, 'add');
if (cat === 'users') {
// 사용자 추가는 renderUserList 내부에서 별도로 처리하거나 여기서 호출 가능
// 현재 renderUserList에서 별도로 핸들링하고 있으므로 중복 실행 방지
return;
}
if (cat === 'hw') {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
} else if (cat === 'sw') {
const swType = tab === '외부' ? '외부SW' : (tab === '내부' ? '내부SW' : '외부SW');
openSwModal({ id: newId, asset_type: swType } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
}
return;
}
});
createIcons({
icons: { Download, Upload, FileSpreadsheet, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw }
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
});
window.addEventListener('refresh-view', () => refreshView());
}
document.addEventListener('DOMContentLoaded', initApp);

8
src/map-editor-main.ts Normal file
View File

@@ -0,0 +1,8 @@
import './styles/common.css';
import './styles/map-editor.css';
import { MapEditor } from './views/MapEditor';
document.addEventListener('DOMContentLoaded', () => {
const editor = new MapEditor();
editor.init();
});

View File

@@ -1,7 +1,73 @@
:root {
--primary-color: #1E5149;
--primary-hover: #153c36;
--primary-light: #edf2f1;
/* --- System Colors (Added) --- */
--color-red: #F21D0D;
--color-pink: #E8175E;
--color-magenta: #B92ED1;
--color-purple: #6D3DC2;
--color-navy: #4255bd;
--color-blue: #0D8DF2;
--color-cyan: #03AEFC;
--color-green: #4DB251;
--color-yellow: #FFBF00;
--color-orange: #FF9800;
--color-dahong: #FF3D00;
--color-brown: #A0705F;
--color-iron: #7F7F7F;
--color-steel: #688897;
--color-red-light: #FEE9E7;
--color-pink-light: #FDE8EF;
--color-magenta-light: #F8EBFB;
--color-purple-light: #F1ECF9;
--color-navy-light: #EDEEF9;
--color-blue-light: #E7F4FE;
--color-cyan-light: #E6F7FF;
--color-green-light: #EEF8EE;
--color-yellow-light: #FFF9E6;
--color-orange-light: #FFF5E6;
--color-dahong-light: #FFECE6;
--color-brown-light: #F6F1EF;
--color-iron-light: #F3F3F3;
--color-steel-light: #F0F4F5;
--color-red-medium: #FAA59E;
--color-pink-medium: #F6A2BF;
--color-magenta-medium: #E3ABEC;
--color-purple-medium: #C5B1E7;
--color-navy-medium: #B3BBE5;
--color-blue-medium: #9ED1FA;
--color-cyan-medium: #9ADFFE;
--color-green-medium: #B8E0B9;
--color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199;
--color-dahong: #FF3D00;
--color-dahong-light: #FFECE6;
--color-dahong-medium: #FFB199;
--color-dahong-dark: #cc3100;
/* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB;
--primary-lv-2: #A5B9B6;
--primary-lv-3: #789792;
--primary-lv-4: #4B746D;
--primary-lv-5: #35635C;
--primary-lv-6: #1E5149;
--primary-lv-7: #1B443D;
--primary-lv-8: #193833;
--primary-lv-9: #162A27;
/* --- Semantic Colors --- */
--primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: var(--color-dahong-light);
--edit-mode-focus: var(--color-dahong-medium);
--edit-mode-dark: var(--color-dahong-dark);
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
@@ -9,27 +75,31 @@
--bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: #dc2626;
--danger: var(--color-red);
--info: var(--color-blue);
--success: var(--color-green);
--warning: var(--color-orange);
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
--header-height: 52px;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
letter-spacing: -0.02em;
/* 모든 요소에 자간 규칙 일괄 적용 */
}
body {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
letter-spacing: -0.02em;
font-size: 14px;
overflow: hidden;
}
@@ -57,14 +127,32 @@ body {
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.main-logo {
height: 34px;
width: auto;
}
.brand h1 {
font-size: 1.2rem;
font-size: 1.1rem;
/* 전체적으로 살짝 축소 */
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
margin-right: 1rem;
}
.brand h1 span { color: var(--primary-color); }
.brand h1 .sub-title {
font-size: 0.85rem;
/* 영문 제목은 더 작게 */
color: var(--primary-color);
font-weight: 600;
margin-left: 0.25rem;
}
.integrated-nav {
flex: 1;
@@ -93,7 +181,7 @@ body {
}
.lnb-shelf {
display: none;
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
@@ -118,7 +206,11 @@ body {
white-space: nowrap;
}
.lnb-item:hover { color: var(--primary-color); background-color: var(--bg-color); }
.lnb-item:hover {
color: var(--primary-color);
background-color: var(--bg-color);
}
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
@@ -126,12 +218,23 @@ body {
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-5px); }
to { opacity: 1; transform: translateX(0); }
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* --- Global Actions & Buttons --- */
.header-actions { display: flex; gap: 0.3rem; align-items: center; }
.header-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
.btn {
display: inline-flex;
@@ -145,27 +248,136 @@ body {
cursor: pointer;
height: 28px;
line-height: 1;
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
flex-shrink: 0; /* 크기 찌그러짐 방지 */
}
.btn i, .btn svg { width: 12px !important; height: 12px !important; }
.btn i,
.btn svg {
width: 12px !important;
height: 12px !important;
}
.btn-primary { background-color: var(--primary-color); color: var(--white); border: 1px solid var(--primary-color); }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-outline {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.btn-danger {
color: var(--danger) !important;
border-color: var(--danger) !important;
}
/* --- Layout Frame --- */
.content-area {
flex: 1;
padding: 2rem;
overflow-y: auto;
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
overflow: hidden;
/* 전체 스크롤 차단 */
display: flex;
flex-direction: column;
}
.view-container {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.5rem;
overflow: hidden;
/* 내부 스크롤을 유도하기 위해 설정 */
}
.hidden { display: none !important; }
.text-nowrap { white-space: nowrap; }
/* --- Footer --- */
.main-footer {
height: 40px;
background-color: var(--white);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 1.5rem;
flex-shrink: 0;
}
.main-footer p {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-size: 0.75rem;
font-weight: 300;
line-height: 1.25rem;
letter-spacing: -0.0175rem;
color: var(--text-muted);
user-select: none;
pointer-events: all;
-webkit-user-drag: none;
margin: 0;
padding: 0;
box-sizing: border-box;
}
.hidden {
display: none !important;
}
.text-nowrap {
white-space: nowrap;
}
/* --- Utility Styles --- */
.badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.badge-primary {
background-color: var(--primary-color);
color: white;
}
.badge-muted {
background-color: #9CA3AF;
color: white;
}
.text-tag {
color: var(--text-muted);
font-size: 11px;
padding: 1px 5px;
border: 1px solid var(--border-color);
border-radius: 3px;
background-color: var(--bg-light);
}
.font-bold {
font-weight: 700;
}
/* --- Responsive Design (Tablet & Mobile) --- */
@media (max-width: 1200px) {
.header-container { gap: 0.75rem; padding: 0 1rem; }
.brand h1 { font-size: 1rem; }
.brand h1 .sub-title { font-size: 0.75rem; }
}
@media (max-width: 992px) {
.main-header { height: auto; padding: 0.5rem 0; }
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
.content-area { padding: 0 1rem; }
}
@media (max-width: 768px) {
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
.header-actions .btn { padding: 0 0.5rem; }
}

188
src/styles/guide.css Normal file
View File

@@ -0,0 +1,188 @@
/* ITAM Guide Modal Styles - Updated to match common modal style */
/* Tab Container (below header) */
.guide-tabs-container {
background: #FAFAFA;
border-bottom: 1px solid var(--border-color);
padding: 0 1.5rem;
flex-shrink: 0;
}
.guide-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.guide-tabs::-webkit-scrollbar { display: none; }
.guide-tab {
padding: 0.75rem 1.25rem;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
}
.guide-tab:hover {
color: var(--primary-color);
background: rgba(30, 81, 73, 0.04);
}
.guide-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: white;
}
/* Content Area */
.guide-body {
padding-bottom: 2rem;
}
.guide-tab-panel {
display: none;
padding: 1.5rem 0;
animation: guideFadeIn 0.3s ease;
}
.guide-tab-panel.active {
display: block;
}
@keyframes guideFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Section Styles */
.guide-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section h3 {
font-size: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.guide-text {
font-size: 13px;
color: var(--text-main);
line-height: 1.7;
margin: 0;
}
/* Flowchart Styles */
.flow-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background-color: #f9fafb;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.flow-row {
display: flex;
width: 100%;
gap: 1rem;
align-items: center;
}
.flow-step {
flex: 1;
background: white;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.flow-step .step-number {
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.flow-step .step-label {
font-weight: 700;
color: var(--text-main);
font-size: 13px;
display: block;
}
.flow-step .step-desc {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
}
.flow-arrow-right {
color: var(--text-muted);
display: flex;
align-items: center;
}
/* Info Table Style */
.guide-info-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.guide-info-table th {
background: #f8faf9;
color: var(--primary-color);
font-weight: 700;
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.guide-info-table td {
padding: 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: var(--text-main);
}
/* Tip Box Style */
.guide-tip {
background: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
font-size: 13px;
color: var(--primary-color);
line-height: 1.6;
}

159
src/styles/map-editor.css Normal file
View File

@@ -0,0 +1,159 @@
/* ITAM Map Coordinate Editor Styles */
.file-sidebar {
width: 260px;
background: var(--white);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.folder-item {
padding: 10px 15px;
background: var(--bg-light);
font-weight: bold;
font-size: 13px;
border-bottom: 1px solid var(--border-color);
color: var(--primary-color);
}
.file-item {
padding: 8px 25px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px solid var(--bg-color);
transition: background 0.2s;
}
.file-item:hover { background: var(--bg-light); }
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: bold; }
/* Center: Editor Area */
.editor-container {
flex: 1;
position: relative;
overflow: auto;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0; /* 전용 배경색 유지 */
}
.img-wrapper {
position: relative;
display: inline-block;
box-shadow: 0 0 30px rgba(0,0,0,0.3);
background: var(--white);
line-height: 0;
}
.img-wrapper img {
display: block;
max-width: calc(100vw - 650px);
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar {
width: 350px;
background: var(--white);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
}
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: 1.2rem; }
.sidebar p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list {
flex: 1;
overflow-y: auto;
margin-bottom: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
background: var(--bg-light);
}
.box-item {
font-family: monospace;
font-size: 11px;
padding: 6px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.box-item:hover { background: var(--white); }
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: 16px; padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
/* Drawing Elements */
.draw-box {
position: absolute;
border: 2px solid var(--edit-mode-color);
background: rgba(255, 61, 0, 0.2);
pointer-events: none;
z-index: 100;
}
.placed-box {
position: absolute;
border: 1.5px solid var(--primary-color);
background: rgba(30, 81, 73, 0.15);
cursor: pointer;
z-index: 50;
}
.placed-box:hover {
background: rgba(30, 81, 73, 0.4);
border-color: #000;
}
.placed-box.selected {
border: 2.5px solid var(--edit-mode-color);
z-index: 60;
box-shadow: 0 0 10px rgba(255,61,0,0.5);
}
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: var(--primary-color);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: var(--edit-mode-color);
background: rgba(255,255,255,0.8);
}
#save-status {
margin-top: 8px;
font-size: 11px;
color: var(--success);
text-align: center;
font-weight: bold;
height: 14px;
}

View File

@@ -47,21 +47,26 @@
}
.modal-header .btn-icon {
color: #FFFFFF !important;
color: var(--white) !important;
cursor: pointer;
background: none !important;
border: none !important;
}
.modal-header .btn-icon i,
.modal-header .btn-icon svg {
width: 20px !important; /* Original natural size */
height: 20px !important;
stroke: #FFFFFF !important;
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--primary-color);
transition: opacity 0.2s;
}
.modal-header .btn-icon:hover {
background: none !important;
.btn-icon:hover {
opacity: 0.7;
}
.modal-body {
@@ -116,6 +121,21 @@
box-shadow: none !important;
}
.grid-form.is-view-mode input[type="file"] {
display: none !important;
}
.grid-form.is-view-mode .btn-helper {
display: none !important;
}
.grid-form.is-view-mode button:not(.btn-loc-action) {
pointer-events: none !important;
background: none !important;
border: none !important;
opacity: 0.8;
}
.grid-form.is-view-mode select::-ms-expand {
display: none !important;
}
@@ -123,7 +143,7 @@
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
@@ -140,8 +160,8 @@
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00;
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1);
border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px var(--edit-mode-focus);
}
.form-section-title:first-child {
@@ -167,6 +187,10 @@
background-color: var(--white);
}
.form-group textarea {
resize: none;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
@@ -194,6 +218,100 @@
max-width: 950px;
}
/* Upload Preview Specific */
.upload-sidebar {
width: 240px;
border-right: 1px solid var(--border-color);
background-color: var(--bg-light);
padding: 1.5rem 1rem;
overflow-y: auto;
flex-shrink: 0;
}
.upload-tab-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upload-tab-btn {
padding: 0.75rem 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
border: 1px solid transparent;
color: var(--text-main);
}
.upload-tab-btn:hover {
background-color: var(--white);
border-color: var(--border-color);
}
.upload-tab-btn.active {
background-color: var(--white);
color: var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-color: var(--border-color);
font-weight: 700;
}
.preview-table-container {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--white);
overflow: hidden;
}
.preview-stats-bar {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--white);
}
.preview-table {
width: 100%;
border-collapse: collapse;
min-width: max-content;
}
.preview-table thead {
position: sticky;
top: 0;
z-index: 10;
background-color: var(--bg-light);
box-shadow: 0 1px 0 var(--border-color);
}
.preview-table th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--border-color);
color: var(--text-muted);
}
.preview-table td {
padding: 0.75rem 1rem;
font-size: 13px;
border-bottom: 1px solid #f1f5f9;
color: var(--text-main);
}
.preview-table tr:hover {
background-color: var(--bg-light);
}
.modal-body-split {
display: flex;
gap: 2rem;
@@ -213,6 +331,9 @@
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
@@ -225,6 +346,35 @@
color: var(--text-main);
}
/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */
.is-readonly-field {
border-color: transparent !important;
background-color: transparent !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 600 !important;
cursor: default;
padding-left: 0 !important;
}
/* 입력 필드 + 버튼 그룹 (자산번호 생성 등) */
.input-with-btn {
display: flex;
gap: 0.5rem;
align-items: center;
width: 100%;
}
.input-with-btn input {
flex: 1;
min-width: 0; /* flex 컨테이너 안에서 너비 압축 방지 */
}
.input-with-btn .btn {
flex-shrink: 0;
white-space: nowrap;
}
.history-timeline {
flex: 1;
overflow-y: auto;
@@ -358,3 +508,186 @@
color: #3b82f6;
background: #eff6ff;
}
/* Layout Map & Image Picker Styles */
.layout-map-container {
position: relative;
display: inline-block;
cursor: crosshair;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.layout-map-container.readonly {
cursor: default;
}
.layout-map-container.readonly .map-seat-obj {
pointer-events: none;
}
.digital-overlay-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 8;
}
.digital-map-svg {
width: 100%; height: 100%;
}
.map-seat-obj {
fill: rgba(30, 81, 73, 0.02);
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
stroke-width: 0.2;
cursor: pointer;
pointer-events: all;
transition: all 0.2s;
}
.map-seat-obj:hover {
fill: rgba(30, 81, 73, 0.3);
stroke: rgba(30, 81, 73, 0.6);
stroke-width: 0.5;
}
.layout-map-img {
display: block;
max-width: 100%;
max-height: 75vh;
user-select: none;
-webkit-user-drag: none;
}
.layout-marker {
position: absolute;
width: 14px;
height: 14px;
background-color: rgba(30, 81, 73, 0.7);
border: 2px solid #FFFFFF;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 10;
box-shadow: 0 0 8px rgba(0,0,0,0.4);
}
.pulse-marker {
background-color: rgba(255, 61, 0, 0.8) !important;
border-color: #FFFFFF !important;
animation: marker-pulse 1.2s infinite;
}
@keyframes marker-pulse {
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
}
.image-picker-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
z-index: 2500;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.image-picker-header {
width: 100%;
max-width: 900px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.image-picker-header h3 {
color: white;
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.image-picker-content {
background: white;
padding: 8px;
border-radius: 8px;
max-width: 95vw;
max-height: 80vh;
overflow: auto;
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.picker-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 60px;
background: rgba(0,0,0,0.5);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
z-index: 100;
user-select: none;
transition: background 0.2s;
}
.picker-nav:hover { background: rgba(0,0,0,0.8); }
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
.image-picker-footer {
margin-top: 20px;
display: flex;
gap: 12px;
}
.btn-loc-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px;
font-size: 10px !important;
font-weight: 600;
border-radius: 4px;
height: 24px;
min-width: 52px;
white-space: nowrap;
transition: all 0.2s;
}
.btn-loc-view {
background-color: var(--primary-color);
color: white !important;
border: none;
}
.btn-loc-view:hover {
background-color: #163d37;
}
.location-detail-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.location-detail-container select {
flex: 1;
}

View File

@@ -1,14 +1,52 @@
/* --- Page Header for Description --- */
.page-header {
padding: 1rem 0 0.2rem 0;
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: var(--primary-color);
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.page-title i {
display: flex;
align-items: center;
}
.page-title svg {
width: 18px;
height: 18px;
}
.page-description {
font-size: 12px;
color: var(--text-muted);
margin: 0;
line-height: 1.4;
opacity: 0.8;
}
/* --- Table View & Filter Styles --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: 1.25rem;
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
gap: 0.75rem; /* 간격 축소 및 통일 */
padding: 1.2rem 0;
border-bottom: 1px solid var(--border-color);
align-items: flex-end;
margin-bottom: 0.5rem;
}
.search-item {
@@ -18,12 +56,24 @@
}
.search-item.flex-1 {
flex: 1;
flex: 1; /* 검색창이 남은 공간을 채우도록 설정 */
min-width: 250px;
}
.search-actions {
display: flex;
gap: 0.5rem; /* 버튼들 간의 간격 */
align-items: center;
}
.search-actions .btn {
height: 38px;
padding: 0 1rem;
}
.search-item label {
font-size: 11px;
font-weight: 800;
font-weight: 700;
color: var(--text-muted);
}
@@ -35,70 +85,148 @@
border-radius: 4px;
font-size: 14px;
outline: none;
background-color: var(--white);
}
/* 셀렉트 박스 화살표 여백 절대 고정 (수정 금지) */
.search-item select {
padding-right: 2.5rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2.5rem !important;
cursor: pointer;
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary-color);
}
/* 필터 초기화 버튼 크기 조정 (입력창 높이 38px에 맞춤) */
.btn-reset {
height: 38px !important;
padding: 0 0.8rem !important;
font-size: 12px !important;
display: inline-flex !important;
align-items: center !important;
gap: 0.35rem !important;
border-radius: 4px !important;
color: var(--text-muted) !important;
padding: 0 1.2rem !important;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0; /* 불필요한 마진 제거 */
}
.table-container {
flex: 1;
background-color: var(--white);
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
border-left: none;
border-right: none;
overflow: auto;
max-height: calc(100vh - 240px);
position: relative;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
table-layout: auto;
}
th, td {
padding: 1rem 1.5rem;
padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--border-color);
text-align: left;
text-align: left; /* 기본은 좌측 정렬 */
white-space: nowrap;
}
th {
background-color: #FAFAFA;
font-weight: 700;
color: var(--text-muted);
font-size: 12px;
thead {
position: sticky;
top: 0;
z-index: 10;
box-shadow: inset 0 -1px 0 var(--border-color);
text-transform: uppercase;
z-index: 50;
}
th {
background-color: var(--bg-light) !important;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
position: sticky;
top: 0;
z-index: 50;
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
text-transform: none;
}
td {
font-size: 14px;
font-size: 13px;
color: var(--text-main);
font-weight: 400;
}
tbody tr:hover {
background-color: #F9FAFB;
background-color: var(--bg-color);
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 11px;
height: 24px;
/* 정렬 클래스 강제 적용 */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
/* 메모 컬럼 전용: 가장 길게 표시되도록 너비 조정 및 줄바꿈 허용 */
.col-memo {
width: 20%;
min-width: 250px;
white-space: normal !important;
word-break: break-all;
line-height: 1.4;
text-align: left !important;
}
.btn-icon {
padding: 0.25rem;
border: none;
background: none;
cursor: pointer;
color: var(--text-muted);
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--primary-color);
}
.btn-icon svg {
width: 16px;
height: 16px;
}
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
position: relative;
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
}
th.sortable:hover {
background-color: #F3F4F6;
color: var(--primary-color);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.6rem;
top: 50%;
transform: translateY(-50%);
font-size: 11px;
opacity: 0.3;
transition: all 0.2s;
}
th.sortable.asc::after {
content: '▲';
opacity: 1;
color: var(--primary-color);
}
th.sortable.desc::after {
content: '▼';
opacity: 1;
color: var(--primary-color);
}

View File

@@ -1,7 +1,7 @@
import { state } from '../../core/state';
import { HardwareAsset } from '../../core/excelHandler';
import { openHwModal } from '../../components/Modal/HWModal';
import { calculateAssetAge, normalizeDate } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
declare var Chart: any;
@@ -12,14 +12,14 @@ export function renderHwDashboard(container: HTMLElement) {
let totalAge = 0;
let countWithDate = 0;
let over5YearsCount = 0;
let latestAsset: HardwareAsset | null = null;
let latestAsset: any | null = null;
let latestYear = 0;
const ageGroups = { stable: 0, warning: 0, critical: 0 };
const yearlyCount: Record<string, number> = {};
allHw.forEach(a => {
const pDate = a. || (a as any).purchase_date;
const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key];
if (!pDate) return;
const age = calculateAssetAge(pDate);
@@ -53,10 +53,10 @@ export function renderHwDashboard(container: HTMLElement) {
// 교체 시급 대상 TOP 10 (오래된 순)
const criticalList = [...allHw]
.filter(a => (a. || (a as any).purchase_date))
.filter(a => a[ASSET_SCHEMA.PURCHASE_DATE.key])
.sort((a, b) => {
const dateA = new Date(normalizeDate(a. || (a as any).purchase_date)).getTime();
const dateB = new Date(normalizeDate(b. || (b as any).purchase_date)).getTime();
const dateA = new Date(normalizeDate(a[ASSET_SCHEMA.PURCHASE_DATE.key])).getTime();
const dateB = new Date(normalizeDate(b[ASSET_SCHEMA.PURCHASE_DATE.key])).getTime();
return dateA - dateB;
})
.slice(0, 10);
@@ -65,22 +65,29 @@ export function renderHwDashboard(container: HTMLElement) {
container.innerHTML = `
<div class="view-container">
<div class="dashboard-header-stats" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.5rem; margin-bottom: 2rem;">
<div class="dashboard-card stat-card">
<div class="stat-label">전체 평균 사용 연수</div>
<div class="stat-value">${avgAge}<span class="unit">년</span></div>
<div class="stat-footer">권장 교체 주기: 4.5년</div>
</div>
<div class="dashboard-card stat-card ${over5Rate >= 20 ? 'critical' : ''}">
<div class="stat-label">5년 이상 노후 자산 비율</div>
<div class="stat-value" style="${over5Rate >= 20 ? 'color:var(--danger)' : ''}">${over5Rate}<span class="unit">%</span></div>
<div class="stat-footer">${over5YearsCount}대의 자산이 교체 대상을 초과함</div>
</div>
<div class="dashboard-card stat-card">
<div class="stat-label">최신 도입 모델 (${latestYear}년)</div>
<div class="stat-value" style="font-size: 1.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${latestAsset?. || '정보 없음'}">
${latestAsset?. || '정보 없음'}
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">전체 평균 사용 연수</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">전체 자산 기준 (권장 4.5년)</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${avgAge}년</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${Math.min(100, (Number(avgAge)/4.5)*100)}%; height: 100%; background-color: var(--dash-primary);"></div>
</div>
<div class="stat-footer">가장 최근 자산번호: ${latestAsset?. || '-'}</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">5년 이상 노후 자산 비율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">총 ${over5YearsCount}대 해당</div>
<div style="font-size: 2rem; font-weight:700; color:${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">${over5Rate}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${over5Rate}%; height: 100%; background-color: ${over5Rate >= 20 ? 'var(--dash-danger)' : 'var(--dash-primary)'};"></div>
</div>
</div>
<div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">최신 도입 모델 (${latestYear}년)</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">자산번호: ${latestAsset ? latestAsset[ASSET_SCHEMA.ASSET_CODE.key] : '-'}</div>
<div style="font-size: 1.25rem; font-weight:700; color:var(--primary-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; height: 3rem; display: flex; align-items: center;" title="${latestAsset ? latestAsset[ASSET_SCHEMA.MODEL_NAME.key] : '정보 없음'}">
${latestAsset ? (latestAsset[ASSET_SCHEMA.MODEL_NAME.key] || latestAsset[ASSET_SCHEMA.ASSET_NAME.key] || '정보 없음') : '정보 없음'}
</div>
<div style="width: 100%; height: 4px; background-color: var(--primary-color); border-radius: 2px; margin-top: 0.5rem;"></div>
</div>
</div>
@@ -100,27 +107,31 @@ export function renderHwDashboard(container: HTMLElement) {
<table>
<thead>
<tr>
<th>순위</th>
<th>자산번호</th>
<th>유형</th>
<th>모델명</th>
<th>사용자/담당자</th>
<th>구매일</th>
<th>연령</th>
<th style="text-align:center;">순위</th>
<th style="text-align:center;">자산번호</th>
<th style="text-align:center;">유형</th>
<th style="text-align:center;">모델명</th>
<th style="text-align:center;">담당자</th>
<th style="text-align:center;">구매일</th>
<th style="text-align:center;">연령</th>
</tr>
</thead>
<tbody>
${criticalList.map((a, i) => `
<tr class="clickable-row" data-id="${a.id}">
${criticalList.map((a, i) => {
const pDate = a[ASSET_SCHEMA.PURCHASE_DATE.key];
const age = calculateAssetAge(pDate);
return `
<tr class="clickable-row" data-id="${a.id}" style="cursor:pointer;">
<td style="text-align:center; font-weight:600; color:var(--text-muted)">${i + 1}</td>
<td>${a. || '-'}</td>
<td><span class="badge-type">${a.type}</span></td>
<td>${a. || a. || '-'}</td>
<td>${a. || a._정 || '-'}</td>
<td style="text-align:center;">${a. || (a as any).purchase_date || '-'}</td>
<td style="text-align:center;"><strong style="color:${calculateAssetAge(a. || (a as any).purchase_date) >= 5 ? 'var(--danger)' : 'inherit'}">${calculateAssetAge(a. || (a as any).purchase_date)}년</strong></td>
<td style="text-align:center;">${a[ASSET_SCHEMA.ASSET_CODE.key] || '-'}</td>
<td style="text-align:center;"><span class="badge-type">${a[ASSET_SCHEMA.ASSET_TYPE.key] || a.category || ''}</span></td>
<td>${a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-'}</td>
<td style="text-align:center;">${a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'}</td>
<td style="text-align:center;">${pDate || '-'}</td>
<td style="text-align:center;"><strong style="color:${age >= 5 ? 'var(--danger)' : 'inherit'}">${age}년</strong></td>
</tr>
`).join('')}
`;
}).join('')}
</tbody>
</table>
</div>
@@ -144,7 +155,7 @@ export function renderHwDashboard(container: HTMLElement) {
function initAgingCharts(ageGroups: any, yearlyCount: Record<string, number>) {
const agingCtx = document.getElementById('chart-aging-dist') as HTMLCanvasElement;
if (agingCtx) {
if (agingCtx && typeof Chart !== 'undefined') {
new Chart(agingCtx, {
type: 'doughnut',
data: {
@@ -165,7 +176,7 @@ function initAgingCharts(ageGroups: any, yearlyCount: Record<string, number>) {
}
const trendCtx = document.getElementById('chart-purchase-trend') as HTMLCanvasElement;
if (trendCtx) {
if (trendCtx && typeof Chart !== 'undefined') {
const years = Object.keys(yearlyCount).sort();
new Chart(trendCtx, {
type: 'bar',

Some files were not shown because too many files have changed in this diff Show More