chore: clean up build artifacts, temporary excel locks, duplicate plans, and commit current project state
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 18s
ITAM Docker Build Check / docker-build-check (push) Failing after 21s

This commit is contained in:
이태훈
2026-06-22 11:26:26 +09:00
parent 7b631ab858
commit 621b05a890
135 changed files with 22565 additions and 42690 deletions

View File

@@ -1,7 +1,7 @@
{ {
"Path": "./backend/coverage.out", "Path": "./backend/coverage.out",
"Thresholds": { "Thresholds": {
"baron-sso-backend/internal/handler": 10, "baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10 "baron-sso-backend/internal/service": 10
} }
} }

16
.gitignore vendored
View File

@@ -1,8 +1,8 @@
node_modules/ node_modules/
.gemini .gemini
.env .env
dist/ dist/
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db
backups/ backups/

View File

@@ -1,103 +1,103 @@
# 자산관리 시스템 운영 오픈 보고 요약 # 자산관리 시스템 운영 오픈 보고 요약
## 1. 운영 방식 ## 1. 운영 방식
```mermaid ```mermaid
flowchart LR flowchart LR
A[개발 완료] --> B[개발 완료 검증] A[개발 완료] --> B[개발 완료 검증]
B --> C[운영 서버 반영] B --> C[운영 서버 반영]
C --> D[최종 점검] C --> D[최종 점검]
D --> E[서비스 오픈] D --> E[서비스 오픈]
linkStyle default stroke:#d32f2f,stroke-width:2px; linkStyle default stroke:#d32f2f,stroke-width:2px;
``` ```
| 구분 | 운영 방향 | | 구분 | 운영 방향 |
| --- | --- | | --- | --- |
| 반영 기준 | 개발 완료 및 검증 후 운영에 반영 | | 반영 기준 | 개발 완료 및 검증 후 운영에 반영 |
| 반영 방식 | 운영 담당자 기준 통제 절차 | | 반영 방식 | 운영 담당자 기준 통제 절차 |
| 데이터 연계 | 기존 외부 DB와 연동 | | 데이터 연계 | 기존 외부 DB와 연동 |
| 안정성 확보 | 반영 전 점검, 반영 전 백업, 반영 후 확인 | | 안정성 확보 | 반영 전 점검, 반영 전 백업, 반영 후 확인 |
임의 반영 배제, 개발 완료 및 검증 결과 기준 운영 반영 방식. 임의 반영 배제, 개발 완료 및 검증 결과 기준 운영 반영 방식.
--- ---
## 2. 운영 배포 절차 ## 2. 운영 배포 절차
```mermaid ```mermaid
flowchart TD flowchart TD
A[배포 준비 완료] --> B[사전 점검] A[배포 준비 완료] --> B[사전 점검]
B --> C[백업 수행] B --> C[백업 수행]
C --> D[운영 배포 실행] C --> D[운영 배포 실행]
D --> E[접속 및 기능 확인] D --> E[접속 및 기능 확인]
E --> F[오픈] E --> F[오픈]
B1[환경 설정 확인] B1[환경 설정 확인]
B2[외부 DB 연결 확인] B2[외부 DB 연결 확인]
B3[배포 파일 확인] B3[배포 파일 확인]
B --> B1 B --> B1
B --> B2 B --> B2
B --> B3 B --> B3
linkStyle default stroke:#d32f2f,stroke-width:2px; linkStyle default stroke:#d32f2f,stroke-width:2px;
``` ```
| 단계 | 목적 | 담당 | | 단계 | 목적 | 담당 |
| --- | --- | --- | | --- | --- | --- |
| 사전 점검 | 운영 반영에 필요한 조건 확인 | 개발팀 + 운영 | | 사전 점검 | 운영 반영에 필요한 조건 확인 | 개발팀 + 운영 |
| 백업 수행 | 문제 발생 시 신속 복구 대비 | 운영 | | 백업 수행 | 문제 발생 시 신속 복구 대비 | 운영 |
| 운영 배포 실행 | 운영 서버에 검증 완료 결과 반영 | 운영 | | 운영 배포 실행 | 운영 서버에 검증 완료 결과 반영 | 운영 |
| 접속 및 기능 확인 | 주요 화면과 서비스 상태 확인 | 개발팀 + 운영 | | 접속 및 기능 확인 | 주요 화면과 서비스 상태 확인 | 개발팀 + 운영 |
| 오픈 | 사용자 대상 서비스 오픈 | 운영 | | 오픈 | 사용자 대상 서비스 오픈 | 운영 |
--- ---
## 3. 예상 일정 ## 3. 예상 일정
```mermaid ```mermaid
gantt gantt
title 자산관리 시스템 운영 오픈 일정 title 자산관리 시스템 운영 오픈 일정
dateFormat YYYY-MM-DD dateFormat YYYY-MM-DD
axisFormat %m/%d axisFormat %m/%d
section 준비 단계 section 준비 단계
배포 구성 정리 및 환경 보완 :done, a1, 2026-06-18, 2026-06-24 배포 구성 정리 및 환경 보완 :done, a1, 2026-06-18, 2026-06-24
section 검증 단계 section 검증 단계
테스트 배포 및 점검 :a2, 2026-06-25, 2026-06-27 테스트 배포 및 점검 :a2, 2026-06-25, 2026-06-27
오픈 전 최종 확인 :a3, 2026-06-28, 2026-06-30 오픈 전 최종 확인 :a3, 2026-06-28, 2026-06-30
section 오픈 section 오픈
운영 오픈 :milestone, a4, 2026-07-01, 1d 운영 오픈 :milestone, a4, 2026-07-01, 1d
``` ```
| 구간 | 일정 | 주요 내용 | | 구간 | 일정 | 주요 내용 |
| --- | --- | --- | | --- | --- | --- |
| 준비 단계 | 6월 18일 ~ 6월 24일 | 운영 환경 정리 및 배포 준비 완료 | | 준비 단계 | 6월 18일 ~ 6월 24일 | 운영 환경 정리 및 배포 준비 완료 |
| 검증 단계 | 6월 25일 ~ 6월 30일 | 테스트 배포, 접속 확인, 최종 보완 | | 검증 단계 | 6월 25일 ~ 6월 30일 | 테스트 배포, 접속 확인, 최종 보완 |
| 오픈 시점 | 7월 1일 | 운영 서비스 시작 | | 오픈 시점 | 7월 1일 | 운영 서비스 시작 |
6월 30일까지 준비 및 검증 완료, 7월 1일 오픈. 6월 30일까지 준비 및 검증 완료, 7월 1일 오픈.
--- ---
## 4. 보고 요약 ## 4. 보고 요약
| 항목 | 보고 내용 | | 항목 | 보고 내용 |
| --- | --- | | --- | --- |
| 통제된 배포 | 개발 완료 및 검증 후 운영 반영 | | 통제된 배포 | 개발 완료 및 검증 후 운영 반영 |
| 운영 안정성 | 점검과 백업 후 반영 | | 운영 안정성 | 점검과 백업 후 반영 |
| 데이터 연속성 | 기존 외부 DB와 연계 유지 | | 데이터 연속성 | 기존 외부 DB와 연계 유지 |
| 오픈 목표 | 7월 1일 서비스 개시 | | 오픈 목표 | 7월 1일 서비스 개시 |
1. 통제 절차 기반 운영 반영. 1. 통제 절차 기반 운영 반영.
2. 오픈 전 점검 및 백업 기반 리스크 관리. 2. 오픈 전 점검 및 백업 기반 리스크 관리.
3. 6월 30일까지 준비 완료, 7월 1일 오픈. 3. 6월 30일까지 준비 완료, 7월 1일 오픈.
--- ---
## 5. 결론 ## 5. 결론
자산관리 시스템, 6월 30일까지 운영 배포 준비 및 검증 완료, 7월 1일 오픈. 자산관리 시스템, 6월 30일까지 운영 배포 준비 및 검증 완료, 7월 1일 오픈.

View File

@@ -1,46 +1,46 @@
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules) # 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. 1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: 2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
3. **개선 작업 절차 (Test-First Approach)**: 3. **개선 작업 절차 (Test-First Approach)**:
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. 4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**: 5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**:
- 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다. - 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다.
- 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다. - 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다.
6. **REDGREENRefactor 개발 원칙**: 6. **REDGREENRefactor 개발 원칙**:
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다. - 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다. - **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다. - **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
- **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다. - **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다.
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다. - 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다. - 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다. - 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다.
--- ---
### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access) ### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access)
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수) 1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다. 2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
3. **구동 명령어**: 3. **구동 명령어**:
```bash ```bash
npm run dev npm run dev
``` ```
* 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다. * 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
4. **IP 확인 방법**: 4. **IP 확인 방법**:
* Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유. * Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
--- ---
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide) ### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오. 디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)** 👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**

View File

@@ -1,30 +1,30 @@
# 📝 작업 보고서 (2026-06-15) # 📝 작업 보고서 (2026-06-15)
## 1. 서버 및 개발 환경 설정 ## 1. 서버 및 개발 환경 설정
- **백엔드 서버 구동**: 3000번 포트(DB 서버) 정상 구동 완료. - **백엔드 서버 구동**: 3000번 포트(DB 서버) 정상 구동 완료.
- **프론트엔드 서버 구동**: 8080번 포트 정상 구동 완료. - **프론트엔드 서버 구동**: 8080번 포트 정상 구동 완료.
- **브랜치 전환**: \`db_setting\` 브랜치로 전환 및 최신 코드 Pull 완료. - **브랜치 전환**: \`db_setting\` 브랜치로 전환 및 최신 코드 Pull 완료.
## 2. 데이터베이스 정제 및 보강 (Surgical Update) ## 2. 데이터베이스 정제 및 보강 (Surgical Update)
- **사용자 정보(system_users) 업데이트**: - **사용자 정보(system_users) 업데이트**:
- 엑셀(\`system_User (20260615).xlsx\`) 기반 987건 신규 입력. - 엑셀(\`system_User (20260615).xlsx\`) 기반 987건 신규 입력.
- 기존 백업 데이터(212건)와 병합하여 총 1,199건의 사용자 DB 구축. - 기존 백업 데이터(212건)와 병합하여 총 1,199건의 사용자 DB 구축.
- **PC 자산(asset_pc) 데이터 입력**: - **PC 자산(asset_pc) 데이터 입력**:
- 엑셀(\`asset_pc (2026.06.15).xlsx\`) 기반 1,030건 입력 완료. - 엑셀(\`asset_pc (2026.06.15).xlsx\`) 기반 1,030건 입력 완료.
- **용량 정제**: 괄호 제거 및 4자리 GB 단위를 TB로 자동 변환 (예: 1863GB -> 1.86TB). - **용량 정제**: 괄호 제거 및 4자리 GB 단위를 TB로 자동 변환 (예: 1863GB -> 1.86TB).
- **구매일 보강**: 연도 데이터에 월/일 추가 (\`YYYY-12-01\` 형식으로 통일). - **구매일 보강**: 연도 데이터에 월/일 추가 (\`YYYY-12-01\` 형식으로 통일).
- **자산번호 재매핑**: \`PC-YYYY12-NNNN\` 형식으로 전수 재부여 및 기존 번호와의 연속성 유지. - **자산번호 재매핑**: \`PC-YYYY12-NNNN\` 형식으로 전수 재부여 및 기존 번호와의 연속성 유지.
## 3. 부서 및 자산 유형 정상화 ## 3. 부서 및 자산 유형 정상화
- **부서명 통합**: '총괄기획실', '기술개발센터', '한맥', '장헌', 'PTC', '현타' 등을 제외한 1,045건의 부서명을 **'삼안'**으로 일괄 통합. - **부서명 통합**: '총괄기획실', '기술개발센터', '한맥', '장헌', 'PTC', '현타' 등을 제외한 1,045건의 부서명을 **'삼안'**으로 일괄 통합.
- **자산 유형 교정 (핵심)**: - **자산 유형 교정 (핵심)**:
- 엑셀의 오기입과 상관없이 **사번(emp_no) 존재 여부**를 기준으로 자산 유형을 재분류. - 엑셀의 오기입과 상관없이 **사번(emp_no) 존재 여부**를 기준으로 자산 유형을 재분류.
- 사번이 있는 991건 -> **개인PC**로 정상화. - 사번이 있는 991건 -> **개인PC**로 정상화.
- 사번이 없는 39건 -> **공용PC**로 지정 및 사용자명 '공용'으로 정리. - 사번이 없는 39건 -> **공용PC**로 지정 및 사용자명 '공용'으로 정리.
## 4. 운영 규칙 업데이트 ## 4. 운영 규칙 업데이트
- **README.md 수정**: 'DB 삭제 및 초기화 절대 엄금 (Rule 5)' 항목 추가. - **README.md 수정**: 'DB 삭제 및 초기화 절대 엄금 (Rule 5)' 항목 추가.
--- ---
**보고자**: Gemini CLI **보고자**: Gemini CLI
**상태**: 소스 코드 수정 없음, 데이터베이스 정제 완료. **상태**: 소스 코드 수정 없음, 데이터베이스 정제 완료.

File diff suppressed because it is too large Load Diff

View File

@@ -1,211 +0,0 @@
('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')

View File

@@ -1,189 +0,0 @@
('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)

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,58 +0,0 @@
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

@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.backend dockerfile: Dockerfile.backend
container_name: itam-backend container_name: dachs-backend
working_dir: /app working_dir: /app
env_file: env_file:
- .env - .env
@@ -28,7 +28,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.frontend dockerfile: Dockerfile.frontend
container_name: itam-frontend container_name: dachs-frontend
working_dir: /app working_dir: /app
depends_on: depends_on:
- backend - backend

View File

@@ -1,45 +1,45 @@
# [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설 # [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설
## 1. 개요 ## 1. 개요
기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함. 기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함.
--- ---
## 2. 주요 작업 내용 ## 2. 주요 작업 내용
### 📂 소프트웨어 관리 프레임워크 재구조화 ### 📂 소프트웨어 관리 프레임워크 재구조화
- **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임. - **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임.
1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW 1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW
2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리) 2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리)
3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스 3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스
- **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함. - **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함.
### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설 ### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설
- **전용 리스트 뷰 (`CloudListView.ts`)**: - **전용 리스트 뷰 (`CloudListView.ts`)**:
- 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함. - 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함.
- **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함. - **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함.
- **클라우드 전문 모달 (`CloudModal.ts`)**: - **클라우드 전문 모달 (`CloudModal.ts`)**:
- 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함. - 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함.
- **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함. - **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함.
### 📊 대시보드(Dashboard) 리팩토링 및 고도화 ### 📊 대시보드(Dashboard) 리팩토링 및 고도화
- **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함. - **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함.
- **데이터 시각화**: - **데이터 시각화**:
- **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함. - **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함.
- **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함. - **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함.
- **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함. - **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함.
### 🪟 UX 및 데이터 정합성 강화 ### 🪟 UX 및 데이터 정합성 강화
- **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함. - **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함.
- **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함. - **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함.
- **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함. - **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함.
--- ---
## 3. 향후 과제 ## 3. 향후 과제
- 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토 - 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토
- 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토 - 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토
--- ---
**작업자**: Antigravity (AI Assistant) **작업자**: Antigravity (AI Assistant)
**상태**: 완료 (2026-04-17) **상태**: 완료 (2026-04-17)

View File

@@ -1,33 +1,33 @@
# [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현 # [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현
## 1. 개요 ## 1. 개요
소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다. 소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다.
## 2. 작업 상세 내용 ## 2. 작업 상세 내용
### A. S/W 목록(Table) 개선 ### A. S/W 목록(Table) 개선
- **상태 자동 계산 시스템 도입**: - **상태 자동 계산 시스템 도입**:
- 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시. - 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시.
- 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시. - 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시.
- **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임. - **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임.
### B. 상세 정보 모달 개편 (`SWModal.ts`) ### B. 상세 정보 모달 개편 (`SWModal.ts`)
- **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계. - **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계.
- **날짜 입력 필드 개선**: - **날짜 입력 필드 개선**:
- '구매일' 필드에 캘린더 피커(Calendar Picker) 적용. - '구매일' 필드에 캘린더 피커(Calendar Picker) 적용.
- '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용. - '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용.
- 직접 입력("yyyy-mm-dd") 형식도 동시 지원. - 직접 입력("yyyy-mm-dd") 형식도 동시 지원.
### C. 계약 업데이트(갱신) 관리 기능 ### C. 계약 업데이트(갱신) 관리 기능
- **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현. - **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현.
- 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적. - 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적.
- 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동. - 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동.
## 3. 관련 파일 ## 3. 관련 파일
- `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링. - `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링.
- `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직. - `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직.
- `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일. - `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일.
## 4. 확인 사항 ## 4. 확인 사항
- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인. - 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료. - 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.

View File

@@ -1,64 +1,64 @@
# [보고서] IT 자산 실시간 통합 관리 시스템(RMM) 도입 계획서 # [보고서] IT 자산 실시간 통합 관리 시스템(RMM) 도입 계획서
## 1. 도입 배경 및 목적 ## 1. 도입 배경 및 목적
- **현황**: 현재 시스템은 수동 입력 기반의 정적 자산 대장으로 운영되어, 실제 장비의 가동 상태나 장애 여부를 실시간으로 파악하는 데 한계가 있음. - **현황**: 현재 시스템은 수동 입력 기반의 정적 자산 대장으로 운영되어, 실제 장비의 가동 상태나 장애 여부를 실시간으로 파악하는 데 한계가 있음.
- **목적**: 전산자산(서버, PC)의 실시간 상태 정보를 자동 수집하고 장애 징후를 사전에 탐지하여, 선제적 유지보수 체계를 구축하고 운영 효율성을 극대화함. - **목적**: 전산자산(서버, PC)의 실시간 상태 정보를 자동 수집하고 장애 징후를 사전에 탐지하여, 선제적 유지보수 체계를 구축하고 운영 효율성을 극대화함.
## 2. 시스템 주요 기능 ## 2. 시스템 주요 기능
### 2.1 실시간 가동 상태 모니터링 ### 2.1 실시간 가동 상태 모니터링
- 주요 자원(CPU, Memory, Disk) 사용률 실시간 수집 - 주요 자원(CPU, Memory, Disk) 사용률 실시간 수집
- 운영체제(OS) 및 주요 시스템 서비스의 정상 작동 여부 확인 - 운영체제(OS) 및 주요 시스템 서비스의 정상 작동 여부 확인
- 자산 리스트 내 상태 인디케이터(정상/주의/장애) 표시 - 자산 리스트 내 상태 인디케이터(정상/주의/장애) 표시
### 2.2 원격 제어 및 지원 통합 ### 2.2 원격 제어 및 지원 통합
- **기술적 구현 방식 (원클릭 자동 연결)**: 웹사이트에서 전화번호를 누르면 전화 앱이 켜지거나, 이메일 주소를 누르면 메일 창이 뜨는 것과 동일한 원리인 'URL 프로토콜 핸들러' 기술을 적용함. - **기술적 구현 방식 (원클릭 자동 연결)**: 웹사이트에서 전화번호를 누르면 전화 앱이 켜지거나, 이메일 주소를 누르면 메일 창이 뜨는 것과 동일한 원리인 'URL 프로토콜 핸들러' 기술을 적용함.
- **자동화 프로세스**: 관리자가 화면의 [연결] 버튼을 클릭하면, 시스템이 팀뷰어나 애니데스크에 "A장비로 연결해줘"라는 신호를 직접 보냄. - **자동화 프로세스**: 관리자가 화면의 [연결] 버튼을 클릭하면, 시스템이 팀뷰어나 애니데스크에 "A장비로 연결해줘"라는 신호를 직접 보냄.
- **편의성**: 관리자가 대상 장비의 ID나 비밀번호를 직접 복사해서 프로그램에 입력할 필요 없이, 클릭 한 번으로 내 PC에 설치된 원격 소프트웨어가 자동 실행되며 즉시 화면이 연결되도록 구현함. - **편의성**: 관리자가 대상 장비의 ID나 비밀번호를 직접 복사해서 프로그램에 입력할 필요 없이, 클릭 한 번으로 내 PC에 설치된 원격 소프트웨어가 자동 실행되며 즉시 화면이 연결되도록 구현함.
- **유연한 접속 모드 지원**: - **유연한 접속 모드 지원**:
- **무인 접속(Unattended Access)**: 서버 및 공용 장비의 경우, 사전에 등록된 자격 증명을 통해 관리자 승인만으로 즉시 접속하여 야간 또는 긴급 장애에 대응함. - **무인 접속(Unattended Access)**: 서버 및 공용 장비의 경우, 사전에 등록된 자격 증명을 통해 관리자 승인만으로 즉시 접속하여 야간 또는 긴급 장애에 대응함.
- **사용자 승인 접속(Attended Access)**: 개인용 PC의 경우, 사용자의 화면에 접속 요청 팝업을 띄우고 승인 시에만 화면 공유를 시작하여 개인정보 보호 및 보안 규정을 준수함. - **사용자 승인 접속(Attended Access)**: 개인용 PC의 경우, 사용자의 화면에 접속 요청 팝업을 띄우고 승인 시에만 화면 공유를 시작하여 개인정보 보호 및 보안 규정을 준수함.
- **보안 및 감사 로그 자동화**: - **보안 및 감사 로그 자동화**:
- 원격 접속이 시작되는 시점에 관리자 정보, 접속 목적, 대상 장비 정보를 DB에 자동 기록함. - 원격 접속이 시작되는 시점에 관리자 정보, 접속 목적, 대상 장비 정보를 DB에 자동 기록함.
- 세션 종료 후 총 작업 시간 및 조치 내역을 입력하도록 유도하여 투명한 유지보수 이력을 관리함. - 세션 종료 후 총 작업 시간 및 조치 내역을 입력하도록 유도하여 투명한 유지보수 이력을 관리함.
### 2.3 원격 지원 상세 워크플로우 (Remote Support Workflow) ### 2.3 원격 지원 상세 워크플로우 (Remote Support Workflow)
관리자가 장애를 인지하고 조치를 완료하기까지의 표준 프로세스는 다음과 같습니다. 관리자가 장애를 인지하고 조치를 완료하기까지의 표준 프로세스는 다음과 같습니다.
1. **지원 요청 및 대상 선택**: 관리자가 ITAM 대시보드 또는 리스트에서 장애가 발생한 자산을 선택하고 '원격 지원 시작' 버튼을 클릭함. 1. **지원 요청 및 대상 선택**: 관리자가 ITAM 대시보드 또는 리스트에서 장애가 발생한 자산을 선택하고 '원격 지원 시작' 버튼을 클릭함.
2. **접속 모드 자동 판별**: 2. **접속 모드 자동 판별**:
- **서버(무인)**: 시스템이 저장된 자격 증명을 확인하고 관리자에게 '즉시 연결' 팝업을 띄움. - **서버(무인)**: 시스템이 저장된 자격 증명을 확인하고 관리자에게 '즉시 연결' 팝업을 띄움.
- **PC(유인)**: 관리자가 '접속 요청' 버튼을 누르면, 대상 PC 화면에 "관리자가 원격 제어를 요청했습니다. 승인하시겠습니까?" 팝업이 전송됨. - **PC(유인)**: 관리자가 '접속 요청' 버튼을 누르면, 대상 PC 화면에 "관리자가 원격 제어를 요청했습니다. 승인하시겠습니까?" 팝업이 전송됨.
3. **세션 초기화 및 로그 생성**: 접속 시도가 승인되면 서버는 즉시 [접속 일시, 관리자 ID, 대상 자산 번호]를 포함한 '세션 로그'를 생성하고 상태를 '진행 중'으로 변경함. 3. **세션 초기화 및 로그 생성**: 접속 시도가 승인되면 서버는 즉시 [접속 일시, 관리자 ID, 대상 자산 번호]를 포함한 '세션 로그'를 생성하고 상태를 '진행 중'으로 변경함.
4. **프로토콜 핸들러 실행**: 브라우저가 관리자 PC의 원격 제어 앱(TeamViewer 등)을 자동으로 실행하며, 대상 장비의 ID와 패스워드 정보를 암호화된 인자로 전달하여 즉시 화면이 연결됨. 4. **프로토콜 핸들러 실행**: 브라우저가 관리자 PC의 원격 제어 앱(TeamViewer 등)을 자동으로 실행하며, 대상 장비의 ID와 패스워드 정보를 암호화된 인자로 전달하여 즉시 화면이 연결됨.
5. **조치 및 지원 수행**: 관리자가 실시간으로 장비를 제어하여 장애를 복구함. 5. **조치 및 지원 수행**: 관리자가 실시간으로 장비를 제어하여 장애를 복구함.
6. **세션 종료 및 결과 기록**: 6. **세션 종료 및 결과 기록**:
- 관리자가 원격 제어 앱을 종료하면, ITAM 웹 화면에 '조치 결과 입력' 창이 활성화됨. - 관리자가 원격 제어 앱을 종료하면, ITAM 웹 화면에 '조치 결과 입력' 창이 활성화됨.
- 관리자가 조치 내용(예: 서비스 재시작, 패치 적용 등)을 입력하고 저장하면 세션 로그가 최종 확정됨. - 관리자가 조치 내용(예: 서비스 재시작, 패치 적용 등)을 입력하고 저장하면 세션 로그가 최종 확정됨.
7. **이력 보관**: 완료된 모든 이력은 '자산 상세 정보 > 유지보수 이력' 탭에서 언제든지 열람 및 보고서 출력이 가능함. 7. **이력 보관**: 완료된 모든 이력은 '자산 상세 정보 > 유지보수 이력' 탭에서 언제든지 열람 및 보고서 출력이 가능함.
### 3.3 장애 사전 탐지 및 알림 ### 3.3 장애 사전 탐지 및 알림
- 설정된 임계치(예: 디스크 잔량 10% 미만) 초과 시 즉시 알림 발송 - 설정된 임계치(예: 디스크 잔량 10% 미만) 초과 시 즉시 알림 발송
- 장기 미접속 또는 점검 누락 장비의 실시간 식별 - 장기 미접속 또는 점검 누락 장비의 실시간 식별
## 3. 운영 프로세스 및 메커니즘 ## 3. 운영 프로세스 및 메커니즘
1. **데이터 수집 (Collection)**: 각 자산에 배치된 에이전트가 시스템 정보를 주기적으로 추출함. 1. **데이터 수집 (Collection)**: 각 자산에 배치된 에이전트가 시스템 정보를 주기적으로 추출함.
2. **분석 및 판별 (Analysis)**: 수집된 데이터를 중앙 서버에서 분석하여 장비의 상태 등급을 판정함. 2. **분석 및 판별 (Analysis)**: 수집된 데이터를 중앙 서버에서 분석하여 장비의 상태 등급을 판정함.
3. **가시화 (Visualization)**: 통합 관리 대시보드를 통해 전체 자산의 헬스 상태를 실시간으로 출력함. 3. **가시화 (Visualization)**: 통합 관리 대시보드를 통해 전체 자산의 헬스 상태를 실시간으로 출력함.
4. **대응 (Action)**: 장애 감지 시 원격 제어 기능을 호출하여 즉각적인 기술 지원을 수행함. 4. **대응 (Action)**: 장애 감지 시 원격 제어 기능을 호출하여 즉각적인 기술 지원을 수행함.
## 4. 핵심 기술 및 도구 ## 4. 핵심 기술 및 도구
- **에이전트**: PowerShell 기반의 경량 스크립트를 활용하여 별도의 상용 소프트웨어 설치 없이 시스템 정보 수집. - **에이전트**: PowerShell 기반의 경량 스크립트를 활용하여 별도의 상용 소프트웨어 설치 없이 시스템 정보 수집.
- **백엔드**: Node.js 환경에서 대용량 점검 데이터를 효율적으로 처리하고 데이터베이스화함. - **백엔드**: Node.js 환경에서 대용량 점검 데이터를 효율적으로 처리하고 데이터베이스화함.
- **프론트엔드**: TypeScript를 활용하여 직관적이고 반응성이 뛰어난 관리자 대시보드 구현. - **프론트엔드**: TypeScript를 활용하여 직관적이고 반응성이 뛰어난 관리자 대시보드 구현.
- **원격 솔루션**: 보안성이 검증된 TeamViewer/AnyDesk의 프로토콜 연동을 통한 안전한 원격 접속 환경 구축. - **원격 솔루션**: 보안성이 검증된 TeamViewer/AnyDesk의 프로토콜 연동을 통한 안전한 원격 접속 환경 구축.
## 5. 기대 효과 ## 5. 기대 효과
- **가용성 증대**: 장애 발생 전 사전 조치를 통해 시스템 다운타임을 최소화하고 업무 연속성 확보. - **가용성 증대**: 장애 발생 전 사전 조치를 통해 시스템 다운타임을 최소화하고 업무 연속성 확보.
- **비용 절감**: 현장 방문 점검 최소화 및 원격 조치를 통한 IT 운영 관리 비용 및 시간 절감. - **비용 절감**: 현장 방문 점검 최소화 및 원격 조치를 통한 IT 운영 관리 비용 및 시간 절감.
- **데이터 기반 의무**: 객관적인 성능 지표 및 점검 이력을 바탕으로 정밀한 자산 교체 주기 산정 및 감사 대응. - **데이터 기반 의무**: 객관적인 성능 지표 및 점검 이력을 바탕으로 정밀한 자산 교체 주기 산정 및 감사 대응.
- **관리 생산성 향상**: 자산 정보 조회와 실시간 관리를 단일 플랫폼으로 통합하여 업무 프로세스 간소화. - **관리 생산성 향상**: 자산 정보 조회와 실시간 관리를 단일 플랫폼으로 통합하여 업무 프로세스 간소화.
## 6. 향후 계획 ## 6. 향후 계획
- 1단계: 서버 자산 중심의 실시간 모니터링 및 대시보드 구축 - 1단계: 서버 자산 중심의 실시간 모니터링 및 대시보드 구축
- 2단계: 전사 PC 대상 원격 지원 및 보안 점검 기능 확대 적용 - 2단계: 전사 PC 대상 원격 지원 및 보안 점검 기능 확대 적용
- 3단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화 - 3단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화

View File

@@ -1,318 +1,318 @@
# 전산자산 원격 점검 및 관리 시스템(RMM) 구축 조사 보고서 (상세판) # 전산자산 원격 점검 및 관리 시스템(RMM) 구축 조사 보고서 (상세판)
## 1. RMM(Remote Monitoring & Management) 개요 ## 1. RMM(Remote Monitoring & Management) 개요
RMM(Remote Monitoring & Management)은 서버, 업무용 PC, 노트북 등 IT 자산에 에이전트를 설치하여 RMM(Remote Monitoring & Management)은 서버, 업무용 PC, 노트북 등 IT 자산에 에이전트를 설치하여
중앙 관리 서버에서 상태를 자동 수집하고, 이상 발생 시 경고를 발송하며, 필요 시 원격 접속으로 문제를 해결하는 중앙 관리 서버에서 상태를 자동 수집하고, 이상 발생 시 경고를 발송하며, 필요 시 원격 접속으로 문제를 해결하는
기업용 IT 운영 관리 체계입니다. 기업용 IT 운영 관리 체계입니다.
### 주요 기능 ### 주요 기능
- CPU, 메모리, 디스크 상태 모니터링 - CPU, 메모리, 디스크 상태 모니터링
- Windows 서비스 및 프로세스 상태 점검 - Windows 서비스 및 프로세스 상태 점검
- OS 패치 및 백신 상태 확인 - OS 패치 및 백신 상태 확인
- 자동 점검 스케줄링 (1일 1~2회 이상) - 자동 점검 스케줄링 (1일 1~2회 이상)
- 이상 발생 시 이메일/메신저 알림 - 이상 발생 시 이메일/메신저 알림
- 원격 접속을 통한 장애 조치 - 원격 접속을 통한 장애 조치
- 점검 이력 및 감사 로그 보관 - 점검 이력 및 감사 로그 보관
--- ---
## 2. 구축 목표 ## 2. 구축 목표
### 서버 및 서버용 PC ### 서버 및 서버용 PC
- 하루 1~2회 자동 점검 - 하루 1~2회 자동 점검
- 주요 시스템 자원 및 서비스 상태 수집 - 주요 시스템 자원 및 서비스 상태 수집
- 이상 발생 시 관리자 즉시 통보 - 이상 발생 시 관리자 즉시 통보
### 업무용 PC ### 업무용 PC
- 중앙 관리 서버에서 정기 점검 - 중앙 관리 서버에서 정기 점검
- 패치 및 보안 상태 확인 - 패치 및 보안 상태 확인
### 개인 PC ### 개인 PC
- 사용자가 직접 점검 실행 - 사용자가 직접 점검 실행
- 결과를 중앙 서버에 업로드 - 결과를 중앙 서버에 업로드
### 관리자 ### 관리자
- 마지막 점검 일시 확인 - 마지막 점검 일시 확인
- 성공/실패 여부 확인 - 성공/실패 여부 확인
- 미실행 장비 식별 - 미실행 장비 식별
- 필요 시 즉시 원격 접속 - 필요 시 즉시 원격 접속
--- ---
## 3. 기대 효과 ## 3. 기대 효과
- 장애 조기 탐지 및 사전 예방 - 장애 조기 탐지 및 사전 예방
- 현장 방문 최소화 - 현장 방문 최소화
- 점검 누락 방지 - 점검 누락 방지
- 감사 대응 자료 자동 확보 - 감사 대응 자료 자동 확보
- 자산 운영 현황 실시간 가시화 - 자산 운영 현황 실시간 가시화
- 사용자 점검 이행 여부 관리 - 사용자 점검 이행 여부 관리
--- ---
## 4. 전체 시스템 아키텍처 ## 4. 전체 시스템 아키텍처
```text ```text
[관리자 웹 포털] [관리자 웹 포털]
├─ 대시보드 ├─ 대시보드
├─ 점검 결과 조회 ├─ 점검 결과 조회
├─ 원격 접속 버튼 ├─ 원격 접속 버튼
├─ 알림 관리 ├─ 알림 관리
└─ 사용자 수행 현황 └─ 사용자 수행 현황
[중앙 관리 서버] [중앙 관리 서버]
├─ 스케줄러 ├─ 스케줄러
├─ 데이터 수집 API ├─ 데이터 수집 API
├─ 분석 엔진 ├─ 분석 엔진
├─ 알림 시스템 ├─ 알림 시스템
└─ 데이터베이스 └─ 데이터베이스
[에이전트 설치 대상] [에이전트 설치 대상]
├─ 서버 ├─ 서버
├─ 서버용 PC ├─ 서버용 PC
├─ 업무용 PC ├─ 업무용 PC
└─ 개인 PC └─ 개인 PC
``` ```
--- ---
## 5. 주요 구성 요소 ## 5. 주요 구성 요소
### 5.1 중앙 관리 서버 ### 5.1 중앙 관리 서버
- 스케줄 실행 - 스케줄 실행
- 상태 분석 - 상태 분석
- 데이터 저장 - 데이터 저장
- 알림 전송 - 알림 전송
- 웹 서비스 제공 - 웹 서비스 제공
### 5.2 에이전트 프로그램 ### 5.2 에이전트 프로그램
- PowerShell 또는 Python 기반 - PowerShell 또는 Python 기반
- 상태 수집 - 상태 수집
- 중앙 서버 전송 - 중앙 서버 전송
### 5.3 관리자 웹 대시보드 ### 5.3 관리자 웹 대시보드
- 실시간 현황 조회 - 실시간 현황 조회
- 점검 이력 확인 - 점검 이력 확인
- 원격 접속 실행 - 원격 접속 실행
### 5.4 원격 접속 솔루션 ### 5.4 원격 접속 솔루션
- TeamViewer Tensor - TeamViewer Tensor
- AnyDesk - AnyDesk
- Microsoft Remote Help - Microsoft Remote Help
### 5.5 데이터베이스 ### 5.5 데이터베이스
- SQL Server 또는 PostgreSQL - SQL Server 또는 PostgreSQL
### 5.6 알림 시스템 ### 5.6 알림 시스템
- 이메일 - 이메일
- Microsoft Teams - Microsoft Teams
- Slack - Slack
--- ---
## 6. 점검 항목 ## 6. 점검 항목
### 공통 점검 항목 ### 공통 점검 항목
- CPU 사용률 - CPU 사용률
- 메모리 사용률 - 메모리 사용률
- 디스크 여유 공간 - 디스크 여유 공간
- 네트워크 연결 상태 - 네트워크 연결 상태
- 시스템 부팅 시간 - 시스템 부팅 시간
- 재부팅 필요 여부 - 재부팅 필요 여부
### 서버 추가 항목 ### 서버 추가 항목
- 주요 서비스 실행 여부 - 주요 서비스 실행 여부
- 이벤트 로그 오류 - 이벤트 로그 오류
- 백업 결과 - 백업 결과
- DB 상태 - DB 상태
### PC 추가 항목 ### PC 추가 항목
- 백신 업데이트 여부 - 백신 업데이트 여부
- Windows Update 상태 - Windows Update 상태
- BitLocker 상태 - BitLocker 상태
### 개인 PC ### 개인 PC
- 기본 시스템 상태 - 기본 시스템 상태
- 점검 수행 여부 및 시간 기록 - 점검 수행 여부 및 시간 기록
--- ---
## 7. 운영 프로세스 ## 7. 운영 프로세스
### 정상 운영 ### 정상 운영
1. 스케줄러가 하루 1~2회 자동 실행 1. 스케줄러가 하루 1~2회 자동 실행
2. 에이전트가 점검 수행 2. 에이전트가 점검 수행
3. 결과를 중앙 서버로 전송 3. 결과를 중앙 서버로 전송
4. 분석 엔진이 정상 여부 판정 4. 분석 엔진이 정상 여부 판정
5. 대시보드에 저장 5. 대시보드에 저장
### 이상 발생 시 ### 이상 발생 시
1. 임계치 초과 또는 서비스 중지 감지 1. 임계치 초과 또는 서비스 중지 감지
2. 관리자에게 알림 발송 2. 관리자에게 알림 발송
3. 관리자가 원격 접속 3. 관리자가 원격 접속
4. 조치 내용 기록 4. 조치 내용 기록
### 개인 PC ### 개인 PC
1. 사용자가 '점검 실행' 버튼 클릭 1. 사용자가 '점검 실행' 버튼 클릭
2. 스크립트 수행 2. 스크립트 수행
3. 결과 업로드 3. 결과 업로드
4. 관리자가 이행 여부 확인 4. 관리자가 이행 여부 확인
--- ---
## 8. 개인 PC 자가 점검 기능 ## 8. 개인 PC 자가 점검 기능
### 사용자 화면 ### 사용자 화면
- 점검 실행 버튼 - 점검 실행 버튼
- 결과 요약 표시 - 결과 요약 표시
- 마지막 점검 시간 표시 - 마지막 점검 시간 표시
### 관리자 확인 항목 ### 관리자 확인 항목
- 마지막 점검 일시 - 마지막 점검 일시
- 성공/실패 여부 - 성공/실패 여부
- 미실행 기간 - 미실행 기간
- 이상 발생 내역 - 이상 발생 내역
--- ---
## 9. 관리자 대시보드 구성 ## 9. 관리자 대시보드 구성
- 전체 자산 현황 - 전체 자산 현황
- 정상/경고/장애 통계 - 정상/경고/장애 통계
- 최근 점검 성공률 - 최근 점검 성공률
- 미점검 장비 목록 - 미점검 장비 목록
- 개인 PC 수행 현황 - 개인 PC 수행 현황
- 원격 접속 바로가기 - 원격 접속 바로가기
- 월간 보고서 - 월간 보고서
--- ---
## 10. 솔루션 비교 ## 10. 솔루션 비교
| 솔루션 | 특징 | 적합도 | | 솔루션 | 특징 | 적합도 |
|------|------|------| |------|------|------|
| Microsoft Intune | 엔드포인트 관리 및 규정 준수 | 매우 높음 | | Microsoft Intune | 엔드포인트 관리 및 규정 준수 | 매우 높음 |
| TeamViewer Tensor | 기업용 원격 접속 및 RMM 연동 | 매우 높음 | | TeamViewer Tensor | 기업용 원격 접속 및 RMM 연동 | 매우 높음 |
| ManageEngine Endpoint Central | 자산, 패치, 원격 관리 통합 | 매우 높음 | | ManageEngine Endpoint Central | 자산, 패치, 원격 관리 통합 | 매우 높음 |
| Zabbix | 오픈소스 모니터링 | 높음 | | Zabbix | 오픈소스 모니터링 | 높음 |
| Splashtop Remote Support | 원격 지원 + RMM | 높음 | | Splashtop Remote Support | 원격 지원 + RMM | 높음 |
| Power BI | 대시보드 및 보고 | 매우 높음 | | Power BI | 대시보드 및 보고 | 매우 높음 |
--- ---
## 11. 권장 구축 방안 ## 11. 권장 구축 방안
### 권장 아키텍처 ### 권장 아키텍처
- Microsoft Intune - Microsoft Intune
- TeamViewer Tensor - TeamViewer Tensor
- PowerShell 자동 점검 스크립트 - PowerShell 자동 점검 스크립트
- Microsoft SQL Server - Microsoft SQL Server
- Power BI - Power BI
- Microsoft Teams 알림 - Microsoft Teams 알림
### 권장 이유 ### 권장 이유
- Windows 환경과 높은 호환성 - Windows 환경과 높은 호환성
- 보안 및 감사 기능 우수 - 보안 및 감사 기능 우수
- 사용자 PC까지 통합 관리 가능 - 사용자 PC까지 통합 관리 가능
- 경영진 보고 자동화 가능 - 경영진 보고 자동화 가능
--- ---
## 12. 보안 요구사항 ## 12. 보안 요구사항
- MFA(다중 인증) - MFA(다중 인증)
- RBAC(역할 기반 권한 관리) - RBAC(역할 기반 권한 관리)
- TLS 암호화 - TLS 암호화
- 감사 로그 저장 - 감사 로그 저장
- 승인된 관리자만 원격 접속 - 승인된 관리자만 원격 접속
- 사용자 동의 기반 개인 PC 점검 - 사용자 동의 기반 개인 PC 점검
--- ---
## 13. 구축 일정 (예시) ## 13. 구축 일정 (예시)
| 단계 | 기간 | | 단계 | 기간 |
|------|------| |------|------|
| 요구사항 분석 | 2주 | | 요구사항 분석 | 2주 |
| 솔루션 선정 | 2주 | | 솔루션 선정 | 2주 |
| PoC | 4주 | | PoC | 4주 |
| 설계 및 개발 | 6주 | | 설계 및 개발 | 6주 |
| 시범 운영 | 4주 | | 시범 운영 | 4주 |
| 전사 확대 | 4주 | | 전사 확대 | 4주 |
총 예상 기간: 약 4~6개월 총 예상 기간: 약 4~6개월
--- ---
## 14. 예상 비용 (예시) ## 14. 예상 비용 (예시)
| 항목 | 비용 수준 | | 항목 | 비용 수준 |
|------|----------| |------|----------|
| Intune 라이선스 | 사용자당 월 과금 | | Intune 라이선스 | 사용자당 월 과금 |
| TeamViewer Tensor | 동시 세션 기준 | | TeamViewer Tensor | 동시 세션 기준 |
| 개발 비용 | 중~고 | | 개발 비용 | 중~고 |
| 운영 비용 | 중간 | | 운영 비용 | 중간 |
--- ---
## 15. 구축 우선순위 ## 15. 구축 우선순위
### 1단계 ### 1단계
- 핵심 서버 모니터링 - 핵심 서버 모니터링
- 관리자 대시보드 - 관리자 대시보드
### 2단계 ### 2단계
- 원격 접속 통합 - 원격 접속 통합
- 자동 알림 - 자동 알림
### 3단계 ### 3단계
- 개인 PC 자가 점검 - 개인 PC 자가 점검
### 4단계 ### 4단계
- Power BI 경영 보고 - Power BI 경영 보고
--- ---
## 16. 최종 권장안 ## 16. 최종 권장안
> Microsoft Intune + TeamViewer Tensor + PowerShell + SQL Server + Power BI > Microsoft Intune + TeamViewer Tensor + PowerShell + SQL Server + Power BI
이 조합은 다음 요구사항을 모두 충족합니다. 이 조합은 다음 요구사항을 모두 충족합니다.
- 자동 점검 - 자동 점검
- 이상 탐지 - 이상 탐지
- 원격 접속 - 원격 접속
- 사용자 자가 점검 - 사용자 자가 점검
- 이력 관리 - 이력 관리
- 감사 대응 - 감사 대응
- 경영진 보고 - 경영진 보고
--- ---
## 17. 공식 출처 및 링크 ## 17. 공식 출처 및 링크
- Microsoft Intune: https://intune.microsoft.com - Microsoft Intune: https://intune.microsoft.com
- TeamViewer Tensor: https://www.teamviewer.com/en/tensor/ - TeamViewer Tensor: https://www.teamviewer.com/en/tensor/
- TeamViewer RMM 소개: https://www.teamviewer.com/en/solutions/use-cases/rmm-remote-monitoring-management/ - TeamViewer RMM 소개: https://www.teamviewer.com/en/solutions/use-cases/rmm-remote-monitoring-management/
- ManageEngine Endpoint Central: https://www.manageengine.com/products/endpoint-central/ - ManageEngine Endpoint Central: https://www.manageengine.com/products/endpoint-central/
- Zabbix: https://www.zabbix.com - Zabbix: https://www.zabbix.com
- Power BI: https://powerbi.microsoft.com - Power BI: https://powerbi.microsoft.com
- Microsoft SQL Server: https://www.microsoft.com/sql-server - Microsoft SQL Server: https://www.microsoft.com/sql-server
- Splashtop RMM 설명: https://www.splashtop.com/blog/what-is-remote-monitoring-and-management - Splashtop RMM 설명: https://www.splashtop.com/blog/what-is-remote-monitoring-and-management
--- ---
## 18. 결론 ## 18. 결론
본 시스템은 서버, 업무용 PC, 개인 PC를 통합 관리하여 본 시스템은 서버, 업무용 PC, 개인 PC를 통합 관리하여
정기적인 자동 점검과 이상 탐지, 원격 접속, 사용자 자가 점검, 점검 이력 관리까지 지원하는 정기적인 자동 점검과 이상 탐지, 원격 접속, 사용자 자가 점검, 점검 이력 관리까지 지원하는
기업용 IT 운영 플랫폼입니다. 기업용 IT 운영 플랫폼입니다.
특히 개인 PC의 자가 점검 기능과 관리자 추적 기능을 포함함으로써 특히 개인 PC의 자가 점검 기능과 관리자 추적 기능을 포함함으로써
규정 준수와 운영 효율성을 동시에 확보할 수 있습니다. 규정 준수와 운영 효율성을 동시에 확보할 수 있습니다.

View File

@@ -1,379 +1,379 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0">
<title>PC 사양 대시보드 시각화 개선 기획서</title> <title>PC 사양 대시보드 시각화 개선 기획서</title>
<!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR --> <!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--primary: #4F46E5; --primary: #4F46E5;
--primary-light: #EEF2FF; --primary-light: #EEF2FF;
--secondary: #10B981; --secondary: #10B981;
--secondary-light: #D1FAE5; --secondary-light: #D1FAE5;
--text-dark: #0F172A; --text-dark: #0F172A;
--text-muted: #64748B; --text-muted: #64748B;
--border-color: #E2E8F0; --border-color: #E2E8F0;
--bg-light: #F8FAFC; --bg-light: #F8FAFC;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text-dark); color: var(--text-dark);
background-color: #FFFFFF; background-color: #FFFFFF;
line-height: 1.6; line-height: 1.6;
letter-spacing: -0.02em; letter-spacing: -0.02em;
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
} }
.container { .container {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
} }
/* Header Styling */ /* Header Styling */
header { header {
border-bottom: 2px solid var(--text-dark); border-bottom: 2px solid var(--text-dark);
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.doc-category { .doc-category {
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--primary); color: var(--primary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
h1 { h1 {
font-size: 2.25rem; font-size: 2.25rem;
font-weight: 900; font-weight: 900;
color: var(--text-dark); color: var(--text-dark);
line-height: 1.2; line-height: 1.2;
} }
.meta-info { .meta-info {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;
margin-top: 1rem; margin-top: 1rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
} }
.meta-info span strong { .meta-info span strong {
color: var(--text-dark); color: var(--text-dark);
} }
/* Section Styling */ /* Section Styling */
section { section {
margin-bottom: 3rem; margin-bottom: 3rem;
} }
h2 { h2 {
font-size: 1.4rem; font-size: 1.4rem;
font-weight: 800; font-weight: 800;
color: var(--text-dark); color: var(--text-dark);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
h2::before { h2::before {
content: ''; content: '';
display: inline-block; display: inline-block;
width: 4px; width: 4px;
height: 18px; height: 18px;
background-color: var(--primary); background-color: var(--primary);
border-radius: 2px; border-radius: 2px;
} }
p { p {
font-size: 1rem; font-size: 1rem;
color: #334155; color: #334155;
margin-bottom: 1rem; margin-bottom: 1rem;
text-align: justify; text-align: justify;
} }
/* List & Card Styling */ /* List & Card Styling */
ul { ul {
list-style-position: inside; list-style-position: inside;
margin-left: 0.5rem; margin-left: 0.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
li { li {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: #334155; color: #334155;
} }
.spec-card { .spec-card {
background-color: var(--bg-light); background-color: var(--bg-light);
border-left: 4px solid var(--primary); border-left: 4px solid var(--primary);
border-radius: 4px; border-radius: 4px;
padding: 1.25rem; padding: 1.25rem;
margin: 1.5rem 0; margin: 1.5rem 0;
} }
.spec-card h3 { .spec-card h3 {
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
color: var(--text-dark); color: var(--text-dark);
} }
/* Table Styling */ /* Table Styling */
.table-container { .table-container {
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
margin: 1.5rem 0; margin: 1.5rem 0;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 0.9rem; font-size: 0.9rem;
text-align: left; text-align: left;
} }
th { th {
background-color: var(--bg-light); background-color: var(--bg-light);
font-weight: 700; font-weight: 700;
color: var(--text-dark); color: var(--text-dark);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
td { td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
color: #334155; color: #334155;
} }
tr:last-child td { tr:last-child td {
border-bottom: none; border-bottom: none;
} }
.badge { .badge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
text-align: center; text-align: center;
} }
.badge-primary { .badge-primary {
color: var(--primary); color: var(--primary);
background-color: var(--primary-light); background-color: var(--primary-light);
} }
.badge-secondary { .badge-secondary {
color: var(--secondary); color: var(--secondary);
background-color: var(--secondary-light); background-color: var(--secondary-light);
} }
/* Highlight box */ /* Highlight box */
.note-box { .note-box {
background-color: #FFFBEB; background-color: #FFFBEB;
border: 1px solid #FCD34D; border: 1px solid #FCD34D;
border-radius: 6px; border-radius: 6px;
padding: 1rem; padding: 1rem;
margin: 1.5rem 0; margin: 1.5rem 0;
font-size: 0.95rem; font-size: 0.95rem;
color: #92400E; color: #92400E;
} }
.note-box strong { .note-box strong {
color: #78350F; color: #78350F;
} }
footer { footer {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
padding-top: 1.5rem; padding-top: 1.5rem;
margin-top: 4rem; margin-top: 4rem;
text-align: center; text-align: center;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-muted); color: var(--text-muted);
} }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<div class="doc-category">기획 명세서 / Product Specification</div> <div class="doc-category">기획 명세서 / Product Specification</div>
<h1>PC 사양 대시보드 시각화 개선 기획서</h1> <h1>PC 사양 대시보드 시각화 개선 기획서</h1>
<div class="meta-info"> <div class="meta-info">
<span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span> <span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span>
<span>최종 수정일: <strong>2026. 05. 28</strong></span> <span>최종 수정일: <strong>2026. 05. 28</strong></span>
<span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span> <span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span>
</div> </div>
</header> </header>
<!-- 1. 개요 및 목적 --> <!-- 1. 개요 및 목적 -->
<section> <section>
<h2>기획 개요 및 목적</h2> <h2>기획 개요 및 목적</h2>
<p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p> <p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p>
</section> </section>
<!-- 2. 주요 개선 사항 --> <!-- 2. 주요 개선 사항 -->
<section> <section>
<h2>주요 개선 내역</h2> <h2>주요 개선 내역</h2>
<div class="spec-card"> <div class="spec-card">
<h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3> <h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3>
<ul> <ul>
<li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li> <li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li>
<li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li> <li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li>
</ul> </ul>
</div> </div>
<div class="spec-card"> <div class="spec-card">
<h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3> <h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3>
<ul> <ul>
<li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li> <li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li>
<li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li> <li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li>
<li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li> <li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li>
</ul> </ul>
</div> </div>
</section> </section>
<!-- 3. 데이터 정의 --> <!-- 3. 데이터 정의 -->
<section> <section>
<h2>직무별 평균 및 권장 사양 점수 스펙</h2> <h2>직무별 평균 및 권장 사양 점수 스펙</h2>
<p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p> <p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p>
<div class="note-box"> <div class="note-box">
<strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br> <strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br>
AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외) AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외)
</div> </div>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>정렬 순위</th> <th>정렬 순위</th>
<th>직무명</th> <th>직무명</th>
<th>실제 평균 사양 점수 (Bar)</th> <th>실제 평균 사양 점수 (Bar)</th>
<th>기본 권장 사양 점수 (기준)</th> <th>기본 권장 사양 점수 (기준)</th>
<th>대소 관계 평가</th> <th>대소 관계 평가</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>1</td> <td>1</td>
<td><strong>AI 개발자</strong></td> <td><strong>AI 개발자</strong></td>
<td>88.0 점</td> <td>88.0 점</td>
<td>95 점</td> <td>95 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>2</td> <td>2</td>
<td><strong>편집 디자이너</strong></td> <td><strong>편집 디자이너</strong></td>
<td>80.2 점</td> <td>80.2 점</td>
<td>75 점</td> <td>75 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td> <td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr> </tr>
<tr> <tr>
<td>3</td> <td>3</td>
<td><strong>3D 디자이너</strong></td> <td><strong>3D 디자이너</strong></td>
<td>78.4 점</td> <td>78.4 점</td>
<td>90 점</td> <td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>4</td> <td>4</td>
<td><strong>UXUI 디자이너</strong></td> <td><strong>UXUI 디자이너</strong></td>
<td>72.7 점</td> <td>72.7 점</td>
<td>70 점</td> <td>70 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td> <td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr> </tr>
<tr> <tr>
<td>5</td> <td>5</td>
<td><strong>3D 개발자</strong></td> <td><strong>3D 개발자</strong></td>
<td>67.8 점</td> <td>67.8 점</td>
<td>90 점</td> <td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>6</td> <td>6</td>
<td><strong>프로그램 개발자</strong></td> <td><strong>프로그램 개발자</strong></td>
<td>67.3 점</td> <td>67.3 점</td>
<td>80 점</td> <td>80 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>7</td> <td>7</td>
<td><strong>BIM모델러</strong></td> <td><strong>BIM모델러</strong></td>
<td>62.1 점</td> <td>62.1 점</td>
<td>75 점</td> <td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>8</td> <td>8</td>
<td><strong>엔지니어</strong></td> <td><strong>엔지니어</strong></td>
<td>42.9 점</td> <td>42.9 점</td>
<td>60 점</td> <td>60 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>9</td> <td>9</td>
<td><strong>웹 개발자</strong></td> <td><strong>웹 개발자</strong></td>
<td>39.2 점</td> <td>39.2 점</td>
<td>75 점</td> <td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>10</td> <td>10</td>
<td><strong>기획자</strong></td> <td><strong>기획자</strong></td>
<td>38.6 점</td> <td>38.6 점</td>
<td>50 점</td> <td>50 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td> <td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr> </tr>
<tr> <tr>
<td>11</td> <td>11</td>
<td><strong>감리원</strong></td> <td><strong>감리원</strong></td>
<td>-</td> <td>-</td>
<td>40.0 점</td> <td>40.0 점</td>
<td><span class="badge badge-secondary">데이터 없음</span></td> <td><span class="badge badge-secondary">데이터 없음</span></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 4. 기술 구현 세부사항 --> <!-- 4. 기술 구현 세부사항 -->
<section> <section>
<h2>기술 구현 세부 사양</h2> <h2>기술 구현 세부 사양</h2>
<div class="spec-card" style="border-left-color: var(--secondary);"> <div class="spec-card" style="border-left-color: var(--secondary);">
<h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3> <h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3>
<p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p> <p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p>
<ul> <ul>
<li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li> <li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li>
<li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li> <li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li>
<li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li> <li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li>
</ul> </ul>
</div> </div>
</section> </section>
<footer> <footer>
<p>&copy; 2026 HM ITAM Systems. All rights reserved.</p> <p>&copy; 2026 HM ITAM Systems. All rights reserved.</p>
</footer> </footer>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,60 +1,60 @@
# 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획 # 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획
본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다. 본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다.
## 1. 목적 ## 1. 목적
- 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화 - 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화
- 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악 - 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악
- 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택 - 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택
## 2. 관리 대상 이력 (Watch Fields) ## 2. 관리 대상 이력 (Watch Fields)
다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다. 다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다.
1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함 1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함
2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함 2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함
3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC) 3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC)
4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등) 4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등)
## 3. 기술 설계 (Technical Design) ## 3. 기술 설계 (Technical Design)
### A. 데이터베이스 (DB) ### A. 데이터베이스 (DB)
- **대상 테이블**: `asset_history` - **대상 테이블**: `asset_history`
- **컬럼 구조 활용 및 보완**: - **컬럼 구조 활용 및 보완**:
- `asset_id`: 대상 자산 식별자 - `asset_id`: 대상 자산 식별자
- `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE) - `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE)
- `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장 - `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장
- `cost`: 관련 비용 발생 시 기록 (수리비 등) - `cost`: 관련 비용 발생 시 기록 (수리비 등)
- `log_user`: 변경을 수행한 작업자 - `log_user`: 변경을 수행한 작업자
- `log_date`: 변경 발생 일시 - `log_date`: 변경 발생 일시
### B. 백엔드 (Server-side Logic) ### B. 백엔드 (Server-side Logic)
- **위치**: `server.js``POST /api/asset/:category/save` 엔드포인트 - **위치**: `server.js``POST /api/asset/:category/save` 엔드포인트
- **동작 흐름**: - **동작 흐름**:
1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장. 1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장.
2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조. 2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조.
3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트. 3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트.
4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함. 4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함.
### C. 프론트엔드 (UI/UX) ### C. 프론트엔드 (UI/UX)
- **위치**: `HWModal.ts` 우측 `modal-history-area` - **위치**: `HWModal.ts` 우측 `modal-history-area`
- **개선 사항**: - **개선 사항**:
- `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용. - `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용.
- "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입. - "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입.
- 스크롤을 통한 무제한 누적 이력 조회 지원. - 스크롤을 통한 무제한 누적 이력 조회 지원.
## 4. 단계별 구현 로직 ## 4. 단계별 구현 로직
### 1단계: 서버 로직 고도화 ### 1단계: 서버 로직 고도화
- `server.js`에 비교 함수(`compareAndLog`) 구현. - `server.js`에 비교 함수(`compareAndLog`) 구현.
- 각 자산 카테고리별 저장 로직에 비교 로직 삽입. - 각 자산 카테고리별 저장 로직에 비교 로직 삽입.
### 2단계: DB 데이터 마이그레이션 (필요시) ### 2단계: DB 데이터 마이그레이션 (필요시)
- 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검. - 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검.
### 3단계: UI 타임라인 렌더링 개선 ### 3단계: UI 타임라인 렌더링 개선
- `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가. - `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가.
- `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인. - `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인.
## 5. 검증 계획 ## 5. 검증 계획
- **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인. - **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인.
- **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인. - **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인.
- **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인. - **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인.

View File

@@ -1,49 +1,49 @@
# 📗 ITAM 프로젝트 구성 및 협업 가이드 # 📗 ITAM 프로젝트 구성 및 협업 가이드
본 문서는 ITAM(IT Asset Management System)의 프로젝트 구조와 공동 작업을 위한 가이드를 제공합니다. 본 문서는 ITAM(IT Asset Management System)의 프로젝트 구조와 공동 작업을 위한 가이드를 제공합니다.
## 1. 프로젝트 아키텍처 개요 ## 1. 프로젝트 아키텍처 개요
ITAM은 **중앙 상태 관리(Centralized State)**와 **컴포넌트 기반 UI(Component-based UI)** 구조로 설계되었습니다. 모든 UI와 비즈니스 로직은 기능별로 독립된 파일로 분리되어 있어, 여러 작업자가 충돌 없이 동시에 개발할 수 있습니다. ITAM은 **중앙 상태 관리(Centralized State)**와 **컴포넌트 기반 UI(Component-based UI)** 구조로 설계되었습니다. 모든 UI와 비즈니스 로직은 기능별로 독립된 파일로 분리되어 있어, 여러 작업자가 충돌 없이 동시에 개발할 수 있습니다.
## 2. 핵심 디렉토리 구조 및 역할 ## 2. 핵심 디렉토리 구조 및 역할
### 🏗️ 제어 로직 (Core) ### 🏗️ 제어 로직 (Core)
* **`src/main.ts`**: 시스템 관제탑. 전체 컴포넌트 초기화 및 메인 렌더링 흐름을 제어합니다. * **`src/main.ts`**: 시스템 관제탑. 전체 컴포넌트 초기화 및 메인 렌더링 흐름을 제어합니다.
* **`src/state.ts`**: **전역 데이터 창고**. 자산 데이터(`masterData`)와 현재 탭 상태를 중앙에서 관리합니다. 데이터 구조 변경 시 가장 먼저 확인해야 할 파일입니다. * **`src/state.ts`**: **전역 데이터 창고**. 자산 데이터(`masterData`)와 현재 탭 상태를 중앙에서 관리합니다. 데이터 구조 변경 시 가장 먼저 확인해야 할 파일입니다.
### 🛠️ 상세 페이지 및 모달 (Modals) ### 🛠️ 상세 페이지 및 모달 (Modals)
모든 자산의 추가/수정/삭제 로직은 `src/components/Modal/` 폴더 내에 독립적으로 구성되어 있습니다. 모든 자산의 추가/수정/삭제 로직은 `src/components/Modal/` 폴더 내에 독립적으로 구성되어 있습니다.
* **`BaseModal.ts`**: 모든 모달의 공통 기능(닫기, ESC 처리, 배경 클릭)을 담당합니다. * **`BaseModal.ts`**: 모든 모달의 공통 기능(닫기, ESC 처리, 배경 클릭)을 담당합니다.
* **`PCModal.ts`**: 개인PC 전용 상세 정보 및 사양 관리. * **`PCModal.ts`**: 개인PC 전용 상세 정보 및 사양 관리.
* **`HWModal.ts`**: 서버, 전산비품 자산 상세 정보 관리. * **`HWModal.ts`**: 서버, 전산비품 자산 상세 정보 관리.
* **`StorageModal.ts`**: 스토리지(NAS/DAS) 특화 필드 및 정보 관리. * **`StorageModal.ts`**: 스토리지(NAS/DAS) 특화 필드 및 정보 관리.
* **`SWModal.ts`**: 소프트웨어 라이선스 기본 정보 관리. * **`SWModal.ts`**: 소프트웨어 라이선스 기본 정보 관리.
* **`SWUserModal.ts`**: **복잡한 로직 영역**. 소프트웨어별 사용자 할당/해제 및 매핑 로직을 담당합니다. * **`SWUserModal.ts`**: **복잡한 로직 영역**. 소프트웨어별 사용자 할당/해제 및 매핑 로직을 담당합니다.
### 📊 화면 렌더링 (Views) ### 📊 화면 렌더링 (Views)
* **`src/views/DashboardView.ts`**: HW/SW 현황 통계 및 요약 차트 화면을 렌더링합니다. * **`src/views/DashboardView.ts`**: HW/SW 현황 통계 및 요약 차트 화면을 렌더링합니다.
* **`src/views/AssetTableView.ts`**: 각 카테고리별 자산 목록 테이블을 렌더링합니다. * **`src/views/AssetTableView.ts`**: 각 카테고리별 자산 목록 테이블을 렌더링합니다.
## 3. 공동 작업 가이드 (협업 전략) ## 3. 공동 작업 가이드 (협업 전략)
본 프로젝트는 파일 단위로 역할이 명확히 나뉘어 있어, **담당 영역에 따라 독립적인 작업이 가능**합니다. 본 프로젝트는 파일 단위로 역할이 명확히 나뉘어 있어, **담당 영역에 따라 독립적인 작업이 가능**합니다.
### 👤 담당자별 권장 작업 영역 ### 👤 담당자별 권장 작업 영역
| 작업 대상 | 담당 파일 (Primary) | 설명 | | 작업 대상 | 담당 파일 (Primary) | 설명 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **하드웨어(HW) 담당** | `PCModal.ts`, `HWModal.ts`, `StorageModal.ts` | 하드웨어 상세 페이지 및 사양 관리 로직 개발 | | **하드웨어(HW) 담당** | `PCModal.ts`, `HWModal.ts`, `StorageModal.ts` | 하드웨어 상세 페이지 및 사양 관리 로직 개발 |
| **소프트웨어(SW) 담당** | `SWModal.ts`, `SWUserModal.ts` | 소프트웨어 정보 및 사용자 할당 시스템 개발 | | **소프트웨어(SW) 담당** | `SWModal.ts`, `SWUserModal.ts` | 소프트웨어 정보 및 사용자 할당 시스템 개발 |
### 🤝 공통 영역 및 주의사항 ### 🤝 공통 영역 및 주의사항
아래 파일들은 두 담당자가 공통으로 사용하는 영역이므로, 수정 시 Git 충돌에 유의하고 소통이 필요합니다. 아래 파일들은 두 담당자가 공통으로 사용하는 영역이므로, 수정 시 Git 충돌에 유의하고 소통이 필요합니다.
1. **`src/state.ts`**: 데이터 인터페이스(Interface)를 변경할 경우. 1. **`src/state.ts`**: 데이터 인터페이스(Interface)를 변경할 경우.
2. **`src/views/AssetTableView.ts`**: 목록 테이블의 공통 스타일이나 HW/SW 테이블 구조를 변경할 경우. 2. **`src/views/AssetTableView.ts`**: 목록 테이블의 공통 스타일이나 HW/SW 테이블 구조를 변경할 경우.
3. **`src/views/DashboardView.ts`**: 대시보드 통계 알고리즘을 변경할 경우. 3. **`src/views/DashboardView.ts`**: 대시보드 통계 알고리즘을 변경할 경우.
## 4. 데이터 흐름 (Data Flow) ## 4. 데이터 흐름 (Data Flow)
1. **데이터 조회**: `AssetTableView`에서 항목 클릭 → 담당 모달의 `openModal(asset)` 호출 → 폼 바인딩. 1. **데이터 조회**: `AssetTableView`에서 항목 클릭 → 담당 모달의 `openModal(asset)` 호출 → 폼 바인딩.
2. **데이터 저장**: 모달에서 '저장' 버튼 클릭 → `state.ts`의 전역 상태 업데이트 → `main.ts``renderContent()` 호출 → 전체 화면 즉시 갱신. 2. **데이터 저장**: 모달에서 '저장' 버튼 클릭 → `state.ts`의 전역 상태 업데이트 → `main.ts``renderContent()` 호출 → 전체 화면 즉시 갱신.
--- ---
**Tip**: 새로운 기능을 추가할 때는 `main.ts`에 코드를 직접 작성하지 말고, 적절한 폴더 아래에 새 파일을 만들어 `import` 하시기 바랍니다. **Tip**: 새로운 기능을 추가할 때는 `main.ts`에 코드를 직접 작성하지 말고, 적절한 폴더 아래에 새 파일을 만들어 `import` 하시기 바랍니다.

View File

@@ -1,48 +1,48 @@
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide) # 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다. 본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
--- ---
### 1. 디자인 철학 (Design Philosophy) ### 1. 디자인 철학 (Design Philosophy)
* **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다. * **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다.
* **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다. * **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다.
* **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다. * **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다.
### 2. 타이포그래피 및 자간 (Typography & Letter-spacing) ### 2. 타이포그래피 및 자간 (Typography & Letter-spacing)
* **Font Family**: `Pretendard` 단일 폰트를 사용합니다. * **Font Family**: `Pretendard` 단일 폰트를 사용합니다.
* **Letter-spacing**: 모든 텍스트에 `-0.02em` (-2%) 자간을 적용하여 밀도 있는 가독성을 확보합니다. * **Letter-spacing**: 모든 텍스트에 `-0.02em` (-2%) 자간을 적용하여 밀도 있는 가독성을 확보합니다.
* **Typography Scale**: * **Typography Scale**:
* **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트 * **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트
* **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨, 테이블 헤더 * **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨, 테이블 헤더
* **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터 * **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터
* **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목 * **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목
* **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목 * **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목
* **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표 * **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표
* **Layout Units**: * **Layout Units**:
* **Header Height**: `clamp(50px, 8vmin, 90px)` * **Header Height**: `clamp(50px, 8vmin, 90px)`
* **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)` * **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)`
* **Radius**: `clamp(6px, 1.5vmin, 16px)` * **Radius**: `clamp(6px, 1.5vmin, 16px)`
### 3. 컬러 팔레트 (Vercel Stark Palette) ### 3. 컬러 팔레트 (Vercel Stark Palette)
* **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소. * **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소.
* **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘. * **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘.
* **Border**: `#ebebeb` (Hairline) - 정보 구분선. * **Border**: `#ebebeb` (Hairline) - 정보 구분선.
* **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2). * **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2).
* **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`). * **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`).
### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules) ### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules)
* **Header & Navigation**: * **Header & Navigation**:
* 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다. * 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다.
* **Unified Filter Bar**: * **Unified Filter Bar**:
* 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다. * 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다.
* **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다. * **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다.
* **Dashboard**: * **Dashboard**:
* **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다. * **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다.
* **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다. * **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다.
* **Footer**: * **Footer**:
* 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다. * 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다.
* 상단에 1px 헤어라인 구분선을 가집니다. * 상단에 1px 헤어라인 구분선을 가집니다.
* **Security & UX**: * **Security & UX**:
* **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다. * **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다.
* **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다. * **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다.

View File

@@ -1,77 +1,77 @@
# H/W 자산관리 시스템 프로토타입 구현 계획 (Excel 기반 DB) # H/W 자산관리 시스템 프로토타입 구현 계획 (Excel 기반 DB)
현재 프로젝트는 DB 연동 없이 프로토타입으로 개발되며, 초기 H/W 자산을 엑셀 파일로 관리할 수 있도록 설계합니다. 지정된 디자인 가이드라인(`README.md`)에 따라 세련되고 전문적인 UI를 Vite + Vanilla JS (또는 TS) 기반으로 구축합니다. 현재 프로젝트는 DB 연동 없이 프로토타입으로 개발되며, 초기 H/W 자산을 엑셀 파일로 관리할 수 있도록 설계합니다. 지정된 디자인 가이드라인(`README.md`)에 따라 세련되고 전문적인 UI를 Vite + Vanilla JS (또는 TS) 기반으로 구축합니다.
## User Review Required ## User Review Required
> [!IMPORTANT] > [!IMPORTANT]
> **엑셀을 DB처럼 사용하는 방식**에 대한 주요 동작 흐름은 다음과 같습니다. 해당 방식이 의도하신 바와 맞는지 확인 부탁드립니다. > **엑셀을 DB처럼 사용하는 방식**에 대한 주요 동작 흐름은 다음과 같습니다. 해당 방식이 의도하신 바와 맞는지 확인 부탁드립니다.
> 1. 화면 진입 시 제공되는 **템플릿 엑셀 파일 다운로드** > 1. 화면 진입 시 제공되는 **템플릿 엑셀 파일 다운로드**
> 2. 해당 양식에 맞춰 데이터 입력 후 브라우저에 **엑셀 파일 업로드(Import)** > 2. 해당 양식에 맞춰 데이터 입력 후 브라우저에 **엑셀 파일 업로드(Import)**
> 3. 웹 상에서 **Data Table 형태로 렌더링** (편집/추가/삭제 기능 제공) > 3. 웹 상에서 **Data Table 형태로 렌더링** (편집/추가/삭제 기능 제공)
> 4. 수정이 완료된 후 **엑셀 파일로 다시 다운로드(Export)** 하여 로컬에 저장 > 4. 수정이 완료된 후 **엑셀 파일로 다시 다운로드(Export)** 하여 로컬에 저장
## 데이터 스키마 설계 (H/W 자산) ## 데이터 스키마 설계 (H/W 자산)
요청하신 항목에 맞춘 필수 H/W 자산 데이터 필드입니다. 엑셀의 열(Column)로 활용됩니다. 요청하신 항목에 맞춘 필수 H/W 자산 데이터 필드입니다. 엑셀의 열(Column)로 활용됩니다.
- **법인 (Company)**: 소속 법인 - **법인 (Company)**: 소속 법인
- **자산코드 (AssetCode)**: 자산의 고유 식별자 - **자산코드 (AssetCode)**: 자산의 고유 식별자
- **명칭 (DeviceName)**: 모델명 또는 기기명 - **명칭 (DeviceName)**: 모델명 또는 기기명
- **위치 (Location)**: 현재 파악된 물리적 위치 - **위치 (Location)**: 현재 파악된 물리적 위치
- **관리자 (Manager)**: 실 사용자 또는 담당자 - **관리자 (Manager)**: 실 사용자 또는 담당자
- **IP주소 (IPAddress)**: 할당된 IP - **IP주소 (IPAddress)**: 할당된 IP
- **MAC address (MacAddress)**: 기기 고유 MAC 주소 - **MAC address (MacAddress)**: 기기 고유 MAC 주소
- **H/W 사양 (HWSpecs)**: CPU, RAM, Storage 등 사양 요약 - **H/W 사양 (HWSpecs)**: CPU, RAM, Storage 등 사양 요약
- **OS (OperatingSystem)**: 설치된 운영체제 정보 - **OS (OperatingSystem)**: 설치된 운영체제 정보
## Proposed Changes ## Proposed Changes
### 1. 개발 환경 설정 (Vite 기반) ### 1. 개발 환경 설정 (Vite 기반)
- `npx create-vite` 를 사용하여 `vanilla-ts` (또는 `vanilla`) 프로젝트를 현재 디렉토리에 초기화합니다. - `npx create-vite` 를 사용하여 `vanilla-ts` (또는 `vanilla`) 프로젝트를 현재 디렉토리에 초기화합니다.
- `package.json``vite.config.ts` 설정 (포트 8080 및 host 허용) - `package.json``vite.config.ts` 설정 (포트 8080 및 host 허용)
- Excel 제어를 위해 `xlsx` (SheetJS) 라이브러리를 설치합니다. - Excel 제어를 위해 `xlsx` (SheetJS) 라이브러리를 설치합니다.
#### [NEW] package.json #### [NEW] package.json
#### [NEW] vite.config.ts #### [NEW] vite.config.ts
--- ---
### 2. UI 및 디자인 컴포넌트 (`README.md` 가이드라인 준수) ### 2. UI 및 디자인 컴포넌트 (`README.md` 가이드라인 준수)
- 디자인: Box-less Design, Line-based Division 적용 - 디자인: Box-less Design, Line-based Division 적용
- 컬러: `#1E5149`(Point), `#E5E7EB`(Border), `#F9FAFB`(Background) - 컬러: `#1E5149`(Point), `#E5E7EB`(Border), `#F9FAFB`(Background)
- 폰트: `Pretendard`, `Letter Spacing: -0.02em` 적용 - 폰트: `Pretendard`, `Letter Spacing: -0.02em` 적용
- 테이블 요소: 구분선만 사용하는 미니멀 테이블 - 테이블 요소: 구분선만 사용하는 미니멀 테이블
#### [NEW] index.css #### [NEW] index.css
--- ---
### 3. 메인 HTML 및 로직 구현 ### 3. 메인 HTML 및 로직 구현
- **File Upload Area**: 엑셀 파일을 불러오거나 템플릿을 다운로드 할 수 있는 상단 컨트롤 영역. - **File Upload Area**: 엑셀 파일을 불러오거나 템플릿을 다운로드 할 수 있는 상단 컨트롤 영역.
- **Data Table**: 파싱된 H/W 자산 리스트를 출력. - **Data Table**: 파싱된 H/W 자산 리스트를 출력.
- **Modal Component**: `README.md`에 정의된 2열 그리드와 우측 상단 닫기, 하단 저장 버튼이 포함된 정보 수정/생성 모달. - **Modal Component**: `README.md`에 정의된 2열 그리드와 우측 상단 닫기, 하단 저장 버튼이 포함된 정보 수정/생성 모달.
- **Excel Logic**: 업로드된 Excel 데이터를 파싱하여 JSON 형태로 브라우저 메모리에 들고 처리한 후 다시 Excel로 내보내는 기능. - **Excel Logic**: 업로드된 Excel 데이터를 파싱하여 JSON 형태로 브라우저 메모리에 들고 처리한 후 다시 Excel로 내보내는 기능.
#### [NEW] index.html #### [NEW] index.html
#### [NEW] src/main.ts #### [NEW] src/main.ts
#### [NEW] src/excelHandler.ts #### [NEW] src/excelHandler.ts
## Open Questions ## Open Questions
> [!WARNING] > [!WARNING]
> 1. 프론트엔드 프레임워크 강제 규정이 없다면, 경량화 및 설정 편의를 위해 `Vite + Vanilla TypeScript` 를 사용하는 것이 괜찮으신가요? (원하신다면 React나 Vue로도 가능합니다.) > 1. 프론트엔드 프레임워크 강제 규정이 없다면, 경량화 및 설정 편의를 위해 `Vite + Vanilla TypeScript` 를 사용하는 것이 괜찮으신가요? (원하신다면 React나 Vue로도 가능합니다.)
> 2. 추가적인 검색 필터(예: 법인별, 관리자별 검색)가 당장 도입되어야 하는 필수 기능인가요? > 2. 추가적인 검색 필터(예: 법인별, 관리자별 검색)가 당장 도입되어야 하는 필수 기능인가요?
## Verification Plan ## Verification Plan
### Automated Tests ### Automated Tests
- `npm run dev` 를 통해 `http://localhost:8080` 포트 개방 성공 여부 확인. - `npm run dev` 를 통해 `http://localhost:8080` 포트 개방 성공 여부 확인.
- 브라우저 기능 테스트: - 브라우저 기능 테스트:
- 템플릿 다운로드 클릭 -> 정상적인 `hw_assets.xlsx` 다운로드 여부 - 템플릿 다운로드 클릭 -> 정상적인 `hw_assets.xlsx` 다운로드 여부
- 샘플 엑셀 파일 업로드 -> 데이터 테이블에 행(Row) 생성 여부 - 샘플 엑셀 파일 업로드 -> 데이터 테이블에 행(Row) 생성 여부
- 항목 더블 클릭 혹은 [수정] 버튼 클릭 시 -> 모달 팝업 및 데이터 연동 확인 - 항목 더블 클릭 혹은 [수정] 버튼 클릭 시 -> 모달 팝업 및 데이터 연동 확인
- [저장 후 내보내기] 클릭 -> 업데이트된 데이터가 포함된 새 엑셀 파일 다운로드 확인 - [저장 후 내보내기] 클릭 -> 업데이트된 데이터가 포함된 새 엑셀 파일 다운로드 확인
### Manual Verification ### Manual Verification
- 디자인 요구사항(`Pretendard`, `#1E5149`, Border-less 컨셉) 반영 확인을 스크린샷 렌더링으로 사용자와 상호작용합니다. - 디자인 요구사항(`Pretendard`, `#1E5149`, Border-less 컨셉) 반영 확인을 스크린샷 렌더링으로 사용자와 상호작용합니다.

View File

@@ -1,51 +0,0 @@
# 구조 개선 및 다중 탭(Depth 2) 도입 계획
사용자 요청에 따라 H/W와 S/W를 구분하고, 그 하위에 각각 대시보드 및 상세 항목(개인PC, 서버 등) 탭을 나누는 네비게이션 구조를 도입합니다. 바닐라 JS 기반에서 각 탭마다 다른 데이터 테이블을 그려내는 아키텍처로 개선합니다.
## User Review Required
> [!IMPORTANT]
> 1. **엑셀 관리 방식 (Sheets 분리)**: 단일 엑셀 파일 안에 여러 개의 시트(Sheet)를 나누어 관리하는 방식으로 제안합니다. 한 번 엑셀을 업로드하면, `개인PC`, `서버`, `스토리지`, `전산비품` 등 각각의 시트를 한방에 파싱하여 각 탭에 적용하도록 구성하겠습니다.
> 2. **S/W 스키마**: 현재 H/W 기반 데이터 스키마만 정의되어 있습니다. [구독 소프트웨어]와 [영구 소프트웨어] 탭 개발을 위한 데이터 항목들(예: 사용기간, 라이선스키, 결제방식 등)은 아직 정해지지 않았으므로 일단 공통 S/W 데이터 스키마 임시 템플릿(S/W명, 유형, 라이선스키, 할당된 사용자 등)으로 만들어 두고 추후 수정할 수 있도록 개발해도 될까요?
## Proposed Changes
### 1. UI/UX: 2 Depth 네비게이션 (`index.html`, `style.css`)
- **좌측(또는 상단) GNB (Global Navigation Bar)**: H/W 와 S/W 를 스위치할 수 있는 메인 탭 생성.
- **LNB (Local Navigation Bar)**: 메인 탭 전환 시 나타나는 서브 탭(H/W: 대시보드/PC/서버/스토리지/비품, S/W: 대시보드/구독/영구).
- `README.md` 가이드라인에 따라 화면을 분할하고 정보 밀도를 높이기 위해 Box-less, Line-based Layout 유지.
#### [MODIFY] index.html
#### [MODIFY] src/style.css
---
### 2. 다중 데이터 구조 및 상태 관리 (`main.ts`)
- 현재 선택된 메뉴 뎁스(예: `activeCategory = 'HW'`, `activeSubTab = '개인PC'`)에 따라 렌더링 함수가 동기화되도록 라우팅/상태 관리 로직 추가.
- `Dashboard` 탭 진입 시, 모든 서브 탭 데이터의 갯수(Total PCs, Total Servers 등)를 한눈에 볼 수 있는 요약 영역(Summary Cards/Charts 영역) 예약 및 구현.
#### [MODIFY] src/main.ts
---
### 3. 멀티-시트(Multi-sheet) 엑셀 파싱 (`excelHandler.ts`)
- `SheetJS` 기능을 확장하여 다운로드/데이터 추출 시 다중 시트 생성.
- **H/W 템플릿 시트명**: `[개인PC, 서버, 스토리지, 전산비품]`
- **S/W 템플릿 시트명**: `[구독SW, 영구SW]`
#### [MODIFY] src/excelHandler.ts
## Open Questions
> [!WARNING]
> * 왼쪽 사이드바로 메뉴를 구성하는 것이 좋을까요, 상단 가로바(Top Nav) 2단으로 구성하는 것이 좋을까요? Reference 이미지가 따로 없다면 범용적으로 관리하기 편한 **왼쪽 사이드바 구조(Sidebar Menu)** 를 제안합니다. (진행 승인 시 사이드바 형태로 구현합니다.)
## Verification Plan
### Automated Tests
- 좌측 `H/W`, `S/W` 클릭 시 서브 메뉴가 정상 토글되는지 검증(`main.ts` DOM class toggle 확인).
- 서브 메뉴 `서버` 클릭 시 빈 테이블(또는 서버 자산 테이블)이 그려지는지 확인.
- 달라진 구조로 `엑셀 템플릿 양식`을 다운로드했을 때 파일에 다수의 시트(Sheet)가 정상 분류되어 있는지 확인.
### Manual Verification
- 브라우저 에이전트를 통해 바뀐 화면의 스크린샷(LNB 사이드바, Dashboard 화면 등)을 찍어 사용자에게 보고.

View File

@@ -1,47 +0,0 @@
# 임시 DB 생성 및 S/W 사용자 관리 개편
임시 DB 엑셀 파일 생성과 S/W 목록의 '할당자' 속성 UI 개편에 대한 기술 구현 계획입니다.
## User Review Required
> [!IMPORTANT]
> **사용자 관리 데이터 저장 방식에 대한 피드백이 필요합니다.**
> 엑셀을 임시 DB로 사용하고 있기 때문에, "사용자 관리" 팝업에서 추가/삭제된 사용자 목록을 엑셀에 저장할 때 **쉼표(,)로 구분된 하나의 문자열**(예: `홍길동, 김철수, 이영희`)로 기존 `할당자` 컬럼에 업데이트 하는 방식을 제안합니다. 이 방식이 괜찮으신가요?
## Proposed Changes
### 1. 임시 DB 연동
임시로 사용할 초기 엑셀 파일(`temp_db.xlsx`)을 프로젝트 루트에 스크립트를 통해 생성합니다.
- 개인PC, 서버, 구독SW, 영구SW 시트에 각각 구성을 확인할 수 있는 dummy 데이터 1~2개씩을 포함하여 생성합니다.
- 향후 화면에서 '엑셀 업로드'를 통해 이 파일을 업로드하여 데이터를 화면에 뿌려볼 수 있습니다. (원하시면 페이지 로드 시 이 파일을 임포트하도록 로직을 변경할 수도 있으나, 브라우저 단에서 로컬 파일을 자동 리딩하는 것은 제한이 있으므로 기본적으로는 파일을 제공만 합니다.)
---
### 2. 컴포넌트: HTML 구조 변경
#### [MODIFY] [index.html](file:///c:/Project/HM%20ITAM/index.html)
- `sw-asset-modal`의 폼 내용 중 "할당자" 입력 폼(<label> 및 <input>) 제거
- 관리 팝업을 위한 `sw-user-modal` 모달 오버레이 마크업 추가
기존 유저 목록을 보여주고, 새 사용자를 추가하거나 기존 사용자를 삭제할 수 있는 UI (리스트, 추가 인풋, 추가 버튼 기반) 작성
---
### 3. 컴포넌트: 로직 및 스타일
#### [MODIFY] [src/main.ts](file:///c:/Project/HM%20ITAM/src/main.ts)
- S/W 렌더링 영역(`renderTable`)에서 데스크탑 뷰의 `<th>할당자</th>` 및 해당하는 셀(`<td>`) 제거
- S/W `관리` 탭(`<td>`)에 수정 버튼(`btn-edit`) 옆에 사용자 관리 아이콘 (Lucide의 `Users` 또는 `UserCog` 아이콘 활용) 추가
- 사용자 관리 아이콘 클릭 시 `sw-user-modal` 팝업 띄우는 이벤트 리스너 추가
- `sw-user-modal` 팝업 내에서 사용자를 추가/삭제하고 '저장' 시, 해당 S/W 자산의 `할당자` 데이터를 갱신하도록 처리 (쉼표 구분 형태)
#### [MODIFY] [src/excelHandler.ts](file:///c:/Project/HM%20ITAM/src/excelHandler.ts)
- (선택 사항) `SW_HEADERS`나 엑셀 파싱 로직은 그대로 두어 하위 호환성 유지. 사용자가 데이터를 쉼표 형태로 주고 받을 것이므로 별도의 인터페이스 변경은 없음.
## Open Questions
- 사용자 관리 팝업에서 저장할 때, 이름 말고 '부서'나 '직급' 같은 추가적인 정보도 관리가 필요하신가요? (기본적으로는 엑셀에 단일 텍스트로 보존되므로 '이름'만 관리하는 것으로 설계했습니다.)
- 개발 환경(Vite)에서 초기 로딩 시 `temp_db.xlsx`를 자동으로 불러오도록 Vite의 플러그인 또는 fetch 로직을 추가하는 것을 원하시나요? 아니면 엑셀 파일만 만들어 드리고 사용자가 '엑셀 업로드' 버튼으로 직접 연동해 쓰는 방식이 좋으신가요?
## Verification Plan
### Manual Verification
1. `npm run dev` 후 브라우저 접속
2. 프로젝트 폴더에 `temp_db.xlsx` 파일이 생성되었는지 확인
3. 소프트웨어 > 영구/구독 탭 진입 시 "할당자" 테이블 헤더가 사라진 것 확인
4. 관리 탭의 "사용자 관리" 아이콘 클릭 시, 해당 소프트웨어의 사용자를 등록하고 삭제할 수 있는 팝업 등장하는지 확인
5. 사용자 아이콘을 클릭해 홍길동, 김철수 등록 후, 전체 엑셀 저장 혹은 다운로드 시 엑셀 파일 내의 '할당자' 열에 `홍길동,김철수` 로 잘 들어가는지 확인

View File

@@ -1,72 +1,72 @@
# HM ITAM (IT Asset Management) ERP 기능 명세서 # HM ITAM (IT Asset Management) ERP 기능 명세서
## 1. 개요 (Overview) ## 1. 개요 (Overview)
본 시스템은 데이터베이스(DB) 연결 없이 브라우저 단에서 엑셀(Excel) 파일을 로컬 데이터베이스의 대체재로 활용하여 구동되는 **IT 자산관리(H/W, S/W) 프로토타입 대시보드**입니다. 사용자는 별도의 백엔드 없이 엑셀 파일을 업로드하고, 웹 상에서 조회 및 수정 후 다시 엑셀로 저장(Export)할 수 있습니다. 본 시스템은 데이터베이스(DB) 연결 없이 브라우저 단에서 엑셀(Excel) 파일을 로컬 데이터베이스의 대체재로 활용하여 구동되는 **IT 자산관리(H/W, S/W) 프로토타입 대시보드**입니다. 사용자는 별도의 백엔드 없이 엑셀 파일을 업로드하고, 웹 상에서 조회 및 수정 후 다시 엑셀로 저장(Export)할 수 있습니다.
## 2. 전체 레이아웃 (Layout & Navigation) ## 2. 전체 레이아웃 (Layout & Navigation)
화면은 좌측 사이드바 구조(Depth 2)를 채택하여 정보 탐색의 편의성을 고려하였습니다. 화면은 좌측 사이드바 구조(Depth 2)를 채택하여 정보 탐색의 편의성을 고려하였습니다.
### 2.1. 좌측 메인 내비게이션 (Sidebar) ### 2.1. 좌측 메인 내비게이션 (Sidebar)
* **하드웨어 (H/W)**: 대시보드, 개인PC, 서버, 스토리지, 전산비품 * **하드웨어 (H/W)**: 대시보드, 개인PC, 서버, 스토리지, 전산비품
* **소프트웨어 (S/W)**: 대시보드, 구독 소프트웨어, 영구 소프트웨어 * **소프트웨어 (S/W)**: 대시보드, 구독 소프트웨어, 영구 소프트웨어
### 2.2. 우측 메인 영역 (Main Content) ### 2.2. 우측 메인 영역 (Main Content)
* **상단 컨트롤 패널**: 좌측 탭에 상관없이 데이터를 일괄 제어하는 통합 엑셀 버튼 3종(`통합 양식 다운로드`, `엑셀 업로드`, `일괄 엑셀 저장`)이 위치합니다. * **상단 컨트롤 패널**: 좌측 탭에 상관없이 데이터를 일괄 제어하는 통합 엑셀 버튼 3종(`통합 양식 다운로드`, `엑셀 업로드`, `일괄 엑셀 저장`)이 위치합니다.
* **타이틀 바**: 사용자가 현재 어느 탭(ex. `하드웨어 / 개인PC`)에 위치하고 있는지 동적으로 표시합니다. * **타이틀 바**: 사용자가 현재 어느 탭(ex. `하드웨어 / 개인PC`)에 위치하고 있는지 동적으로 표시합니다.
* **콘텐츠 뷰**: 대시보드 선택 시 각 자산의 요약 맵(Summary Grid)을, 하위 자산 항목 선택 시 Data Table을 렌더링합니다. * **콘텐츠 뷰**: 대시보드 선택 시 각 자산의 요약 맵(Summary Grid)을, 하위 자산 항목 선택 시 Data Table을 렌더링합니다.
--- ---
## 3. 핵심 기능 (Core Features) ## 3. 핵심 기능 (Core Features)
### 3.1. 엑셀 연동 기반 CRUD 로직 파이프라인 ### 3.1. 엑셀 연동 기반 CRUD 로직 파이프라인
* **통합 양식 다운로드 (Template Export)**: 자산이 없는 경우, 초기 세팅을 돕기 위해 빈 엑셀 템플릿(Master File)을 다운로드할 수 있습니다. 다운로드된 파일은 **다중 시트(Multi-sheet)**`개인PC`, `서버`, `스토리지`, `전산비품`, `구독SW`, `영구SW` 6개의 탭이 분리 생성됩니다. * **통합 양식 다운로드 (Template Export)**: 자산이 없는 경우, 초기 세팅을 돕기 위해 빈 엑셀 템플릿(Master File)을 다운로드할 수 있습니다. 다운로드된 파일은 **다중 시트(Multi-sheet)**`개인PC`, `서버`, `스토리지`, `전산비품`, `구독SW`, `영구SW` 6개의 탭이 분리 생성됩니다.
* **엑셀 업로드 (Import/Parse)**: `SheetJS (xlsx)` 라이브러리를 통해 다중 시트 형태의 엑셀 파일을 업로드하면, 한 번에 브라우저 내의 자산 리스트 객체(Array)로 매핑되어 각 탭에 뿌려집니다. * **엑셀 업로드 (Import/Parse)**: `SheetJS (xlsx)` 라이브러리를 통해 다중 시트 형태의 엑셀 파일을 업로드하면, 한 번에 브라우저 내의 자산 리스트 객체(Array)로 매핑되어 각 탭에 뿌려집니다.
* **자산 조회 (Read)**: 각 자산 항목(ex. 서버) 탭을 클릭하여 들어가면, 해당 시트(Type)에 속한 자산 목록만 필터링되어 테이블로 노출됩니다. * **자산 조회 (Read)**: 각 자산 항목(ex. 서버) 탭을 클릭하여 들어가면, 해당 시트(Type)에 속한 자산 목록만 필터링되어 테이블로 노출됩니다.
* **자산 추가/수정 (Create/Update)**: 테이블 우측의 `[수정]` 버튼 또는 상단의 `[자산 추가]`를 클릭하면 모달 팝업이 등장하여 H/W와 S/W에 맞는 각기 다른 양식 폼 데이터를 브라우저 메모리에 업데이트합니다. * **자산 추가/수정 (Create/Update)**: 테이블 우측의 `[수정]` 버튼 또는 상단의 `[자산 추가]`를 클릭하면 모달 팝업이 등장하여 H/W와 S/W에 맞는 각기 다른 양식 폼 데이터를 브라우저 메모리에 업데이트합니다.
* **자산 삭제 (Delete)**: 모달 팝업 좌측 하단의 `[삭제]` 버튼을 통해 해당 단일 항목을 삭제할 수 있습니다. * **자산 삭제 (Delete)**: 모달 팝업 좌측 하단의 `[삭제]` 버튼을 통해 해당 단일 항목을 삭제할 수 있습니다.
* **일괄 엑셀 저장 (Save/Export)**: 모든 추가/수정/삭제 작업이 완료되면 버튼을 눌러 변경된 전체 메모리 데이터를 다시 **다중 시트 엑셀 파일** 형태로 로컬 PC에 떨굽니다. * **일괄 엑셀 저장 (Save/Export)**: 모든 추가/수정/삭제 작업이 완료되면 버튼을 눌러 변경된 전체 메모리 데이터를 다시 **다중 시트 엑셀 파일** 형태로 로컬 PC에 떨굽니다.
### 3.2. 대시보드 (Dashboard) ### 3.2. 대시보드 (Dashboard)
* **H/W 대시보드**: 개인PC, 서버, 스토리지, 전산비품의 총 수량을 Grid 기반 카드로 요약하여 보여줍니다. * **H/W 대시보드**: 개인PC, 서버, 스토리지, 전산비품의 총 수량을 Grid 기반 카드로 요약하여 보여줍니다.
* **S/W 대시보드**: 구독 소프트웨어, 영구 소프트웨어의 총 라이선스 개수를 요약하여 보여줍니다. * **S/W 대시보드**: 구독 소프트웨어, 영구 소프트웨어의 총 라이선스 개수를 요약하여 보여줍니다.
--- ---
## 4. 데이터 스키마 (Data Schema) ## 4. 데이터 스키마 (Data Schema)
자산 항목은 H/W와 S/W 두 가지의 다른 구조로 관리됩니다. 자산 항목은 H/W와 S/W 두 가지의 다른 구조로 관리됩니다.
### 4.1. H/W 자산 스키마 (Hardware Asset) ### 4.1. H/W 자산 스키마 (Hardware Asset)
`[개인PC, 서버, 스토리지, 전산비품]` 각각 동일한 스키마 구조를 가집니다. `[개인PC, 서버, 스토리지, 전산비품]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 | | 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- | | :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 | | **법인** | `String` | 필 | 자산의 소속 법인 |
| **자산코드** | `String` | 필 | 고유 자산 식별코드 | | **자산코드** | `String` | 필 | 고유 자산 식별코드 |
| **명칭** | `String` | 필 | 모델명 또는 기기명 | | **명칭** | `String` | 필 | 모델명 또는 기기명 |
| **위치** | `String` | 선 | 물리적 위치 (ex. 개발실) | | **위치** | `String` | 선 | 물리적 위치 (ex. 개발실) |
| **관리자** | `String` | 선 | 실 사용자 또는 책임자 | | **관리자** | `String` | 선 | 실 사용자 또는 책임자 |
| **IP주소** | `String` | 선 | 할당된 고정/유동 IP | | **IP주소** | `String` | 선 | 할당된 고정/유동 IP |
| **MAC address** | `String` | 선 | 기기 고유 물리적 주소 | | **MAC address** | `String` | 선 | 기기 고유 물리적 주소 |
| **OS** | `String` | 선 | 설치된 운영체제 정보 | | **OS** | `String` | 선 | 설치된 운영체제 정보 |
| **H/W 사양** | `String` | 선 | CPU, RAM, Storage 요약 스펙 | | **H/W 사양** | `String` | 선 | CPU, RAM, Storage 요약 스펙 |
### 4.2. S/W 자산 스키마 (Software Asset) ### 4.2. S/W 자산 스키마 (Software Asset)
`[구독SW, 영구SW]` 각각 동일한 스키마 구조를 가집니다. `[구독SW, 영구SW]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 | | 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- | | :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 | | **법인** | `String` | 필 | 자산의 소속 법인 |
| **S/W명** | `String` | 필 | 소프트웨어 제품 명칭 | | **S/W명** | `String` | 필 | 소프트웨어 제품 명칭 |
| **라이선스키** | `String` | 선 | 발급된 S/W 활성화 키 | | **라이선스키** | `String` | 선 | 발급된 S/W 활성화 키 |
| **할당자** | `String` | 선 | 사용하는 사용자 또는 팀 | | **할당자** | `String` | 선 | 사용하는 사용자 또는 팀 |
| **사용기간** | `String` | 선 | 구독 혹은 만료 기한 표기 | | **사용기간** | `String` | 선 | 구독 혹은 만료 기한 표기 |
| **비고** | `String` | 선 | 기타 참고용 안내 사항 | | **비고** | `String` | 선 | 기타 참고용 안내 사항 |
--- ---
## 5. UI/UX 디자인 정책 (Design Constraint) ## 5. UI/UX 디자인 정책 (Design Constraint)
1. **Color (Achromatic & Green)**: `Deep Green(#1E5149)` 을 메인 포인트 색상으로 사용하여 전문성과 정돈된 느낌을 줍니다. 배경이나 나머지 요소는 무채색 베이스입니다. 1. **Color (Achromatic & Green)**: `Deep Green(#1E5149)` 을 메인 포인트 색상으로 사용하여 전문성과 정돈된 느낌을 줍니다. 배경이나 나머지 요소는 무채색 베이스입니다.
2. **Typography**: 가독성이 우수한 `Pretendard` 서체를 사용하고, 자간을 약간 좁혀 밀도 있고 깔끔한 느낌을 줍니다. 2. **Typography**: 가독성이 우수한 `Pretendard` 서체를 사용하고, 자간을 약간 좁혀 밀도 있고 깔끔한 느낌을 줍니다.
3. **Box-less 스타일링**: 과도한 박스와 테두리를 없애고(Border-based), 얇은 구분 영역만으로 테이블과 폼의 요소를 분리하여 세련된 데이터 표현을 만듭니다. 3. **Box-less 스타일링**: 과도한 박스와 테두리를 없애고(Border-based), 얇은 구분 영역만으로 테이블과 폼의 요소를 분리하여 세련된 데이터 표현을 만듭니다.

View File

@@ -1,61 +1,61 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>한맥가족 자산관리시스템</title> <title>한맥가족 자산관리시스템</title>
<link rel="stylesheet" <link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" /> href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script> <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head> </head>
<body> <body>
<div class="app-layout" id="app-layout" style="display: none;"> <div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header --> <!-- Single-Line Integrated Header -->
<header class="main-header"> <header class="main-header">
<div class="header-container" id="nav-container"> <div class="header-container" id="nav-container">
<div class="brand"> <div class="brand">
<!-- <img src="/image 92.png" alt="Logo" class="main-logo" /> --> <!-- <img src="/image 92.png" alt="Logo" class="main-logo" /> -->
<h1>한맥자산관리시스템</h1> <h1>한맥자산관리시스템</h1>
</div> </div>
<!-- Navigation (GNB + LNB in same row) --> <!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav"> <nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side --> <!-- JS will render main items and sub items here side-by-side -->
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<div class="role-switcher" id="role-switcher"> <div class="role-switcher" id="role-switcher">
<span class="role-label user active">실무자</span> <span class="role-label user active">실무자</span>
<label class="switch"> <label class="switch">
<input type="checkbox" id="role-toggle-checkbox"> <input type="checkbox" id="role-toggle-checkbox">
<span class="slider round"></span> <span class="slider round"></span>
</label> </label>
<span class="role-label admin">관리자</span> <span class="role-label admin">관리자</span>
</div> </div>
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 --> <button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드"> <button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
<i data-lucide="book-open"></i> 가이드 <i data-lucide="book-open"></i> 가이드
</button> </button>
</div> </div>
</div> </div>
</header> </header>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="content-area" id="main-content"> <main class="content-area" id="main-content">
<!-- Components inject views here --> <!-- Components inject views here -->
</main> </main>
<!-- Footer --> <!-- Footer -->
<footer class="main-footer"> <footer class="main-footer">
<p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p> <p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
</footer> </footer>
</div> </div>
<!-- All modals are injected dynamically --> <!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,42 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM Map Coordinate Editor v3.0</title> <title>ITAM Map Coordinate Editor v3.0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
</head> </head>
<body class="editor-body"> <body class="editor-body">
<!-- Left: File Selector --> <!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar"> <div class="file-sidebar" id="file-sidebar">
<!-- Rendered by MapEditor.ts --> <!-- Rendered by MapEditor.ts -->
</div> </div>
<!-- Center: Main Editor --> <!-- Center: Main Editor -->
<div class="editor-container" id="container"> <div class="editor-container" id="container">
<div class="img-wrapper" id="wrapper"> <div class="img-wrapper" id="wrapper">
<img src="" id="target-img" alt="Map Image"> <img src="" id="target-img" alt="Map Image">
</div> </div>
</div> </div>
<!-- Right: Control Panel --> <!-- Right: Control Panel -->
<div class="sidebar"> <div class="sidebar">
<h2>Map Editor <small class="editor-version">v3.0</small></h2> <h2>Map Editor <small class="editor-version">v3.0</small></h2>
<div class="current-path" id="current-path">파일을 선택하세요</div> <div class="current-path" id="current-path">파일을 선택하세요</div>
<p> <p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다. 드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
</p> </p>
<div class="box-list" id="box-list"></div> <div class="box-list" id="box-list"></div>
<div class="actions"> <div class="actions">
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button> <button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button> <button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
<div id="save-status"></div> <div id="save-status"></div>
</div> </div>
</div> </div>
<script type="module" src="/src/map-editor-main.ts"></script> <script type="module" src="/src/map-editor-main.ts"></script>
</body> </body>
</html> </html>

4178
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,26 @@
{ {
"name": "hm-itam", "name": "hm-itam",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"server": "node server.js", "server": "node server.js",
"db-init": "node db_init.js" "db-init": "node db_init.js"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.2.0" "vite": "^5.2.0"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"iconv-lite": "^0.7.2", "iconv-lite": "^0.7.2",
"lucide": "^0.364.0", "lucide": "^0.364.0",
"mysql2": "^3.22.1", "mysql2": "^3.22.1",
"xlsx": "^0.18.5" "xlsx": "^0.18.5"
} }
} }

View File

@@ -1,134 +1,134 @@
import wmi import wmi
import requests import requests
import json import json
import socket import socket
import platform import platform
import sys import sys
import time import time
def collect_specs(): def collect_specs():
try: try:
c = wmi.WMI() c = wmi.WMI()
computer = c.Win32_ComputerSystem()[0] computer = c.Win32_ComputerSystem()[0]
os_info = c.Win32_OperatingSystem()[0] os_info = c.Win32_OperatingSystem()[0]
proc = c.Win32_Processor()[0] proc = c.Win32_Processor()[0]
board = c.Win32_BaseBoard()[0] board = c.Win32_BaseBoard()[0]
# 1. 상세 GPU 정보 수집 (모든 그래픽 카드) # 1. 상세 GPU 정보 수집 (모든 그래픽 카드)
gpu_list = [] gpu_list = []
for g in c.Win32_VideoController(): for g in c.Win32_VideoController():
gpu_list.append(g.Name) gpu_list.append(g.Name)
gpu_info = ", ".join(gpu_list) if gpu_list else "N/A" gpu_info = ", ".join(gpu_list) if gpu_list else "N/A"
# 2. 모든 저장장치 정보 수집 및 SSD/HDD 구분 # 2. 모든 저장장치 정보 수집 및 SSD/HDD 구분
storage_list = [] storage_list = []
# Windows 8 이상에서 작동하는 상세 저장소 정보 수집 시도 # Windows 8 이상에서 작동하는 상세 저장소 정보 수집 시도
physical_disks = {} physical_disks = {}
try: try:
storage_c = wmi.WMI(namespace="Root\\Microsoft\\Windows\\Storage") storage_c = wmi.WMI(namespace="Root\\Microsoft\\Windows\\Storage")
for d in storage_c.MSFT_PhysicalDisk(): for d in storage_c.MSFT_PhysicalDisk():
# MediaType: 3(HDD), 4(SSD), 0(Unspecified) # MediaType: 3(HDD), 4(SSD), 0(Unspecified)
physical_disks[d.DeviceId] = d.MediaType physical_disks[d.DeviceId] = d.MediaType
except: except:
pass pass
for d in c.Win32_DiskDrive(): for d in c.Win32_DiskDrive():
size_gb = round(float(d.Size) / (1024**3)) if d.Size else 0 size_gb = round(float(d.Size) / (1024**3)) if d.Size else 0
# 미디어 타입 판단 # 미디어 타입 판단
media_type = physical_disks.get(str(d.Index), 0) media_type = physical_disks.get(str(d.Index), 0)
prefix = "" prefix = ""
if media_type == 4: if media_type == 4:
prefix = "[SSD] " prefix = "[SSD] "
elif media_type == 3: elif media_type == 3:
prefix = "[HDD] " prefix = "[HDD] "
else: else:
# 힌트가 없을 경우 모델명으로 추측 # 힌트가 없을 경우 모델명으로 추측
cap = d.Caption.upper() cap = d.Caption.upper()
if "SSD" in cap or "NVME" in cap or "FLASH" in cap: if "SSD" in cap or "NVME" in cap or "FLASH" in cap:
prefix = "[SSD] " prefix = "[SSD] "
else: else:
prefix = "[HDD] " prefix = "[HDD] "
storage_list.append(f"{prefix}{d.Caption} ({size_gb}GB)") storage_list.append(f"{prefix}{d.Caption} ({size_gb}GB)")
# DB 필드(SSD1, SSD2, SSD3)에 나눠 담기 # DB 필드(SSD1, SSD2, SSD3)에 나눠 담기
storage1 = storage_list[0] if len(storage_list) > 0 else "N/A" storage1 = storage_list[0] if len(storage_list) > 0 else "N/A"
storage2 = storage_list[1] if len(storage_list) > 1 else "" storage2 = storage_list[1] if len(storage_list) > 1 else ""
storage3 = storage_list[2] if len(storage_list) > 2 else "" storage3 = storage_list[2] if len(storage_list) > 2 else ""
# 실시간 데이터 추출 # 실시간 데이터 추출
specs = { specs = {
"메인보드": f"{board.Manufacturer} {board.Product}".strip(), "메인보드": f"{board.Manufacturer} {board.Product}".strip(),
"CPU": proc.Name.strip(), "CPU": proc.Name.strip(),
"RAM": f"{round(float(computer.TotalPhysicalMemory) / (1024**3))}GB", "RAM": f"{round(float(computer.TotalPhysicalMemory) / (1024**3))}GB",
"OS": os_info.Caption, "OS": os_info.Caption,
"GPU": gpu_info, "GPU": gpu_info,
"SSD1": storage1, "SSD1": storage1,
"SSD2": storage2, "SSD2": storage2,
"SSD3": storage3, "SSD3": storage3,
"비고": "실시간 에이전트(EXE) 자동 수집" "비고": "실시간 에이전트(EXE) 자동 수집"
} }
return specs return specs
except Exception as e: except Exception as e:
print(f"데이터 수집 중 오류 발생: {e}") print(f"데이터 수집 중 오류 발생: {e}")
return None return None
def send_data(specs, server_url, asset_code): def send_data(specs, server_url, asset_code):
try: try:
# 전송 데이터에 자산코드 추가 (식별용) # 전송 데이터에 자산코드 추가 (식별용)
specs["자산코드"] = asset_code specs["자산코드"] = asset_code
print(f"\n📡 서버로 전송 중... ({server_url})") print(f"\n📡 서버로 전송 중... ({server_url})")
response = requests.post(server_url, json=specs, timeout=10) response = requests.post(server_url, json=specs, timeout=10)
if response.status_code == 200: if response.status_code == 200:
print("✅ 전송 성공! ITAM 시스템에서 확인하세요.") print("✅ 전송 성공! ITAM 시스템에서 확인하세요.")
else: else:
print(f"❌ 전송 실패: 서버 응답 코드 {response.status_code}") print(f"❌ 전송 실패: 서버 응답 코드 {response.status_code}")
except Exception as e: except Exception as e:
print(f"❌ 서버 연결 오류: {e}") print(f"❌ 서버 연결 오류: {e}")
print("서버가 켜져 있는지, URL이 맞는지 확인해주세요.") print("서버가 켜져 있는지, URL이 맞는지 확인해주세요.")
if __name__ == "__main__": if __name__ == "__main__":
print("========================================") print("========================================")
print(" ITAM PC 실시간 사양 수집 에이전트 (v1.1)") print(" ITAM PC 실시간 사양 수집 에이전트 (v1.1)")
print("========================================\n") print("========================================\n")
# 1. 정보 수집 # 1. 정보 수집
print("🔍 하드웨어 정보를 읽어오는 중...") print("🔍 하드웨어 정보를 읽어오는 중...")
data = collect_specs() data = collect_specs()
if data: if data:
print("\n[수집된 실제 사양]") print("\n[수집된 실제 사양]")
display_map = { display_map = {
"메인보드": "메인보드", "메인보드": "메인보드",
"CPU": "CPU", "CPU": "CPU",
"RAM": "RAM", "RAM": "RAM",
"OS": "OS", "OS": "OS",
"GPU": "GPU", "GPU": "GPU",
"SSD1": "Storage 1", "SSD1": "Storage 1",
"SSD2": "Storage 2", "SSD2": "Storage 2",
"SSD3": "Storage 3", "SSD3": "Storage 3",
"비고": "비고" "비고": "비고"
} }
for key, value in data.items(): for key, value in data.items():
if value: # 값이 있는 경우만 표시 if value: # 값이 있는 경우만 표시
label = display_map.get(key, key) label = display_map.get(key, key)
print(f" - {label}: {value}") print(f" - {label}: {value}")
print("\n" + "="*40) print("\n" + "="*40)
asset_code = input("등록할 자산번호를 입력하세요 (예: PC-001): ").strip() asset_code = input("등록할 자산번호를 입력하세요 (예: PC-001): ").strip()
if not asset_code: if not asset_code:
print("❌ 자산번호 없이는 전송할 수 없습니다.") print("❌ 자산번호 없이는 전송할 수 없습니다.")
else: else:
server_ip = input("서버 IP를 입력하세요 (기본값 localhost): ").strip() server_ip = input("서버 IP를 입력하세요 (기본값 localhost): ").strip()
if not server_ip: server_ip = "localhost" if not server_ip: server_ip = "localhost"
target_url = f"http://{server_ip}:3000/api/agent/collect" target_url = f"http://{server_ip}:3000/api/agent/collect"
confirm = input("\n위 정보를 서버로 전송할까요? (y/n): ") confirm = input("\n위 정보를 서버로 전송할까요? (y/n): ")
if confirm.lower() == 'y': if confirm.lower() == 'y':
send_data(data, target_url, asset_code) send_data(data, target_url, asset_code)
print("\n5초 후 프로그램이 종료됩니다...") print("\n5초 후 프로그램이 종료됩니다...")
time.sleep(5) time.sleep(5)

View File

@@ -1,38 +1,38 @@
# -*- mode: python ; coding: utf-8 -*- # -*- mode: python ; coding: utf-8 -*-
a = Analysis( a = Analysis(
['pc_agent.py'], ['pc_agent.py'],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
noarchive=False, noarchive=False,
optimize=0, optimize=0,
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
exe = EXE( exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
a.binaries, a.binaries,
a.datas, a.datas,
[], [],
name='pc_agent', name='pc_agent',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=True, upx=True,
upx_exclude=[], upx_exclude=[],
runtime_tmpdir=None, runtime_tmpdir=None,
console=True, console=True,
disable_windowed_traceback=False, disable_windowed_traceback=False,
argv_emulation=False, argv_emulation=False,
target_arch=None, target_arch=None,
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
) )

View File

@@ -1,429 +1,429 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title> <title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--primary: #4F46E5; --primary: #4F46E5;
--primary-light: #EEF2FF; --primary-light: #EEF2FF;
--secondary: #10B981; --secondary: #10B981;
--secondary-light: #D1FAE5; --secondary-light: #D1FAE5;
--danger: #EF4444; --danger: #EF4444;
--danger-light: #FEE2E2; --danger-light: #FEE2E2;
--warning: #F59E0B; --warning: #F59E0B;
--warning-light: #FEF3C7; --warning-light: #FEF3C7;
--purple: #7C3AED; --purple: #7C3AED;
--purple-light: #EDE9FE; --purple-light: #EDE9FE;
--text-dark: #0F172A; --text-dark: #0F172A;
--text-body: #334155; --text-body: #334155;
--text-muted: #64748B; --text-muted: #64748B;
--border: #E2E8F0; --border: #E2E8F0;
--bg-light: #F8FAFC; --bg-light: #F8FAFC;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
font-family: 'Outfit', 'Noto Sans KR', sans-serif; font-family: 'Outfit', 'Noto Sans KR', sans-serif;
color: var(--text-body); color: var(--text-body);
background: #fff; background: #fff;
letter-spacing: -0.02em; letter-spacing: -0.02em;
line-height: 1.7; line-height: 1.7;
} }
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; } .page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
/* ─ Header ─ */ /* ─ Header ─ */
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; } .doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
.doc-label { .doc-label {
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary); display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px; background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
} }
.version-badge { .version-badge {
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary); display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px; background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
margin-left: 0.5rem; vertical-align: middle; margin-left: 0.5rem; vertical-align: middle;
} }
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; } .doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; } .meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; } .meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; } .meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; } .meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
/* ─ Sections ─ */ /* ─ Sections ─ */
section { margin-bottom: 3.5rem; } section { margin-bottom: 3.5rem; }
h2 { h2 {
font-size: 1.3rem; font-weight: 800; color: var(--text-dark); font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border); padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem; margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
} }
h2 .num { h2 .num {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; background: var(--primary); color: #fff; width: 28px; height: 28px; background: var(--primary); color: #fff;
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0; border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
} }
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; } h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; } p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
/* ─ Boxes ─ */ /* ─ Boxes ─ */
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; } .box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); } .box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); } .box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); } .box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); } .box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); } .box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; } .box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
/* ─ Score formula block ─ */ /* ─ Score formula block ─ */
.formula { .formula {
background: #1E293B; color: #E2E8F0; border-radius: 8px; background: #1E293B; color: #E2E8F0; border-radius: 8px;
padding: 1rem 1.25rem; font-family: 'Courier New', monospace; padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2; font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
} }
.formula .comment { color: #64748B; } .formula .comment { color: #64748B; }
.formula .key { color: #93C5FD; } .formula .key { color: #93C5FD; }
.formula .val { color: #6EE7B7; } .formula .val { color: #6EE7B7; }
.formula .warn { color: #FCD34D; } .formula .warn { color: #FCD34D; }
/* ─ Three-col score grid ─ */ /* ─ Three-col score grid ─ */
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; } .score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } } @media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; } .score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.score-card-header { .score-card-header {
background: var(--bg-light); padding: 0.65rem 1rem; background: var(--bg-light); padding: 0.65rem 1rem;
font-weight: 700; font-size: 0.88rem; color: var(--text-dark); font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
} }
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); } .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
.dot-green { background: var(--secondary); } .dot-green { background: var(--secondary); }
.dot-purple { background: var(--purple); } .dot-purple { background: var(--purple); }
/* ─ Tables ─ */ /* ─ Tables ─ */
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; } .tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; } th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; } td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-light); } tr:hover td { background: var(--bg-light); }
/* ─ Badges ─ */ /* ─ Badges ─ */
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; } .badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
.b-primary { color: var(--primary); background: var(--primary-light); } .b-primary { color: var(--primary); background: var(--primary-light); }
.b-green { color: #065F46; background: var(--secondary-light); } .b-green { color: #065F46; background: var(--secondary-light); }
.b-red { color: #991B1B; background: var(--danger-light); } .b-red { color: #991B1B; background: var(--danger-light); }
.b-yellow { color: #92400E; background: var(--warning-light); } .b-yellow { color: #92400E; background: var(--warning-light); }
.b-purple { color: #5B21B6; background: var(--purple-light); } .b-purple { color: #5B21B6; background: var(--purple-light); }
/* ─ Flow ─ */ /* ─ Flow ─ */
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; } .flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; } .flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
.flow-step.gpu { background: var(--purple-light); color: var(--purple); } .flow-step.gpu { background: var(--purple-light); color: var(--purple); }
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; } .flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
/* ─ GPU tier table highlight ─ */ /* ─ GPU tier table highlight ─ */
.tier-S td:first-child { font-weight: 800; color: #DC2626; } .tier-S td:first-child { font-weight: 800; color: #DC2626; }
.tier-A td:first-child { font-weight: 700; color: var(--primary); } .tier-A td:first-child { font-weight: 700; color: var(--primary); }
.tier-B td:first-child { font-weight: 700; color: var(--secondary); } .tier-B td:first-child { font-weight: 700; color: var(--secondary); }
.tier-C td:first-child { color: var(--warning); font-weight: 600; } .tier-C td:first-child { color: var(--warning); font-weight: 600; }
.tier-D td:first-child { color: var(--text-muted); } .tier-D td:first-child { color: var(--text-muted); }
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); } footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<!-- HEADER --> <!-- HEADER -->
<header class="doc-header"> <header class="doc-header">
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div> <div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
<h1>PC 사양 적정성 분석 기획서<br> <h1>PC 사양 적정성 분석 기획서<br>
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);"> <span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식) 100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
</span> </span>
</h1> </h1>
<div class="meta-grid"> <div class="meta-grid">
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div> <div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div> <div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div> <div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div> <div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
</div> </div>
</header> </header>
<!-- 1. 개요 --> <!-- 1. 개요 -->
<section> <section>
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2> <h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
<p> <p>
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다. v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라 누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른 <strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다. 체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
</p> </p>
<div class="flow"> <div class="flow">
<div class="flow-step">① 기본 100점 만점</div> <div class="flow-step">① 기본 100점 만점</div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
<div class="flow-step">② CPU 등급/세대 감점</div> <div class="flow-step">② CPU 등급/세대 감점</div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
<div class="flow-step">③ RAM 용량 감점</div> <div class="flow-step">③ RAM 용량 감점</div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
<div class="flow-step gpu">④ GPU 등급 감점</div> <div class="flow-step gpu">④ GPU 등급 감점</div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
<div class="flow-step">⑤ 연식 노후 감점</div> <div class="flow-step">⑤ 연식 노후 감점</div>
<div class="flow-arrow"></div> <div class="flow-arrow"></div>
<div class="flow-step">⑥ 최종 실질 성능 점수</div> <div class="flow-step">⑥ 최종 실질 성능 점수</div>
</div> </div>
<div class="formula"> <div class="formula">
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span> <span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>)) <span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
</div> </div>
</section> </section>
<!-- 2. CPU 감점 룰 --> <!-- 2. CPU 감점 룰 -->
<section> <section>
<h2><span class="num">2</span>CPU 사양 감점 기준</h2> <h2><span class="num">2</span>CPU 사양 감점 기준</h2>
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong><strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p> <p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong><strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
<div class="formula"> <div class="formula">
<span class="comment">// [CPU 등급 감점]</span> <span class="comment">// [CPU 등급 감점]</span>
i9 / Ryzen 9 → <span class="val">0점 감점</span> i9 / Ryzen 9 → <span class="val">0점 감점</span>
i7 / Ryzen 7 → <span class="val">-5점 감점</span> i7 / Ryzen 7 → <span class="val">-5점 감점</span>
i5 / Ryzen 5 → <span class="val">-15점 감점</span> i5 / Ryzen 5 → <span class="val">-15점 감점</span>
i3 / Ryzen 3 → <span class="val">-25점 감점</span> i3 / Ryzen 3 → <span class="val">-25점 감점</span>
기타 → <span class="val">-30점 감점</span> 기타 → <span class="val">-30점 감점</span>
<span class="comment">// [CPU 세대 노후 감점]</span> <span class="comment">// [CPU 세대 노후 감점]</span>
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span> 최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span> 과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span> 구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span> 노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
</div> </div>
<h3>CPU 조합별 감점 예시</h3> <h3>CPU 조합별 감점 예시</h3>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead> <thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
<tbody> <tbody>
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr> <tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr> <tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr> <tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr> <tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr> <tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr> <tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr> <tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 3. RAM 감점 룰 --> <!-- 3. RAM 감점 룰 -->
<section> <section>
<h2><span class="num">3</span>RAM 용량 감점 기준</h2> <h2><span class="num">3</span>RAM 용량 감점 기준</h2>
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p> <p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead> <thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
<tbody> <tbody>
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr> <tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr> <tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr> <tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr> <tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 4. GPU 감점 룰 --> <!-- 4. GPU 감점 룰 -->
<section> <section>
<h2><span class="num">4</span>GPU 성능 감점 기준</h2> <h2><span class="num">4</span>GPU 성능 감점 기준</h2>
<p> <p>
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다. 3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다. GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
</p> </p>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead> <thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
<tbody> <tbody>
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr> <tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr> <tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr> <tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr> <tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 5. 종합 점수 감점 사례 --> <!-- 5. 종합 점수 감점 사례 -->
<section> <section>
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2> <h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead> <thead>
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr> <tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td> <td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
</tr> </tr>
<tr> <tr>
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td> <td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
</tr> </tr>
<tr> <tr>
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td> <td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
</tr> </tr>
<tr> <tr>
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td> <td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
</tr> </tr>
<tr> <tr>
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td> <td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
</tr> </tr>
<tr> <tr>
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td> <td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
</tr> </tr>
<tr> <tr>
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td> <td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 6. 직무별 평균 및 권장 점수 --> <!-- 6. 직무별 평균 및 권장 점수 -->
<section> <section>
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2> <h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p> <p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead> <thead>
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr> <tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr> <tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr> <tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr> <tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr> <tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr> <tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr> <tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr> <tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr> <tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr> <tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr> <tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr> <tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="box box-blue"> <div class="box box-blue">
<div class="box-title">📌 대소 관계 조건 충족 확인</div> <div class="box-title">📌 대소 관계 조건 충족 확인</div>
AI 개발자(88.0) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(38.6) ✅ AI 개발자(88.0) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(38.6) ✅
</div> </div>
</section> </section>
<!-- 7. 적정성 판별 기준 --> <!-- 7. 적정성 판별 기준 -->
<section> <section>
<h2><span class="num">7</span>적정성 판별 기준</h2> <h2><span class="num">7</span>적정성 판별 기준</h2>
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p> <p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
<div class="formula"> <div class="formula">
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span> <span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
IF <span class="val">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달) IF <span class="val">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
IF <span class="val">개인 실질 점수 &gt; avgScore × 1.30</span><span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과) IF <span class="val">개인 실질 점수 &gt; avgScore × 1.30</span><span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
ELSE → <span class="key">"적정"</span> ELSE → <span class="key">"적정"</span>
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead> <thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
<tbody> <tbody>
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 &lt; 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr> <tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 &lt; 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr> <tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 &gt; 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr> <tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 &gt; 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<!-- 8. 신뢰도 검토 --> <!-- 8. 신뢰도 검토 -->
<section> <section>
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2> <h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
<h3>✅ 신뢰 가능한 부분</h3> <h3>✅ 신뢰 가능한 부분</h3>
<div class="box box-green"> <div class="box box-green">
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;"> <ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li> <li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 &gt; 4080 &gt; 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li> <li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 &gt; 4080 &gt; 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li> <li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li> <li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
</ul> </ul>
</div> </div>
<h3>⚠️ 여전히 남아있는 한계점</h3> <h3>⚠️ 여전히 남아있는 한계점</h3>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead> <thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
<tbody> <tbody>
<tr> <tr>
<td><strong>노트북 TDP 미반영</strong></td> <td><strong>노트북 TDP 미반영</strong></td>
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td> <td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
<td><span class="badge b-yellow">중간</span></td> <td><span class="badge b-yellow">중간</span></td>
</tr> </tr>
<tr> <tr>
<td><strong>SSD 유형 미반영</strong></td> <td><strong>SSD 유형 미반영</strong></td>
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td> <td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
<td><span class="badge b-yellow">중간</span></td> <td><span class="badge b-yellow">중간</span></td>
</tr> </tr>
<tr> <tr>
<td><strong>GPU 세부 파생 모델 한계</strong></td> <td><strong>GPU 세부 파생 모델 한계</strong></td>
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td> <td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
<td><span class="badge b-yellow">중간</span></td> <td><span class="badge b-yellow">중간</span></td>
</tr> </tr>
<tr> <tr>
<td><strong>GPU 세대 보정 미적용</strong></td> <td><strong>GPU 세대 보정 미적용</strong></td>
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td> <td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
<td><span class="badge b-primary">낮음</span></td> <td><span class="badge b-primary">낮음</span></td>
</tr> </tr>
<tr> <tr>
<td><strong>실측 벤치마크 미연동</strong></td> <td><strong>실측 벤치마크 미연동</strong></td>
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td> <td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
<td><span class="badge b-yellow">중간</span></td> <td><span class="badge b-yellow">중간</span></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="box box-blue"> <div class="box box-blue">
<div class="box-title">💡 종합 신뢰도 평가</div> <div class="box-title">💡 종합 신뢰도 평가</div>
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다. GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다. 다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다. 현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
</div> </div>
</section> </section>
<!-- 9. 개선 로드맵 --> <!-- 9. 개선 로드맵 -->
<section> <section>
<h2><span class="num">9</span>향후 개선 로드맵</h2> <h2><span class="num">9</span>향후 개선 로드맵</h2>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table> <table>
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead> <thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
<tbody> <tbody>
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td></td></tr> <tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td></td></tr> <tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td></td></tr> <tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td></td></tr> <tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td></td></tr> <tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td></td></tr> <tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td></td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</section> </section>
<footer> <footer>
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) &nbsp;·&nbsp; 2026. 05. 28</p> <p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) &nbsp;·&nbsp; 2026. 05. 28</p>
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p> <p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
</footer> </footer>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,354 +1,354 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Center Chair Map (View Only)</title> <title>Center Chair Map (View Only)</title>
<style> <style>
:root { :root {
--ink: #152330; --ink: #152330;
--muted: #627286; --muted: #627286;
--paper: rgba(255,255,255,0.86); --paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1); --line: rgba(21,35,48,0.1);
--accent: #0f766e; --accent: #0f766e;
--bg: #edf2f6; --bg: #edf2f6;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif; font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink); color: var(--ink);
background: background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%), radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%); linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
overflow: hidden; overflow: hidden;
} }
.page { .page {
min-height: 100vh; min-height: 100vh;
padding: 0; padding: 0;
} }
.shell { .shell {
min-height: 100vh; min-height: 100vh;
} }
.panel { .panel {
border-radius: 0; border-radius: 0;
border: none; border: none;
background: transparent; background: transparent;
backdrop-filter: none; backdrop-filter: none;
box-shadow: none; box-shadow: none;
} }
.viewer { .viewer {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
min-height: 100vh; min-height: 100vh;
} }
.viewer-head { .viewer-head {
position: absolute; position: absolute;
top: 16px; top: 16px;
left: 16px; left: 16px;
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
} }
.chip { .chip {
padding: 10px 12px; padding: 10px 12px;
border-radius: 16px; border-radius: 16px;
background: rgba(255,255,255,0.82); background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94); border: 1px solid rgba(255,255,255,0.94);
color: var(--muted); color: var(--muted);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08); box-shadow: 0 8px 24px rgba(21,35,48,0.08);
display: inline-block; display: inline-block;
} }
.viewer-actions { .viewer-actions {
position: absolute; position: absolute;
left: 16px; left: 16px;
top: 64px; top: 64px;
z-index: 2; z-index: 2;
} }
button { button {
border: none; border: none;
border-radius: 999px; border-radius: 999px;
padding: 10px 14px; padding: 10px 14px;
font: inherit; font: inherit;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
color: white; color: white;
background: linear-gradient(135deg, #0f766e, #115e59); background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18); box-shadow: 0 10px 22px rgba(15,118,110,0.18);
} }
canvas { canvas {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
display: block; display: block;
cursor: grab; cursor: grab;
} }
canvas.dragging { cursor: grabbing; } canvas.dragging { cursor: grabbing; }
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="shell"> <div class="shell">
<main class="panel viewer"> <main class="panel viewer">
<div class="viewer-head"> <div class="viewer-head">
<div class="chip" id="scale-chip"></div> <div class="chip" id="scale-chip"></div>
</div> </div>
<div class="viewer-actions"> <div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button> <button type="button" id="fit-btn">전체 맞춤</button>
</div> </div>
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</main> </main>
</div> </div>
</div> </div>
<script src="./center_chair_people_payload.js?v=20260403a"></script> <script src="./center_chair_people_payload.js?v=20260403a"></script>
<script> <script>
const DATA = window.CHAIR_MAP_DATA; const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) { function decodeSegments(base64) {
const binary = atob(base64); const binary = atob(base64);
const bytes = new Uint8Array(binary.length); const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer); return new Int32Array(bytes.buffer);
} }
const bgTileRanges = DATA.bgTileRanges; const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64); const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64); const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({ const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count key, name, kind, start, count
})); }));
const meta = DATA.meta; const meta = DATA.meta;
const world = meta.headerBounds; const world = meta.headerBounds;
const canvas = document.getElementById("canvas"); const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const scaleChip = document.getElementById("scale-chip"); const scaleChip = document.getElementById("scale-chip");
// --- Added for Point Picking & Marker --- // --- Added for Point Picking & Marker ---
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null; let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null; let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
const chairGeometry = chairs.map((chair) => { const chairGeometry = chairs.map((chair) => {
let minX = Infinity; let minX = Infinity;
let minY = Infinity; let minY = Infinity;
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
const path = new Path2D(); const path = new Path2D();
for (let i = chair.start; i < chair.start + chair.count; i += 1) { for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4; const offset = i * 4;
const x1 = chairSegValues[offset] / 10; const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10; const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10; const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10; const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1); path.moveTo(x1, y1);
path.lineTo(x2, y2); path.lineTo(x2, y2);
minX = Math.min(minX, x1, x2); minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2); minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2); maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2); maxY = Math.max(maxY, y1, y2);
} }
return { ...chair, minX, minY, maxX, maxY, path }; return { ...chair, minX, minY, maxX, maxY, path };
}); });
const camera = { scale: 1, offsetX: 0, offsetY: 0 }; const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1; let pixelRatio = window.devicePixelRatio || 1;
let dragging = false; let dragging = false;
let dragStart = null; let dragStart = null;
let rafPending = false; let rafPending = false;
function resize() { function resize() {
pixelRatio = window.devicePixelRatio || 1; pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio); canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio); canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit(); fit();
} }
function fit() { function fit() {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX; const width = world.maxX - world.minX;
const height = world.maxY - world.minY; const height = world.maxY - world.minY;
const pad = 36; const pad = 36;
const scaleX = (rect.width - pad * 2) / width; const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height; const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY); camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2; camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2; camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw(); requestDraw();
} }
function drawGrid(width, height) { function drawGrid(width, height) {
ctx.save(); ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)"; ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1; ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) { for (let x = 120; x < width; x += 120) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x, 0); ctx.moveTo(x, 0);
ctx.lineTo(x, height); ctx.lineTo(x, height);
ctx.stroke(); ctx.stroke();
} }
for (let y = 120; y < height; y += 120) { for (let y = 120; y < height; y += 120) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, y); ctx.moveTo(0, y);
ctx.lineTo(width, y); ctx.lineTo(width, y);
ctx.stroke(); ctx.stroke();
} }
ctx.restore(); ctx.restore();
} }
function worldToScreen(x, y) { function worldToScreen(x, y) {
return { return {
x: x * camera.scale + camera.offsetX, x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY, y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
}; };
} }
function screenToWorld(x, y) { function screenToWorld(x, y) {
return { return {
x: (x - camera.offsetX) / camera.scale, x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale, y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
}; };
} }
function requestDraw() { function requestDraw() {
if (rafPending) return; if (rafPending) return;
rafPending = true; rafPending = true;
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
rafPending = false; rafPending = false;
draw(); draw();
}); });
} }
function applyWorldTransform() { function applyWorldTransform() {
ctx.setTransform( ctx.setTransform(
pixelRatio * camera.scale, pixelRatio * camera.scale,
0, 0,
0, 0,
-pixelRatio * camera.scale, -pixelRatio * camera.scale,
pixelRatio * camera.offsetX, pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY) pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
); );
} }
function draw() { function draw() {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height); ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height); drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height); const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0); const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x); const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x); const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y); const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y); const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save(); ctx.save();
applyWorldTransform(); applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)"; ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale; ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize; const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize); const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize); const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize); const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize); const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) { for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) { for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`]; const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue; if (!range) continue;
const start = range[0]; const start = range[0];
const count = range[1]; const count = range[1];
for (let i = start; i < start + count; i += 1) { for (let i = start; i < start + count; i += 1) {
const offset = i * 4; const offset = i * 4;
const x1 = bgSegValues[offset] / 10; const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10; const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10; const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10; const y2 = bgSegValues[offset + 3] / 10;
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX || if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue; Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2); ctx.lineTo(x2, y2);
ctx.stroke(); ctx.stroke();
} }
} }
} }
ctx.lineWidth = 1.35 / camera.scale; ctx.lineWidth = 1.35 / camera.scale;
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)"; ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
for (const chair of chairGeometry) { for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue; if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
ctx.stroke(chair.path); ctx.stroke(chair.path);
} }
// --- Draw Marker --- // --- Draw Marker ---
if (markerX !== null && markerY !== null) { if (markerX !== null && markerY !== null) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2); ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
ctx.fillStyle = "rgba(220, 38, 38, 0.8)"; ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
ctx.fill(); ctx.fill();
ctx.strokeStyle = "#fff"; ctx.strokeStyle = "#fff";
ctx.lineWidth = 10 / camera.scale; ctx.lineWidth = 10 / camera.scale;
ctx.stroke(); ctx.stroke();
} }
ctx.restore(); ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`; scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
} }
canvas.addEventListener("pointerdown", (event) => { canvas.addEventListener("pointerdown", (event) => {
dragging = true; dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY }; dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging"); canvas.classList.add("dragging");
}); });
window.addEventListener("pointerup", (event) => { window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) { if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y); const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) { if (move < 4) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left; const mx = event.clientX - rect.left;
const my = event.clientY - rect.top; const my = event.clientY - rect.top;
const worldPos = screenToWorld(mx, my); const worldPos = screenToWorld(mx, my);
markerX = worldPos.x; markerX = worldPos.x;
markerY = worldPos.y; markerY = worldPos.y;
requestDraw(); requestDraw();
// Notify parent window // Notify parent window
window.parent.postMessage({ window.parent.postMessage({
type: 'PICK_LOCATION', type: 'PICK_LOCATION',
x: markerX.toFixed(2), x: markerX.toFixed(2),
y: markerY.toFixed(2) y: markerY.toFixed(2)
}, '*'); }, '*');
} }
} }
dragging = false; dragging = false;
dragStart = null; dragStart = null;
canvas.classList.remove("dragging"); canvas.classList.remove("dragging");
}); });
window.addEventListener("pointermove", (event) => { window.addEventListener("pointermove", (event) => {
if (dragging && dragStart) { if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x); camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y); camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
requestDraw(); requestDraw();
} }
}); });
canvas.addEventListener("wheel", (event) => { canvas.addEventListener("wheel", (event) => {
event.preventDefault(); event.preventDefault();
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left; const mx = event.clientX - rect.left;
const my = event.clientY - rect.top; const my = event.clientY - rect.top;
const before = screenToWorld(mx, my); const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92; const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor)); camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y); const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x; camera.offsetX += mx - after.x;
camera.offsetY += my - after.y; camera.offsetY += my - after.y;
requestDraw(); requestDraw();
}, { passive: false }); }, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit); document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("resize", resize); window.addEventListener("resize", resize);
resize(); resize();
</script> </script>
</body> </body>
</html> </html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,24 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function analyzeCodes() { async function analyzeCodes() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
// 새 자산들의 연도 분포 확인 // 새 자산들의 연도 분포 확인
const [years] = await connection.query('SELECT DISTINCT purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"'); const [years] = await connection.query('SELECT DISTINCT purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"');
console.log('New assets years:', years.map(y => y.purchase_date)); console.log('New assets years:', years.map(y => y.purchase_date));
// 기존 자산 코드 패턴 확인 // 기존 자산 코드 패턴 확인
const [existing] = await connection.query('SELECT asset_code FROM asset_core WHERE asset_code LIKE "PC-%" LIMIT 5'); const [existing] = await connection.query('SELECT asset_code FROM asset_core WHERE asset_code LIKE "PC-%" LIMIT 5');
console.log('Existing code sample:', existing); console.log('Existing code sample:', existing);
await connection.end(); await connection.end();
} }
analyzeCodes().catch(console.error); analyzeCodes().catch(console.error);

View File

@@ -1,11 +1,11 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const workbook = XLSX.readFile('backupDB_20260602.xlsx'); const workbook = XLSX.readFile('backupDB_20260602.xlsx');
console.log('Sheet Names:', workbook.SheetNames); console.log('Sheet Names:', workbook.SheetNames);
if (workbook.SheetNames.includes('system_users')) { if (workbook.SheetNames.includes('system_users')) {
const sheet = workbook.Sheets['system_users']; const sheet = workbook.Sheets['system_users'];
const data = XLSX.utils.sheet_to_json(sheet); const data = XLSX.utils.sheet_to_json(sheet);
console.log('system_users found! Count:', data.length); console.log('system_users found! Count:', data.length);
console.log('Sample:', data.slice(0, 2)); console.log('Sample:', data.slice(0, 2));
} else { } else {
console.log('system_users sheet not found in backupDB_20260602.xlsx'); console.log('system_users sheet not found in backupDB_20260602.xlsx');
} }

View File

@@ -1,24 +1,24 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function checkCodes() { async function checkCodes() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('--- Asset Codes Sample ---'); console.log('--- Asset Codes Sample ---');
const [rows] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10'); const [rows] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows); console.log(rows);
console.log('\n--- Other Asset Codes Sample ---'); console.log('\n--- Other Asset Codes Sample ---');
const [rows2] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id NOT LIKE "PC_20260615_%" AND asset_code IS NOT NULL LIMIT 5'); const [rows2] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id NOT LIKE "PC_20260615_%" AND asset_code IS NOT NULL LIMIT 5');
console.log(rows2); console.log(rows2);
await connection.end(); await connection.end();
} }
checkCodes().catch(console.error); checkCodes().catch(console.error);

View File

@@ -1,40 +1,40 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function checkPublicPCs() { async function checkPublicPCs() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('🔍 공용 PC(Public PC)로 추정되는 자산 조회 중...'); console.log('🔍 공용 PC(Public PC)로 추정되는 자산 조회 중...');
// 사번이 없거나, 사용자명에 '공용'이 포함된 데이터 조회 // 사번이 없거나, 사용자명에 '공용'이 포함된 데이터 조회
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT id, asset_code, user_current, emp_no, current_dept, asset_type SELECT id, asset_code, user_current, emp_no, current_dept, asset_type
FROM asset_core FROM asset_core
WHERE (emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%') WHERE (emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%')
AND id LIKE 'PC_20260615_%' AND id LIKE 'PC_20260615_%'
`); `);
console.log(`📊 발견된 공용 PC 후보: ${rows.length}`); console.log(`📊 발견된 공용 PC 후보: ${rows.length}`);
if (rows.length > 0) { if (rows.length > 0) {
console.table(rows.slice(0, 20)); // 상위 20개 샘플 출력 console.table(rows.slice(0, 20)); // 상위 20개 샘플 출력
// 요약 통계 // 요약 통계
const summary = { const summary = {
only_no_emp: rows.filter(r => (!r.emp_no) && !r.user_current.includes('공용')).length, only_no_emp: rows.filter(r => (!r.emp_no) && !r.user_current.includes('공용')).length,
only_public_name: rows.filter(r => r.emp_no && r.user_current.includes('공용')).length, only_public_name: rows.filter(r => r.emp_no && r.user_current.includes('공용')).length,
both: rows.filter(r => (!r.emp_no) && r.user_current.includes('공용')).length both: rows.filter(r => (!r.emp_no) && r.user_current.includes('공용')).length
}; };
console.log('\n📈 요약 통계:', summary); console.log('\n📈 요약 통계:', summary);
} }
await connection.end(); await connection.end();
} }
checkPublicPCs().catch(console.error); checkPublicPCs().catch(console.error);

View File

@@ -1,77 +1,77 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function updateAndCompare() { async function updateAndCompare() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('🚀 [Step 1 & 2] "undefined" 사번 및 빈 사용자명 정리 중...'); console.log('🚀 [Step 1 & 2] "undefined" 사번 및 빈 사용자명 정리 중...');
const [updateResult] = await connection.query(` const [updateResult] = await connection.query(`
UPDATE asset_core UPDATE asset_core
SET user_current = '공용', emp_no = NULL SET user_current = '공용', emp_no = NULL
WHERE id LIKE "PC_20260615_%" AND (emp_no = 'undefined' OR emp_no IS NULL OR emp_no = '') WHERE id LIKE "PC_20260615_%" AND (emp_no = 'undefined' OR emp_no IS NULL OR emp_no = '')
`); `);
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}`); console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}`);
console.log('\n🔍 [Step 3] 엑셀 데이터와 DB asset_type 비교 분석 중...'); console.log('\n🔍 [Step 3] 엑셀 데이터와 DB asset_type 비교 분석 중...');
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet); const excelData = XLSX.utils.sheet_to_json(sheet);
// DB 데이터 로드 // DB 데이터 로드
const [dbRows] = await connection.query('SELECT id, asset_type, user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%"'); const [dbRows] = await connection.query('SELECT id, asset_type, user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%"');
const dbMap = new Map(); const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r)); dbRows.forEach(r => dbMap.set(r.id, r));
const mismatches = []; const mismatches = [];
const publicButExcelPersonal = []; const publicButExcelPersonal = [];
for (let i = 0; i < excelData.length; i++) { for (let i = 0; i < excelData.length; i++) {
const excelRow = excelData[i]; const excelRow = excelData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const dbRow = dbMap.get(assetId); const dbRow = dbMap.get(assetId);
if (!dbRow) continue; if (!dbRow) continue;
const excelType = excelRow.asset_type || '개인PC'; const excelType = excelRow.asset_type || '개인PC';
// 1. 단순 타입 불일치 체크 // 1. 단순 타입 불일치 체크
if (dbRow.asset_type !== excelType) { if (dbRow.asset_type !== excelType) {
mismatches.push({ mismatches.push({
id: assetId, id: assetId,
excel_type: excelType, excel_type: excelType,
db_type: dbRow.asset_type, db_type: dbRow.asset_type,
user: dbRow.user_current user: dbRow.user_current
}); });
} }
// 2. 엑셀은 '개인PC'인데 데이터는 공용(사번없음)인 경우 탐색 // 2. 엑셀은 '개인PC'인데 데이터는 공용(사번없음)인 경우 탐색
if (excelType === '개인PC' && (!dbRow.emp_no || dbRow.user_current === '공용')) { if (excelType === '개인PC' && (!dbRow.emp_no || dbRow.user_current === '공용')) {
publicButExcelPersonal.push({ publicButExcelPersonal.push({
id: assetId, id: assetId,
excel_user: excelRow.user_current, excel_user: excelRow.user_current,
excel_dept: excelRow.current_dept, excel_dept: excelRow.current_dept,
db_user: dbRow.user_current db_user: dbRow.user_current
}); });
} }
} }
console.log(`\n📊 분석 결과:`); console.log(`\n📊 분석 결과:`);
console.log(`- 엑셀과 DB의 asset_type 불일치: ${mismatches.length}`); console.log(`- 엑셀과 DB의 asset_type 불일치: ${mismatches.length}`);
console.log(`- 엑셀은 '개인PC'이나 사번이 없어 '공용'으로 잡힌 항목: ${publicButExcelPersonal.length}`); console.log(`- 엑셀은 '개인PC'이나 사번이 없어 '공용'으로 잡힌 항목: ${publicButExcelPersonal.length}`);
if (publicButExcelPersonal.length > 0) { if (publicButExcelPersonal.length > 0) {
console.log('\n⚠ 엑셀은 개인PC이나 데이터가 미비한 항목 (상위 10개):'); console.log('\n⚠ 엑셀은 개인PC이나 데이터가 미비한 항목 (상위 10개):');
console.table(publicButExcelPersonal.slice(0, 10)); console.table(publicButExcelPersonal.slice(0, 10));
} }
await connection.end(); await connection.end();
} }
updateAndCompare().catch(console.error); updateAndCompare().catch(console.error);

View File

@@ -1,25 +1,25 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function debugPublic() { async function debugPublic() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT user_current, emp_no, COUNT(*) as count SELECT user_current, emp_no, COUNT(*) as count
FROM asset_core FROM asset_core
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
GROUP BY user_current, emp_no GROUP BY user_current, emp_no
HAVING emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%' OR user_current = '' HAVING emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%' OR user_current = ''
`); `);
console.table(rows); console.table(rows);
await connection.end(); await connection.end();
} }
debugPublic().catch(console.error); debugPublic().catch(console.error);

View File

@@ -1,69 +1,69 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function deepAudit() { async function deepAudit() {
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet); const excelData = XLSX.utils.sheet_to_json(sheet);
console.log('📊 [Excel Audit] Total Rows:', excelData.length); console.log('📊 [Excel Audit] Total Rows:', excelData.length);
// 1. 엑셀 내 asset_type 종류 확인 // 1. 엑셀 내 asset_type 종류 확인
const excelTypes = new Set(); const excelTypes = new Set();
excelData.forEach(r => excelTypes.add(r.asset_type)); excelData.forEach(r => excelTypes.add(r.asset_type));
console.log('Excel Asset Types:', Array.from(excelTypes)); console.log('Excel Asset Types:', Array.from(excelTypes));
// 2. '공용' 키워드가 들어간 모든 행 추출 // 2. '공용' 키워드가 들어간 모든 행 추출
const publicKeywords = ['공용', '공통', '테스트', 'TEST']; const publicKeywords = ['공용', '공통', '테스트', 'TEST'];
const potentialPublicInExcel = excelData.filter(r => { const potentialPublicInExcel = excelData.filter(r => {
const name = String(r.user_current || ''); const name = String(r.user_current || '');
const type = String(r.asset_type || ''); const type = String(r.asset_type || '');
const memo = String(r.memo || ''); const memo = String(r.memo || '');
return publicKeywords.some(k => name.includes(k) || type.includes(k) || memo.includes(k)) || !r.emp_no; return publicKeywords.some(k => name.includes(k) || type.includes(k) || memo.includes(k)) || !r.emp_no;
}); });
console.log(`\n🔍 [Potential Public/Issue Rows in Excel]: ${potentialPublicInExcel.length}`); console.log(`\n🔍 [Potential Public/Issue Rows in Excel]: ${potentialPublicInExcel.length}`);
console.table(potentialPublicInExcel.slice(0, 30).map(r => ({ console.table(potentialPublicInExcel.slice(0, 30).map(r => ({
emp_no: r.emp_no, emp_no: r.emp_no,
user: r.user_current, user: r.user_current,
dept: r.current_dept, dept: r.current_dept,
type: r.asset_type, type: r.asset_type,
memo: r.memo memo: r.memo
}))); })));
// 3. DB와 대조 (특히 엑셀엔 사번이 있는데 DB엔 공용으로 된 게 있는지) // 3. DB와 대조 (특히 엑셀엔 사번이 있는데 DB엔 공용으로 된 게 있는지)
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
const [dbRows] = await connection.query('SELECT id, user_current, emp_no, asset_type FROM asset_core WHERE id LIKE "PC_20260615_%"'); const [dbRows] = await connection.query('SELECT id, user_current, emp_no, asset_type FROM asset_core WHERE id LIKE "PC_20260615_%"');
// 엑셀은 개인PC인데 DB는 공용인 경우 (또는 그 반대) // 엑셀은 개인PC인데 DB는 공용인 경우 (또는 그 반대)
const issues = []; const issues = [];
for (let i = 0; i < excelData.length; i++) { for (let i = 0; i < excelData.length; i++) {
const ex = excelData[i]; const ex = excelData[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbRows.find(r => r.id === id); const db = dbRows.find(r => r.id === id);
if (!db) continue; if (!db) continue;
const isExcelPublic = !ex.emp_no || String(ex.user_current).includes('공용'); const isExcelPublic = !ex.emp_no || String(ex.user_current).includes('공용');
const isDbPublic = !db.emp_no || String(db.user_current).includes('공용'); const isDbPublic = !db.emp_no || String(db.user_current).includes('공용');
if (isExcelPublic !== isDbPublic) { if (isExcelPublic !== isDbPublic) {
issues.push({ id, excel_user: ex.user_current, db_user: db.user_current, excel_emp: ex.emp_no, db_emp: db.emp_no }); issues.push({ id, excel_user: ex.user_current, db_user: db.user_current, excel_emp: ex.emp_no, db_emp: db.emp_no });
} }
} }
console.log(`\n⚠️ [Consistency Issues]: ${issues.length}`); console.log(`\n⚠️ [Consistency Issues]: ${issues.length}`);
if (issues.length > 0) console.table(issues); if (issues.length > 0) console.table(issues);
await connection.end(); await connection.end();
} }
deepAudit().catch(console.error); deepAudit().catch(console.error);

View File

@@ -1,61 +1,61 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function extractFailures() { async function extractFailures() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: DB_HOST, host: DB_HOST,
user: DB_USER, user: DB_USER,
password: DB_PASS, password: DB_PASS,
database: DB_NAME, database: DB_NAME,
port: parseInt(DB_PORT || '3306') port: parseInt(DB_PORT || '3306')
}); });
console.log('🔍 실패 데이터 추출 중...'); console.log('🔍 실패 데이터 추출 중...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet); const rawData = XLSX.utils.sheet_to_json(sheet);
// 현재 DB에 존재하는 모든 asset_core ID 조회 // 현재 DB에 존재하는 모든 asset_core ID 조회
const [existingRows] = await connection.query('SELECT id FROM asset_core'); const [existingRows] = await connection.query('SELECT id FROM asset_core');
const existingIds = new Set(existingRows.map(r => r.id)); const existingIds = new Set(existingRows.map(r => r.id));
const failures = []; const failures = [];
for (let i = 0; i < rawData.length; i++) { for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]; const row = rawData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
// DB에 해당 ID가 없는 경우 = 실패(충돌 등의 이유로 입력되지 않음) 또는 스킵된 데이터 // DB에 해당 ID가 없는 경우 = 실패(충돌 등의 이유로 입력되지 않음) 또는 스킵된 데이터
// 하지만 이전 로그에서 'Duplicate entry'로 에러가 났던 항목들을 찾는 것이 목적 // 하지만 이전 로그에서 'Duplicate entry'로 에러가 났던 항목들을 찾는 것이 목적
// 로직상 ID 생성 규칙에 따라 해당 ID가 DB에 없으면 입력에 실패한 행임 // 로직상 ID 생성 규칙에 따라 해당 ID가 DB에 없으면 입력에 실패한 행임
if (!existingIds.has(assetId)) { if (!existingIds.has(assetId)) {
failures.push({ failures.push({
excel_row: i + 2, excel_row: i + 2,
generated_id: assetId, generated_id: assetId,
...row ...row
}); });
} }
} }
if (failures.length > 0) { if (failures.length > 0) {
const newWb = XLSX.utils.book_new(); const newWb = XLSX.utils.book_new();
const newWs = XLSX.utils.json_to_sheet(failures); const newWs = XLSX.utils.json_to_sheet(failures);
XLSX.utils.book_append_sheet(newWb, newWs, 'Failures'); XLSX.utils.book_append_sheet(newWb, newWs, 'Failures');
const fileName = 'asset_pc_failures_20260615.xlsx'; const fileName = 'asset_pc_failures_20260615.xlsx';
XLSX.writeFile(newWb, fileName); XLSX.writeFile(newWb, fileName);
console.log(`✅ 추출 완료: ${failures.length}건의 실패 데이터를 ${fileName}에 저장했습니다.`); console.log(`✅ 추출 완료: ${failures.length}건의 실패 데이터를 ${fileName}에 저장했습니다.`);
} else { } else {
console.log('입력되지 않은 데이터가 없습니다.'); console.log('입력되지 않은 데이터가 없습니다.');
} }
await connection.end(); await connection.end();
} }
extractFailures().catch(console.error); extractFailures().catch(console.error);

View File

@@ -1,29 +1,29 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function findPotentialPublic() { async function findPotentialPublic() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('--- Searching for rows with no emp_no or "공용" in user_current ---'); console.log('--- Searching for rows with no emp_no or "공용" in user_current ---');
// 사번이 'undefined', 'null', 빈값, 또는 사용자명에 '공용'이 들어간 데이터 // 사번이 'undefined', 'null', 빈값, 또는 사용자명에 '공용'이 들어간 데이터
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT id, user_current, emp_no SELECT id, user_current, emp_no
FROM asset_core FROM asset_core
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '' OR emp_no = 'undefined' OR user_current LIKE '%공용%') AND (emp_no IS NULL OR emp_no = '' OR emp_no = 'undefined' OR user_current LIKE '%공용%')
`); `);
console.log('Count:', rows.length); console.log('Count:', rows.length);
if (rows.length > 0) console.table(rows); if (rows.length > 0) console.table(rows);
await connection.end(); await connection.end();
} }
findPotentialPublic().catch(console.error); findPotentialPublic().catch(console.error);

View File

@@ -1,47 +1,47 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function fixAssetTypes() { async function fixAssetTypes() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('🚀 [데이터 정상화] 사번 기준 자산 유형 재설정 시작...'); console.log('🚀 [데이터 정상화] 사번 기준 자산 유형 재설정 시작...');
// 1. 사번이 있는 모든 신규 자산을 '개인PC'로 강제 전환 // 1. 사번이 있는 모든 신규 자산을 '개인PC'로 강제 전환
const [personalResult] = await connection.query(` const [personalResult] = await connection.query(`
UPDATE asset_core UPDATE asset_core
SET asset_type = '개인PC' SET asset_type = '개인PC'
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
AND emp_no IS NOT NULL AND emp_no IS NOT NULL
AND emp_no != '' AND emp_no != ''
`); `);
console.log(`✅ 개인PC 정상화 완료: ${personalResult.affectedRows}건 (사번 존재 항목)`); console.log(`✅ 개인PC 정상화 완료: ${personalResult.affectedRows}건 (사번 존재 항목)`);
// 2. 사번이 없는 모든 신규 자산을 '공용PC'로 강제 전환 // 2. 사번이 없는 모든 신규 자산을 '공용PC'로 강제 전환
const [publicResult] = await connection.query(` const [publicResult] = await connection.query(`
UPDATE asset_core UPDATE asset_core
SET asset_type = '공용PC', user_current = '공용' SET asset_type = '공용PC', user_current = '공용'
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '') AND (emp_no IS NULL OR emp_no = '')
`); `);
console.log(`✅ 공용PC 정상화 완료: ${publicResult.affectedRows}건 (사번 부재 항목)`); console.log(`✅ 공용PC 정상화 완료: ${publicResult.affectedRows}건 (사번 부재 항목)`);
// 3. 최종 결과 확인 // 3. 최종 결과 확인
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT asset_type, COUNT(*) as count SELECT asset_type, COUNT(*) as count
FROM asset_core FROM asset_core
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
GROUP BY asset_type GROUP BY asset_type
`); `);
console.log('\n📊 최종 자산 유형 분포:'); console.log('\n📊 최종 자산 유형 분포:');
console.table(rows); console.table(rows);
await connection.end(); await connection.end();
} }
fixAssetTypes().catch(console.error); fixAssetTypes().catch(console.error);

View File

@@ -1,118 +1,118 @@
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const pool = mysql.createPool({ const pool = mysql.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'), port: parseInt(process.env.DB_PORT || '3306'),
}); });
// 하드웨어 출시 연도 데이터베이스 (CPU/GPU) // 하드웨어 출시 연도 데이터베이스 (CPU/GPU)
const RELEASE_DATES = { const RELEASE_DATES = {
// Intel CPU Generations (Mainstream desktop release month/year) // Intel CPU Generations (Mainstream desktop release month/year)
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10', 'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10', 'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11', 'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03', 'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05', 'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10', 'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10', 'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01', 'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08', 'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-4': '2013-06', 'i5-4': '2013-06', 'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04', 'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01', 'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU Series // NVIDIA GPU Series
'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06', 'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06',
'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02', 'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02',
'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01', 'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01',
'GTX 1660': '2019-03', 'GTX 1650': '2019-04', 'GTX 1660': '2019-03', 'GTX 1650': '2019-04',
'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10', 'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10',
'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01' 'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01'
}; };
function inferDateFromSpecs(cpu, gpu) { function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase(); const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase(); const gpuStr = (gpu || '').toUpperCase();
let inferred = null; let inferred = null;
// 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음) // 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음)
for (const [key, date] of Object.entries(RELEASE_DATES)) { for (const [key, date] of Object.entries(RELEASE_DATES)) {
if (gpuStr.includes(key)) { if (gpuStr.includes(key)) {
inferred = date; inferred = date;
break; break;
} }
} }
// 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우) // 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우)
if (!inferred) { if (!inferred) {
for (const [key, date] of Object.entries(RELEASE_DATES)) { for (const [key, date] of Object.entries(RELEASE_DATES)) {
// i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인 // i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인
if (cpuStr.includes(key)) { if (cpuStr.includes(key)) {
inferred = date; inferred = date;
break; break;
} }
} }
} }
return inferred ? `${inferred}-01` : null; return inferred ? `${inferred}-01` : null;
} }
async function run() { async function run() {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id LEFT JOIN asset_spec s ON c.id = s.asset_id
`); `);
const updates = []; const updates = [];
const unchanged = []; const unchanged = [];
for (const row of rows) { for (const row of rows) {
const currentVal = (row.purchase_date || '').trim(); const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 부정확한 경우만 처리 // 구매일자가 없거나 부정확한 경우만 처리
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) { if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) {
const specDate = inferDateFromSpecs(row.cpu, row.gpu); const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) { if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu }); updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
} else { } else {
unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu }); unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
} }
} }
} }
console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`); console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`);
for (const item of updates) { for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]); await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`); console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
} }
if (unchanged.length > 0) { if (unchanged.length > 0) {
console.log('\n⚠ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:'); console.log('\n⚠ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:');
unchanged.forEach(u => { unchanged.forEach(u => {
if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`); if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`);
}); });
} }
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`); console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
} finally { } finally {
connection.release(); connection.release();
pool.end(); pool.end();
} }
} }
run(); run();

View File

@@ -1,128 +1,128 @@
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const pool = mysql.createPool({ const pool = mysql.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'), port: parseInt(process.env.DB_PORT || '3306'),
}); });
// 하드웨어 출시 연도/월 데이터베이스 // 하드웨어 출시 연도/월 데이터베이스
const RELEASE_DATES = { const RELEASE_DATES = {
// Intel CPU // Intel CPU
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10', 'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10', 'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11', 'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03', 'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05', 'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10', 'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10', 'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01', 'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08', 'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell 'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell
'i7-4': '2013-06', 'i5-4': '2013-06', 'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04', 'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01', 'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU // NVIDIA GPU
'RTX 40': '2022-10', 'RTX 40': '2022-10',
'RTX 30': '2020-09', 'RTX 30': '2020-09',
'RTX 20': '2018-09', 'RTX 20': '2018-09',
'GTX 16': '2019-02', 'GTX 16': '2019-02',
'GTX 10': '2016-05', 'GTX 10': '2016-05',
'GTX 9': '2014-09', 'GTX 9': '2014-09',
'GTX 750': '2014-02', 'GTX 750': '2014-02',
'GTX 7': '2013-05', 'GTX 7': '2013-05',
'GTX 6': '2012-03' 'GTX 6': '2012-03'
}; };
// 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함) // 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함)
const YEAR_ONLY = { const YEAR_ONLY = {
'I5-4': 2013, 'I5-4': 2013,
'I5-6': 2015, 'I5-6': 2015,
'I7-7': 2017, 'I7-7': 2017,
'GTX 750': 2014 'GTX 750': 2014
}; };
function inferDateFromSpecs(cpu, gpu) { function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase(); const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase(); const gpuStr = (gpu || '').toUpperCase();
let latestYear = 0; let latestYear = 0;
let latestMonth = 0; let latestMonth = 0;
// 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음 // 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음
for (const [key, dateStr] of Object.entries(RELEASE_DATES)) { for (const [key, dateStr] of Object.entries(RELEASE_DATES)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) { if (cpuStr.includes(key) || gpuStr.includes(key)) {
const [y, m] = dateStr.split('-').map(Number); const [y, m] = dateStr.split('-').map(Number);
if (y > latestYear || (y === latestYear && m > latestMonth)) { if (y > latestYear || (y === latestYear && m > latestMonth)) {
latestYear = y; latestYear = y;
latestMonth = m; latestMonth = m;
} }
} }
} }
// 매칭된 정보가 있는 경우 // 매칭된 정보가 있는 경우
if (latestYear > 0) { if (latestYear > 0) {
// 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용) // 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용)
// 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단 // 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단
// RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되, // RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되,
// 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강 // 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강
return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`; return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`;
} }
// 연도만 매칭되는 경우 (지시사항: 후속년도 12월) // 연도만 매칭되는 경우 (지시사항: 후속년도 12월)
for (const [key, year] of Object.entries(YEAR_ONLY)) { for (const [key, year] of Object.entries(YEAR_ONLY)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) { if (cpuStr.includes(key) || gpuStr.includes(key)) {
return `${year + 1}-12-01`; return `${year + 1}-12-01`;
} }
} }
return null; return null;
} }
async function run() { async function run() {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
const [rows] = await connection.query(` const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id LEFT JOIN asset_spec s ON c.id = s.asset_id
`); `);
const updates = []; const updates = [];
for (const row of rows) { for (const row of rows) {
const currentVal = (row.purchase_date || '').trim(); const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산 // 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') { if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') {
const specDate = inferDateFromSpecs(row.cpu, row.gpu); const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) { if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu }); updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
} }
} }
} }
console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`); console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`);
for (const item of updates) { for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]); await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`); console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
} }
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`); console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
} finally { } finally {
connection.release(); connection.release();
pool.end(); pool.end();
} }
} }
run(); run();

View File

@@ -1,88 +1,88 @@
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
const pool = mysql.createPool({ const pool = mysql.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'), port: parseInt(process.env.DB_PORT || '3306'),
}); });
async function run() { async function run() {
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
// 먼저 잘못 들어간 0000-00-01 등 복구 // 먼저 잘못 들어간 0000-00-01 등 복구
console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...'); console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...');
await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'"); await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'");
const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core'); const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core');
const updates = []; const updates = [];
const missing = []; const missing = [];
for (const row of rows) { for (const row of rows) {
const code = (row.asset_code || '').trim(); const code = (row.asset_code || '').trim();
const currentVal = (row.purchase_date || '').trim(); const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined' 인 경우 대상 // 구매일자가 없거나 '-', 'undefined' 인 경우 대상
if (!currentVal || currentVal === '-' || currentVal === 'undefined') { if (!currentVal || currentVal === '-' || currentVal === 'undefined') {
let inferredDate = null; let inferredDate = null;
// 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001) // 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001)
const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/); const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/);
if (match6) { if (match6) {
inferredDate = `${match6[1]}-${match6[2]}-01`; inferredDate = `${match6[1]}-${match6[2]}-01`;
} else { } else {
// 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리 // 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리
const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/); const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/);
if (matchYearSeq) { if (matchYearSeq) {
inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일 inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일
} else { } else {
// 3. PREFIX-YYNNN 형식 (예: PC-24001) // 3. PREFIX-YYNNN 형식 (예: PC-24001)
const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/); const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/);
if (matchShort) { if (matchShort) {
inferredDate = `20${matchShort[1]}-01-01`; inferredDate = `20${matchShort[1]}-01-01`;
} }
} }
} }
// 0000 등의 잘못된 매칭 방지 // 0000 등의 잘못된 매칭 방지
if (inferredDate && !inferredDate.startsWith('0000')) { if (inferredDate && !inferredDate.startsWith('0000')) {
updates.push({ id: row.id, date: inferredDate, code: code }); updates.push({ id: row.id, date: inferredDate, code: code });
} else { } else {
missing.push({ id: row.id, code: code, category: row.category }); missing.push({ id: row.id, code: code, category: row.category });
} }
} }
} }
console.log(`${updates.length}건의 자산을 업데이트합니다.`); console.log(`${updates.length}건의 자산을 업데이트합니다.`);
for (const item of updates) { for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]); await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code} -> ${item.date}`); console.log(`[Update] ${item.code} -> ${item.date}`);
} }
console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---'); console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---');
if (missing.length === 0) { if (missing.length === 0) {
console.log('없음'); console.log('없음');
} else { } else {
// 중복 제거 및 정렬하여 보고 // 중복 제거 및 정렬하여 보고
const uniqueMissing = missing.filter(m => m.code !== ''); const uniqueMissing = missing.filter(m => m.code !== '');
uniqueMissing.forEach(m => { uniqueMissing.forEach(m => {
console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`); console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`);
}); });
} }
console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`); console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`);
} catch (err) { } catch (err) {
console.error('Error:', err); console.error('Error:', err);
} finally { } finally {
connection.release(); connection.release();
pool.end(); pool.end();
} }
} }
run(); run();

View File

@@ -1,122 +1,122 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importAssets() { async function importAssets() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: DB_HOST, host: DB_HOST,
user: DB_USER, user: DB_USER,
password: DB_PASS, password: DB_PASS,
database: DB_NAME, database: DB_NAME,
port: parseInt(DB_PORT || '3306') port: parseInt(DB_PORT || '3306')
}); });
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비...'); console.log('🚀 [Step 1] 데이터 로드 및 사전 준비...');
// 1. 엑셀 파일 로드 // 1. 엑셀 파일 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet); const rawData = XLSX.utils.sheet_to_json(sheet);
// 2. system_users 데이터 맵 생성 (사번 기준 빠른 조회를 위함) // 2. system_users 데이터 맵 생성 (사번 기준 빠른 조회를 위함)
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users'); const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map(); const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u)); userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 3. 기존 자산 중복 체크용 맵 생성 (emp_no + asset_type + category) // 3. 기존 자산 중복 체크용 맵 생성 (emp_no + asset_type + category)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category FROM asset_core'); const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category FROM asset_core');
const existingSet = new Set(); const existingSet = new Set();
existingAssets.forEach(a => { existingAssets.forEach(a => {
existingSet.add(`${a.emp_no}|${a.asset_type}|${a.category}`); existingSet.add(`${a.emp_no}|${a.asset_type}|${a.category}`);
}); });
console.log(`📊 처리 대상 데이터: ${rawData.length}`); console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0; let skipCount = 0;
let insertCount = 0; let insertCount = 0;
for (let i = 0; i < rawData.length; i++) { for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]; const row = rawData[i];
const empNo = String(row.emp_no); const empNo = String(row.emp_no);
const assetType = row.asset_type || '개인PC'; const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC'; const category = row.category || 'PC';
// 중복 체크 // 중복 체크
if (existingSet.has(`${empNo}|${assetType}|${category}`)) { if (existingSet.has(`${empNo}|${assetType}|${category}`)) {
skipCount++; skipCount++;
continue; continue;
} }
// [Step 2] 데이터 정제 // [Step 2] 데이터 정제
// 1. 사용자 정보 매칭 // 1. 사용자 정보 매칭
const matchedUser = userMap.get(empNo); const matchedUser = userMap.get(empNo);
const userName = matchedUser ? matchedUser.user_name : row.user_current; const userName = matchedUser ? matchedUser.user_name : row.user_current;
const deptName = matchedUser ? matchedUser.dept_name : row.current_dept; const deptName = matchedUser ? matchedUser.dept_name : row.current_dept;
const position = matchedUser ? matchedUser.position : ''; const position = matchedUser ? matchedUser.position : '';
// 2. 날짜 최적화 (purchase_date_1, purchase_date_2 중 최신값) // 2. 날짜 최적화 (purchase_date_1, purchase_date_2 중 최신값)
const d1 = parseInt(row.purchase_date_1) || 0; const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0; const d2 = parseInt(row.purchase_date_2) || 0;
const latestDate = Math.max(d1, d2); const latestDate = Math.max(d1, d2);
const purchaseDate = latestDate > 0 ? String(latestDate) : ''; const purchaseDate = latestDate > 0 ? String(latestDate) : '';
// 3. 고유 ID 생성 // 3. 고유 ID 생성
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19); const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try { try {
// [Step 3] DB 입력 // [Step 3] DB 입력
// A. asset_core 입력 // A. asset_core 입력
await connection.query( await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type, `INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_corp, purchase_date, memo, manager_primary, current_dept, user_current, emp_no, user_position, created_at, updated_at) purchase_corp, purchase_date, memo, manager_primary, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role, row.asset_purpose, row.service_type, [assetId, assetId, category, assetType, row.current_role, row.asset_purpose, row.service_type,
'', purchaseDate, row.memo || '', '', deptName, userName, empNo, position, now, now] '', purchaseDate, row.memo || '', '', deptName, userName, empNo, position, now, now]
); );
// B. asset_spec 입력 // B. asset_spec 입력
await connection.query( await connection.query(
`INSERT INTO asset_spec (asset_id, model_name, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO asset_spec (asset_id, model_name, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?, ?)`,
[assetId, '', row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || ''] [assetId, '', row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
); );
// C. asset_volume 입력 (SSD1, SSD2, HDD1~4) // C. asset_volume 입력 (SSD1, SSD2, HDD1~4)
const volumes = [ const volumes = [
{ type: 'SSD', cap: row.SDD1, slot: 1 }, { type: 'SSD', cap: row.SDD1, slot: 1 },
{ type: 'SSD', cap: row.SDD2, slot: 2 }, { type: 'SSD', cap: row.SDD2, slot: 2 },
{ type: 'HDD', cap: row.HDD1, slot: 3 }, { type: 'HDD', cap: row.HDD1, slot: 3 },
{ type: 'HDD', cap: row.HDD2, slot: 4 }, { type: 'HDD', cap: row.HDD2, slot: 4 },
{ type: 'HDD', cap: row.HDD3, slot: 5 }, { type: 'HDD', cap: row.HDD3, slot: 5 },
{ type: 'HDD', cap: row.HDD4, slot: 6 } { type: 'HDD', cap: row.HDD4, slot: 6 }
]; ];
for (const vol of volumes) { for (const vol of volumes) {
if (vol.cap && vol.cap !== '0' && vol.cap !== 0) { if (vol.cap && vol.cap !== '0' && vol.cap !== 0) {
await connection.query( await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, slot_no) VALUES (?, ?, ?, ?)`, `INSERT INTO asset_volume (asset_id, disk_type, capacity, slot_no) VALUES (?, ?, ?, ?)`,
[assetId, vol.type, String(vol.cap), vol.slot] [assetId, vol.type, String(vol.cap), vol.slot]
); );
} }
} }
insertCount++; insertCount++;
existingSet.add(`${empNo}|${assetType}|${category}`); // 실시간 중복 방지 추가 existingSet.add(`${empNo}|${assetType}|${category}`); // 실시간 중복 방지 추가
} catch (err) { } catch (err) {
console.error(`❌ [${empNo}] 처리 중 오류:`, err.message); console.error(`❌ [${empNo}] 처리 중 오류:`, err.message);
} }
} }
console.log(`\n✨ 작업 완료!`); console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`); console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`); console.log(`- 중복 스킵: ${skipCount}`);
await connection.end(); await connection.end();
} }
importAssets().catch(console.error); importAssets().catch(console.error);

View File

@@ -1,164 +1,164 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 용량 정제 함수 // 용량 정제 함수
function parseCapacity(val) { function parseCapacity(val) {
if (!val || val === '0' || val === 0) return null; if (!val || val === '0' || val === 0) return null;
let str = String(val).toUpperCase(); let str = String(val).toUpperCase();
// 1. 괄호와 그 안의 내용 제거 // 1. 괄호와 그 안의 내용 제거
str = str.replace(/\(.*\)/g, '').trim(); str = str.replace(/\(.*\)/g, '').trim();
// 2. 숫자와 단위 분리 // 2. 숫자와 단위 분리
const numMatch = str.match(/[\d.]+/); const numMatch = str.match(/[\d.]+/);
if (!numMatch) return null; if (!numMatch) return null;
let num = parseFloat(numMatch[0]); let num = parseFloat(numMatch[0]);
let unit = 'GB'; // 기본 단위 let unit = 'GB'; // 기본 단위
if (str.includes('TB')) { if (str.includes('TB')) {
unit = 'TB'; unit = 'TB';
} else if (str.includes('GB')) { } else if (str.includes('GB')) {
// 4자리수 GB인 경우 TB로 전환 (지시사항 1번) // 4자리수 GB인 경우 TB로 전환 (지시사항 1번)
if (num >= 1000) { if (num >= 1000) {
num = num / 1000; num = num / 1000;
unit = 'TB'; unit = 'TB';
} else { } else {
unit = 'GB'; unit = 'GB';
} }
} else { } else {
// 단위가 명시되지 않은 경우 숫자의 크기로 판단 // 단위가 명시되지 않은 경우 숫자의 크기로 판단
if (num >= 1000) { if (num >= 1000) {
num = num / 1000; num = num / 1000;
unit = 'TB'; unit = 'TB';
} }
} }
return { return {
capacity: parseFloat(num.toFixed(2)), capacity: parseFloat(num.toFixed(2)),
unit: unit unit: unit
}; };
} }
async function importAssets() { async function importAssets() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: DB_HOST, host: DB_HOST,
user: DB_USER, user: DB_USER,
password: DB_PASS, password: DB_PASS,
database: DB_NAME, database: DB_NAME,
port: parseInt(DB_PORT || '3306') port: parseInt(DB_PORT || '3306')
}); });
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비 (정제 로직 강화)...'); console.log('🚀 [Step 1] 데이터 로드 및 사전 준비 (정제 로직 강화)...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet); const rawData = XLSX.utils.sheet_to_json(sheet);
// system_users 데이터 맵 // system_users 데이터 맵
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users'); const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map(); const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u)); userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 기존 자산 중복 체크용 (emp_no + asset_type + category + user_current) // 기존 자산 중복 체크용 (emp_no + asset_type + category + user_current)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category, user_current FROM asset_core'); const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category, user_current FROM asset_core');
const existingSet = new Set(); const existingSet = new Set();
existingAssets.forEach(a => { existingAssets.forEach(a => {
existingSet.add(`${a.emp_no || ''}|${a.asset_type}|${a.category}|${a.user_current}`); existingSet.add(`${a.emp_no || ''}|${a.asset_type}|${a.category}|${a.user_current}`);
}); });
console.log(`📊 처리 대상 데이터: ${rawData.length}`); console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0; let skipCount = 0;
let insertCount = 0; let insertCount = 0;
let errorCount = 0; let errorCount = 0;
for (let i = 0; i < rawData.length; i++) { for (let i = 0; i < rawData.length; i++) {
const row = rawData[i]; const row = rawData[i];
const empNo = row.emp_no ? String(row.emp_no) : ''; // 사번 없는 행 처리 (지시사항 3번) const empNo = row.emp_no ? String(row.emp_no) : ''; // 사번 없는 행 처리 (지시사항 3번)
const assetType = row.asset_type || '개인PC'; const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC'; const category = row.category || 'PC';
const userCurrent = row.user_current || ''; const userCurrent = row.user_current || '';
// 중복 체크 // 중복 체크
const dupKey = `${empNo}|${assetType}|${category}|${userCurrent}`; const dupKey = `${empNo}|${assetType}|${category}|${userCurrent}`;
if (existingSet.has(dupKey)) { if (existingSet.has(dupKey)) {
skipCount++; skipCount++;
continue; continue;
} }
// [Step 2] 데이터 정제 // [Step 2] 데이터 정제
const matchedUser = empNo ? userMap.get(empNo) : null; const matchedUser = empNo ? userMap.get(empNo) : null;
const userName = matchedUser ? matchedUser.user_name : userCurrent; const userName = matchedUser ? matchedUser.user_name : userCurrent;
const deptName = matchedUser ? matchedUser.dept_name : (row.current_dept || ''); const deptName = matchedUser ? matchedUser.dept_name : (row.current_dept || '');
const position = matchedUser ? matchedUser.position : ''; const position = matchedUser ? matchedUser.position : '';
const d1 = parseInt(row.purchase_date_1) || 0; const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0; const d2 = parseInt(row.purchase_date_2) || 0;
const purchaseDate = Math.max(d1, d2) > 0 ? String(Math.max(d1, d2)) : ''; const purchaseDate = Math.max(d1, d2) > 0 ? String(Math.max(d1, d2)) : '';
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19); const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try { try {
// [Step 3] DB 입력 // [Step 3] DB 입력
// A. asset_core // A. asset_core
await connection.query( await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type, `INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_date, memo, current_dept, user_current, emp_no, user_position, created_at, updated_at) purchase_date, memo, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role || '', row.asset_purpose || '', row.service_type || '', [assetId, assetId, category, assetType, row.current_role || '', row.asset_purpose || '', row.service_type || '',
purchaseDate, row.memo || '', deptName, userName, empNo, position, now, now] purchaseDate, row.memo || '', deptName, userName, empNo, position, now, now]
); );
// B. asset_spec // B. asset_spec
await connection.query( await connection.query(
`INSERT INTO asset_spec (asset_id, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO asset_spec (asset_id, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?)`,
[assetId, row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || ''] [assetId, row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
); );
// C. asset_volume // C. asset_volume
const volCols = [ const volCols = [
{ key: 'SDD1', type: 'SSD', slot: 1 }, { key: 'SDD1', type: 'SSD', slot: 1 },
{ key: 'SDD2', type: 'SSD', slot: 2 }, { key: 'SDD2', type: 'SSD', slot: 2 },
{ key: 'HDD1', type: 'HDD', slot: 3 }, { key: 'HDD1', type: 'HDD', slot: 3 },
{ key: 'HDD2', type: 'HDD', slot: 4 }, { key: 'HDD2', type: 'HDD', slot: 4 },
{ key: 'HDD3', type: 'HDD', slot: 5 }, { key: 'HDD3', type: 'HDD', slot: 5 },
{ key: 'HDD4', type: 'HDD', slot: 6 } { key: 'HDD4', type: 'HDD', slot: 6 }
]; ];
for (const col of volCols) { for (const col of volCols) {
const rawVol = row[col.key]; const rawVol = row[col.key];
const parsed = parseCapacity(rawVol); const parsed = parseCapacity(rawVol);
if (parsed) { if (parsed) {
await connection.query( await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)`,
[assetId, col.type, parsed.capacity, parsed.unit, col.slot] [assetId, col.type, parsed.capacity, parsed.unit, col.slot]
); );
} }
} }
insertCount++; insertCount++;
existingSet.add(dupKey); existingSet.add(dupKey);
} catch (err) { } catch (err) {
errorCount++; errorCount++;
console.error(`❌ [Row ${i + 2}] ${empNo || 'Public'}: ${err.message}`); console.error(`❌ [Row ${i + 2}] ${empNo || 'Public'}: ${err.message}`);
} }
} }
console.log(`\n✨ 작업 완료!`); console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`); console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`); console.log(`- 중복 스킵: ${skipCount}`);
console.log(`- 오류 실패: ${errorCount}`); console.log(`- 오류 실패: ${errorCount}`);
await connection.end(); await connection.end();
} }
importAssets().catch(console.error); importAssets().catch(console.error);

View File

@@ -1,61 +1,61 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importUsers() { async function importUsers() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: DB_HOST, host: DB_HOST,
user: DB_USER, user: DB_USER,
password: DB_PASS, password: DB_PASS,
database: DB_NAME, database: DB_NAME,
port: parseInt(DB_PORT || '3306') port: parseInt(DB_PORT || '3306')
}); });
console.log('🚀 Excel 데이터 로드 중...'); console.log('🚀 Excel 데이터 로드 중...');
const workbook = XLSX.readFile('system_User (20260615).xlsx'); const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet); const data = XLSX.utils.sheet_to_json(sheet);
console.log(`📊 총 ${data.length}개의 데이터를 찾았습니다.`); console.log(`📊 총 ${data.length}개의 데이터를 찾았습니다.`);
// 기존 데이터 삭제 여부 (사용자 요구사항에 따라 결정 가능하지만, 보통 초기화 후 재입입) // 기존 데이터 삭제 여부 (사용자 요구사항에 따라 결정 가능하지만, 보통 초기화 후 재입입)
// 여기서는 중복 방지를 위해 기존 데이터를 삭제하고 새로 넣는 방식을 취하겠습니다. // 여기서는 중복 방지를 위해 기존 데이터를 삭제하고 새로 넣는 방식을 취하겠습니다.
console.log('🧹 기존 system_users 데이터 삭제 중...'); console.log('🧹 기존 system_users 데이터 삭제 중...');
await connection.query('DELETE FROM system_users'); await connection.query('DELETE FROM system_users');
console.log('📥 데이터 삽입 중...'); console.log('📥 데이터 삽입 중...');
let successCount = 0; let successCount = 0;
for (let i = 0; i < data.length; i++) { for (let i = 0; i < data.length; i++) {
const row = data[i]; const row = data[i];
const { emp_no, user_name, dept_name, position, status } = row; const { emp_no, user_name, dept_name, position, status } = row;
// ID 생성 (USR_ + 인덱스 001 형식) // ID 생성 (USR_ + 인덱스 001 형식)
const id = `USR_${String(i + 1).padStart(3, '0')}`; const id = `USR_${String(i + 1).padStart(3, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19); const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try { try {
await connection.query( await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, createdAt] [id, String(emp_no), user_name, dept_name, position, status, createdAt]
); );
successCount++; successCount++;
} catch (err) { } catch (err) {
console.error(`❌ 삽입 실패 (Row ${i + 2}):`, err.message); console.error(`❌ 삽입 실패 (Row ${i + 2}):`, err.message);
} }
} }
console.log(`✅ 완료: ${successCount}개의 사용자가 성공적으로 등록되었습니다.`); console.log(`✅ 완료: ${successCount}개의 사용자가 성공적으로 등록되었습니다.`);
await connection.end(); await connection.end();
} }
importUsers().catch(err => { importUsers().catch(err => {
console.error('❌ 작업 중 오류 발생:', err); console.error('❌ 작업 중 오류 발생:', err);
process.exit(1); process.exit(1);
}); });

View File

@@ -1,7 +1,7 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log('Headers:', JSON.stringify(data[0], null, 2)); console.log('Headers:', JSON.stringify(data[0], null, 2));
console.log('Sample Row 1:', JSON.stringify(data[1], null, 2)); console.log('Sample Row 1:', JSON.stringify(data[1], null, 2));

View File

@@ -1,6 +1,6 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const workbook = XLSX.readFile('system_User (20260615).xlsx'); const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName]; const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 }); const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log(JSON.stringify(data.slice(0, 5), null, 2)); console.log(JSON.stringify(data.slice(0, 5), null, 2));

View File

@@ -1,25 +1,25 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config({ path: './.env' }); require('dotenv').config({ path: './.env' });
async function main() { async function main() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
try { try {
const [schema] = await connection.query(`SHOW CREATE TABLE asset_volume`); const [schema] = await connection.query(`SHOW CREATE TABLE asset_volume`);
console.log('Schema:', schema[0]['Create Table']); console.log('Schema:', schema[0]['Create Table']);
const [rows] = await connection.query(`SELECT * FROM asset_volume LIMIT 20`); const [rows] = await connection.query(`SELECT * FROM asset_volume LIMIT 20`);
console.log('Sample Data:', JSON.stringify(rows, null, 2)); console.log('Sample Data:', JSON.stringify(rows, null, 2));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
await connection.end(); await connection.end();
} }
} }
main(); main();

View File

@@ -1,25 +1,25 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config({ path: './.env' }); require('dotenv').config({ path: './.env' });
async function main() { async function main() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
try { try {
const [rows] = await connection.query( const [rows] = await connection.query(
`SELECT * FROM asset_core WHERE asset_purpose LIKE '%바론%' OR asset_purpose LIKE '%SSO%'` `SELECT * FROM asset_core WHERE asset_purpose LIKE '%바론%' OR asset_purpose LIKE '%SSO%'`
); );
console.log('Results:', JSON.stringify(rows, null, 2)); console.log('Results:', JSON.stringify(rows, null, 2));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
await connection.end(); await connection.end();
} }
} }
main(); main();

View File

@@ -1,17 +1,17 @@
require('dotenv').config({ path: './.env' }); require('dotenv').config({ path: './.env' });
async function main() { async function main() {
const url = `http://${process.env.DB_HOST || 'localhost'}:3000/api/assets/master`; const url = `http://${process.env.DB_HOST || 'localhost'}:3000/api/assets/master`;
try { try {
const res = await fetch(url); const res = await fetch(url);
const data = await res.json(); const data = await res.json();
// find asset with id "9pvkqyi" // find asset with id "9pvkqyi"
const serverList = data.server || []; const serverList = data.server || [];
const target = serverList.find(s => s.id === '9pvkqyi'); const target = serverList.find(s => s.id === '9pvkqyi');
console.log('Server list asset:', JSON.stringify(target, null, 2)); console.log('Server list asset:', JSON.stringify(target, null, 2));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
main(); main();

View File

@@ -1,18 +1,18 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function rawCheck() { async function rawCheck() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
const [rows] = await connection.query('SELECT user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10'); const [rows] = await connection.query('SELECT user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows); console.log(rows);
await connection.end(); await connection.end();
} }
rawCheck().catch(console.error); rawCheck().catch(console.error);

View File

@@ -1,85 +1,85 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function rebuildAssetCodes() { async function rebuildAssetCodes() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('🚀 [Step 1] 신규 자산 구매일 업데이트 (YYYY-12-01)...'); console.log('🚀 [Step 1] 신규 자산 구매일 업데이트 (YYYY-12-01)...');
// 1. 오늘 입력한 자산들 조회 // 1. 오늘 입력한 자산들 조회
const [rows] = await connection.query( const [rows] = await connection.query(
'SELECT id, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"' 'SELECT id, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"'
); );
console.log(`대상 자산: ${rows.length}`); console.log(`대상 자산: ${rows.length}`);
// 2. 구매일자 업데이트 (연도만 있는 경우 -12-01 추가) // 2. 구매일자 업데이트 (연도만 있는 경우 -12-01 추가)
for (const row of rows) { for (const row of rows) {
if (row.purchase_date && row.purchase_date.length === 4) { if (row.purchase_date && row.purchase_date.length === 4) {
const newDate = `${row.purchase_date}-12-01`; const newDate = `${row.purchase_date}-12-01`;
await connection.query( await connection.query(
'UPDATE asset_core SET purchase_date = ? WHERE id = ?', 'UPDATE asset_core SET purchase_date = ? WHERE id = ?',
[newDate, row.id] [newDate, row.id]
); );
} }
} }
console.log('✅ 구매일 업데이트 완료.'); console.log('✅ 구매일 업데이트 완료.');
console.log('\n🚀 [Step 2] 자산번호(asset_code) 재매핑 시작...'); console.log('\n🚀 [Step 2] 자산번호(asset_code) 재매핑 시작...');
// 3. 연도별로 그룹화하여 자산번호 부여 // 3. 연도별로 그룹화하여 자산번호 부여
// 연도 목록 추출 // 연도 목록 추출
const [yearRows] = await connection.query( const [yearRows] = await connection.query(
'SELECT DISTINCT LEFT(purchase_date, 4) as year FROM asset_core WHERE id LIKE "PC_20260615_%" ORDER BY year' 'SELECT DISTINCT LEFT(purchase_date, 4) as year FROM asset_core WHERE id LIKE "PC_20260615_%" ORDER BY year'
); );
for (const yRow of yearRows) { for (const yRow of yearRows) {
const year = yRow.year; const year = yRow.year;
const yearMonth = `${year}12`; const yearMonth = `${year}12`;
const pattern = `PC-${yearMonth}-%`; const pattern = `PC-${yearMonth}-%`;
console.log(`--- [${year}년] 처리 중 ---`); console.log(`--- [${year}년] 처리 중 ---`);
// 해당 연도/월의 기존 최대 순번 조회 // 해당 연도/월의 기존 최대 순번 조회
const [maxRows] = await connection.query( const [maxRows] = await connection.query(
'SELECT asset_code FROM asset_core WHERE asset_code LIKE ? AND id NOT LIKE "PC_20260615_%"', 'SELECT asset_code FROM asset_core WHERE asset_code LIKE ? AND id NOT LIKE "PC_20260615_%"',
[pattern] [pattern]
); );
let maxSeq = 0; let maxSeq = 0;
maxRows.forEach(r => { maxRows.forEach(r => {
const parts = r.asset_code.split('-'); const parts = r.asset_code.split('-');
const seq = parseInt(parts[2]); const seq = parseInt(parts[2]);
if (seq > maxSeq) maxSeq = seq; if (seq > maxSeq) maxSeq = seq;
}); });
console.log(`기존 최대 순번: ${maxSeq}`); console.log(`기존 최대 순번: ${maxSeq}`);
// 해당 연도 자산들 순차적으로 번호 부여 // 해당 연도 자산들 순차적으로 번호 부여
const [assetsOfYear] = await connection.query( const [assetsOfYear] = await connection.query(
'SELECT id FROM asset_core WHERE id LIKE "PC_20260615_%" AND purchase_date LIKE ? ORDER BY id', 'SELECT id FROM asset_core WHERE id LIKE "PC_20260615_%" AND purchase_date LIKE ? ORDER BY id',
[`${year}-12%`] [`${year}-12%`]
); );
let currentSeq = maxSeq + 1; let currentSeq = maxSeq + 1;
for (const asset of assetsOfYear) { for (const asset of assetsOfYear) {
const newCode = `PC-${yearMonth}-${String(currentSeq).padStart(4, '0')}`; const newCode = `PC-${yearMonth}-${String(currentSeq).padStart(4, '0')}`;
await connection.query( await connection.query(
'UPDATE asset_core SET asset_code = ? WHERE id = ?', 'UPDATE asset_core SET asset_code = ? WHERE id = ?',
[newCode, asset.id] [newCode, asset.id]
); );
currentSeq++; currentSeq++;
} }
console.log(`신규 부여 완료: ${assetsOfYear.length}건 (순번 ${maxSeq + 1} ~ ${currentSeq - 1})`); console.log(`신규 부여 완료: ${assetsOfYear.length}건 (순번 ${maxSeq + 1} ~ ${currentSeq - 1})`);
} }
console.log('\n✨ 모든 작업이 완료되었습니다.'); console.log('\n✨ 모든 작업이 완료되었습니다.');
await connection.end(); await connection.end();
} }
rebuildAssetCodes().catch(console.error); rebuildAssetCodes().catch(console.error);

View File

@@ -1,85 +1,85 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function reexamineData() { async function reexamineData() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log('🧐 [전수 조사] 엑셀 vs DB 데이터 비교 분석...'); console.log('🧐 [전수 조사] 엑셀 vs DB 데이터 비교 분석...');
// 1. 엑셀 데이터 로드 // 1. 엑셀 데이터 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx'); const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]]; const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelRows = XLSX.utils.sheet_to_json(sheet); const excelRows = XLSX.utils.sheet_to_json(sheet);
// 2. DB 데이터 로드 // 2. DB 데이터 로드
const [dbRows] = await connection.query(` const [dbRows] = await connection.query(`
SELECT id, asset_code, asset_type, user_current, emp_no, current_dept SELECT id, asset_code, asset_type, user_current, emp_no, current_dept
FROM asset_core FROM asset_core
WHERE id LIKE "PC_20260615_%" WHERE id LIKE "PC_20260615_%"
`); `);
const dbMap = new Map(); const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r)); dbRows.forEach(r => dbMap.set(r.id, r));
const report = { const report = {
total: excelRows.length, total: excelRows.length,
publicInExcelWithEmpNo: [], // 엑셀은 공용PC인데 사번이 있는 경우 publicInExcelWithEmpNo: [], // 엑셀은 공용PC인데 사번이 있는 경우
personalInExcelNoEmpNo: [], // 엑셀은 개인PC인데 사번이 없는 경우 personalInExcelNoEmpNo: [], // 엑셀은 개인PC인데 사번이 없는 경우
typeMismatch: [], // 엑셀과 DB의 asset_type이 다른 경우 typeMismatch: [], // 엑셀과 DB의 asset_type이 다른 경우
userMismatch: [] // 사용자명이 크게 다른 경우 userMismatch: [] // 사용자명이 크게 다른 경우
}; };
for (let i = 0; i < excelRows.length; i++) { for (let i = 0; i < excelRows.length; i++) {
const ex = excelRows[i]; const ex = excelRows[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`; const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbMap.get(id); const db = dbMap.get(id);
if (!db) continue; if (!db) continue;
const exType = ex.asset_type || '개인PC'; const exType = ex.asset_type || '개인PC';
const exEmpNo = ex.emp_no ? String(ex.emp_no) : null; const exEmpNo = ex.emp_no ? String(ex.emp_no) : null;
const exUser = ex.user_current || ''; const exUser = ex.user_current || '';
// A. 공용PC인데 사번이 있는 경우 (가장 큰 혼란 포인트) // A. 공용PC인데 사번이 있는 경우 (가장 큰 혼란 포인트)
if (exType === '공용PC' && exEmpNo) { if (exType === '공용PC' && exEmpNo) {
report.publicInExcelWithEmpNo.push({ id, exUser, exEmpNo, exDept: ex.current_dept }); report.publicInExcelWithEmpNo.push({ id, exUser, exEmpNo, exDept: ex.current_dept });
} }
// B. 개인PC인데 사번이 없는 경우 // B. 개인PC인데 사번이 없는 경우
if (exType === '개인PC' && !exEmpNo) { if (exType === '개인PC' && !exEmpNo) {
report.personalInExcelNoEmpNo.push({ id, exUser, exDept: ex.current_dept }); report.personalInExcelNoEmpNo.push({ id, exUser, exDept: ex.current_dept });
} }
// C. DB와의 타입 불일치 (현재 DB 상태 체크) // C. DB와의 타입 불일치 (현재 DB 상태 체크)
if (db.asset_type !== exType) { if (db.asset_type !== exType) {
report.typeMismatch.push({ id, exType, dbType: db.asset_type, user: db.user_current }); report.typeMismatch.push({ id, exType, dbType: db.asset_type, user: db.user_current });
} }
} }
console.log('\n================================================'); console.log('\n================================================');
console.log(`📊 전수 조사 요약 (총 ${report.total}건)`); console.log(`📊 전수 조사 요약 (총 ${report.total}건)`);
console.log(`1. 엑셀은 '공용PC'이나 '사번'이 있는 항목: ${report.publicInExcelWithEmpNo.length}`); console.log(`1. 엑셀은 '공용PC'이나 '사번'이 있는 항목: ${report.publicInExcelWithEmpNo.length}`);
console.log(`2. 엑셀은 '개인PC'이나 '사번'이 없는 항목: ${report.personalInExcelNoEmpNo.length}`); console.log(`2. 엑셀은 '개인PC'이나 '사번'이 없는 항목: ${report.personalInExcelNoEmpNo.length}`);
console.log(`3. 현재 DB와 엑셀의 '자산유형' 불일치: ${report.typeMismatch.length}`); console.log(`3. 현재 DB와 엑셀의 '자산유형' 불일치: ${report.typeMismatch.length}`);
console.log('================================================\n'); console.log('================================================\n');
if (report.publicInExcelWithEmpNo.length > 0) { if (report.publicInExcelWithEmpNo.length > 0) {
console.log('⚠️ [그룹 1] 공용PC인데 실사용자/관리자가 지정된 사례 (샘플 15건):'); console.log('⚠️ [그룹 1] 공용PC인데 실사용자/관리자가 지정된 사례 (샘플 15건):');
console.table(report.publicInExcelWithEmpNo.slice(0, 15)); console.table(report.publicInExcelWithEmpNo.slice(0, 15));
} }
if (report.personalInExcelNoEmpNo.length > 0) { if (report.personalInExcelNoEmpNo.length > 0) {
console.log('\n⚠ [그룹 2] 개인PC인데 사번 정보가 누락된 사례 (샘플 15건):'); console.log('\n⚠ [그룹 2] 개인PC인데 사번 정보가 누락된 사례 (샘플 15건):');
console.table(report.personalInExcelNoEmpNo.slice(0, 15)); console.table(report.personalInExcelNoEmpNo.slice(0, 15));
} }
await connection.end(); await connection.end();
} }
reexamineData().catch(console.error); reexamineData().catch(console.error);

View File

@@ -1,92 +1,92 @@
const XLSX = require('xlsx'); const XLSX = require('xlsx');
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const dotenv = require('dotenv'); const dotenv = require('dotenv');
const path = require('path'); const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') }); dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env; const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreAndMerge() { async function restoreAndMerge() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: DB_HOST, host: DB_HOST,
user: DB_USER, user: DB_USER,
password: DB_PASS, password: DB_PASS,
database: DB_NAME, database: DB_NAME,
port: parseInt(DB_PORT || '3306') port: parseInt(DB_PORT || '3306')
}); });
console.log('🔄 데이터 복구 및 병합 시작...'); console.log('🔄 데이터 복구 및 병합 시작...');
// 1. 백업 파일에서 기존 데이터(212건) 로드 // 1. 백업 파일에서 기존 데이터(212건) 로드
const workbookBackup = XLSX.readFile('backupDB_20260602.xlsx'); const workbookBackup = XLSX.readFile('backupDB_20260602.xlsx');
const oldUsers = XLSX.utils.sheet_to_json(workbookBackup.Sheets['system_users']); const oldUsers = XLSX.utils.sheet_to_json(workbookBackup.Sheets['system_users']);
// 2. 신규 파일에서 데이터(987건) 로드 // 2. 신규 파일에서 데이터(987건) 로드
const workbookNew = XLSX.readFile('system_User (20260615).xlsx'); const workbookNew = XLSX.readFile('system_User (20260615).xlsx');
const newUsers = XLSX.utils.sheet_to_json(workbookNew.Sheets[workbookNew.SheetNames[0]]); const newUsers = XLSX.utils.sheet_to_json(workbookNew.Sheets[workbookNew.SheetNames[0]]);
console.log(`기본 백업 데이터: ${oldUsers.length}`); console.log(`기본 백업 데이터: ${oldUsers.length}`);
console.log(`신규 추가 데이터: ${newUsers.length}`); console.log(`신규 추가 데이터: ${newUsers.length}`);
// 테이블 비우기 (실수를 바로잡기 위해 다시 시작) // 테이블 비우기 (실수를 바로잡기 위해 다시 시작)
await connection.query('DELETE FROM system_users'); await connection.query('DELETE FROM system_users');
const insertedEmpNos = new Set(); const insertedEmpNos = new Set();
let restoreCount = 0; let restoreCount = 0;
let addCount = 0; let addCount = 0;
// 3. 기존 데이터 복구 (ID 보존 시도) // 3. 기존 데이터 복구 (ID 보존 시도)
for (const user of oldUsers) { for (const user of oldUsers) {
const { id, emp_no, user_name, dept_name, position, status, created_at } = user; const { id, emp_no, user_name, dept_name, position, status, created_at } = user;
// 엑셀 날짜 처리 (숫자로 되어 있을 경우) // 엑셀 날짜 처리 (숫자로 되어 있을 경우)
let finalCreatedAt = created_at; let finalCreatedAt = created_at;
if (typeof created_at === 'number') { if (typeof created_at === 'number') {
const date = new Date((created_at - 25569) * 86400 * 1000); const date = new Date((created_at - 25569) * 86400 * 1000);
finalCreatedAt = date.toISOString().replace('T', ' ').substring(0, 19); finalCreatedAt = date.toISOString().replace('T', ' ').substring(0, 19);
} }
try { try {
await connection.query( await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, finalCreatedAt] [id, String(emp_no), user_name, dept_name, position, status, finalCreatedAt]
); );
insertedEmpNos.add(String(emp_no)); insertedEmpNos.add(String(emp_no));
restoreCount++; restoreCount++;
} catch (err) { } catch (err) {
console.error(`❌ 복구 실패 (emp_no: ${emp_no}):`, err.message); console.error(`❌ 복구 실패 (emp_no: ${emp_no}):`, err.message);
} }
} }
// 4. 신규 데이터 추가 (중복 제외) // 4. 신규 데이터 추가 (중복 제외)
for (let i = 0; i < newUsers.length; i++) { for (let i = 0; i < newUsers.length; i++) {
const user = newUsers[i]; const user = newUsers[i];
const { emp_no, user_name, dept_name, position, status } = user; const { emp_no, user_name, dept_name, position, status } = user;
const strEmpNo = String(emp_no); const strEmpNo = String(emp_no);
if (insertedEmpNos.has(strEmpNo)) { if (insertedEmpNos.has(strEmpNo)) {
continue; // 이미 복구된 데이터는 스킵 continue; // 이미 복구된 데이터는 스킵
} }
// 신규 데이터용 ID 생성 (기존 ID와 겹치지 않게 'NEW_' 접두어 또는 시퀀스 사용) // 신규 데이터용 ID 생성 (기존 ID와 겹치지 않게 'NEW_' 접두어 또는 시퀀스 사용)
// 여기서는 단순히 시퀀스로 처리 (최대 ID 확인 후 +1 하는 방식이 좋으나 여기선 간단히) // 여기서는 단순히 시퀀스로 처리 (최대 ID 확인 후 +1 하는 방식이 좋으나 여기선 간단히)
const id = `USR_N_${String(i + 1).padStart(4, '0')}`; const id = `USR_N_${String(i + 1).padStart(4, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19); const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try { try {
await connection.query( await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, strEmpNo, user_name, dept_name, position, status, createdAt] [id, strEmpNo, user_name, dept_name, position, status, createdAt]
); );
addCount++; addCount++;
} catch (err) { } catch (err) {
console.error(`❌ 추가 실패 (emp_no: ${emp_no}):`, err.message); console.error(`❌ 추가 실패 (emp_no: ${emp_no}):`, err.message);
} }
} }
console.log(`✅ 복구 완료: 기존 ${restoreCount}건 복구, 신규 ${addCount}건 추가 (총 ${restoreCount + addCount}건)`); console.log(`✅ 복구 완료: 기존 ${restoreCount}건 복구, 신규 ${addCount}건 추가 (총 ${restoreCount + addCount}건)`);
await connection.end(); await connection.end();
} }
restoreAndMerge().catch(console.error); restoreAndMerge().catch(console.error);

View File

@@ -1,32 +1,32 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
require('dotenv').config(); require('dotenv').config();
async function updateDepartments() { async function updateDepartments() {
const connection = await mysql.createConnection({ const connection = await mysql.createConnection({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASS, password: process.env.DB_PASS,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306') port: parseInt(process.env.DB_PORT || '3306')
}); });
console.log("🚀 부서명 '삼안' 통합 업데이트 시작..."); console.log("🚀 부서명 '삼안' 통합 업데이트 시작...");
const [result] = await connection.query(` const [result] = await connection.query(`
UPDATE asset_core UPDATE asset_core
SET current_dept = '삼안' SET current_dept = '삼안'
WHERE current_dept NOT IN ('총괄기획실', '기술개발센터', '현타', '장헌', '한맥', 'PTC', '', '삼안') WHERE current_dept NOT IN ('총괄기획실', '기술개발센터', '현타', '장헌', '한맥', 'PTC', '', '삼안')
AND current_dept IS NOT NULL AND current_dept IS NOT NULL
`); `);
console.log(`✅ 업데이트 완료: ${result.affectedRows}건의 부서명이 '삼안'으로 변경되었습니다.`); console.log(`✅ 업데이트 완료: ${result.affectedRows}건의 부서명이 '삼안'으로 변경되었습니다.`);
// 최종 확인용 카운트 // 최종 확인용 카운트
const [rows] = await connection.query('SELECT current_dept, COUNT(*) as count FROM asset_core GROUP BY current_dept'); const [rows] = await connection.query('SELECT current_dept, COUNT(*) as count FROM asset_core GROUP BY current_dept');
console.log('\n📊 최종 부서 분포:'); console.log('\n📊 최종 부서 분포:');
console.table(rows); console.table(rows);
await connection.end(); await connection.end();
} }
updateDepartments().catch(console.error); updateDepartments().catch(console.error);

View File

@@ -1,125 +1,125 @@
#!/usr/bin/env sh #!/usr/bin/env sh
set -eu set -eu
COMMAND="${1:-help}" COMMAND="${1:-help}"
ENV_FILE="${ENV_FILE:-.env}" ENV_FILE="${ENV_FILE:-.env}"
BACKUP_ROOT="${BACKUP_ROOT:-backups}" BACKUP_ROOT="${BACKUP_ROOT:-backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}" RETENTION_DAYS="${RETENTION_DAYS:-14}"
TIMESTAMP="${BACKUP_TIMESTAMP:-$(date +%Y%m%d_%H%M%S)}" TIMESTAMP="${BACKUP_TIMESTAMP:-$(date +%Y%m%d_%H%M%S)}"
log() { log() {
printf '[backup] %s\n' "$*" printf '[backup] %s\n' "$*"
} }
fail() { fail() {
printf '[backup] %s\n' "$*" >&2 printf '[backup] %s\n' "$*" >&2
exit 1 exit 1
} }
require_command() { require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1" command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
} }
has_command() { has_command() {
command -v "$1" >/dev/null 2>&1 command -v "$1" >/dev/null 2>&1
} }
load_env() { load_env() {
[ -f "$ENV_FILE" ] || fail "Env file not found: $ENV_FILE" [ -f "$ENV_FILE" ] || fail "Env file not found: $ENV_FILE"
case "$ENV_FILE" in case "$ENV_FILE" in
*/*) env_path="$ENV_FILE" ;; */*) env_path="$ENV_FILE" ;;
*) env_path="./$ENV_FILE" ;; *) env_path="./$ENV_FILE" ;;
esac esac
set -a set -a
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$env_path" . "$env_path"
set +a set +a
: "${DB_HOST:?DB_HOST is required in $ENV_FILE}" : "${DB_HOST:?DB_HOST is required in $ENV_FILE}"
: "${DB_PORT:=3306}" : "${DB_PORT:=3306}"
: "${DB_USER:?DB_USER is required in $ENV_FILE}" : "${DB_USER:?DB_USER is required in $ENV_FILE}"
: "${DB_PASS:?DB_PASS is required in $ENV_FILE}" : "${DB_PASS:?DB_PASS is required in $ENV_FILE}"
: "${DB_NAME:?DB_NAME is required in $ENV_FILE}" : "${DB_NAME:?DB_NAME is required in $ENV_FILE}"
} }
db_dump() { db_dump() {
require_command gzip require_command gzip
load_env load_env
mkdir -p "$BACKUP_ROOT/db" mkdir -p "$BACKUP_ROOT/db"
output_path="$BACKUP_ROOT/db/${DB_NAME}_${TIMESTAMP}.sql.gz" output_path="$BACKUP_ROOT/db/${DB_NAME}_${TIMESTAMP}.sql.gz"
log "Creating DB dump: $output_path" log "Creating DB dump: $output_path"
if has_command mysqldump; then if has_command mysqldump; then
MYSQL_PWD="$DB_PASS" mysqldump \ MYSQL_PWD="$DB_PASS" mysqldump \
--host="$DB_HOST" \ --host="$DB_HOST" \
--port="$DB_PORT" \ --port="$DB_PORT" \
--user="$DB_USER" \ --user="$DB_USER" \
--single-transaction \ --single-transaction \
--quick \ --quick \
--routines \ --routines \
--triggers \ --triggers \
"$DB_NAME" | gzip > "$output_path" "$DB_NAME" | gzip > "$output_path"
elif has_command docker; then elif has_command docker; then
docker exec itam-backend sh -lc "MYSQL_PWD=\"$DB_PASS\" exec mysqldump --host=\"$DB_HOST\" --port=\"$DB_PORT\" --user=\"$DB_USER\" --single-transaction --quick --routines --triggers \"$DB_NAME\"" | gzip > "$output_path" docker exec itam-backend sh -lc "MYSQL_PWD=\"$DB_PASS\" exec mysqldump --host=\"$DB_HOST\" --port=\"$DB_PORT\" --user=\"$DB_USER\" --single-transaction --quick --routines --triggers \"$DB_NAME\"" | gzip > "$output_path"
else else
fail "Required command not found: mysqldump (and docker fallback unavailable)" fail "Required command not found: mysqldump (and docker fallback unavailable)"
fi fi
log "DB dump completed: $output_path" log "DB dump completed: $output_path"
} }
files_backup() { files_backup() {
require_command tar require_command tar
mkdir -p "$BACKUP_ROOT/files" mkdir -p "$BACKUP_ROOT/files"
archive_path="$BACKUP_ROOT/files/runtime_${TIMESTAMP}.tar.gz" archive_path="$BACKUP_ROOT/files/runtime_${TIMESTAMP}.tar.gz"
set -- set --
[ -f "$ENV_FILE" ] && set -- "$@" "$ENV_FILE" [ -f "$ENV_FILE" ] && set -- "$@" "$ENV_FILE"
[ -d "uploads" ] && set -- "$@" "uploads" [ -d "uploads" ] && set -- "$@" "uploads"
[ -f "map_config.json" ] && set -- "$@" "map_config.json" [ -f "map_config.json" ] && set -- "$@" "map_config.json"
[ "$#" -gt 0 ] || fail "No runtime files found to archive" [ "$#" -gt 0 ] || fail "No runtime files found to archive"
log "Creating runtime archive: $archive_path" log "Creating runtime archive: $archive_path"
tar -czf "$archive_path" "$@" tar -czf "$archive_path" "$@"
log "Runtime archive completed: $archive_path" log "Runtime archive completed: $archive_path"
} }
cleanup_backups() { cleanup_backups() {
require_command find require_command find
[ -d "$BACKUP_ROOT" ] || { [ -d "$BACKUP_ROOT" ] || {
log "Backup root does not exist, skipping cleanup: $BACKUP_ROOT" log "Backup root does not exist, skipping cleanup: $BACKUP_ROOT"
return 0 return 0
} }
log "Deleting backup files older than ${RETENTION_DAYS} days from $BACKUP_ROOT" log "Deleting backup files older than ${RETENTION_DAYS} days from $BACKUP_ROOT"
find "$BACKUP_ROOT" -type f -mtime "+$RETENTION_DAYS" -print -delete find "$BACKUP_ROOT" -type f -mtime "+$RETENTION_DAYS" -print -delete
} }
case "$COMMAND" in case "$COMMAND" in
db) db)
db_dump db_dump
;; ;;
files) files)
files_backup files_backup
;; ;;
full) full)
db_dump db_dump
files_backup files_backup
;; ;;
cleanup) cleanup)
cleanup_backups cleanup_backups
;; ;;
help|--help|-h) help|--help|-h)
log "Commands: db | files | full | cleanup" log "Commands: db | files | full | cleanup"
;; ;;
*) *)
fail "Unknown command: $COMMAND" fail "Unknown command: $COMMAND"
;; ;;
esac esac

1594
server.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,280 +1,280 @@
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide'; import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
import { state } from '../core/state'; import { state } from '../core/state';
import './guide.css'; import './guide.css';
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ─── // ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
interface GuideTabConfig { interface GuideTabConfig {
id: string; id: string;
label: string; label: string;
content: string; content: string;
} }
const GUIDE_TABS: GuideTabConfig[] = [ const GUIDE_TABS: GuideTabConfig[] = [
{ {
id: 'overview', id: 'overview',
label: '📋 개요', label: '📋 개요',
content: ` content: `
<section class="guide-section"> <section class="guide-section">
<h3>IT 자산관리 시스템 개요</h3> <h3>IT 자산관리 시스템 개요</h3>
<p class="guide-text"> <p class="guide-text">
HM IT 자산관리 시스템(ITAM)은 기업의 IT 자산을 <strong>도입부터 폐기까지</strong> 전 과정에서 효율적으로 관리하기 위한 통합 플랫폼입니다.<br> HM IT 자산관리 시스템(ITAM)은 기업의 IT 자산을 <strong>도입부터 폐기까지</strong> 전 과정에서 효율적으로 관리하기 위한 통합 플랫폼입니다.<br>
하드웨어(PC, 서버, 스토리지, 전산비품, 모바일기기)와 소프트웨어(구독SW, 영구SW, 클라우드)를 체계적으로 추적하고 유지보수합니다. 하드웨어(PC, 서버, 스토리지, 전산비품, 모바일기기)와 소프트웨어(구독SW, 영구SW, 클라우드)를 체계적으로 추적하고 유지보수합니다.
</p> </p>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>전체 자산관리 프로세스</h3> <h3>전체 자산관리 프로세스</h3>
<div class="flow-container"> <div class="flow-container">
<div class="flow-row"> <div class="flow-row">
<div class="flow-step"> <div class="flow-step">
<span class="step-number">1</span> <span class="step-number">1</span>
<div><span class="step-label">도입/구매</span><p class="step-desc">자산 구매 요청 → 승인 → 발주</p></div> <div><span class="step-label">도입/구매</span><p class="step-desc">자산 구매 요청 → 승인 → 발주</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">2</span> <span class="step-number">2</span>
<div><span class="step-label">등록/배정</span><p class="step-desc">자산번호 부여 → 시스템 등록 → 사용자 할당</p></div> <div><span class="step-label">등록/배정</span><p class="step-desc">자산번호 부여 → 시스템 등록 → 사용자 할당</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">3</span> <span class="step-number">3</span>
<div><span class="step-label">운영/유지</span><p class="step-desc">현황 모니터링 → 점검/수리 → 이력 관리</p></div> <div><span class="step-label">운영/유지</span><p class="step-desc">현황 모니터링 → 점검/수리 → 이력 관리</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">4</span> <span class="step-number">4</span>
<div><span class="step-label">반납/폐기</span><p class="step-desc">자산 회수 → 데이터 소거 → 폐기 처리</p></div> <div><span class="step-label">반납/폐기</span><p class="step-desc">자산 회수 → 데이터 소거 → 폐기 처리</p></div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>시스템 기본 사용방법</h3> <h3>시스템 기본 사용방법</h3>
<table class="guide-info-table"> <table class="guide-info-table">
<thead><tr><th>기능</th><th>방법</th></tr></thead> <thead><tr><th>기능</th><th>방법</th></tr></thead>
<tbody> <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> <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> </tbody>
</table> </table>
</section> </section>
` `
}, },
{ {
id: 'pc', id: 'pc',
label: '💻 개인PC', label: '💻 개인PC',
content: ` content: `
<section class="guide-section"> <section class="guide-section">
<h3>개인PC 관리 가이드</h3> <h3>개인PC 관리 가이드</h3>
<p class="guide-text"> <p class="guide-text">
임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다. 임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
</p> </p>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>관리 프로세스</h3> <h3>관리 프로세스</h3>
<div class="flow-container"> <div class="flow-container">
<div class="flow-row"> <div class="flow-row">
<div class="flow-step"> <div class="flow-step">
<span class="step-number">1</span> <span class="step-number">1</span>
<div><span class="step-label">구매 및 입고</span><p class="step-desc">구매 요청 → 발주 → 입고 검수</p></div> <div><span class="step-label">구매 및 입고</span><p class="step-desc">구매 요청 → 발주 → 입고 검수</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">2</span> <span class="step-number">2</span>
<div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div> <div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div>
</div> </div>
</div> </div>
<i data-lucide="chevron-down" class="flow-arrow"></i> <i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row"> <div class="flow-row">
<div class="flow-step"> <div class="flow-step">
<span class="step-number">3</span> <span class="step-number">3</span>
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 및 설치위치 기록</p></div> <div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 및 설치위치 기록</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">4</span> <span class="step-number">4</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div> <div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div>
</div> </div>
</div> </div>
<i data-lucide="chevron-down" class="flow-arrow"></i> <i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row"> <div class="flow-row">
<div class="flow-step"> <div class="flow-step">
<span class="step-number">5</span> <span class="step-number">5</span>
<div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 및 데이터 소거</p></div> <div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 및 데이터 소거</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">6</span> <span class="step-number">6</span>
<div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div> <div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>주요 관리 항목</h3> <h3>주요 관리 항목</h3>
<table class="guide-info-table"> <table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead> <thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<tbody> <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>실제 사용자 및 소속 부서</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>제조사 모델 및 CPU/RAM 등</td><td>등록 시</td></tr>
<tr><td>구매금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr> </tbody> <tr><td>구매금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr> </tbody>
</table> </table>
</section> </section>
<div class="guide-tip"> <div class="guide-tip">
<strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다. <strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다.
</div> </div>
` `
}, },
{ {
id: 'server', id: 'server',
label: '🖥️ 서버/스토리지', label: '🖥️ 서버/스토리지',
content: ` content: `
<section class="guide-section"> <section class="guide-section">
<h3>인프라 자산 관리 가이드</h3> <h3>인프라 자산 관리 가이드</h3>
<p class="guide-text"> <p class="guide-text">
서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다. 서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다.
</p> </p>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>관리 프로세스</h3> <h3>관리 프로세스</h3>
<div class="flow-container"> <div class="flow-container">
<div class="flow-row"> <div class="flow-row">
<div class="flow-step"> <div class="flow-step">
<span class="step-number">1</span> <span class="step-number">1</span>
<div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div> <div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">2</span> <span class="step-number">2</span>
<div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 및 자산번호 부여</p></div> <div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 및 자산번호 부여</p></div>
</div> </div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span> <span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step"> <div class="flow-step">
<span class="step-number">3</span> <span class="step-number">3</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div> <div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>필수 입력 항목</h3> <h3>필수 입력 항목</h3>
<table class="guide-info-table"> <table class="guide-info-table">
<thead><tr><th>항목</th><th>중요성</th></tr></thead> <thead><tr><th>항목</th><th>중요성</th></tr></thead>
<tbody> <tbody>
<tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr> <tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr>
<tr><td><strong>설치위치</strong></td><td>IDC 또는 서버실 내의 정확한 랙 위치</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>
<tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr> <tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr>
</tbody> </tbody>
</table> </table>
</section> </section>
<div class="guide-warn"> <div class="guide-warn">
<strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다. <strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다.
</div> </div>
` `
}, },
{ {
id: 'software', id: 'software',
label: '💾 소프트웨어', label: '💾 소프트웨어',
content: ` content: `
<section class="guide-section"> <section class="guide-section">
<h3>소프트웨어 자산 관리 가이드</h3> <h3>소프트웨어 자산 관리 가이드</h3>
<p class="guide-text"> <p class="guide-text">
구독형(SaaS) 및 영구형 라이선스를 관리합니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다. 구독형(SaaS) 및 영구형 라이선스를 관리합니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다.
</p> </p>
</section> </section>
<section class="guide-section"> <section class="guide-section">
<h3>라이선스 관리 포인트</h3> <h3>라이선스 관리 포인트</h3>
<table class="guide-info-table"> <table class="guide-info-table">
<thead><tr><th>구분</th><th>관리 내용</th></tr></thead> <thead><tr><th>구분</th><th>관리 내용</th></tr></thead>
<tbody> <tbody>
<tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정 및 비용 정산</td></tr> <tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정 및 비용 정산</td></tr>
<tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr> <tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr>
<tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr> <tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr>
</tbody> </tbody>
</table> </table>
</section> </section>
<div class="guide-tip"> <div class="guide-tip">
<strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요. <strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요.
</div> </div>
` `
} }
]; ];
// ─── 가이드 모달 초기화 ─── // ─── 가이드 모달 초기화 ───
export function initGuide() { export function initGuide() {
const body = document.body; const body = document.body;
if (document.getElementById('guide-overlay')) return; if (document.getElementById('guide-overlay')) return;
const overlay = document.createElement('div'); const overlay = document.createElement('div');
overlay.className = 'modal-overlay hidden'; overlay.className = 'modal-overlay hidden';
overlay.id = 'guide-overlay'; overlay.id = 'guide-overlay';
const tabsHtml = GUIDE_TABS.map((tab, i) => const tabsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>` `<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>`
).join(''); ).join('');
const panelsHtml = GUIDE_TABS.map((tab, i) => const panelsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>` `<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>`
).join(''); ).join('');
overlay.innerHTML = ` overlay.innerHTML = `
<div class="modal-content wide" id="guide-modal" style="height: 90vh;"> <div class="modal-content wide" id="guide-modal" style="height: 90vh;">
<div class="modal-header"> <div class="modal-header">
<h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2> <h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2>
<button class="btn-icon" id="btn-close-guide"> <button class="btn-icon" id="btn-close-guide">
<i data-lucide="x"></i> <i data-lucide="x"></i>
</button> </button>
</div> </div>
<div class="guide-tabs-container"> <div class="guide-tabs-container">
<div class="guide-tabs">${tabsHtml}</div> <div class="guide-tabs">${tabsHtml}</div>
</div> </div>
<div class="modal-body" style="padding-top: 0;"> <div class="modal-body" style="padding-top: 0;">
<div class="guide-body">${panelsHtml}</div> <div class="guide-body">${panelsHtml}</div>
</div> </div>
</div> </div>
`; `;
body.appendChild(overlay); body.appendChild(overlay);
const openGuide = () => { const openGuide = () => {
console.log('📖 Opening Full Guide Modal...'); console.log('📖 Opening Full Guide Modal...');
overlay.classList.remove('hidden'); overlay.classList.remove('hidden');
}; };
const closeGuide = () => overlay.classList.add('hidden'); const closeGuide = () => overlay.classList.add('hidden');
const triggerBtn = document.getElementById('btn-open-guide-header'); const triggerBtn = document.getElementById('btn-open-guide-header');
if (triggerBtn) { if (triggerBtn) {
triggerBtn.addEventListener('click', openGuide); triggerBtn.addEventListener('click', openGuide);
} }
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); });
document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide); document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide);
const tabs = overlay.querySelectorAll('.guide-tab'); const tabs = overlay.querySelectorAll('.guide-tab');
const panels = overlay.querySelectorAll('.guide-tab-panel'); const panels = overlay.querySelectorAll('.guide-tab-panel');
tabs.forEach(tab => { tabs.forEach(tab => {
tab.addEventListener('click', () => { tab.addEventListener('click', () => {
const targetId = tab.getAttribute('data-guide-tab'); const targetId = tab.getAttribute('data-guide-tab');
tabs.forEach(t => t.classList.remove('active')); tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active')); panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active'); tab.classList.add('active');
overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active'); overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
}); });
}); });
createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } }); createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } });
} }

View File

@@ -1,143 +1,143 @@
import { createIcons, X } from 'lucide'; import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils'; import { setEditLock } from './ModalUtils';
import './modal.css'; import './modal.css';
/** /**
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다. * 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
*/ */
export abstract class BaseModal { export abstract class BaseModal {
protected idPrefix: string; protected idPrefix: string;
protected title: string; protected title: string;
protected currentAsset: any | null = null; protected currentAsset: any | null = null;
protected isEditMode: boolean = false; protected isEditMode: boolean = false;
protected currentMode: 'view' | 'edit' | 'add' = 'view'; protected currentMode: 'view' | 'edit' | 'add' = 'view';
protected modalEl: HTMLElement | null = null; protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null; protected formEl: HTMLFormElement | null = null;
constructor(idPrefix: string, title: string) { constructor(idPrefix: string, title: string) {
this.idPrefix = idPrefix; this.idPrefix = idPrefix;
this.title = title; this.title = title;
} }
/** /**
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩 * 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
*/ */
public init(onSave: () => void, closeModalsFn: () => void) { public init(onSave: () => void, closeModalsFn: () => void) {
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용) // 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) { if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML()); document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
} }
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`); this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement; this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등) // 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`); const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`); const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
const closeAction = () => { const closeAction = () => {
this.close(); this.close();
closeModalsFn(); // 전역 모달 상태 해제 콜백 closeModalsFn(); // 전역 모달 상태 해제 콜백
}; };
btnCloseHeader?.addEventListener('click', closeAction); btnCloseHeader?.addEventListener('click', closeAction);
btnCancelFooter?.addEventListener('click', closeAction); btnCancelFooter?.addEventListener('click', closeAction);
// 3. 자식 클래스 전용 초기화 로직 실행 // 3. 자식 클래스 전용 초기화 로직 실행
this.initChildLogic(onSave, closeModalsFn); this.initChildLogic(onSave, closeModalsFn);
// 4. 아이콘 초기화 // 4. 아이콘 초기화
createIcons({ icons: { X } }); createIcons({ icons: { X } });
} }
/** /**
* 모달 열기: 데이터 바인딩 및 모드 설정 * 모달 열기: 데이터 바인딩 및 모드 설정
*/ */
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
this.currentAsset = asset; this.currentAsset = asset;
this.currentMode = mode; this.currentMode = mode;
this.isEditMode = (mode === 'add' || mode === 'edit'); this.isEditMode = (mode === 'add' || mode === 'edit');
// 폼 초기화 추가 // 폼 초기화 추가
if (this.formEl) this.formEl.reset(); if (this.formEl) this.formEl.reset();
// fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리 // fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리
this.fillFormData(asset); this.fillFormData(asset);
this.setEditLockMode(mode); this.setEditLockMode(mode);
if (this.modalEl) { if (this.modalEl) {
this.modalEl.classList.remove('hidden'); this.modalEl.classList.remove('hidden');
const content = this.modalEl.querySelector('.modal-content'); const content = this.modalEl.querySelector('.modal-content');
if (content) { if (content) {
if (mode === 'view') content.classList.add('is-view-mode'); if (mode === 'view') content.classList.add('is-view-mode');
else content.classList.remove('is-view-mode'); else content.classList.remove('is-view-mode');
} }
} }
this.onAfterOpen(asset, mode); this.onAfterOpen(asset, mode);
} }
/** /**
* 모달 닫기: 상태 초기화 * 모달 닫기: 상태 초기화
*/ */
public close() { public close() {
if (this.modalEl) { if (this.modalEl) {
this.modalEl.classList.add('hidden'); this.modalEl.classList.add('hidden');
} }
this.isEditMode = false; this.isEditMode = false;
this.currentAsset = null; this.currentAsset = null;
this.onAfterClose(); this.onAfterClose();
} }
/** /**
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어 * 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
*/ */
protected setEditLockMode(mode: 'view' | 'edit' | 'add') { protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
setEditLock(`${this.idPrefix}-asset-form`, mode, { setEditLock(`${this.idPrefix}-asset-form`, mode, {
saveBtnId: `btn-save-${this.idPrefix}-asset`, saveBtnId: `btn-save-${this.idPrefix}-asset`,
revertBtnId: `btn-revert-${this.idPrefix}-edit`, revertBtnId: `btn-revert-${this.idPrefix}-edit`,
addLogBtnId: `btn-add-${this.idPrefix}-log` addLogBtnId: `btn-add-${this.idPrefix}-log`
}); });
} }
// --- 추상 메서드: 자식 클래스에서 구현해야 함 --- // --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
protected abstract renderFrameHTML(): string; protected abstract renderFrameHTML(): string;
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void; protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
protected abstract fillFormData(asset: any): void; protected abstract fillFormData(asset: any): void;
protected abstract onAfterOpen(asset: any, mode: string): void; protected abstract onAfterOpen(asset: any, mode: string): void;
// --- 훅(Hook) 메서드: 필요 시 오버라이드 --- // --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
protected onAfterClose(): void {} protected onAfterClose(): void {}
} }
/** /**
* --- 레거시 호환성을 위한 함수형 익스포트 --- * --- 레거시 호환성을 위한 함수형 익스포트 ---
* 기존 코드들이 참조하고 있는 함수들을 유지합니다. * 기존 코드들이 참조하고 있는 함수들을 유지합니다.
*/ */
export function closeModals() { export function closeModals() {
const modals = document.querySelectorAll('.modal-overlay'); const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden')); modals.forEach(modal => modal.classList.add('hidden'));
} }
export function initBaseModal() { export function initBaseModal() {
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음) // ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
const picker = document.querySelector('.image-picker-overlay'); const picker = document.querySelector('.image-picker-overlay');
if (picker) { if (picker) {
picker.remove(); picker.remove();
} else { } else {
closeModals(); closeModals();
} }
} }
}); });
return { closeAllModals: closeModals }; return { closeAllModals: closeModals };
} }
export function openModal(modalId: string) { export function openModal(modalId: string) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) { if (modal) {
modal.classList.remove('hidden'); modal.classList.remove('hidden');
} }
} }

View File

@@ -1,136 +1,136 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { ASSET_SCHEMA } from '../../core/schema'; import { ASSET_SCHEMA } from '../../core/schema';
import { createIcons, X } from 'lucide'; import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = ` const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden"> <div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2> <h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="table-container"> <div class="table-container">
<table> <table>
<thead></thead> <thead></thead>
<tbody id="dashboard-detail-tbody"></tbody> <tbody id="dashboard-detail-tbody"></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div></div> <div></div>
<button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button> <button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
export function initDashboardDetailModal() { export function initDashboardDetailModal() {
if (!document.getElementById('dashboard-detail-modal')) { if (!document.getElementById('dashboard-detail-modal')) {
document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML); document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML);
} }
const modal = document.getElementById('dashboard-detail-modal')!; const modal = document.getElementById('dashboard-detail-modal')!;
const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!; const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!;
const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!; const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!;
const closeModal = () => modal.classList.add('hidden'); const closeModal = () => modal.classList.add('hidden');
closeBtn.addEventListener('click', closeModal); closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal); cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); }); modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
createIcons({ icons: { X } }); createIcons({ icons: { X } });
} }
export function openDashboardDetail(title: string, list: any[]) { export function openDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal'); const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return; if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title'); const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody'); const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return; if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead'); const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return; if (!thead) return;
titleEl.textContent = title; titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</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 = ''; tbody.innerHTML = '';
if (list.length === 0) { if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else { } else {
list.forEach((asset, idx) => { list.forEach((asset, idx) => {
let manager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || asset.user_current || '-'; 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] || '-'; let name = asset[ASSET_SCHEMA.MODEL_NAME.key] || asset[ASSET_SCHEMA.ASSET_NAME.key] || '-';
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.category || asset[ASSET_SCHEMA.ASSET_TYPE.key]}</td><td>${name}</td><td>${asset[ASSET_SCHEMA.LOCATION.key]||'-'}</td><td>${manager}</td><td>${asset[ASSET_SCHEMA.PURCHASE_DATE.key]||'-'}</td><td>${asset[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||'-'}</td>`; 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); tbody.appendChild(tr);
}); });
} }
modal.classList.remove('hidden'); modal.classList.remove('hidden');
} }
export function openSwDashboardDetail(title: string, list: any[]) { export function openSwDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal'); const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return; if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title'); const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody'); const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return; if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead'); const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return; if (!thead) return;
titleEl.textContent = title; 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 = ''; tbody.innerHTML = '';
list.forEach((sw, idx) => { list.forEach((sw, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.asset_type || sw.type}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${sw[ASSET_SCHEMA.ASSET_COUNT.key]}</td><td>${sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]}</td>`; 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); tbody.appendChild(tr);
}); });
modal.classList.remove('hidden'); modal.classList.remove('hidden');
} }
export function openSwUsageDetail(title: string, list: any[]) { export function openSwUsageDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal'); const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return; if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title'); const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody'); const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return; if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead'); const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return; if (!thead) return;
titleEl.textContent = title; 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 = ''; tbody.innerHTML = '';
list.forEach((sw, idx) => { list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === 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 qty = Number(sw[ASSET_SCHEMA.ASSET_COUNT.key] || 0);
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${qty}</td><td>${assigned}</td><td>${qty - assigned}</td>`; 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); tbody.appendChild(tr);
}); });
modal.classList.remove('hidden'); modal.classList.remove('hidden');
} }
export function openCloudDashboardDetail(title: string, list: any[]) { export function openCloudDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal'); const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return; if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title'); const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody'); const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return; if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead'); const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return; if (!thead) return;
titleEl.textContent = title; 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 = ''; tbody.innerHTML = '';
if (list.length === 0) { if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 2rem;">해당 내역이 없습니다.</td></tr>`; tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 2rem;">해당 내역이 없습니다.</td></tr>`;
} else { } else {
list.forEach((sw, idx) => { list.forEach((sw, idx) => {
const priceStr = sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/[^0-9]/g, '')).toLocaleString() : '0'; const 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'); const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.DEV_OBJ.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]||'-'}</td><td>${sw.pay_day ? sw.pay_day + '일' : '-'}</td><td>₩ ${priceStr}</td>`; 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); tbody.appendChild(tr);
}); });
} }
modal.classList.remove('hidden'); modal.classList.remove('hidden');
} }

View File

@@ -1,212 +1,212 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, History, Plus } from 'lucide'; import { createIcons, X, Save, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler'; import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema'; import { UI_TEXT } from '../../core/schema';
class DomainAssetModal extends BaseModal { class DomainAssetModal extends BaseModal {
constructor() { constructor() {
super('domain', '도메인 정보'); super('domain', '도메인 정보');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="domain-asset-modal" class="modal-overlay hidden"> <div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2> <h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div id="domain-header-identity" class="header-identity"></div> <div id="domain-header-identity" class="header-identity"></div>
</div> </div>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-body-split"> <div class="modal-body-split">
<div class="modal-form-area"> <div class="modal-form-area">
<form id="domain-asset-form" class="grid-form"> <form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" /> <input type="hidden" id="domain-id" name="id" />
<div class="form-section-title">기본 정보</div> <div class="form-section-title">기본 정보</div>
<div class="form-group"> <div class="form-group">
<label>구분</label> <label>구분</label>
<select id="domain-type" name="type"> <select id="domain-type" name="type">
<option value="호스팅">호스팅</option> <option value="호스팅">호스팅</option>
<option value="도메인">도메인</option> <option value="도메인">도메인</option>
<option value="기타">기타</option> <option value="기타">기타</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>관리법인</label> <label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select> <select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>서비스명</label> <label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required /> <input type="text" id="domain-service-name" name="service_name" required />
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>관리도메인</label> <label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required /> <input type="text" id="domain-name" name="domain_name" required />
</div> </div>
<div class="form-section-title">계약 및 비용</div> <div class="form-section-title">계약 및 비용</div>
<div class="form-group"> <div class="form-group">
<label>계약시작일</label> <label>계약시작일</label>
<input type="date" id="domain-start-date" name="start_date" /> <input type="date" id="domain-start-date" name="start_date" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>만료예정일</label> <label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" /> <input type="date" id="domain-expiry-date" name="expiry_date" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>비용 (연간/월간)</label> <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, ',')" /> <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>
<div class="form-section-title">담당자 및 비고</div> <div class="form-section-title">담당자 및 비고</div>
<div class="form-group"> <div class="form-group">
<label>정담당자</label> <label>정담당자</label>
<input type="text" id="domain-manager-main" name="manager_main" /> <input type="text" id="domain-manager-main" name="manager_main" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>부담당자</label> <label>부담당자</label>
<input type="text" id="domain-manager-sub" name="manager_sub" /> <input type="text" id="domain-manager-sub" name="manager_sub" />
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>비고</label> <label>비고</label>
<textarea id="domain-remarks" name="remarks" rows="3"></textarea> <textarea id="domain-remarks" name="remarks" rows="3"></textarea>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header"> <div class="history-header">
<h3><i data-lucide="history"></i> 변경 이력</h3> <h3><i data-lucide="history"></i> 변경 이력</h3>
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm"> <button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus"></i> 이력 추가 <i data-lucide="plus"></i>
</button> </button>
</div> </div>
<div id="domain-history-list" class="history-timeline"></div> <div id="domain-history-list" class="history-timeline"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button> <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-cancel-domain-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button> <button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-domain-asset')!; const saveBtn = document.getElementById('btn-save-domain-asset')!;
const revertBtn = document.getElementById('btn-revert-domain-edit')!; const revertBtn = document.getElementById('btn-revert-domain-edit')!;
const deleteBtn = document.getElementById('btn-delete-domain-asset')!; const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
if (!this.isEditMode) { if (!this.isEditMode) {
this.setEditLockMode('edit'); this.setEditLockMode('edit');
this.isEditMode = true; this.isEditMode = true;
return; return;
} }
const formData = new FormData(this.formEl!); const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset }; const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; }); formData.forEach((value, key) => { updated[key] = value; });
if (!updated.service_name || !updated.domain_name) { if (!updated.service_name || !updated.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.'); alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return; return;
} }
if (await saveAsset('domain', updated)) { if (await saveAsset('domain', updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
this.setEditLockMode('view'); this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset); if (this.currentAsset) this.fillFormData(this.currentAsset);
}); });
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return; if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
if (await deleteAsset('domain', this.currentAsset.id)) { if (await deleteAsset('domain', this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
createIcons({ icons: { History, Plus, Save, X } }); createIcons({ icons: { History, Plus, Save, X } });
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
setFieldValue('domain-id', asset.id); setFieldValue('domain-id', asset.id);
setFieldValue('domain-type', asset.type || '호스팅'); setFieldValue('domain-type', asset.type || '호스팅');
setFieldValue('domain-corp', asset.corp || ''); setFieldValue('domain-corp', asset.corp || '');
setFieldValue('domain-service-name', asset.service_name || ''); setFieldValue('domain-service-name', asset.service_name || '');
setFieldValue('domain-name', asset.domain_name || ''); setFieldValue('domain-name', asset.domain_name || '');
setFieldValue('domain-start-date', formatExcelDate(asset.start_date)); setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date)); setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
setFieldValue('domain-price', asset.price || ''); setFieldValue('domain-price', asset.price || '');
setFieldValue('domain-manager-main', asset.manager_main || ''); setFieldValue('domain-manager-main', asset.manager_main || '');
setFieldValue('domain-manager-sub', asset.manager_sub || ''); setFieldValue('domain-manager-sub', asset.manager_sub || '');
setFieldValue('domain-remarks', asset.remarks || ''); setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id); this.renderHistory(asset.id);
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
protected onAfterOpen(asset: any, mode: string): void { protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title'); const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세'; if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset'); const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block'; if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
private updateHeaderIdentity(asset: any) { private updateHeaderIdentity(asset: any) {
const container = document.getElementById('domain-header-identity'); const container = document.getElementById('domain-header-identity');
if (!container) return; if (!container) return;
if (this.currentMode === 'add') { if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>'; container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return; return;
} }
const type = getFieldValue('domain-type') || asset.type || ''; const type = getFieldValue('domain-type') || asset.type || '';
const serviceName = getFieldValue('domain-service-name') || asset.service_name || ''; const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
const domainName = getFieldValue('domain-name') || asset.domain_name || ''; const domainName = getFieldValue('domain-name') || asset.domain_name || '';
container.innerHTML = ` container.innerHTML = `
<span class="asset-code-title">${serviceName}</span> <span class="asset-code-title">${serviceName}</span>
<span class="service-type-badge">${type}</span> <span class="service-type-badge">${type}</span>
<span class="asset-type-label">${domainName}</span> <span class="asset-type-label">${domainName}</span>
`; `;
} }
private renderHistory(assetId: string) { private renderHistory(assetId: string) {
const container = document.getElementById('domain-history-list'); const container = document.getElementById('domain-history-list');
if (!container) return; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId); const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
if (logs.length === 0) { if (logs.length === 0) {
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>'; container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
} else { } else {
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join(''); container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
} }
} }
} }
export const domainModal = new DomainAssetModal(); export const domainModal = new DomainAssetModal();
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); } 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); } 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

@@ -1,295 +1,295 @@
import { state, saveJobSpec, deleteJobSpec } from '../../core/state'; import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils'; import { setFieldValue } from './ModalUtils';
import { UI_TEXT } from '../../core/schema'; import { UI_TEXT } from '../../core/schema';
import { calculatePcScoreDeductive } from '../../core/utils'; import { calculatePcScoreDeductive } from '../../core/utils';
class JobSpecModal extends BaseModal { class JobSpecModal extends BaseModal {
constructor() { constructor() {
super('job-spec', '직무별 기준 사양'); super('job-spec', '직무별 기준 사양');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="job-spec-asset-modal" class="modal-overlay hidden"> <div id="job-spec-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow"> <div class="modal-content narrow">
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2> <h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2>
<div id="job-spec-header-identity" class="header-identity"></div> <div id="job-spec-header-identity" class="header-identity"></div>
</div> </div>
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="job-spec-asset-form" class="grid-form vertical-form"> <form id="job-spec-asset-form" class="grid-form vertical-form">
<input type="hidden" id="job-spec-id" name="id" /> <input type="hidden" id="job-spec-id" name="id" />
<div class="form-group"> <div class="form-group">
<label>직무명</label> <label>직무명</label>
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required /> <input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required />
</div> </div>
<div class="form-group relative"> <div class="form-group relative">
<label>권장 CPU 사양</label> <label>권장 CPU 사양</label>
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" /> <input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" />
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div> <div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group relative"> <div class="form-group relative">
<label>권장 RAM 사양</label> <label>권장 RAM 사양</label>
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" /> <input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" />
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div> <div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group relative"> <div class="form-group relative">
<label>권장 GPU 사양</label> <label>권장 GPU 사양</label>
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" /> <input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" />
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div> <div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>성능 기준 점수 (이상, 자동 계산됨)</label> <label>성능 기준 점수 (이상, 자동 계산됨)</label>
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly /> <input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>비고 (메모)</label> <label>비고 (메모)</label>
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea> <textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button> <button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button> <button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button> <button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.autocomplete-list { .autocomplete-list {
position: absolute; position: absolute;
top: 100%; top: 100%;
left: 0; left: 0;
right: 0; right: 0;
max-height: 150px; max-height: 150px;
overflow-y: auto; overflow-y: auto;
background-color: white; background-color: white;
border: 1px solid var(--border-color, #E2E8F0); border: 1px solid var(--border-color, #E2E8F0);
border-top: none; border-top: none;
border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px;
z-index: 1000; z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
.autocomplete-item { .autocomplete-item {
padding: 8px 12px; padding: 8px 12px;
font-size: 13px; font-size: 13px;
color: #334155; color: #334155;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.autocomplete-item:hover { .autocomplete-item:hover {
background-color: #F1F5F9; background-color: #F1F5F9;
color: #1E5149; color: #1E5149;
font-weight: 600; font-weight: 600;
} }
</style> </style>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-job-spec-asset')!; const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!; const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!; const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
if (!this.isEditMode) { if (!this.isEditMode) {
this.setEditLockMode('edit'); this.setEditLockMode('edit');
this.isEditMode = true; this.isEditMode = true;
return; return;
} }
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim(); const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim(); const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim(); const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim(); const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value; const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim(); const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
if (!jobName) { if (!jobName) {
alert('직무명을 입력해 주세요.'); alert('직무명을 입력해 주세요.');
return; return;
} }
const updated = { const updated = {
id: this.currentAsset.id || null, id: this.currentAsset.id || null,
job_name: jobName, job_name: jobName,
cpu_standard: cpuStd, cpu_standard: cpuStd,
ram_standard: ramStd, ram_standard: ramStd,
gpu_standard: gpuStd, gpu_standard: gpuStd,
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0, min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
remarks: remarks remarks: remarks
}; };
if (await saveJobSpec(updated)) { if (await saveJobSpec(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
this.setEditLockMode('view'); this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset); if (this.currentAsset) this.fillFormData(this.currentAsset);
}); });
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return; if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return; if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
if (await deleteJobSpec(this.currentAsset.id)) { if (await deleteJobSpec(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
// 자동완성 바인딩 // 자동완성 바인딩
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU'); this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM'); this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU'); this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
// 실시간 점수 계산 이벤트 바인딩 // 실시간 점수 계산 이벤트 바인딩
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard']; const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
inputs.forEach(id => { inputs.forEach(id => {
const el = document.getElementById(id); const el = document.getElementById(id);
el?.addEventListener('input', () => this.updateMinScore()); el?.addEventListener('input', () => this.updateMinScore());
el?.addEventListener('change', () => this.updateMinScore()); el?.addEventListener('change', () => this.updateMinScore());
}); });
} }
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) { private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
const input = document.getElementById(inputId) as HTMLInputElement; const input = document.getElementById(inputId) as HTMLInputElement;
const list = document.getElementById(autocompleteId) as HTMLDivElement; const list = document.getElementById(autocompleteId) as HTMLDivElement;
if (!input || !list) return; if (!input || !list) return;
const showList = (filterText: string = '') => { const showList = (filterText: string = '') => {
if (!this.isEditMode) return; if (!this.isEditMode) return;
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category); const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
const filtered = filterText const filtered = filterText
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase())) ? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
: items; : items;
if (filtered.length === 0) { if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>'; list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
} else { } else {
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join(''); list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
} }
list.classList.remove('hidden'); list.classList.remove('hidden');
}; };
input.addEventListener('focus', () => { input.addEventListener('focus', () => {
showList(input.value); showList(input.value);
}); });
input.addEventListener('input', () => { input.addEventListener('input', () => {
showList(input.value); showList(input.value);
}); });
list.addEventListener('mousedown', (e) => { list.addEventListener('mousedown', (e) => {
const item = (e.target as HTMLElement).closest('.autocomplete-item'); const item = (e.target as HTMLElement).closest('.autocomplete-item');
if (item && item.getAttribute('data-val')) { if (item && item.getAttribute('data-val')) {
input.value = item.getAttribute('data-val') || ''; input.value = item.getAttribute('data-val') || '';
list.classList.add('hidden'); list.classList.add('hidden');
this.updateMinScore(); this.updateMinScore();
} }
}); });
document.addEventListener('mousedown', (e) => { document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) { if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden'); list.classList.add('hidden');
} }
}); });
} }
private updateMinScore(): void { private updateMinScore(): void {
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || ''; const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || ''; const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || ''; const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
const score = calculatePcScoreDeductive(cpu, ram, gpu, ''); const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement; const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
if (minScoreEl) { if (minScoreEl) {
minScoreEl.value = score.toString(); minScoreEl.value = score.toString();
} }
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
setFieldValue('job-spec-id', asset.id || ''); setFieldValue('job-spec-id', asset.id || '');
setFieldValue('job-spec-job-name', asset.job_name || ''); setFieldValue('job-spec-job-name', asset.job_name || '');
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || ''); setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
setFieldValue('job-spec-ram-standard', asset.ram_standard || ''); setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || ''); setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100'); setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
setFieldValue('job-spec-remarks', asset.remarks || ''); setFieldValue('job-spec-remarks', asset.remarks || '');
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
protected onAfterOpen(asset: any, mode: string): void { protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('job-spec-modal-title'); const titleEl = document.getElementById('job-spec-modal-title');
if (titleEl) { if (titleEl) {
if (mode === 'add') { if (mode === 'add') {
titleEl.textContent = '신규 직무별 기준 사양 등록'; titleEl.textContent = '신규 직무별 기준 사양 등록';
} else { } else {
titleEl.textContent = '직무별 기준 사양 상세 편집'; titleEl.textContent = '직무별 기준 사양 상세 편집';
} }
} }
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!; const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
const saveBtn = document.getElementById('btn-save-job-spec-asset')!; const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block'; deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') { if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장'; saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} else { } else {
saveBtn.textContent = '수정'; saveBtn.textContent = '수정';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} }
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
private updateHeaderIdentity(asset: any) { private updateHeaderIdentity(asset: any) {
const container = document.getElementById('job-spec-header-identity'); const container = document.getElementById('job-spec-header-identity');
if (!container) return; if (!container) return;
if (this.currentMode === 'add') { if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>'; container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return; return;
} }
const jobName = asset.job_name || ''; const jobName = asset.job_name || '';
const minScore = asset.min_score || 0; const minScore = asset.min_score || 0;
container.innerHTML = ` container.innerHTML = `
<span class="asset-code-title">${jobName}</span> <span class="asset-code-title">${jobName}</span>
<span class="service-type-badge">${minScore}점 기준</span> <span class="service-type-badge">${minScore}점 기준</span>
`; `;
} }
} }
export const jobSpecModal = new JobSpecModal(); export const jobSpecModal = new JobSpecModal();
export function initJobSpecModal(onSave: () => void, closeModals: () => void) { export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
jobSpecModal.init(onSave, closeModals); jobSpecModal.init(onSave, closeModals);
} }
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
jobSpecModal.open(asset, mode); jobSpecModal.open(asset, mode);
} }

View File

@@ -1,261 +1,261 @@
import { LOCATION_DATA } from './SharedData'; import { LOCATION_DATA } from './SharedData';
/** /**
* 모달 조작 및 UI 생성을 위한 공통 유틸리티 * 모달 조작 및 UI 생성을 위한 공통 유틸리티
*/ */
// 1. Select 박스의 Option HTML 생성 // 1. Select 박스의 Option HTML 생성
export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string { export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string {
let html = includeSelectHint ? '<option value="">선택</option>' : ''; let html = includeSelectHint ? '<option value="">선택</option>' : '';
html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join(''); html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join('');
return html; return html;
} }
// 2. 안전하게 폼 필드 값 설정 (Null 에러 방지) // 2. 안전하게 폼 필드 값 설정 (Null 에러 방지)
export function setFieldValue(id: string, value: any) { export function setFieldValue(id: string, value: any) {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) { if (el) {
el.value = value || ''; el.value = value || '';
} }
} }
// 3. 안전하게 폼 필드 값 읽기 // 3. 안전하게 폼 필드 값 읽기
export function getFieldValue(id: string): string { export function getFieldValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
return el ? el.value : ''; return el ? el.value : '';
} }
// 4. 위치 정보 파싱 및 UI 세팅 // 4. 위치 정보 파싱 및 UI 세팅
export function parseAndSetLocation(bldg: string, detail: 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 bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement; const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null; const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null;
const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null; const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null;
if (!bldgSelect || !detailSelect) return; if (!bldgSelect || !detailSelect) return;
// 초기화 // 초기화
bldgSelect.value = ''; bldgSelect.value = '';
detailSelect.innerHTML = '<option value="">선택</option>'; detailSelect.innerHTML = '<option value="">선택</option>';
if (etcGroup) etcGroup.style.display = 'none'; if (etcGroup) etcGroup.style.display = 'none';
if (!bldg) return; if (!bldg) return;
if (LOCATION_DATA[bldg]) { if (LOCATION_DATA[bldg]) {
bldgSelect.value = bldg; bldgSelect.value = bldg;
// 상세 목록 갱신 // 상세 목록 갱신
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]); detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
if (detail) { if (detail) {
detailSelect.value = detail; detailSelect.value = detail;
if (detail === '기타' && etcGroup && etcInput) { if (detail === '기타' && etcGroup && etcInput) {
etcGroup.style.display = 'flex'; etcGroup.style.display = 'flex';
// 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나 // 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나
// 하위 호환성을 위해 남겨둠 // 하위 호환성을 위해 남겨둠
} }
} }
} }
} }
// 5. 위치 종속성(Cascade) 이벤트 바인딩 // 5. 위치 종속성(Cascade) 이벤트 바인딩
export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) { export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement; const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement; const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = document.getElementById(etcGroupId); const etcGroup = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement; const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
if (!bldgSelect || !detailSelect) return; if (!bldgSelect || !detailSelect) return;
bldgSelect.addEventListener('change', () => { bldgSelect.addEventListener('change', () => {
const bldg = bldgSelect.value; const bldg = bldgSelect.value;
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []); detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []);
if (etcGroup) etcGroup.style.display = 'none'; if (etcGroup) etcGroup.style.display = 'none';
if (etcInput) etcInput.value = ''; if (etcInput) etcInput.value = '';
}); });
detailSelect.addEventListener('change', () => { detailSelect.addEventListener('change', () => {
if (etcGroup) { if (etcGroup) {
etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none'; etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
} }
}); });
} }
// 6. 위치 문자열 조합 (저장용) // 6. 위치 문자열 조합 (저장용)
export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string { export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string {
const bldg = getFieldValue(bldgId); const bldg = getFieldValue(bldgId);
const detail = getFieldValue(detailId); const detail = getFieldValue(detailId);
const etc = getFieldValue(etcInputId); const etc = getFieldValue(etcInputId);
let combined = bldg; let combined = bldg;
if (detail) combined += ` ${detail}`; if (detail) combined += ` ${detail}`;
if (detail === '기타' && etc) combined += ` ${etc}`; if (detail === '기타' && etc) combined += ` ${etc}`;
return combined.trim(); return combined.trim();
} }
// 7. 조회/수정 모드 UI 통합 제어 // 7. 조회/수정 모드 UI 통합 제어
export function setEditLock( export function setEditLock(
formId: string, formId: string,
mode: 'view' | 'add' | 'edit', mode: 'view' | 'add' | 'edit',
options: { options: {
saveBtnId: string, saveBtnId: string,
revertBtnId: string, revertBtnId: string,
generateBtnId?: string, generateBtnId?: string,
addLogBtnId?: string addLogBtnId?: string
} }
) { ) {
const form = document.getElementById(formId) as HTMLFormElement; const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId); const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId); const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null; const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null; const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form) return; if (!form) return;
const isEdit = (mode === 'add' || mode === 'edit'); const isEdit = (mode === 'add' || mode === 'edit');
if (isEdit) { if (isEdit) {
// 편집 모드 활성화 // 편집 모드 활성화
form.classList.remove('is-view-mode'); form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode'); form.classList.add('is-edit-mode');
if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장'); if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장');
if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add'); if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add');
// 모든 필드 활성화 // 모든 필드 활성화
const inputs = form.querySelectorAll('input, select, textarea'); const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => { inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지 (disabled는 해제하되 readOnly를 적용하여 폼 데이터 수집 가능하게 함) // 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지 (disabled는 해제하되 readOnly를 적용하여 폼 데이터 수집 가능하게 함)
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) { if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
el.disabled = false; el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false; if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
} else { } else {
el.disabled = false; el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true; if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
} }
}); });
if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none'); if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none');
if (addLogBtn) addLogBtn.style.display = 'flex'; if (addLogBtn) addLogBtn.style.display = 'flex';
} else { } else {
// 조회 모드 (잠금) // 조회 모드 (잠금)
form.classList.remove('is-edit-mode'); form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode'); form.classList.add('is-view-mode');
if (saveBtn) saveBtn.textContent = '수정'; if (saveBtn) saveBtn.textContent = '수정';
if (revertBtn) revertBtn.classList.add('hidden'); if (revertBtn) revertBtn.classList.add('hidden');
// 모든 필드 잠금 // 모든 필드 잠금
const inputs = form.querySelectorAll('input, select, textarea'); const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => { inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
el.disabled = true; el.disabled = true;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true; if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
}); });
if (generateBtn) generateBtn.style.display = 'none'; if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none'; if (addLogBtn) addLogBtn.style.display = 'none';
} }
} }
/** /**
* 8. 공통 모달 프레임 템플릿 생성 * 8. 공통 모달 프레임 템플릿 생성
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc') * @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
* @param title 모달 제목 * @param title 모달 제목
* @param formContent 각 모달마다 다른 폼 본문 HTML * @param formContent 각 모달마다 다른 폼 본문 HTML
* @param options 설정 (이력 영역 제목 등) * @param options 설정 (이력 영역 제목 등)
*/ */
export function createModalFrameHTML( export function createModalFrameHTML(
idPrefix: string, idPrefix: string,
title: string, title: string,
formContent: string, formContent: string,
options: { historyTitle: string, addLogBtnId: string } options: { historyTitle: string, addLogBtnId: string }
): string { ): string {
return ` return `
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden"> <div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="${idPrefix}-modal-title">${title}</h2> <h2 id="${idPrefix}-modal-title">${title}</h2>
<button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-body-split"> <div class="modal-body-split">
<div class="modal-form-area"> <div class="modal-form-area">
<form id="${idPrefix}-asset-form" class="grid-form"> <form id="${idPrefix}-asset-form" class="grid-form">
<input type="hidden" id="${idPrefix}-asset-id" /> <input type="hidden" id="${idPrefix}-asset-id" />
<input type="hidden" id="${idPrefix}-asset-type-hidden" /> <input type="hidden" id="${idPrefix}-asset-type-hidden" />
${formContent} ${formContent}
</form> </form>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header"> <div class="history-header">
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3> <h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm"> <button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i> 내역 추가 <i data-lucide="plus" class="icon-sm"></i>
</button> </button>
</div> </div>
<div id="${idPrefix}-history-list" class="history-timeline"></div> <div id="${idPrefix}-history-list" class="history-timeline"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button> <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-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button> <button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
/** /**
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심) * 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
*/ */
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) { export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => { Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]); setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
}); });
} }
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any { export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
const result: any = {}; const result: any = {};
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => { Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`); result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
}); });
return result; return result;
} }
/** /**
* 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump) * 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
*/ */
export function applyDateMask(el: HTMLInputElement) { export function applyDateMask(el: HTMLInputElement) {
if (!el) return; if (!el) return;
el.placeholder = 'YYYY-MM-DD'; el.placeholder = 'YYYY-MM-DD';
el.maxLength = 10; el.maxLength = 10;
el.addEventListener('input', (e) => { el.addEventListener('input', (e) => {
let value = el.value.replace(/[^0-9]/g, ''); // 숫자만 남김 let value = el.value.replace(/[^0-9]/g, ''); // 숫자만 남김
let result = ''; let result = '';
if (value.length <= 4) { if (value.length <= 4) {
result = value; result = value;
} else if (value.length <= 6) { } else if (value.length <= 6) {
result = value.substring(0, 4) + '-' + value.substring(4); result = value.substring(0, 4) + '-' + value.substring(4);
} else { } else {
result = value.substring(0, 4) + '-' + value.substring(4, 6) + '-' + value.substring(6, 10); result = value.substring(0, 4) + '-' + value.substring(4, 6) + '-' + value.substring(6, 10);
} }
el.value = result; el.value = result;
}); });
// 엔터 키나 입력 완료 시 유효성 검사 (선택 사항) // 엔터 키나 입력 완료 시 유효성 검사 (선택 사항)
el.addEventListener('blur', () => { el.addEventListener('blur', () => {
const val = el.value; const val = el.value;
if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) { if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) {
// 형식이 맞지 않으면 경고 효과 등을 줄 수 있음 // 형식이 맞지 않으면 경고 효과 등을 줄 수 있음
} }
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +1,171 @@
import { state, savePartsMaster, deletePartsMaster } from '../../core/state'; import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Plus } from 'lucide'; import { createIcons, X, Save, Plus } from 'lucide';
import { UI_TEXT } from '../../core/schema'; import { UI_TEXT } from '../../core/schema';
class PartsMasterModal extends BaseModal { class PartsMasterModal extends BaseModal {
constructor() { constructor() {
super('parts-master', '부품 표준 정보'); super('parts-master', '부품 표준 정보');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="parts-master-asset-modal" class="modal-overlay hidden"> <div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow"> <div class="modal-content narrow">
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2> <h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div id="parts-master-header-identity" class="header-identity"></div> <div id="parts-master-header-identity" class="header-identity"></div>
</div> </div>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="parts-master-asset-form" class="grid-form vertical-form"> <form id="parts-master-asset-form" class="grid-form vertical-form">
<input type="hidden" id="parts-master-id" name="id" /> <input type="hidden" id="parts-master-id" name="id" />
<div class="form-group"> <div class="form-group">
<label>부품 분류</label> <label>부품 분류</label>
<select id="parts-master-category" name="category"> <select id="parts-master-category" name="category">
<option value="CPU">CPU</option> <option value="CPU">CPU</option>
<option value="GPU">GPU</option> <option value="GPU">GPU</option>
<option value="RAM">RAM</option> <option value="RAM">RAM</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>부품 표준 명칭</label> <label>부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required /> <input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>성능 등급</label> <label>성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required /> <input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>감점 점수 (양수로 입력)</label> <label>감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required /> <input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button> <button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button> <button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button> <button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-parts-master-asset')!; const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!; const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!; const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
if (!this.isEditMode) { if (!this.isEditMode) {
this.setEditLockMode('edit'); this.setEditLockMode('edit');
this.isEditMode = true; this.isEditMode = true;
return; return;
} }
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value; const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim(); const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim();
const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim(); const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim();
const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value; const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
if (!compName || !tier || deductStr === '') { if (!compName || !tier || deductStr === '') {
alert('모든 필드를 올바르게 입력해 주세요.'); alert('모든 필드를 올바르게 입력해 주세요.');
return; return;
} }
const updated = { const updated = {
id: this.currentAsset.id || null, id: this.currentAsset.id || null,
category, category,
component_name: compName, component_name: compName,
score_tier: tier, score_tier: tier,
deduction: parseInt(deductStr, 10) deduction: parseInt(deductStr, 10)
}; };
if (await savePartsMaster(updated)) { if (await savePartsMaster(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
this.setEditLockMode('view'); this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset); if (this.currentAsset) this.fillFormData(this.currentAsset);
}); });
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return; if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return; if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(Number(this.currentAsset.id))) { if (await deletePartsMaster(Number(this.currentAsset.id))) {
alert('성공적으로 삭제되었습니다.'); alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
createIcons({ icons: { Plus, X, Save } }); createIcons({ icons: { Plus, X, Save } });
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
setFieldValue('parts-master-id', asset.id || ''); setFieldValue('parts-master-id', asset.id || '');
setFieldValue('parts-master-category', asset.category || 'CPU'); setFieldValue('parts-master-category', asset.category || 'CPU');
setFieldValue('parts-master-component-name', asset.component_name || ''); setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || ''); setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0'); setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
protected onAfterOpen(asset: any, mode: string): void { protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title'); const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) { if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집'; titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
} }
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!; const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!; const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block'; deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') { if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장'; saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} else { } else {
saveBtn.textContent = '수정'; saveBtn.textContent = '수정';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} }
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
private updateHeaderIdentity(asset: any) { private updateHeaderIdentity(asset: any) {
const container = document.getElementById('parts-master-header-identity'); const container = document.getElementById('parts-master-header-identity');
if (!container) return; if (!container) return;
if (this.currentMode === 'add') { if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>'; container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return; return;
} }
const cat = asset.category || ''; const cat = asset.category || '';
const name = asset.component_name || ''; const name = asset.component_name || '';
container.innerHTML = ` container.innerHTML = `
<span class="asset-code-title">${name}</span> <span class="asset-code-title">${name}</span>
<span class="service-type-badge">${cat}</span> <span class="service-type-badge">${cat}</span>
`; `;
} }
} }
export const partsMasterModal = new PartsMasterModal(); export const partsMasterModal = new PartsMasterModal();
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); } export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); } export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }

View File

@@ -1,398 +1,398 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal'; import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide'; import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils'; import { API_BASE_URL } from '../../core/utils';
import { import {
generateOptionsHTML, generateOptionsHTML,
setFieldValue, setFieldValue,
getFieldValue, getFieldValue,
applyDateMask applyDateMask
} from './ModalUtils'; } from './ModalUtils';
class SwAssetModal extends BaseModal { class SwAssetModal extends BaseModal {
constructor() { constructor() {
super('sw', '소프트웨어 상세 정보'); super('sw', '소프트웨어 상세 정보');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="sw-asset-modal" class="modal-overlay hidden"> <div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2> <h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
<div id="sw-header-identity" class="header-identity"></div> <div id="sw-header-identity" class="header-identity"></div>
</div> </div>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="modal-body-split"> <div class="modal-body-split">
<div class="modal-form-area"> <div class="modal-form-area">
<form id="sw-asset-form" class="grid-form"> <form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" /> <input type="hidden" id="sw-asset-id" name="id" />
<div class="form-section-title">기본 정보 (Identity)</div> <div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group"> <div class="form-group">
<label>자산 유형</label> <label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required> <select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option> <option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option> <option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option> <option value="클라우드">클라우드</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label> <label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required> <select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option> <option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option> <option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option> <option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option> <option value="설계S/W">설계S/W</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select> <select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label> <label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required /> <input type="text" id="sw-제품명" name="product_name" required />
</div> </div>
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label> <label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" /> <input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label> <label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" /> <input type="text" id="sw-부서" name="current_dept" />
</div> </div>
<div class="form-group sw-user-tracking"> <div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label> <label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" /> <input type="text" id="sw-user-current" name="user_current" />
</div> </div>
<div class="form-group sw-user-tracking"> <div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label> <label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" /> <input type="text" id="sw-previous-user" name="previous_user" />
</div> </div>
<div class="form-section-title">라이선스 및 계약 정보</div> <div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label> <label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" /> <input type="number" id="sw-수량" name="asset_count" min="0" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label> <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, ',')" /> <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>
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label> <label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" /> <input type="text" id="sw-계정명" name="email_account" />
</div> </div>
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method"> <select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option> <option value="">선택안함</option>
<option value="법인카드">법인카드</option> <option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option> <option value="인보이스">인보이스</option>
</select> </select>
</div> </div>
<div class="form-section-title">관리 및 비고</div> <div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="sw-구매일" name="purchase_date" /> <input type="text" id="sw-구매일" name="purchase_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
</button> </button>
<input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" /> <input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" /> <input type="text" id="sw-납품업체" name="purchase_vendor" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label> <label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" /> <input type="text" id="sw-개발담당자" name="dev_manager" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label> <label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" /> <input type="text" id="sw-기획담당자" name="planning_manager" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label> <label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" /> <input type="text" id="sw-영업담당자" name="sales_manager" />
</div> </div>
<div class="form-group sw-standard-field" id="sw-expiry-group"> <div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label> <label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="sw-만료일" name="expiry_date" /> <input type="text" id="sw-만료일" name="expiry_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
<i data-lucide="calendar"></i> <i data-lucide="calendar"></i>
</button> </button>
<input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" /> <input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label> <label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea> <textarea id="sw-비고" name="memo" rows="2"></textarea>
</div> </div>
</form> </form>
<div id="sw-user-section" class="user-management-section"> <div id="sw-user-section" class="user-management-section">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리"> <button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users"></i> 사용자 관리 <i data-lucide="users"></i> 사용자 관리
</button> </button>
</div> </div>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header"> <div class="history-header">
<h3><i data-lucide="history"></i> 업데이트 내역</h3> <h3><i data-lucide="history"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm"> <button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="rotate-ccw"></i> 계약 업데이트 <i data-lucide="rotate-ccw"></i>
</button> </button>
</div> </div>
<div id="sw-history-list" class="history-timeline"></div> <div id="sw-history-list" class="history-timeline"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button> <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-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button> <button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 계약 업데이트 서브 모달 --> <!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden sub-modal"> <div id="sw-update-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow"> <div class="modal-content narrow">
<div class="modal-header"> <div class="modal-header">
<h2 class="modal-title">계약 업데이트 반영</h2> <h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon">&times;</button> <button id="btn-close-sw-update" class="btn-icon">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="grid-form vertical-form"> <div class="grid-form vertical-form">
<div class="form-group"> <div class="form-group">
<label>업데이트 일자</label> <label>업데이트 일자</label>
<input type="date" id="sw-update-date" /> <input type="date" id="sw-update-date" />
</div> </div>
<div class="form-group sub-sw-update"> <div class="form-group sub-sw-update">
<label>새로운 계약 기간</label> <label>새로운 계약 기간</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" /> <input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
<span>~</span> <span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" /> <input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>발생 비용</label> <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" /> <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>
<div class="form-group"> <div class="form-group">
<label>상세 내용 (메모)</label> <label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" /> <input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div></div> <div></div>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button> <button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button> <button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.hidden-picker { .hidden-picker {
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
</style> </style>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-sw-asset')!; const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!; const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!; const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement; const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
const userAssignBtn = document.getElementById('btn-open-sw-user')!; const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!; const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value)); typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => { ['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement; const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el); if (el) applyDateMask(el);
}); });
userAssignBtn.addEventListener('click', () => { userAssignBtn.addEventListener('click', () => {
if (this.currentAsset) openSwUserModal(this.currentAsset); if (this.currentAsset) openSwUserModal(this.currentAsset);
}); });
const subModal = document.getElementById('sw-update-modal')!; const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden'); const closeUpdate = () => subModal.classList.add('hidden');
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate); document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate); document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
btnOpenUpdate?.addEventListener('click', (e) => { btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; } if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
subModal.classList.remove('hidden'); subModal.classList.remove('hidden');
}); });
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => { document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
e.preventDefault(); e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value; const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value; const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value; const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value; const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value; const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start); if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end); if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost); if (cost) setFieldValue('sw-금액', cost);
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' }; const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, { await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log]) body: JSON.stringify([...state.masterData.logs, log])
}); });
closeUpdate(); onSave(); closeUpdate(); onSave();
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
this.setEditLockMode('view'); this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset); if (this.currentAsset) this.fillFormData(this.currentAsset);
}); });
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; } if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
const type = getFieldValue('sw-asset-type'); const type = getFieldValue('sw-asset-type');
const formData = new FormData(this.formEl!); const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset }; const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; }); formData.forEach((value, key) => { updated[key] = value; });
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal'); let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); } if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
}); });
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return; if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = this.currentAsset.asset_type || this.currentAsset.type; const type = this.currentAsset.asset_type || this.currentAsset.type;
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal'); let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await deleteAsset(categoryKey, this.currentAsset.id)) { if (await deleteAsset(categoryKey, this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals(); alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
} }
}); });
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } }); createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
setFieldValue('sw-asset-id', asset.id); setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type); setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || ''); setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || ''); setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || ''); setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || ''); setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || ''); setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-제품명', asset.product_name || ''); setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || ''); setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || ''); setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || ''); setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || ''); setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || ''); setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || ''); setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || ''); setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || ''); setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') { if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || ''); setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || ''); setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || ''); setFieldValue('sw-결제수단', asset.purchase_method || '');
} else { } else {
setFieldValue('sw-만료일', asset.expiry_date || ''); setFieldValue('sw-만료일', asset.expiry_date || '');
} }
this.renderHistory(asset.id); this.renderHistory(asset.id);
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
protected onAfterOpen(asset: any, mode: string): void { protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type); this.applySwTypeUI(asset.asset_type || asset.type);
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
private updateHeaderIdentity(asset: any) { private updateHeaderIdentity(asset: any) {
const container = document.getElementById('sw-header-identity'); const container = document.getElementById('sw-header-identity');
if (!container) return; if (!container) return;
if (this.currentMode === 'add') { if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>'; container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return; return;
} }
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || ''; const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
const name = getFieldValue('sw-제품명') || asset.product_name || ''; const name = getFieldValue('sw-제품명') || asset.product_name || '';
const corp = getFieldValue('sw-법인') || asset.purchase_corp || ''; const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
container.innerHTML = ` container.innerHTML = `
<span class="asset-code-title">${name}</span> <span class="asset-code-title">${name}</span>
<span class="service-type-badge">${corp}</span> <span class="service-type-badge">${corp}</span>
<span class="asset-type-label">${type}</span> <span class="asset-type-label">${type}</span>
`; `;
} }
private applySwTypeUI(type: string) { private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only'); const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field'); const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section'); const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group'); const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking'); const userTracking = document.querySelectorAll('.sw-user-tracking');
if (type === '클라우드') { if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex'); cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none'); swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none'; if (userSection) userSection.style.display = 'none';
userTracking.forEach(el => (el as HTMLElement).style.display = 'none'); userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
} else { } else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none'); cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex'); swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block'; if (userSection) userSection.style.display = 'block';
if (type === '외부SW' || type === '내부SW') { if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex'; if (expiryGroup) expiryGroup.style.display = 'flex';
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none'); userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
} }
} }
} }
private renderHistory(swId: string) { private renderHistory(swId: string) {
const container = document.getElementById('sw-history-list'); const container = document.getElementById('sw-history-list');
if (!container) return; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId); const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; } 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.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join(''); container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
} }
} }
export const swModal = new SwAssetModal(); export const swModal = new SwAssetModal();
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); } 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); } export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }

View File

@@ -1,270 +1,270 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide'; import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { ORG_LIST } from './SharedData'; import { ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
class SwUserModal extends BaseModal { class SwUserModal extends BaseModal {
private tempSwUsers: any[] = []; private tempSwUsers: any[] = [];
constructor() { constructor() {
super('sw-user', '소프트웨어 사용자 관리'); super('sw-user', '소프트웨어 사용자 관리');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="sw-user-asset-modal" class="modal-overlay hidden"> <div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="sw-user-title" class="modal-title">${this.title}</h2> <h2 id="sw-user-title" class="modal-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div> <div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3> <h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button> <button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
</div> </div>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>조직</th> <th>조직</th>
<th>부서</th> <th>부서</th>
<th>직위</th> <th>직위</th>
<th>이름</th> <th>이름</th>
<th class="text-center">사용기간</th> <th class="text-center">사용기간</th>
<th class="text-center">신청서</th> <th class="text-center">신청서</th>
<th class="text-center">관리</th> <th class="text-center">관리</th>
</tr> </tr>
</thead> </thead>
<tbody id="sw-user-table-body"></tbody> <tbody id="sw-user-table-body"></tbody>
</table> </table>
</div> </div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) --> <!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<form id="sw-user-asset-form" class="hidden"></form> <form id="sw-user-asset-form" class="hidden"></form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button> <button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
<button id="btn-save-sw-user" class="btn btn-primary">저장</button> <button id="btn-save-sw-user" class="btn btn-primary">저장</button>
</div> </div>
</div> </div>
</div> </div>
<!-- 사용자 추가/수정 서브 모달 --> <!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal"> <div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow"> <div class="modal-content narrow">
<div class="modal-header"> <div class="modal-header">
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3> <h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon">&times;</button> <button id="btn-close-user-edit" class="btn-icon">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="sw-user-edit-form" class="grid-form vertical-form"> <form id="sw-user-edit-form" class="grid-form vertical-form">
<input type="hidden" id="edit-user-index" value="-1" /> <input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group"> <div class="form-group">
<label>조직</label> <label>조직</label>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select> <select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>부서</label> <label>부서</label>
<input type="text" id="new-user-부서" /> <input type="text" id="new-user-부서" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>직위</label> <label>직위</label>
<input type="text" id="new-user-직위" /> <input type="text" id="new-user-직위" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>이름</label> <label>이름</label>
<input type="text" id="new-user-이름" required /> <input type="text" id="new-user-이름" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>사용 시작일</label> <label>사용 시작일</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="new-user-시작일" /> <input type="text" id="new-user-시작일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i> <i data-lucide="calendar" class="icon-sm"></i>
</button> </button>
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" /> <input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>사용 종료일</label> <label>사용 종료일</label>
<div class="input-with-btn"> <div class="input-with-btn">
<input type="text" id="new-user-종료일" /> <input type="text" id="new-user-종료일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i> <i data-lucide="calendar" class="icon-sm"></i>
</button> </button>
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" /> <input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>신청서 (증빙)</label> <label>신청서 (증빙)</label>
<input type="file" id="new-user-신청서" /> <input type="file" id="new-user-신청서" />
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-close-user-sub" class="btn btn-outline">취소</button> <button id="btn-close-user-sub" class="btn btn-outline">취소</button>
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button> <button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
.hidden-picker { .hidden-picker {
position: absolute; position: absolute;
width: 0; width: 0;
height: 0; height: 0;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
</style> </style>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const mainSaveBtn = document.getElementById('btn-save-sw-user')!; const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!; const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!; const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => { ['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement; const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el); if (el) applyDateMask(el);
}); });
addUserBtn.addEventListener('click', () => this.openUserEditSubModal()); addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList()); confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => { mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id); const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
const newMapping = { const newMapping = {
sw_id: this.currentAsset!.id, sw_id: this.currentAsset!.id,
userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.]) userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.])
}; };
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any; if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any); else state.masterData.swUsers.push(newMapping as any);
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
}); });
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close()); document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close()); document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
const subModal = document.getElementById('sw-user-edit-modal')!; const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden'); const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub); document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub); document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } }); createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!; const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = ` swInfo.innerHTML = `
<div class="sw-info-header border-b border-hairline pb-4 mb-6"> <div class="sw-info-header border-b border-hairline pb-4 mb-6">
<div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div> <div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div>
<div class="asset-code-title">${asset.product_name || asset. || ''}</div> <div class="asset-code-title">${asset.product_name || asset. || ''}</div>
</div> </div>
`; `;
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id); const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({ this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5] 조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : []; })) : [];
this.renderUserList(); this.renderUserList();
} }
protected onAfterOpen(): void {} protected onAfterOpen(): void {}
private renderUserList() { private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!; const tbody = document.getElementById('sw-user-table-body')!;
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) { if (this.tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>'; tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
return; return;
} }
this.tempSwUsers.forEach((user, idx) => { this.tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td>${user. || ''}</td> <td>${user. || ''}</td>
<td class="text-center">${user. || ''}</td> <td class="text-center">${user. || ''}</td>
<td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td> <td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
<td class="text-center"> <td class="text-center">
<div class="flex gap-2 justify-center items-center"> <div class="flex gap-2 justify-center items-center">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button> <button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button> <button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button>
</div> </div>
</td> </td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
tbody.querySelectorAll('.btn-edit-user').forEach(btn => { tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!); const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
this.openUserEditSubModal(idx); this.openUserEditSubModal(idx);
}); });
}); });
tbody.querySelectorAll('.btn-del-user').forEach(btn => { tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!); const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) { if (confirm('사용자 할당을 삭제하시겠습니까?')) {
this.tempSwUsers.splice(idx, 1); this.renderUserList(); this.tempSwUsers.splice(idx, 1); this.renderUserList();
} }
}); });
}); });
createIcons({ icons: { Paperclip } }); createIcons({ icons: { Paperclip } });
} }
private openUserEditSubModal(idx: number = -1) { private openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!; const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement; const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset(); form.reset();
setFieldValue('edit-user-index', idx); setFieldValue('edit-user-index', idx);
if (idx > -1) { if (idx > -1) {
const user = this.tempSwUsers[idx]; const user = this.tempSwUsers[idx];
setFieldValue('new-user-조직', user.); setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.); setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.); setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.); setFieldValue('new-user-이름', user.);
if (user. && user..includes('~')) { if (user. && user..includes('~')) {
const parts = user..split('~'); const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim()); setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim()); setFieldValue('new-user-종료일', parts[1].trim());
} }
} }
subModal.classList.remove('hidden'); subModal.classList.remove('hidden');
} }
private saveUserDataToList() { private saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index')); const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement; 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 = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx]. : '');
const userData: any = { const userData: any = {
조직: getFieldValue('new-user-조직'), 조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'), 부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'), 직위: getFieldValue('new-user-직위'),
이름: 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); if (idx === -1) this.tempSwUsers.push(userData);
else this.tempSwUsers[idx] = userData; else this.tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden'); document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
this.renderUserList(); this.renderUserList();
} }
} }
export const swUserModal = new SwUserModal(); export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); } export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
export function openSwUserModal(asset: any) { swUserModal.open(asset); } export function openSwUserModal(asset: any) { swUserModal.open(asset); }

View File

@@ -1,81 +1,81 @@
/** /**
* 모든 모달에서 공통으로 사용하는 리스트 데이터 및 설정 * 모든 모달에서 공통으로 사용하는 리스트 데이터 및 설정
*/ */
// 구매법인 목록 // 구매법인 목록
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론']; export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
// 사용조직 목록 // 사용조직 목록
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실']; export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
// 하드웨어 상태 목록 // 하드웨어 상태 목록
export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']; export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타'];
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리) // 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = { export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'], '서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'], 'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'저장매체': ['SSD', 'HDD', '외장HDD'], '저장매체': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'], '네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'], 'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'], '공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'], '업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부SW': ['영구', '구독'], '외부SW': ['영구', '구독'],
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'], '내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'], '비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'], '내빈/외빈': ['선물'],
'시설자산': ['사무가구'] '시설자산': ['사무가구']
}; };
// 설치위치 종속성 데이터 // 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = { export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'], '한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', '센터내부'], '기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['4층', '5층', '6층'], '유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'], '뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54'] 'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
}; };
// 유형별 자산번호 접두사(Prefix) 매핑 // 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = { export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', '서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS', '저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD', '저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB', '노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET', '드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT' '구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
}; };
// 배치도 이미지 매핑 데이터 // 배치도 이미지 매핑 데이터
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = { export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'IDC': { 'IDC': {
'서관202': ['img/location_photo/IDC/서관202.png'], '서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'], '서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'], '서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'], '서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'], '동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'], '동관54': ['img/location_photo/IDC/동관54.png'],
}, },
'기술개발센터': { '기술개발센터': {
'서버실': [ '서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png', 'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png' 'img/location_photo/기술개발센터/서버실/서버실_2.png'
], ],
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png'] '센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
}, },
'한맥빌딩': { '한맥빌딩': {
'1층': ['img/location_photo/한맥빌딩/1층.png'], '1층': ['img/location_photo/한맥빌딩/1층.png'],
'2층': ['img/location_photo/한맥빌딩/2층.png'], '2층': ['img/location_photo/한맥빌딩/2층.png'],
'3층': ['img/location_photo/한맥빌딩/3층.png'], '3층': ['img/location_photo/한맥빌딩/3층.png'],
'4층': ['img/location_photo/한맥빌딩/4층.png'], '4층': ['img/location_photo/한맥빌딩/4층.png'],
'5층': ['img/location_photo/한맥빌딩/5층.png'], '5층': ['img/location_photo/한맥빌딩/5층.png'],
'6층': ['img/location_photo/한맥빌딩/6층.png'], '6층': ['img/location_photo/한맥빌딩/6층.png'],
'7층': ['img/location_photo/한맥빌딩/7층.png'], '7층': ['img/location_photo/한맥빌딩/7층.png'],
'MDF실': [ 'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png', 'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png', 'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png', 'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png' 'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
] ]
} }
}; };

View File

@@ -1,180 +1,180 @@
import { state, saveSystemUser, deleteSystemUser } from '../../core/state'; import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
import { BaseModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils'; import { setFieldValue } from './ModalUtils';
import { createIcons, X, Save } from 'lucide'; import { createIcons, X, Save } from 'lucide';
import { UI_TEXT } from '../../core/schema'; import { UI_TEXT } from '../../core/schema';
class UserModal extends BaseModal { class UserModal extends BaseModal {
constructor() { constructor() {
super('user', '임직원 정보'); super('user', '임직원 정보');
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
return ` return `
<div id="user-asset-modal" class="modal-overlay hidden"> <div id="user-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow"> <div class="modal-content narrow">
<div class="modal-header"> <div class="modal-header">
<div class="header-left"> <div class="header-left">
<h2 id="user-modal-title" class="modal-title">${this.title}</h2> <h2 id="user-modal-title" class="modal-title">${this.title}</h2>
<div id="user-header-identity" class="header-identity"></div> <div id="user-header-identity" class="header-identity"></div>
</div> </div>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">&times;</button> <button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form id="user-asset-form" class="grid-form vertical-form"> <form id="user-asset-form" class="grid-form vertical-form">
<input type="hidden" id="user-id" name="id" /> <input type="hidden" id="user-id" name="id" />
<div class="form-group"> <div class="form-group">
<label>사번</label> <label>사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required /> <input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>사용자명</label> <label>사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required /> <input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>사용조직 (부서)</label> <label>사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required /> <input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>직무 (직급)</label> <label>직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required /> <input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>상태</label> <label>상태</label>
<select id="user-status" name="status"> <select id="user-status" name="status">
<option value="재직">재직</option> <option value="재직">재직</option>
<option value="퇴직">퇴직</option> <option value="퇴직">퇴직</option>
</select> </select>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button> <button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button> <button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button> <button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary">수정</button> <button id="btn-save-user-asset" class="btn btn-primary">수정</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`; `;
} }
protected initChildLogic(onSave: () => void, closeModals: () => void): void { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-user-asset')!; const saveBtn = document.getElementById('btn-save-user-asset')!;
const revertBtn = document.getElementById('btn-revert-user-edit')!; const revertBtn = document.getElementById('btn-revert-user-edit')!;
const deleteBtn = document.getElementById('btn-delete-user-asset')!; const deleteBtn = document.getElementById('btn-delete-user-asset')!;
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return; if (!this.currentAsset) return;
if (!this.isEditMode) { if (!this.isEditMode) {
this.setEditLockMode('edit'); this.setEditLockMode('edit');
this.isEditMode = true; this.isEditMode = true;
return; return;
} }
const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim(); const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim();
const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim(); const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim();
const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim(); const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim();
const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim(); const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
const status = (document.getElementById('user-status') as HTMLSelectElement).value; const status = (document.getElementById('user-status') as HTMLSelectElement).value;
if (!empNo || !userName || !deptName || !position) { if (!empNo || !userName || !deptName || !position) {
alert('모든 필수 입력 필드를 채워주세요.'); alert('모든 필수 입력 필드를 채워주세요.');
return; return;
} }
const updated = { const updated = {
id: this.currentAsset.id || null, id: this.currentAsset.id || null,
emp_no: empNo, emp_no: empNo,
user_name: userName, user_name: userName,
dept_name: deptName, dept_name: deptName,
position: position, position: position,
status: status status: status
}; };
if (await saveSystemUser(updated)) { if (await saveSystemUser(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
this.setEditLockMode('view'); this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset); if (this.currentAsset) this.fillFormData(this.currentAsset);
}); });
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return; if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return; if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
if (await deleteSystemUser(this.currentAsset.id)) { if (await deleteSystemUser(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals(); onSave(); this.close(); closeModals();
} }
}); });
createIcons({ icons: { Save, X } }); createIcons({ icons: { Save, X } });
} }
protected fillFormData(asset: any): void { protected fillFormData(asset: any): void {
setFieldValue('user-id', asset.id || ''); setFieldValue('user-id', asset.id || '');
setFieldValue('user-emp-no', asset.emp_no || ''); setFieldValue('user-emp-no', asset.emp_no || '');
setFieldValue('user-name-input', asset.user_name || ''); setFieldValue('user-name-input', asset.user_name || '');
setFieldValue('user-dept', asset.dept_name || ''); setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || ''); setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직'); setFieldValue('user-status', asset.status || '재직');
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
protected onAfterOpen(asset: any, mode: string): void { protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('user-modal-title'); const titleEl = document.getElementById('user-modal-title');
if (titleEl) { if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정'; titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
} }
const deleteBtn = document.getElementById('btn-delete-user-asset')!; const deleteBtn = document.getElementById('btn-delete-user-asset')!;
const saveBtn = document.getElementById('btn-save-user-asset')!; const saveBtn = document.getElementById('btn-save-user-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block'; deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') { if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = mode === 'add' ? '등록' : '저장'; saveBtn.textContent = mode === 'add' ? '등록' : '저장';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} else { } else {
saveBtn.textContent = '수정'; saveBtn.textContent = '수정';
saveBtn.style.display = 'block'; saveBtn.style.display = 'block';
} }
this.updateHeaderIdentity(asset); this.updateHeaderIdentity(asset);
} }
private updateHeaderIdentity(asset: any) { private updateHeaderIdentity(asset: any) {
const container = document.getElementById('user-header-identity'); const container = document.getElementById('user-header-identity');
if (!container) return; if (!container) return;
if (this.currentMode === 'add') { if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>'; container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return; return;
} }
const empNo = asset.emp_no || ''; const empNo = asset.emp_no || '';
const userName = asset.user_name || ''; const userName = asset.user_name || '';
const dept = asset.dept_name || ''; const dept = asset.dept_name || '';
container.innerHTML = ` container.innerHTML = `
<span class="asset-code-title">${userName}</span> <span class="asset-code-title">${userName}</span>
<span class="service-type-badge">${empNo}</span> <span class="service-type-badge">${empNo}</span>
<span class="asset-type-label">${dept}</span> <span class="asset-type-label">${dept}</span>
`; `;
} }
} }
export const userModal = new UserModal(); export const userModal = new UserModal();
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); } export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); } export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +1,120 @@
import { state } from '../core/state'; import { state } from '../core/state';
const MENU_CONFIG: any = { const MENU_CONFIG: any = {
hw: { hw: {
label: '하드웨어', label: '하드웨어',
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비'] tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
}, },
sw: { sw: {
label: '소프트웨어', label: '소프트웨어',
tabs: ['외부SW', '내부SW'] tabs: ['외부SW', '내부SW']
}, },
ops: { ops: {
label: '운영지원', label: '운영지원',
tabs: ['클라우드', '도메인', '비용관리', '사용자'] tabs: ['클라우드', '도메인', '비용관리', '사용자']
}, },
vip: { vip: {
label: '내빈/외빈', label: '내빈/외빈',
tabs: ['선물'] tabs: ['선물']
}, },
fac: { fac: {
label: '시설자산', label: '시설자산',
tabs: ['사무가구'] tabs: ['사무가구']
} }
}; };
export function renderNavigation(onTabChange: (tab: string) => void) { export function renderNavigation(onTabChange: (tab: string) => void) {
const header = document.querySelector('.main-header') as HTMLElement; const header = document.querySelector('.main-header') as HTMLElement;
const headerContainer = document.querySelector('.header-container')!; const headerContainer = document.querySelector('.header-container')!;
if (!headerContainer) return; if (!headerContainer) return;
const render = () => { const render = () => {
// 1. 헤더 구조 (Vercel Style: Clean Single Row) // 1. 헤더 구조 (Vercel Style: Clean Single Row)
headerContainer.innerHTML = ` headerContainer.innerHTML = `
<div class="brand" id="btn-home-logo" style="cursor: pointer;"> <div class="brand" id="btn-home-logo" style="cursor: pointer;">
<img src="img/image_92.png" class="main-logo" alt="HM Logo" /> <img src="img/image_92.png" class="main-logo" alt="HM Logo" />
<h1>한맥자산관리시스템</h1> <h1>한맥자산관리시스템</h1>
</div> </div>
<nav class="integrated-nav" id="main-nav-list"></nav> <nav class="integrated-nav" id="main-nav-list"></nav>
<div class="header-actions"> <div class="header-actions">
<div class="role-toggle-wrapper"> <div class="role-toggle-wrapper">
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span> <span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
<label class="role-toggle"> <label class="role-toggle">
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}> <input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
<span class="role-slider"></span> <span class="role-slider"></span>
</label> </label>
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span> <span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
</div> </div>
<div class="notification-area"> <div class="notification-area">
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button> <button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
</div> </div>
</div> </div>
`; `;
const navList = document.getElementById('main-nav-list')!; const navList = document.getElementById('main-nav-list')!;
// 2. GNB 메뉴 렌더링 (Ghost Tab Style) // 2. GNB 메뉴 렌더링 (Ghost Tab Style)
Object.keys(MENU_CONFIG).forEach(catKey => { Object.keys(MENU_CONFIG).forEach(catKey => {
const config = MENU_CONFIG[catKey]; const config = MENU_CONFIG[catKey];
const visibleTabs = config.tabs.filter((tab: string) => { const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') return tab === '대시보드'; if (state.currentUserRole === 'admin') return tab === '대시보드';
return tab !== '대시보드'; return tab !== '대시보드';
}); });
if (visibleTabs.length === 0) return; if (visibleTabs.length === 0) return;
visibleTabs.forEach((tab: string) => { visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return; if (tab === '부품 마스터') return;
const item = document.createElement('div'); const item = document.createElement('div');
const isActive = state.activeSubTab === tab; const isActive = state.activeSubTab === tab;
item.className = `gnb-trigger ${isActive ? 'active' : ''}`; item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
item.textContent = tab; item.textContent = tab;
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
state.activeCategory = catKey as any; state.activeCategory = catKey as any;
state.activeSubTab = tab; state.activeSubTab = tab;
render(); render();
onTabChange(tab); onTabChange(tab);
}); });
navList.appendChild(item); navList.appendChild(item);
}); });
}); });
// 3. 관리자 전용 '관리도구' // 3. 관리자 전용 '관리도구'
if (state.currentUserRole === 'admin') { if (state.currentUserRole === 'admin') {
const adminTrigger = document.createElement('div'); const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger admin-trigger'; adminTrigger.className = 'gnb-trigger admin-trigger';
adminTrigger.innerHTML = '관리도구'; adminTrigger.innerHTML = '관리도구';
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank')); adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
navList.appendChild(adminTrigger); navList.appendChild(adminTrigger);
} }
// 4. 이벤트 바인딩 // 4. 이벤트 바인딩
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload()); document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement; const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
roleToggle?.addEventListener('change', () => { roleToggle?.addEventListener('change', () => {
state.currentUserRole = roleToggle.checked ? 'admin' : 'user'; state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
if (state.currentUserRole === 'admin') { if (state.currentUserRole === 'admin') {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; state.activeSubTab = '대시보드';
} else { } else {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; state.activeSubTab = '서버';
} }
render(); render();
onTabChange(state.activeSubTab); onTabChange(state.activeSubTab);
}); });
// 아이콘 생성 // 아이콘 생성
// @ts-ignore // @ts-ignore
if (window.lucide) window.lucide.createIcons(); if (window.lucide) window.lucide.createIcons();
}; };
render(); render();
} }

View File

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

View File

@@ -1,15 +1,15 @@
/** /**
* ITAM 엑셀 핸들러 (지정 날짜 포맷팅 유틸리티) * ITAM 엑셀 핸들러 (지정 날짜 포맷팅 유틸리티)
*/ */
export function formatExcelDate(val: any): string { export function formatExcelDate(val: any): string {
if (!val) return ''; if (!val) return '';
if (typeof val === 'number') { if (typeof val === 'number') {
const date = new Date(Math.round((val - 25569) * 86400 * 1000)); const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0]; return date.toISOString().split('T')[0];
} }
if (typeof val === 'string') { if (typeof val === 'string') {
return val.replace(/\./g, '-').trim(); return val.replace(/\./g, '-').trim();
} }
return String(val); return String(val);
} }

View File

@@ -1,162 +1,162 @@
import { ASSET_SCHEMA, UI_TEXT } from './schema'; import { ASSET_SCHEMA, UI_TEXT } from './schema';
import { generateOptionsHTML } from '../components/Modal/ModalUtils'; import { generateOptionsHTML } from '../components/Modal/ModalUtils';
import { CORP_LIST } from '../components/Modal/SharedData'; import { CORP_LIST } from '../components/Modal/SharedData';
/** /**
* ITAM Unified Filter Bar Component * ITAM Unified Filter Bar Component
* 검색 UI를 표준화하고 한 곳에서 관리합니다. * 검색 UI를 표준화하고 한 곳에서 관리합니다.
*/ */
export interface FilterOptions { export interface FilterOptions {
keywordLabel?: string; keywordLabel?: string;
showCorp?: boolean; showCorp?: boolean;
showDept?: boolean; showDept?: boolean;
showLoc?: boolean; showLoc?: boolean;
showField?: boolean; showField?: boolean;
showType?: boolean; showType?: boolean;
showStatus?: boolean; showStatus?: boolean;
extraHTML?: string; extraHTML?: string;
onFilterChange: (filters: any) => void; onFilterChange: (filters: any) => void;
initialFilters?: any; initialFilters?: any;
fullList?: any[]; // For populating dynamic filters fullList?: any[]; // For populating dynamic filters
} }
/** /**
* 전역 액션 버튼 그룹 생성 (자산 추가 등) * 전역 액션 버튼 그룹 생성 (자산 추가 등)
*/ */
export function getActionButtonsHTML(): string { export function getActionButtonsHTML(): string {
return `<div id="filter-bar-actions" class="header-action-group"></div>`; return `<div id="filter-bar-actions" class="header-action-group"></div>`;
} }
export function renderFilterBar(container: HTMLElement, options: FilterOptions) { export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { const {
keywordLabel = '통합 검색', keywordLabel = '통합 검색',
showCorp = false, showCorp = false,
showDept = false, showDept = false,
showLoc = false, showLoc = false,
showField = false, showField = false,
showType = false, showType = false,
showStatus = false, showStatus = false,
extraHTML = '', extraHTML = '',
onFilterChange, onFilterChange,
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }, initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' },
fullList = [] fullList = []
} = options; } = options;
container.classList.add('search-bar'); // Restored class container.classList.add('search-bar'); // Restored class
// Helper to get unique sorted values // Helper to get unique sorted values
const getUnique = (key: keyof typeof ASSET_SCHEMA | string) => { const getUnique = (key: keyof typeof ASSET_SCHEMA | string) => {
const fieldKey = (ASSET_SCHEMA as any)[key]?.key || key; const fieldKey = (ASSET_SCHEMA as any)[key]?.key || key;
return Array.from(new Set(fullList.map(item => item[fieldKey] || item[(ASSET_SCHEMA as any)[key]?.db]).filter(Boolean))).sort(); return Array.from(new Set(fullList.map(item => item[fieldKey] || item[(ASSET_SCHEMA as any)[key]?.db]).filter(Boolean))).sort();
}; };
container.innerHTML = ` container.innerHTML = `
<div class="search-item flex-1"> <div class="search-item flex-1">
<label>${keywordLabel}</label> <label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}"> <input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div> </div>
${showType ? ` ${showType ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label> <label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type"> <select id="filter-type">
<option value="">전체 유형</option> <option value="">전체 유형</option>
${getUnique('ASSET_TYPE').map(v => `<option value="${v}" ${initialFilters.type === v ? 'selected' : ''}>${v}</option>`).join('')} ${getUnique('ASSET_TYPE').map(v => `<option value="${v}" ${initialFilters.type === v ? 'selected' : ''}>${v}</option>`).join('')}
</select> </select>
</div>` : ''} </div>` : ''}
${showStatus ? ` ${showStatus ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label> <label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="filter-status"> <select id="filter-status">
<option value="">전체 상태</option> <option value="">전체 상태</option>
${getUnique('HW_STATUS').map(v => `<option value="${v}" ${initialFilters.status === v ? 'selected' : ''}>${v}</option>`).join('')} ${getUnique('HW_STATUS').map(v => `<option value="${v}" ${initialFilters.status === v ? 'selected' : ''}>${v}</option>`).join('')}
</select> </select>
</div>` : ''} </div>` : ''}
${showField ? ` ${showField ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label> <label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="filter-field"> <select id="filter-field">
<option value="">전체 분야</option> <option value="">전체 분야</option>
<option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option> <option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option>
<option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option> <option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option>
<option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option> <option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option>
<option value="설계S/W" ${initialFilters.field === '설계S/W' ? 'selected' : ''}>설계S/W</option> <option value="설계S/W" ${initialFilters.field === '설계S/W' ? 'selected' : ''}>설계S/W</option>
</select> </select>
</div>` : ''} </div>` : ''}
${showCorp ? ` ${showCorp ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, initialFilters.corp || '', true)}</select> <select id="filter-corp">${generateOptionsHTML(CORP_LIST, initialFilters.corp || '', true)}</select>
</div>` : ''} </div>` : ''}
${showLoc ? ` ${showLoc ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.LOCATION.ui}</label> <label>${ASSET_SCHEMA.LOCATION.ui}</label>
<select id="filter-loc"> <select id="filter-loc">
<option value="">전체 위치</option> <option value="">전체 위치</option>
${getUnique('LOCATION').map(v => `<option value="${v}" ${initialFilters.loc === v ? 'selected' : ''}>${v}</option>`).join('')} ${getUnique('LOCATION').map(v => `<option value="${v}" ${initialFilters.loc === v ? 'selected' : ''}>${v}</option>`).join('')}
</select> </select>
</div>` : ''} </div>` : ''}
${showDept ? ` ${showDept ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label> <label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="filter-dept"> <select id="filter-dept">
<option value="">전체 조직</option> <option value="">전체 조직</option>
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')} ${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
</select> </select>
</div>` : ''} </div>` : ''}
${extraHTML} ${extraHTML}
<button id="btn-reset-filters" class="btn btn-outline btn-reset"> <button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER} <i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button> </button>
${getActionButtonsHTML()} ${getActionButtonsHTML()}
`; `;
// Bind Events // Bind Events
const triggerChange = () => { const triggerChange = () => {
const filters = { const filters = {
keyword: (container.querySelector('#filter-keyword') as HTMLInputElement)?.value.toLowerCase().trim() || '', keyword: (container.querySelector('#filter-keyword') as HTMLInputElement)?.value.toLowerCase().trim() || '',
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '', corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '', dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '', loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '', field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '', type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || '' status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
}; };
onFilterChange(filters); onFilterChange(filters);
}; };
container.querySelector('#filter-keyword')?.addEventListener('input', triggerChange); container.querySelector('#filter-keyword')?.addEventListener('input', triggerChange);
container.querySelector('#filter-corp')?.addEventListener('change', triggerChange); container.querySelector('#filter-corp')?.addEventListener('change', triggerChange);
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange); container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange); container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
container.querySelector('#filter-field')?.addEventListener('change', triggerChange); container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
container.querySelector('#filter-type')?.addEventListener('change', triggerChange); container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
container.querySelector('#filter-status')?.addEventListener('change', triggerChange); container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => { container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => { ['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => {
const el = container.querySelector(`#${id}`); const el = container.querySelector(`#${id}`);
if (el) (el as any).value = ''; if (el) (el as any).value = '';
}); });
triggerChange(); triggerChange();
}); });
} }
/** /**
* 공통 필터링 로직 * 공통 필터링 로직
*/ */
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) { export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
return list.filter(item => { return list.filter(item => {
const matchKeyword = !filters.keyword || searchKeys.some(key => const matchKeyword = !filters.keyword || searchKeys.some(key =>
String(item[ASSET_SCHEMA[key].key] || item[ASSET_SCHEMA[key].db] || '').toLowerCase().includes(filters.keyword) 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 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 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 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 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; const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status; const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus; return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
}); });
} }

View File

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

View File

@@ -1,293 +1,293 @@
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types'; import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
import { API_BASE_URL } from './utils'; import { API_BASE_URL } from './utils';
// --- State Definitions --- // --- State Definitions ---
export interface AppState { export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc'; activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string; activeSubTab: string;
viewMode: 'location' | 'legacy' | 'list'; viewMode: 'location' | 'legacy' | 'list';
masterData: MasterAssetData; masterData: MasterAssetData;
activeCharts: any[]; activeCharts: any[];
currentUserRole: 'admin' | 'user'; currentUserRole: 'admin' | 'user';
listFilters?: Record<string, any>; listFilters?: Record<string, any>;
} }
// 초기 상태 // 초기 상태
export const state: AppState = { export const state: AppState = {
activeCategory: 'hw', activeCategory: 'hw',
activeSubTab: '대시보드', activeSubTab: '대시보드',
viewMode: 'location', viewMode: 'location',
activeCharts: [], activeCharts: [],
currentUserRole: 'user', currentUserRole: 'user',
listFilters: {}, listFilters: {},
masterData: { masterData: {
users: [], users: [],
pc: [], server: [], storage: [], network: [], pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [], survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [], swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [], cost: [], vip: [],
hw: [], sw: [], hw: [], sw: [],
swUsers: [], logs: [], swUsers: [], logs: [],
jobSpecs: [], jobSpecs: [],
mobile: [] mobile: []
} }
}; };
(window as any).__itam_state = state; (window as any).__itam_state = state;
/** /**
* 통합 V2 스키마에 맞춘 데이터 로드 * 통합 V2 스키마에 맞춘 데이터 로드
*/ */
export async function loadMasterDataFromDB() { export async function loadMasterDataFromDB() {
try { try {
const response = await fetch(`${API_BASE_URL}/api/assets/master`); const response = await fetch(`${API_BASE_URL}/api/assets/master`);
if (!response.ok) throw new Error('Failed to fetch master data'); if (!response.ok) throw new Error('Failed to fetch master data');
const data = await response.json(); const data = await response.json();
// DB의 쪼개진 asset_remote 데이터로부터 가상 대표 속성(IP, MAC, 원격도구)을 주입해주는 전처리 함수 // DB의 쪼개진 asset_remote 데이터로부터 가상 대표 속성(IP, MAC, 원격도구)을 주입해주는 전처리 함수
const preprocessAssets = (assets: any[]) => { const preprocessAssets = (assets: any[]) => {
if (!Array.isArray(assets)) return; if (!Array.isArray(assets)) return;
assets.forEach((asset: any) => { assets.forEach((asset: any) => {
let ip = ''; let ip = '';
let mac = ''; let mac = '';
let remoteTool = ''; let remoteTool = '';
let remoteId = ''; let remoteId = '';
let remotePw = ''; let remotePw = '';
let rems: any[] = []; let rems: any[] = [];
try { try {
rems = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : []; rems = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : [];
} catch(e) {} } catch(e) {}
if (Array.isArray(rems)) { if (Array.isArray(rems)) {
rems.forEach((r: any) => { rems.forEach((r: any) => {
if (r.type === 'IP') { if (r.type === 'IP') {
if (!ip) ip = r.val1 || ''; if (!ip) ip = r.val1 || '';
if (r.val2) { if (r.val2) {
if (String(r.val2).trim().startsWith('{')) { if (String(r.val2).trim().startsWith('{')) {
try { try {
const parsed = JSON.parse(r.val2); const parsed = JSON.parse(r.val2);
remoteTool = r.name || '원격접속'; remoteTool = r.name || '원격접속';
remoteId = parsed.id || ''; remoteId = parsed.id || '';
remotePw = parsed.pw || ''; remotePw = parsed.pw || '';
} catch(e) {} } catch(e) {}
} else { } else {
if (!mac) mac = r.val2 || ''; if (!mac) mac = r.val2 || '';
} }
} }
} else if (r.type === 'MAC') { } else if (r.type === 'MAC') {
if (!mac) mac = r.val1 || ''; if (!mac) mac = r.val1 || '';
} else if (r.type === 'REMOTE') { } else if (r.type === 'REMOTE') {
if (!remoteTool) remoteTool = r.name || ''; if (!remoteTool) remoteTool = r.name || '';
if (!remoteId) remoteId = r.val1 || ''; if (!remoteId) remoteId = r.val1 || '';
if (!remotePw) remotePw = r.val2 || ''; if (!remotePw) remotePw = r.val2 || '';
} }
}); });
} }
// 최상위 가상 속성 바인딩 (목록 및 위치보기 뷰어 매핑용) // 최상위 가상 속성 바인딩 (목록 및 위치보기 뷰어 매핑용)
asset.ip_address = ip; asset.ip_address = ip;
asset.mac_address = mac; asset.mac_address = mac;
asset.remote_tool = remoteTool; asset.remote_tool = remoteTool;
asset.remote_id = remoteId; asset.remote_id = remoteId;
asset.remote_pw = remotePw; asset.remote_pw = remotePw;
}); });
}; };
if (data) { if (data) {
const keys = ['pc', 'server', 'storage', 'network', 'survey', 'equipment', 'officeSupplies']; const keys = ['pc', 'server', 'storage', 'network', 'survey', 'equipment', 'officeSupplies'];
keys.forEach(k => { keys.forEach(k => {
if (data[k]) preprocessAssets(data[k]); if (data[k]) preprocessAssets(data[k]);
}); });
} }
// 전역 상태 업데이트 // 전역 상태 업데이트
state.masterData = { state.masterData = {
...state.masterData, ...state.masterData,
...data, ...data,
jobSpecs: data.jobSpecs || [], jobSpecs: data.jobSpecs || [],
logs: (data.logs || []).map((l: any) => ({ logs: (data.logs || []).map((l: any) => ({
...l, ...l,
assetId: l.asset_id || l.assetId, assetId: l.asset_id || l.assetId,
date: l.log_date || l.date, date: l.log_date || l.date,
user: l.log_user || l.user, user: l.log_user || l.user,
log_date: l.log_date || l.date, log_date: l.log_date || l.date,
log_user: l.log_user || l.user log_user: l.log_user || l.user
})) }))
}; };
// Mapping for backward compatibility // Mapping for backward compatibility
(state.masterData as any).equip = state.masterData.equipment; (state.masterData as any).equip = state.masterData.equipment;
(state.masterData as any).subSw = state.masterData.swExternal; (state.masterData as any).subSw = state.masterData.swExternal;
(state.masterData as any).permSw = state.masterData.swInternal; (state.masterData as any).permSw = state.masterData.swInternal;
// 하드웨어 통합 (대시보드 호환용) // 하드웨어 통합 (대시보드 호환용)
state.masterData.hw = [ state.masterData.hw = [
...state.masterData.pc, ...state.masterData.pc,
...state.masterData.server, ...state.masterData.server,
...state.masterData.storage, ...state.masterData.storage,
...state.masterData.network, ...state.masterData.network,
...state.masterData.survey, ...state.masterData.survey,
...state.masterData.equipment, ...state.masterData.equipment,
...state.masterData.officeSupplies ...state.masterData.officeSupplies
]; ];
// 소프트웨어 통합 // 소프트웨어 통합
state.masterData.sw = [ state.masterData.sw = [
...state.masterData.swInternal, ...state.masterData.swInternal,
...state.masterData.swExternal, ...state.masterData.swExternal,
...(state.masterData.cloud || []) ...(state.masterData.cloud || [])
]; ];
console.log('✅ V2 Normalized data loaded successfully'); console.log('✅ V2 Normalized data loaded successfully');
return true; return true;
} catch (err) { } catch (err) {
console.warn('⚠️ Dummy 로드 실패:', err); console.warn('⚠️ Dummy 로드 실패:', err);
} }
return false; return false;
} }
export function updateState(newState: Partial<AppState>) { export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState); Object.assign(state, newState);
} }
/** /**
* 자산 저장 (V2 Normalized API) * 자산 저장 (V2 Normalized API)
*/ */
export async function saveAsset(category: string, asset: any) { export async function saveAsset(category: string, asset: any) {
try { try {
const url = `${API_BASE_URL}/api/asset/${category}/save`; const url = `${API_BASE_URL}/api/asset/${category}/save`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset) body: JSON.stringify(asset)
}); });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('자산 저장 실패:', err); console.error('자산 저장 실패:', err);
} }
return false; return false;
} }
/** /**
* 자산 삭제 (V2 API) * 자산 삭제 (V2 API)
*/ */
export async function deleteAsset(category: string, assetId: string) { export async function deleteAsset(category: string, assetId: string) {
try { try {
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`; const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
const response = await fetch(url, { method: 'DELETE' }); const response = await fetch(url, { method: 'DELETE' });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('자산 삭제 실패:', err); console.error('자산 삭제 실패:', err);
} }
return false; return false;
} }
export async function savePartsMaster(component: any) { export async function savePartsMaster(component: any) {
try { try {
const url = `${API_BASE_URL}/api/hardware-components/save`; const url = `${API_BASE_URL}/api/hardware-components/save`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component) body: JSON.stringify(component)
}); });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('부품 마스터 저장 실패:', err); console.error('부품 마스터 저장 실패:', err);
} }
return false; return false;
} }
export async function deletePartsMaster(id: number) { export async function deletePartsMaster(id: number) {
try { try {
const url = `${API_BASE_URL}/api/hardware-components/${id}`; const url = `${API_BASE_URL}/api/hardware-components/${id}`;
const response = await fetch(url, { method: 'DELETE' }); const response = await fetch(url, { method: 'DELETE' });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('부품 마스터 삭제 실패:', err); console.error('부품 마스터 삭제 실패:', err);
} }
return false; return false;
} }
export async function saveSystemUser(user: any) { export async function saveSystemUser(user: any) {
try { try {
const url = `${API_BASE_URL}/api/system-users/save`; const url = `${API_BASE_URL}/api/system-users/save`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user) body: JSON.stringify(user)
}); });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('사용자 정보 저장 실패:', err); console.error('사용자 정보 저장 실패:', err);
} }
return false; return false;
} }
export async function deleteSystemUser(id: string) { export async function deleteSystemUser(id: string) {
try { try {
const url = `${API_BASE_URL}/api/system-users/${id}`; const url = `${API_BASE_URL}/api/system-users/${id}`;
const response = await fetch(url, { method: 'DELETE' }); const response = await fetch(url, { method: 'DELETE' });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('사용자 정보 삭제 실패:', err); console.error('사용자 정보 삭제 실패:', err);
} }
return false; return false;
} }
export async function saveJobSpec(spec: any) { export async function saveJobSpec(spec: any) {
try { try {
const url = `${API_BASE_URL}/api/job-specs/save`; const url = `${API_BASE_URL}/api/job-specs/save`;
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec) body: JSON.stringify(spec)
}); });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('직무별 기준 사양 저장 실패:', err); console.error('직무별 기준 사양 저장 실패:', err);
} }
return false; return false;
} }
export async function deleteJobSpec(id: number) { export async function deleteJobSpec(id: number) {
try { try {
const url = `${API_BASE_URL}/api/job-specs/${id}`; const url = `${API_BASE_URL}/api/job-specs/${id}`;
const response = await fetch(url, { method: 'DELETE' }); const response = await fetch(url, { method: 'DELETE' });
if (response.ok) { if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신 await loadMasterDataFromDB(); // 전역 상태 갱신
return true; return true;
} }
} catch (err) { } catch (err) {
console.error('직무별 기준 사양 삭제 실패:', err); console.error('직무별 기준 사양 삭제 실패:', err);
} }
return false; return false;
} }

View File

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

View File

@@ -1,155 +1,155 @@
/** /**
* ITAM Global Type Definitions * ITAM Global Type Definitions
*/ */
export interface BaseAsset { export interface BaseAsset {
id: string; id: string;
asset_code?: string; asset_code?: string;
category?: string; category?: string;
asset_type?: string; asset_type?: string;
purchase_corp?: string; purchase_corp?: string;
purchase_date?: string; purchase_date?: string;
purchase_amount?: number | string; purchase_amount?: number | string;
purchase_vendor?: string; purchase_vendor?: string;
approval_document?: string; approval_document?: string;
service_type?: string; service_type?: string;
manager_primary?: string; manager_primary?: string;
manager_secondary?: string; manager_secondary?: string;
location?: string; location?: string;
location_detail?: string; location_detail?: string;
location_photo?: string; location_photo?: string;
loc_x?: number; loc_x?: number;
loc_y?: number; loc_y?: number;
memo?: string; memo?: string;
updated_at?: string; updated_at?: string;
created_at?: string; created_at?: string;
} }
export interface HardwareAsset extends BaseAsset { export interface HardwareAsset extends BaseAsset {
hw_status?: string; hw_status?: string;
model_name?: string; model_name?: string;
asset_name?: string; asset_name?: string;
asset_mfr?: string; asset_mfr?: string;
current_dept?: string; current_dept?: string;
previous_dept?: string; previous_dept?: string;
user_current?: string; user_current?: string;
emp_no?: string; emp_no?: string;
user_position?: string; user_position?: string;
previous_user?: string; previous_user?: string;
cpu?: string; cpu?: string;
ram?: string; ram?: string;
gpu?: string; gpu?: string;
ssd_1?: string; ssd_1?: string;
ssd_2?: string; ssd_2?: string;
hdd_1?: string; hdd_1?: string;
hdd_2?: string; hdd_2?: string;
hdd_3?: string; hdd_3?: string;
hdd_4?: string; hdd_4?: string;
mainboard?: string; mainboard?: string;
os?: string; os?: string;
ip_address?: string; ip_address?: string;
ip_address_2?: string; ip_address_2?: string;
mac_address?: string; mac_address?: string;
remote_tool?: string; remote_tool?: string;
remote_id?: string; remote_id?: string;
remote_pw?: string; remote_pw?: string;
monitoring?: string; monitoring?: string;
volume?: string; volume?: string;
monitor_inch?: string; monitor_inch?: string;
asset_count?: number | string; asset_count?: number | string;
serial_num?: string; serial_num?: string;
// Normalized V3 fields // Normalized V3 fields
volumes?: any[]; volumes?: any[];
remotes?: any[]; remotes?: any[];
} }
export interface SoftwareAsset extends BaseAsset { export interface SoftwareAsset extends BaseAsset {
sw_status?: string; sw_status?: string;
sw_field?: string; sw_field?: string;
sw_type?: string; sw_type?: string;
dev_objective?: string; dev_objective?: string;
dev_manager?: string; dev_manager?: string;
planning_manager?: string; planning_manager?: string;
sales_manager?: string; sales_manager?: string;
product_name?: string; product_name?: string;
domain_address?: string; domain_address?: string;
email_account?: string; email_account?: string;
email_pw?: string; email_pw?: string;
sw_id?: string; sw_id?: string;
sw_pw?: string; sw_pw?: string;
purchase_method?: string; purchase_method?: string;
asset_purpose?: string; asset_purpose?: string;
asset_status?: string; asset_status?: string;
start_date?: string; start_date?: string;
expired_date?: string; expired_date?: string;
} }
export interface SWUser { export interface SWUser {
id: string; id: string;
sw_id: string; sw_id: string;
user_name: string; user_name: string;
dept: string; dept: string;
corp: string; corp: string;
emp_no?: string; emp_no?: string;
created_at?: string; created_at?: string;
[key: string]: any; [key: string]: any;
} }
export interface HardwareLog { export interface HardwareLog {
id: string; id: string;
asset_id: string; asset_id: string;
log_date: string; log_date: string;
log_user: string; log_user: string;
event_type: string; event_type: string;
details: string; details: string;
old_dept?: string; old_dept?: string;
new_dept?: string; new_dept?: string;
old_user?: string; old_user?: string;
new_user?: string; new_user?: string;
created_at?: string; created_at?: string;
} }
export interface SystemUser { export interface SystemUser {
id: string; id: string;
emp_no: string; emp_no: string;
user_name: string; user_name: string;
dept_name: string; dept_name: string;
position: string; position: string;
status: string; status: string;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
export interface PartsMaster { export interface PartsMaster {
id: number | string; id: number | string;
category: string; category: string;
component_name: string; component_name: string;
score_tier: string; score_tier: string;
deduction: number; deduction: number;
} }
export interface MasterAssetData { export interface MasterAssetData {
users: SystemUser[]; users: SystemUser[];
pc: HardwareAsset[]; pc: HardwareAsset[];
server: HardwareAsset[]; server: HardwareAsset[];
storage: HardwareAsset[]; storage: HardwareAsset[];
network: HardwareAsset[]; network: HardwareAsset[];
survey: HardwareAsset[]; survey: HardwareAsset[];
pcParts: HardwareAsset[]; pcParts: HardwareAsset[];
partsMaster: PartsMaster[]; partsMaster: PartsMaster[];
equipment: HardwareAsset[]; equipment: HardwareAsset[];
officeSupplies: HardwareAsset[]; officeSupplies: HardwareAsset[];
swInternal: SoftwareAsset[]; swInternal: SoftwareAsset[];
swExternal: SoftwareAsset[]; swExternal: SoftwareAsset[];
cloud: SoftwareAsset[]; cloud: SoftwareAsset[];
domain: SoftwareAsset[]; domain: SoftwareAsset[];
cost: any[]; cost: any[];
vip: HardwareAsset[]; vip: HardwareAsset[];
swUsers: SWUser[]; swUsers: SWUser[];
logs: HardwareLog[]; logs: HardwareLog[];
jobSpecs?: any[]; jobSpecs?: any[];
mobile?: HardwareAsset[]; mobile?: HardwareAsset[];
// Integrated arrays // Integrated arrays
hw: HardwareAsset[]; hw: HardwareAsset[];
sw: SoftwareAsset[]; sw: SoftwareAsset[];
} }

View File

@@ -1,359 +1,359 @@
import { PAGE_DESCRIPTIONS } from './schema'; import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = ''; export const API_BASE_URL = '';
/** /**
* ITAM 공통 유틸리티 함수 * ITAM 공통 유틸리티 함수
*/ */
/** /**
* 페이지 헤더(타이틀 및 설명) 렌더링 * 페이지 헤더(타이틀 및 설명) 렌더링
*/ */
export function renderPageHeader(container: HTMLElement, pageId: string) { export function renderPageHeader(container: HTMLElement, pageId: string) {
const config = PAGE_DESCRIPTIONS[pageId]; const config = PAGE_DESCRIPTIONS[pageId];
if (!config) return; if (!config) return;
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'page-header'; header.className = 'page-header';
header.innerHTML = ` header.innerHTML = `
<div class="page-title-group"> <div class="page-title-group">
<h2 class="page-title">${config.title}</h2> <h2 class="page-title">${config.title}</h2>
<p class="page-description">${config.description}</p> <p class="page-description">${config.description}</p>
</div> </div>
`; `;
container.appendChild(header); container.appendChild(header);
} }
/** /**
* 숫자에 천 단위 콤마 추가 (금액 표시용) * 숫자에 천 단위 콤마 추가 (금액 표시용)
*/ */
export function formatPrice(value: string | number): string { export function formatPrice(value: string | number): string {
if (value === undefined || value === null) return ''; if (value === undefined || value === null) return '';
const num = String(value).replace(/[^0-9]/g, ''); const num = String(value).replace(/[^0-9]/g, '');
if (!num) return ''; if (!num) return '';
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ','); return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
} }
/** /**
* HTML 배지 생성 (정/부 담당자, 원격도구 등) * HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/ */
export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string { export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string {
return `<span class="badge badge-${type}">${text}</span>`; return `<span class="badge badge-${type}">${text}</span>`;
} }
/** /**
* 텍스트 내 줄바꿈을 구분자(/)로 변경하여 한 줄로 표시 * 텍스트 내 줄바꿈을 구분자(/)로 변경하여 한 줄로 표시
*/ */
export function formatInline(value: any): string { export function formatInline(value: any): string {
return String(value || '').replace(/\n/g, ' / ').trim(); return String(value || '').replace(/\n/g, ' / ').trim();
} }
/** /**
* 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD) * 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD)
*/ */
export function normalizeDate(dateStr: string): string { export function normalizeDate(dateStr: string): string {
if (!dateStr) return ''; if (!dateStr) return '';
let str = String(dateStr).replace(/\./g, '-').trim(); let str = String(dateStr).replace(/\./g, '-').trim();
// YYYYMM 형식 처리 (6자리 숫자) // YYYYMM 형식 처리 (6자리 숫자)
if (/^\d{6}$/.test(str)) { if (/^\d{6}$/.test(str)) {
return `${str.substring(0, 4)}-${str.substring(4, 6)}`; return `${str.substring(0, 4)}-${str.substring(4, 6)}`;
} }
return str; return str;
} }
/** /**
* 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리) * 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
*/ */
export function calculateAssetAge(purchaseDate: string): number { export function calculateAssetAge(purchaseDate: string): number {
const normalized = normalizeDate(purchaseDate); const normalized = normalizeDate(purchaseDate);
if (!normalized) return 0; if (!normalized) return 0;
const purchase = new Date(normalized); const purchase = new Date(normalized);
if (isNaN(purchase.getTime())) return 0; if (isNaN(purchase.getTime())) return 0;
const diffMs = Date.now() - purchase.getTime(); const diffMs = Date.now() - purchase.getTime();
const age = diffMs / (1000 * 60 * 60 * 24 * 365.25); const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
return Math.max(0, parseFloat(age.toFixed(1))); return Math.max(0, parseFloat(age.toFixed(1)));
} }
/** /**
* 고유 ID 생성 (7자리 랜덤 문자열) * 고유 ID 생성 (7자리 랜덤 문자열)
*/ */
export function generateId(): string { export function generateId(): string {
return Math.random().toString(36).substring(2, 9); return Math.random().toString(36).substring(2, 9);
} }
/** /**
* 두 자산 객체 간의 변경 사항 감지 * 두 자산 객체 간의 변경 사항 감지
*/ */
export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: string, label: string}[]): string { export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: string, label: string}[]): string {
const changes: string[] = []; const changes: string[] = [];
fields.forEach(field => { fields.forEach(field => {
const oldVal = String(oldAsset[field.key] || '').trim(); const oldVal = String(oldAsset[field.key] || '').trim();
const newVal = String(newAsset[field.key] || '').trim(); const newVal = String(newAsset[field.key] || '').trim();
if (oldVal !== newVal) { if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`); changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
} }
}); });
return changes.join('\n'); return changes.join('\n');
} }
/** /**
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순) * 자산 목록 정렬 (기본: 법인별 -> 자산번호 순)
*/ */
export function sortAssets<T>(list: T[]): T[] { export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => { return [...list].sort((a: any, b: any) => {
// 1순위: 법인 (가나다순) // 1순위: 법인 (가나다순)
const corpA = String(a. || a.corp || '').trim(); const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || b.corp || '').trim(); const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1; if (corpA < corpB) return -1;
if (corpA > corpB) return 1; if (corpA > corpB) return 1;
// 2순위: 자산번호/코드 (영문/숫자순) // 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim(); const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || b.id || '').trim(); const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1; if (codeA < codeB) return -1;
if (codeA > codeB) return 1; if (codeA > codeB) return 1;
return 0; return 0;
}); });
} }
/** /**
* 동적 정렬 함수 * 동적 정렬 함수
* @param list 정렬할 목록 * @param list 정렬할 목록
* @param key 정렬 기준 필드 * @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc') * @param direction 정렬 방향 ('asc' | 'desc')
*/ */
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] { export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => { return [...list].sort((a: any, b: any) => {
let valA = a[key]; let valA = a[key];
let valB = b[key]; let valB = b[key];
// 숫자인 경우 처리 // 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') { if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA; return direction === 'asc' ? valA - valB : valB - valA;
} }
// 금액 필드 (숫자형 문자열 포함) 처리 // 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') { if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10); 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); const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA; return direction === 'asc' ? numA - numB : numB - numA;
} }
// 문자열 정렬 (기본) // 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase(); valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase(); valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1; if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1; if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0; return 0;
}); });
} }
/** /**
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠) * 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
*/ */
export function getActionButtonsHTML(): string { export function getActionButtonsHTML(): string {
return ''; return '';
} }
/** /**
* 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식) * 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식)
*/ */
export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number { export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
let score = 100; let score = 100;
if (!cpu) cpu = ''; if (!cpu) cpu = '';
if (!ram) ram = ''; if (!ram) ram = '';
if (!gpu) gpu = ''; if (!gpu) gpu = '';
const cpuUpper = cpu.toUpperCase(); const cpuUpper = cpu.toUpperCase();
const ramUpper = ram.toUpperCase(); const ramUpper = ram.toUpperCase();
const gpuUpper = gpu.toUpperCase(); const gpuUpper = gpu.toUpperCase();
// 1. CPU 등급 감점 (최대 -30점) // 1. CPU 등급 감점 (최대 -30점)
let cpuDeduction = 0; let cpuDeduction = 0;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) { if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
cpuDeduction = 0; cpuDeduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) { } else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
cpuDeduction = 5; cpuDeduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) { } else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
cpuDeduction = 15; cpuDeduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) { } else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
cpuDeduction = 25; cpuDeduction = 25;
} else { } else {
cpuDeduction = 30; cpuDeduction = 30;
} }
score -= cpuDeduction; score -= cpuDeduction;
// 2. CPU 세대 노후 감점 (최대 -15점) // 2. CPU 세대 노후 감점 (최대 -15점)
let genDeduction = 0; let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/); const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0; let gen = 0;
if (intelMatch && intelMatch[1]) { if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1]; const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
} }
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/); const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0; let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) { if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1]; const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
} }
if (intelMatch) { if (intelMatch) {
if (gen >= 12) genDeduction = 0; if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5; else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10; else if (gen >= 8) genDeduction = 10;
else genDeduction = 15; else genDeduction = 15;
} else if (amdMatch) { } else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0; if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5; else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10; else genDeduction = 10;
} else { } else {
genDeduction = 15; genDeduction = 15;
} }
score -= genDeduction; score -= genDeduction;
// 3. RAM 용량 감점 (최대 -25점) // 3. RAM 용량 감점 (최대 -25점)
const ramMatch = ramUpper.match(/(\d+)\s*GB/); const ramMatch = ramUpper.match(/(\d+)\s*GB/);
let ramDeduction = 25; let ramDeduction = 25;
if (ramMatch && ramMatch[1]) { if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10); const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) ramDeduction = 0; if (ramVal >= 32) ramDeduction = 0;
else if (ramVal >= 16) ramDeduction = 10; else if (ramVal >= 16) ramDeduction = 10;
else if (ramVal >= 8) ramDeduction = 20; else if (ramVal >= 8) ramDeduction = 20;
else ramDeduction = 25; else ramDeduction = 25;
} }
score -= ramDeduction; score -= ramDeduction;
// 4. GPU 성능 감점 (최대 -25점) // 4. GPU 성능 감점 (최대 -25점)
let gpuDeduction = 25; let gpuDeduction = 25;
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') { if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
gpuDeduction = 25; gpuDeduction = 25;
} else if ( } else if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') || gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') || gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000') gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) { ) {
gpuDeduction = 0; gpuDeduction = 0;
} else if ( } else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') || gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO') gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) { ) {
gpuDeduction = 5; gpuDeduction = 5;
} else if ( } else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') || gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600') gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) { ) {
gpuDeduction = 15; gpuDeduction = 15;
} else { } else {
gpuDeduction = 25; gpuDeduction = 25;
} }
score -= gpuDeduction; score -= gpuDeduction;
// 5. 연식(노후도) 감점 (최대 -15점) // 5. 연식(노후도) 감점 (최대 -15점)
let age = 0; let age = 0;
if (purchaseDate && purchaseDate !== '-') { if (purchaseDate && purchaseDate !== '-') {
let normalized = purchaseDate.replace(/\./g, '-').trim(); let normalized = purchaseDate.replace(/\./g, '-').trim();
if (/^\d{6}$/.test(normalized)) { if (/^\d{6}$/.test(normalized)) {
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`; normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
} }
const purchase = new Date(normalized); const purchase = new Date(normalized);
if (!isNaN(purchase.getTime())) { if (!isNaN(purchase.getTime())) {
// 2026년 5월 31일 기준 경과연수 계산 // 2026년 5월 31일 기준 경과연수 계산
const mockToday = new Date('2026-05-31'); const mockToday = new Date('2026-05-31');
const diffMs = mockToday.getTime() - purchase.getTime(); const diffMs = mockToday.getTime() - purchase.getTime();
age = diffMs / (1000 * 60 * 60 * 24 * 365.25); age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
age = Math.max(0, parseFloat(age.toFixed(1))); age = Math.max(0, parseFloat(age.toFixed(1)));
} }
} }
let ageDeduction = 0; let ageDeduction = 0;
if (age < 1) ageDeduction = 0; if (age < 1) ageDeduction = 0;
else if (age < 2) ageDeduction = 3; else if (age < 2) ageDeduction = 3;
else if (age < 3) ageDeduction = 6; else if (age < 3) ageDeduction = 6;
else if (age < 4) ageDeduction = 9; else if (age < 4) ageDeduction = 9;
else if (age < 5) ageDeduction = 12; else if (age < 5) ageDeduction = 12;
else ageDeduction = 15; else ageDeduction = 15;
score -= ageDeduction; score -= ageDeduction;
return Math.max(10, score); return Math.max(10, score);
} }
/** /**
* 성능 점수 기준 등급 뱃지 메타 정보 가져오기 * 성능 점수 기준 등급 뱃지 메타 정보 가져오기
*/ */
export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } { export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } {
if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' }; if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' };
if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' }; if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' }; if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' }; return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
} }
/** /**
* Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별 * Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별
*/ */
export function isWindows11Incompatible(cpu: string, ram: string): boolean { export function isWindows11Incompatible(cpu: string, ram: string): boolean {
if (!cpu) return true; if (!cpu) return true;
const cpuUpper = cpu.toUpperCase(); const cpuUpper = cpu.toUpperCase();
// 1. RAM 4GB 미만은 공식 미지원 // 1. RAM 4GB 미만은 공식 미지원
if (ram) { if (ram) {
const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/); const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) { if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10); const ramVal = parseInt(ramMatch[1], 10);
if (ramVal < 4) return true; if (ramVal < 4) return true;
} }
} }
// 2. CPU 세대 검사 // 2. CPU 세대 검사
// Intel CPU 세대 판정 // Intel CPU 세대 판정
const intelMatch = cpuUpper.match(/I\d-?(\d+)/); const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
if (intelMatch && intelMatch[1]) { if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1]; const numStr = intelMatch[1];
let gen = 0; let gen = 0;
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750) else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750)
if (gen > 0 && gen < 8) return true; // 8세대 미만 불가 if (gen > 0 && gen < 8) return true; // 8세대 미만 불가
return false; return false;
} }
// AMD Ryzen CPU 세대 판정 // AMD Ryzen CPU 세대 판정
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/); const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
if (amdMatch && amdMatch[1]) { if (amdMatch && amdMatch[1]) {
const numStr = amdMatch[1]; const numStr = amdMatch[1];
let amdGen = 0; let amdGen = 0;
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등 if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등
if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가 if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가
return false; return false;
} }
// Apple Silicon은 지원 // Apple Silicon은 지원
if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) { if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) {
return false; return false;
} }
// 그 외 확실한 구형 CPU 제품군 // 그 외 확실한 구형 CPU 제품군
const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON']; const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON'];
if (knownOldCpus.some(name => cpuUpper.includes(name))) { if (knownOldCpus.some(name => cpuUpper.includes(name))) {
return true; return true;
} }
// 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주 // 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주
if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) { if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) {
// i5-620M 처럼 옛날 구형 모바일 칩 등 // i5-620M 처럼 옛날 구형 모바일 칩 등
return true; return true;
} }
return false; return false;
} }

View File

@@ -1,205 +1,205 @@
import './styles/common.css'; import './styles/common.css';
import './styles/login.css'; import './styles/login.css';
import { state, loadMasterDataFromDB, saveAsset } from './core/state'; import { state, loadMasterDataFromDB, saveAsset } from './core/state';
import { renderNavigation } from './components/Navigation'; import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView'; import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table'; import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView'; import { renderLocationView } from './views/LocationView';
import { initBaseModal } from './components/Modal/BaseModal'; import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal'; import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal'; import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
import { initUserModal, openUserModal } from './components/Modal/UserModal'; import { initUserModal, openUserModal } from './components/Modal/UserModal';
import { activePartsMasterSubTab } from './views/List/PartsMasterListView'; import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide'; import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal'; import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide'; import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
// 화면 갱신 통합 핸들러 // 화면 갱신 통합 핸들러
function refreshView(tab?: string) { function refreshView(tab?: string) {
const mainContent = document.getElementById('main-content')!; const mainContent = document.getElementById('main-content')!;
if (!mainContent) return; if (!mainContent) return;
const activeTab = tab || state.activeSubTab; const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') { if (activeTab === '대시보드') {
renderDashboard(mainContent); renderDashboard(mainContent);
return; return;
} }
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함 // 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함) // (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
const isServerTab = activeTab === '서버'; const isServerTab = activeTab === '서버';
const effectiveViewMode = isServerTab ? state.viewMode : 'list'; const effectiveViewMode = isServerTab ? state.viewMode : 'list';
mainContent.innerHTML = ` mainContent.innerHTML = `
<div id="view-body" class="view-container"></div> <div id="view-body" class="view-container"></div>
`; `;
const viewBody = document.getElementById('view-body')!; const viewBody = document.getElementById('view-body')!;
if (effectiveViewMode === 'location') { if (effectiveViewMode === 'location') {
renderLocationView(viewBody); renderLocationView(viewBody);
} else { } else {
renderSWTable(viewBody); // 리스트 형식 renderSWTable(viewBody); // 리스트 형식
} }
} }
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨) // 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
async function refreshAllData() { async function refreshAllData() {
await loadMasterDataFromDB(); await loadMasterDataFromDB();
refreshView(); refreshView();
} }
// --- App Initialization --- // --- App Initialization ---
function initApp() { function initApp() {
const mainContent = document.getElementById('main-content')!; const mainContent = document.getElementById('main-content')!;
if (!mainContent) return; if (!mainContent) return;
const { closeAllModals } = initBaseModal(); const { closeAllModals } = initBaseModal();
try { try {
renderNavigation((tab) => { renderNavigation((tab) => {
refreshView(); refreshView();
}); });
initHwModal(() => refreshAllData(), closeAllModals); initHwModal(() => refreshAllData(), closeAllModals);
initSwModal(() => refreshAllData(), closeAllModals); initSwModal(() => refreshAllData(), closeAllModals);
initSwUserModal(() => { initSwUserModal(() => {
loadMasterDataFromDB().then(() => refreshView()); loadMasterDataFromDB().then(() => refreshView());
}, closeAllModals); }, closeAllModals);
initDomainModal(() => refreshAllData(), closeAllModals); initDomainModal(() => refreshAllData(), closeAllModals);
initPartsMasterModal(() => refreshAllData(), closeAllModals); initPartsMasterModal(() => refreshAllData(), closeAllModals);
initJobSpecModal(() => refreshAllData(), closeAllModals); initJobSpecModal(() => refreshAllData(), closeAllModals);
initUserModal(() => refreshAllData(), closeAllModals); initUserModal(() => refreshAllData(), closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
initGuide(); initGuide();
pcFlowModal.init(() => { pcFlowModal.init(() => {
loadMasterDataFromDB().then(() => refreshView()); loadMasterDataFromDB().then(() => refreshView());
}); });
loadMasterDataFromDB().then((success) => { loadMasterDataFromDB().then((success) => {
if (success) { if (success) {
refreshView(); refreshView();
initRoleSwitcher(); // [추가] 역할 전환 토글 초기화 initRoleSwitcher(); // [추가] 역할 전환 토글 초기화
} }
}); });
} catch (e) { console.error('❌ Initialization failed:', e); } } catch (e) { console.error('❌ Initialization failed:', e); }
console.log('🚀 ITAM App Multi-Table Optimized'); console.log('🚀 ITAM App Multi-Table Optimized');
// --- 통합 이벤트 위임 (Dynamic Elements 지원) --- // --- 통합 이벤트 위임 (Dynamic Elements 지원) ---
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// 자산 추가 // 자산 추가
if (target.closest('#btn-add-asset')) { if (target.closest('#btn-add-asset')) {
const tab = state.activeSubTab; const tab = state.activeSubTab;
const cat = state.activeCategory; const cat = state.activeCategory;
const newId = Math.random().toString(36).substring(2, 9); const newId = Math.random().toString(36).substring(2, 9);
if (cat === 'hw') { if (cat === 'hw') {
if (tab === '부품 마스터') { if (tab === '부품 마스터') {
if (activePartsMasterSubTab === 'job-spec') { if (activePartsMasterSubTab === 'job-spec') {
openJobSpecModal({ id: '' } as any, 'add'); openJobSpecModal({ id: '' } as any, 'add');
} else { } else {
openPartsMasterModal({ id: '' } as any, 'add'); openPartsMasterModal({ id: '' } as any, 'add');
} }
} else { } else {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add'); openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
} }
} else if (cat === 'sw') { } else if (cat === 'sw') {
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW'); const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
openSwModal({ id: newId, asset_type: swType } as any, 'add'); openSwModal({ id: newId, asset_type: swType } as any, 'add');
} else if (cat === 'ops') { } else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null); if (tab === '도메인') openDomainModal(null);
else if (tab === '사용자') openUserModal({ id: '' }, 'add'); else if (tab === '사용자') openUserModal({ id: '' }, 'add');
} }
return; return;
} }
// 부품 마스터 탭으로 바로가기 연동 // 부품 마스터 탭으로 바로가기 연동
if (target.closest('#btn-goto-parts-master')) { if (target.closest('#btn-goto-parts-master')) {
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '부품 마스터'; state.activeSubTab = '부품 마스터';
renderNavigation((tab) => { refreshView(); }); renderNavigation((tab) => { refreshView(); });
refreshView(); refreshView();
return; return;
} }
// PC 이동/반납 모달 열기 // PC 이동/반납 모달 열기
if (target.closest('#btn-pc-flow')) { if (target.closest('#btn-pc-flow')) {
pcFlowModal.open(); pcFlowModal.open();
return; return;
} }
}); });
createIcons({ createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
}); });
window.addEventListener('refresh-view', () => refreshView()); window.addEventListener('refresh-view', () => refreshView());
} }
/** /**
* 헤더 역할 전환 토글 로직 * 헤더 역할 전환 토글 로직
*/ */
function initRoleSwitcher() { function initRoleSwitcher() {
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement; const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user'); const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin'); const adminLabel = document.querySelector('.role-label.admin');
if (!checkbox || !userLabel || !adminLabel) return; if (!checkbox || !userLabel || !adminLabel) return;
checkbox.addEventListener('change', () => { checkbox.addEventListener('change', () => {
if (checkbox.checked) { if (checkbox.checked) {
state.currentUserRole = 'admin'; state.currentUserRole = 'admin';
userLabel.classList.remove('active'); userLabel.classList.remove('active');
adminLabel.classList.add('active'); adminLabel.classList.add('active');
document.body.classList.add('admin-mode'); document.body.classList.add('admin-mode');
// 관리자 모드 전환 시 대시보드로 이동 // 관리자 모드 전환 시 대시보드로 이동
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; state.activeSubTab = '대시보드';
} else { } else {
state.currentUserRole = 'user'; state.currentUserRole = 'user';
adminLabel.classList.remove('active'); adminLabel.classList.remove('active');
userLabel.classList.add('active'); userLabel.classList.add('active');
document.body.classList.remove('admin-mode'); document.body.classList.remove('admin-mode');
// 실무자 모드 전환 시 서버 목록으로 이동 // 실무자 모드 전환 시 서버 목록으로 이동
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; state.activeSubTab = '서버';
} }
// 모든 렌더링을 refreshView 하나로 통합하여 규격 유지 // 모든 렌더링을 refreshView 하나로 통합하여 규격 유지
renderNavigation(() => refreshView()); renderNavigation(() => refreshView());
refreshView(); refreshView();
}); });
} }
/** /**
* 앱 초기화 (로그인 과정 없이 즉시 시작) * 앱 초기화 (로그인 과정 없이 즉시 시작)
*/ */
function initializeAppDirectly() { function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container'); const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout'); const appLayout = document.getElementById('app-layout');
// 기본 권한 설정: 실무자 (User) // 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user'; state.currentUserRole = 'user';
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭 state.activeSubTab = '서버'; // 실무자 기본 탭
// 화면 전환 // 화면 전환
if (loginContainer) loginContainer.style.display = 'none'; if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex'; if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링 // 앱 초기화 및 내비게이션(헤더 포함) 렌더링
initApp(); initApp();
renderNavigation((tab) => refreshView(tab)); renderNavigation((tab) => refreshView(tab));
} }
document.addEventListener('DOMContentLoaded', initializeAppDirectly); document.addEventListener('DOMContentLoaded', initializeAppDirectly);

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