Initial commit

This commit is contained in:
2026-04-13 14:17:43 +09:00
commit 6bca7beb8e
37 changed files with 4318 additions and 0 deletions

15
.gemini/settings.json Normal file
View File

@@ -0,0 +1,15 @@
{
"mcpServers": {
"gitea": {
"command": "npx",
"args": [
"-y",
"@andrebuzeli/git-mcp@latest"
],
"env": {
"GITEA_HOST": "https://gitea.hmac.kr",
"GITEA_ACCESS_TOKEN": "0318de8265b9cbabc5e70773e94c59807ec3ad8a"
}
}
}
}

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules/

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
3. **개선 작업 절차 (Test-First Approach)**:
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
---
### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access)
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
3. **구동 명령어**:
```bash
npm run dev
```
* 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
4. **IP 확인 방법**:
* Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
---
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
1. **디자인 철학 (Design Philosophy)**
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
2. **타이포그래피 (Typography)**
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
3. **컬러 팔레트 (Color Palette)**
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay)을 클릭하면 모달이 닫히도록 구현합니다.
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
create_db.js Normal file
View File

@@ -0,0 +1,24 @@
import * as XLSX from 'xlsx';
const hwTabs = ['개인PC', '서버', '스토리지', '전산비품'];
const swTabs = ['구독SW', '영구SW'];
const hwHeaders = ['법인', '자산코드', '명칭', '위치', '관리자', 'IP주소', 'MACaddress', 'HW사양', 'OS'];
const swHeaders = ['법인', 'SW명', '라이선스키', '할당자', '사용기간', '비고'];
const wb = XLSX.utils.book_new();
hwTabs.forEach((tab, i) => {
const wsData = [hwHeaders, [`(주)회사${i}`, `ASSET-${i}00`, `${tab} 모델A`, '본사 1층', '관리자A', '192.168.0.1', '00:00:00:00:00:01', 'Core i7, 16GB RAM', 'Windows 10']];
const ws = XLSX.utils.aoa_to_sheet(wsData);
XLSX.utils.book_append_sheet(wb, ws, tab);
});
swTabs.forEach((tab, i) => {
const wsData = [swHeaders, [`(주)회사${i}`, `${tab} Adobe CC`, '1234-5678-ABCD', '홍길동,김철수', '2024.01~2024.12', '본사 전용']];
const ws = XLSX.utils.aoa_to_sheet(wsData);
XLSX.utils.book_append_sheet(wb, ws, tab);
});
XLSX.writeFile(wb, 'temp_db.xlsx');
console.log('temp_db.xlsx created!');

77
implementation_plan.md Normal file
View File

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

51
implementation_plan2 Normal file
View File

@@ -0,0 +1,51 @@
# 구조 개선 및 다중 탭(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 화면 등)을 찍어 사용자에게 보고.

47
implementation_plan3 Normal file
View File

@@ -0,0 +1,47 @@
# 임시 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. 사용자 아이콘을 클릭해 홍길동, 김철수 등록 후, 전체 엑셀 저장 혹은 다운로드 시 엑셀 파일 내의 '할당자' 열에 `홍길동,김철수` 로 잘 들어가는지 확인

586
index.html Normal file
View File

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

1154
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "hm-itam",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"lucide": "^0.364.0",
"xlsx": "^0.18.5"
}
}

212
src/dummyDataGenerator.ts Normal file
View File

@@ -0,0 +1,212 @@
import { MasterAssetData, HardwareAsset, SoftwareAsset, SWUser } from './excelHandler';
const corps = ['한맥', '삼안', '바론'];
const users = ['홍길동', '김철수', '이영희', '박지훈', '김팀장', '신유진', '윤대웅', '마리아'];
const depts = ['설계팀', '기술팀', '경영지원팀', '영업팀'];
function rand(arr: any[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randDate(startYear: number, endYear: number) {
const y = Math.floor(Math.random() * (endYear - startYear + 1)) + startYear;
const m = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
const d = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function randUser() { // 25% 확률로 유휴자산 할당
return Math.random() < 0.25 ? '' : rand(users);
}
export function generateDummyData(): MasterAssetData {
const hw: HardwareAsset[] = [];
const sw: SoftwareAsset[] = [];
const swUsers: SWUser[] = [];
// 1. 개인PC 50개
for (let i = 1; i <= 50; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '개인PC',
법인: rand(corps),
: `HM-PC-${purchaseYear}-${String(i).padStart(3, '0')}`,
: '',
: `${rand(['본사', '지사'])} ${Math.floor(Math.random()*5)+1}`,
사용자: randUser(),
CPU: rand(['i5-10400', 'i7-12700', 'Ryzen 5', 'Ryzen 7']),
GPU: rand(['-', 'GTX 1660', 'RTX 3060', 'RTX 4070']),
RAM: rand(['16GB', '32GB']),
SSD1: rand(['256GB', '512GB', '1TB']),
SSD2: '',
HDD1: rand(['-', '1TB', '2TB']),
HDD2: '',
구매일: randDate(purchaseYear, purchaseYear),
금액: String(Math.floor(Math.random()*100 + 50) * 10000).replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','),
납품업체: rand(['다나와', '컴퓨존', '오피스디포']),
: '',
: '', IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
// 2. 서버 20개
for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '서버',
법인: rand(corps),
: `HM-SV-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `웹/DB 서버 #${i}`,
: 'IDC / 전산실',
관리자: randUser(),
IP주소: `192.168.10.${i}`,
MACaddress: '00:11:22:33:44:' + String(i).padStart(2, '0'),
OS: rand(['Windows Server 2019', 'Ubuntu 22.04 LTS', 'CentOS 7']),
HW사양: 'Xeon 16Core, 64GB RAM',
구매일: randDate(purchaseYear, purchaseYear),
: '5,000,000',
: '서버뱅크',
: ''
});
}
// 3. 스토리지 20개
for (let i = 1; i <= 20; i++) {
const purchaseYear = Math.floor(Math.random() * 8) + 2017; // 2017~2024
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '스토리지',
법인: rand(corps),
storage유형: rand(['NAS', 'DAS']),
: `HM-ST-${purchaseYear}-${String(i).padStart(3, '0')}`,
: `백업 스토리지 #${i}`,
: '전산실',
모델명: rand(['Synology DS920+', 'QNAP TS-453D']),
용량: rand(['16TB', '32TB', '64TB']),
담당자_정: randUser(),
담당자_부: rand(users),
IP주소: `192.168.20.${i}`,
MACaddress: '',
구매일: randDate(purchaseYear, purchaseYear),
: '1,500,000',
: '스토리지넷',
: '',
: '', OS: '', HW사양: ''
});
}
// 4. 전산비품 (노트북, 태블릿, 휴대폰 각각 5개씩)
const equips = [
{ type: '노트북', code: 'NB', name: 'LG 그램 16인치', price: '1,800,000' },
{ type: '태블릿', code: 'TB', name: '아이패드 프로 12.9', price: '1,500,000' },
{ type: '휴대폰', code: 'PH', name: '갤럭시 S24', price: '1,200,000' }
];
equips.forEach((eq) => {
for (let i = 1; i <= 5; i++) {
const purchaseYear = Math.floor(Math.random() * 6) + 2019; // 2019~2024
hw.push({
id: Math.random().toString(36).substring(2, 9),
type: '전산비품',
법인: rand(corps),
비품유형: eq.type,
: `HM-${eq.code}-${purchaseYear}-${String(i).padStart(3, '0')}`,
명칭: eq.name,
위치: rand(['본사', '지사']),
관리자: randUser(),
구매일: randDate(purchaseYear, purchaseYear),
금액: eq.price,
: '브랜드 총판',
: '',
IP주소: '', MACaddress: '', OS: '', HW사양: ''
});
}
});
// 5. 구독형 S/W 40개
for (let i = 1; i <= 40; i++) {
const swId = Math.random().toString(36).substring(2, 9);
let isExpiring = Math.random() < 0.25;
let endDt = new Date();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
sw.push({
id: swId,
type: '구독SW',
법인: rand(corps),
제품명: rand(['Adobe CC All Apps', 'Microsoft 365', 'Slack Pro', 'Notion Team']),
: '2024-01-01',
: `2024.01.01 ~ ${endStr}`,
: '600,000',
수량: Math.floor(Math.random() * 5) + 3, // 3~7
: `user${i}@hm.com`,
: '총판',
: '연간구독'
});
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀', '기획팀']),
직위: rand(['사원', '대리', '과장']),
이름: rand(users),
사용기간: '2024.01~12',
신청서명: ''
});
}
}
// 6. 영구형 S/W 40개
for (let i = 1; i <= 40; i++) {
const swId = Math.random().toString(36).substring(2, 9);
let isExpiring = Math.random() < 0.25;
let endDt = new Date();
if (isExpiring) {
endDt.setDate(endDt.getDate() + Math.floor(Math.random() * 25) + 1); // 1~25일 뒤 만료
} else {
endDt.setMonth(endDt.getMonth() + Math.floor(Math.random() * 11) + 2); // 넉넉히 남음
}
const endStr = `${endDt.getFullYear()}.${String(endDt.getMonth()+1).padStart(2,'0')}.${String(endDt.getDate()).padStart(2,'0')}`;
sw.push({
id: swId,
type: '영구SW',
법인: rand(corps),
제품명: rand(['AutoCAD 2024', 'Windows 10 Pro', '한컴오피스 2022', 'Visual Studio 2022']),
구매일: '2020-05-15',
유지보수여부: true,
비고: `유지보수: ~ ${endStr}`,
금액: '1,500,000',
수량: Math.floor(Math.random() * 3) + 2, // 2~4
계정명: `sn-2020-${i}`,
납품업체: '오토데스크 / MS'
});
const assignCount = Math.floor(Math.random() * 2) + 1;
for (let j=0; j<assignCount; j++) {
swUsers.push({
id: Math.random().toString(36).substring(2, 9),
swId: swId,
법인: rand(corps),
부서: rand(depts),
: rand(['1팀', '2팀']),
직위: rand(['과장', '차장', '부장']),
이름: rand(users),
사용기간: '영구',
신청서명: ''
});
}
}
return { hw, sw, swUsers };
}

324
src/excelHandler.ts Normal file
View File

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

1236
src/main.ts Normal file

File diff suppressed because it is too large Load Diff

354
src/style.css Normal file
View File

@@ -0,0 +1,354 @@
:root {
--primary-color: #1E5149;
--primary-hover: #153c36;
--primary-light: #edf2f1;
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: #dc2626;
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
}
.shadow-sm {
box-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
}
.rounded-lg {
border-radius: 8px;
}
.dashboard-card {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: none;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.dashboard-layout-2col {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
@media (max-width: 768px) {
.dashboard-layout-2col {
grid-template-columns: 1fr;
}
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Pretendard Variable', Pretendard, sans-serif;
color: var(--text-main);
background-color: var(--bg-color);
line-height: 1.5;
letter-spacing: -0.02em;
font-size: 14px;
}
/* App Layout - Sidebar & Main Content */
.app-layout {
display: flex;
min-height: 100vh;
width: 100%;
}
.sidebar {
width: 260px;
background-color: var(--sidebar-bg);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.sidebar-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-main);
}
.sidebar-header h1 span {
color: var(--primary-color);
}
.nav-section {
padding: 1.5rem 0 0.5rem;
}
.nav-section h3 {
padding: 0 1.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-section h3 i {
width: 14px;
height: 14px;
}
.nav-list {
list-style: none;
}
.nav-list li {
padding: 0.75rem 1.5rem;
margin: 0.25rem 0.75rem;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-main);
font-weight: 500;
transition: all 0.2s;
}
.nav-list li i {
width: 18px;
height: 18px;
color: var(--text-muted);
}
.nav-list li:hover {
background-color: var(--bg-color);
}
.nav-list li.active {
background-color: var(--primary-light);
color: var(--primary-color);
}
.nav-list li.active i {
color: var(--primary-color);
}
/* Main Content Wrapper */
.main-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.top-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background-color: var(--white);
border-bottom: 1px solid var(--border-color);
}
.header-title h2 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.content-area {
padding: 2rem;
flex: 1;
overflow-y: auto;
}
/* Dashboard Grid */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--white);
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
display: flex;
flex-direction: column;
}
.stat-card .title {
font-size: 0.875rem;
color: var(--text-muted);
font-weight: 500;
}
.stat-card .value {
font-size: 2rem;
font-weight: 700;
color: var(--text-main);
margin-top: 0.5rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
line-height: 1;
}
.btn i { width: 16px; height: 16px; }
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-hover);
border-color: var(--primary-hover);
}
.btn-outline {
background-color: transparent;
color: var(--text-main);
border: 1px solid var(--border-color);
}
.btn-outline:hover {
background-color: var(--border-color);
}
.btn-danger {
color: var(--danger);
border-color: #fca5a5;
}
.btn-danger:hover {
background-color: #fef2f2;
}
.btn-icon {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0.25rem;
}
/* Table */
.table-container {
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th, td {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
}
th {
font-weight: 600;
color: var(--text-muted);
font-size: 0.875rem;
white-space: nowrap;
background-color: #FAFAFA;
}
td {
font-size: 0.875rem;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background-color: var(--bg-color); }
.empty-row td { text-align: center; padding: 3rem; color: var(--text-muted); }
/* Modal */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; }
.modal-content {
background-color: var(--white);
width: 100%; max-width: 600px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
transform: translateY(20px);
transition: transform 0.2s ease;
}
.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); }
.modal-header {
background-color: var(--primary-color);
color: var(--white);
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 { font-size: 1.125rem; font-weight: 500; }
.modal-body { padding: 1.5rem; }
.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.form-group { display: flex; flex-direction: column; gap: 0.375rem; }
.form-group.full-width { grid-column: span 2; }
.form-group label { font-size: 0.875rem; font-weight: 500; }
.form-group input, .form-group textarea {
padding: 0.625rem;
border: 1px solid var(--border-color);
border-radius: 4px;
font-family: inherit; font-size: 0.875rem;
outline: none; transition: border-color 0.2s;
}
.form-group input:focus, .form-group textarea:focus { border-color: var(--primary-color); }
.modal-footer {
padding: 1rem 1.5rem; border-top: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.footer-actions { display: flex; gap: 0.5rem; }

24
start_server.bat Normal file
View File

@@ -0,0 +1,24 @@
@echo off
chcp 65001 >nul
title HM ITAM 서버
echo ============================================
echo HM ITAM 개발 서버 시작
echo ============================================
echo.
cd /d "%~dp0"
:: node_modules 존재 여부 확인
if not exist "node_modules" (
echo [INFO] node_modules가 없습니다. 패키지를 설치합니다...
echo.
call npm install
echo.
)
echo [INFO] 개발 서버를 시작합니다...
echo [INFO] 종료하려면 stop_server.bat을 실행하거나 이 창에서 Ctrl+C를 누르세요.
echo.
npm run dev

35
stop_server.bat Normal file
View File

@@ -0,0 +1,35 @@
@echo off
chcp 65001 >nul
title HM ITAM 서버 종료
echo ============================================
echo HM ITAM 개발 서버 종료
echo ============================================
echo.
:: Vite 개발 서버가 사용하는 node 프로세스 찾기
set "found=0"
for /f "tokens=2" %%a in ('netstat -ano ^| findstr ":5173" ^| findstr "LISTENING" 2^>nul') do (
set "found=1"
)
if "%found%"=="0" (
echo [INFO] 실행 중인 Vite 개발 서버를 찾을 수 없습니다.
echo.
pause
exit /b 0
)
echo [INFO] 포트 5173에서 실행 중인 서버를 종료합니다...
echo.
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":5173" ^| findstr "LISTENING"') do (
echo [INFO] PID %%a 프로세스를 종료합니다...
taskkill /PID %%a /F >nul 2>&1
)
echo.
echo [OK] 서버가 종료되었습니다.
echo.
pause

BIN
temp_db.xlsx Normal file

Binary file not shown.

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

8
vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 8080,
host: true, // Listen on all local IPs
},
});

72
기능명세서.md Normal file
View File

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