53 Commits

Author SHA1 Message Date
9d19d8283e 자산관리 시스템 도커라이징 2026-06-17 11:31:10 +09:00
723c4723f6 feat: 개인PC 자산 관리(PC) 페이지에서 '자산 현황' 토글 탭 숨김처리 및 자산 목록으로 기본 고정 2026-06-15 15:01:46 +09:00
a44283281f feat: 대시보드 미니 모달 상세 자산 목록 테이블에 등급 및 점수 컬럼 표기 기능 추가 2026-06-15 14:53:36 +09:00
fa87f383e2 fix: 직무별 기준 사양 대비 부족 사양(0.6배 미만) 및 오버스펙(1.5배 초과) 판정 수치 비율 임계치 조정 2026-06-15 14:51:09 +09:00
6118141f6e feat: 직무별 기준 사양(job_spec_standards) 데이터를 활용한 부족사양, 오버스펙 판정 및 등급별 부족분(구매 필요) 산출 공식 연동 2026-06-15 14:43:53 +09:00
05e23883b8 feat: 직무별 기준 사양 등록 시 부품 마스터 자동완성 검색 및 성능 점수 실시간 자동 계산 기능 추가 2026-06-15 13:29:10 +09:00
8c406fd0b8 fix: 부품 표준 정보 서브 탭 렌더링 시 DOM 쿼리 타이밍 에러 해결 (document.getElementById -> tabContainer.querySelector) 2026-06-15 13:27:20 +09:00
e678f9d653 feat: 부품 마스터 화면 내 직무별 기준 사양 CRUD 및 서브 탭 연동 기능 추가 2026-06-15 13:26:11 +09:00
132e37d0d3 refactor: 사양 부족 및 노후 장비의 교체 수요를 해당 사용자의 직무 권장 사양 등급에 맞추어 분산 합계 계산 2026-06-15 13:19:10 +09:00
d6e75f8b2c refactor: 교체 대상 PC 교체 수요를 보급 PC 신규 구매 필요 수량으로 이전 합산 2026-06-15 13:17:01 +09:00
c35f57acab style: 보급 PC 라벨 단순화 및 부족분 컬럼명을 구매 필요로 변경 2026-06-15 13:14:45 +09:00
97cecb8b50 feat: 5등급 PC 분류 체계 도입 (교체 대상 PC 등급 신설) 2026-06-15 13:12:07 +09:00
a4b620099c feat: 대시보드 폰트 크기, 패딩 조절 및 자산목록 정렬 기준 변경(updated_at 내림차순) 2026-06-15 11:22:07 +09:00
407b9ba531 style: 연도별 PC 노후도 예측 표의 왼편 여백 제거 및 레이아웃 정렬 2026-06-12 10:40:37 +09:00
55c43aa250 merge: remote main updates into local main 2026-06-12 10:37:52 +09:00
9186eb50ca feat: 자산 관리 시스템 고도화 및 DB 정규화 대응 수정
1. 자산 저장 시 500 에러 해결: V3 정규화 스키마에 맞춰 테이블 매핑 최신화 및 저장 로직 안정화
2. 자산 번호 체계 개편: 구매일자(YYYYMM)와 유형을 기반으로 PREFIX-YYYYMM-NNNN 규칙 적용 (코드 로직 수정 및 기존 데이터 전량 갱신)
3. 구매일자 표준화: 모든 purchase_date를 YYYY-MM-DD 형식으로 통일
4. HWModal 기능 복원: 위치 등록 시 다중 사진 페이지네이션(좌우 버튼) 기능 복구
5. 위치 지도 고도화: HTML 인터랙티브 지도 지원 및 이미지 지도 내 좌석 스내핑 로직 추가
2026-06-12 10:29:42 +09:00
8a3727ea61 feat: 대시보드 구분선 디자인 전환, 폰트 확대 및 버그 수정 2026-06-12 08:49:04 +09:00
0c1977f707 style: 폰트 크기를 ux_setting 표준 규격(14px)으로 복구 및 최적화 2026-06-11 13:06:04 +09:00
19e6be27de fix(merge): resolve compile errors and restore remote db IP 2026-06-11 11:49:34 +09:00
accbbdc2fa fix(pc-view): restore pc list view rendering and enable status view toggle for PC 2026-06-11 11:43:51 +09:00
d3c4fa5e66 fix(main): restore dashboard tab rendering check in refreshView 2026-06-11 11:42:03 +09:00
8c1cb6cf93 merge: merge origin/main into HW_Dashboard and resolve conflicts 2026-06-11 11:39:09 +09:00
4810df212a merge: ux_setting 브랜치를 main에 병합 (HWModal UI 고도화 및 PC 탭 제한 사항 반영) 2026-06-11 11:23:09 +09:00
f5a84a77ef feat: PC 페이지 개발 대기 상태 전환 및 자산현황 뷰 노출 범위 제한 (서버 탭 전용) 2026-06-11 11:20:28 +09:00
565802f55b feat(flow-logs): fix text overlapping, show full asset code, filter by current month and support JSON logs 2026-06-11 11:14:04 +09:00
10479aad7e feat: HWModal 네트워크 및 원격 접속 정보 동적 입력 기능 통합 및 UI 최적화 2026-06-11 11:14:00 +09:00
95fbd3f606 fix: 자산 상세 정보 레이아웃 최적화 및 스타일 표준화
- 상세 정보 섹션을 2열 그리드로 변경하여 정보 밀도 향상\n- 항목 제목(12px) 및 내용(14px) 폰트 크기 조정\n- 인라인 스타일을 dashboard.css로 이전 및 중앙 집중화\n- 불필요한 아이콘 제거 및 UI 정돈
2026-06-11 10:37:33 +09:00
207acbdecb fix: LocationView 레이아웃 안정화 및 UI 개선
- 페이지네이션 버튼을 상세위치 옆으로 이동\n- 지도 이미지 밀림 현상 방지를 위한 정렬 방식 수정 및 동기화 로직 보강\n- 상단 메뉴에서 구버전 현황 버튼 제거
2026-06-11 10:08:43 +09:00
164568843b feat: LocationView 고도화 - 지도 클릭 시 사이드바 상세 정보 표시 및 구역 필터링 구현 2026-06-11 09:47:57 +09:00
29c7d5f3d8 fix: 서버 오류 수정 2026-06-10 17:53:52 +09:00
ce1ed40561 feat: 하드웨어 자산 관리 고도화 및 자동 이력 시스템 구축
- 통합 원격 접속 정보 UI 구현 (IP/MAC 및 계정 정보 통합)
- 서버 측 스냅샷 비교 기반 자동 이력(Log) 생성 로직 도입
- 타임라인 UI 개선 (이벤트별 색상 뱃지 및 변동 사항 강조)
- 자산 상세 필드 확장 (서비스 구분, 용도 등)
- 테스트 데이터 생성기 및 이력 계획서 추가
2026-06-10 09:51:03 +09:00
525dbd77d4 feat(nav): restrict admin menu to only display dashboard tab 2026-06-10 09:22:10 +09:00
35c5b1e0fa feat(nav): hide dashboard and admin settings from practitioner role 2026-06-10 09:20:37 +09:00
b87ca2854b feat(role): enable admin login, default Admin to Dashboard, and default Practitioner to Server list 2026-06-10 09:13:46 +09:00
2f88a0fae7 Merge origin/main into HW_Dashboard and resolve conflicts 2026-06-10 09:13:23 +09:00
9a2c35e652 feat(dashboard): update charts and styling on HW_Dashboard 2026-06-10 09:12:29 +09:00
25ebaf4685 refactor: rename asset_network to asset_remote
- DB 테이블명 변경 마이그레이션 스크립트 추가 (migrate_v5_rename_remote.js)

- Backend (server.js): 쿼리 및 매핑 로직을 asset_remote 및 remotes 속성으로 업데이트

- Frontend (HWModal.ts): 폼 필드와 데이터 바인딩을 remotes로 일괄 수정

- 유틸리티 스크립트의 레퍼런스 일괄 업데이트
2026-06-09 18:44:53 +09:00
2b9c965c91 feat: 동적 디스크 확장 기능 및 하드웨어 카테고리 필터링 고도화 2026-06-09 16:29:54 +09:00
4b408b0640 feat: HW 모달 UI 고도화 및 자산 분류 체계 개편 2026-06-09 13:56:05 +09:00
3ab587d342 feat: DB V3 정규화 및 용도 기반 동적 UI 구현
- 백엔드: asset_core(마스터), asset_spec(사양), asset_volume(스토리지), asset_location(위치), asset_network(네트워크/원격) 5개 테이블로 V3 정규화 완료

- 백엔드: /api/assets/master 단일 엔드포인트로 통합 및 서브쿼리 최적화를 통한 UI 하위 호환성 유지

- 백엔드: 저장 로직(save) V3 스키마 분산 저장 및 cascade 기반 삭제 로직 적용

- 프론트엔드(HWModal): '현 용도(current_role)' 필드 추가 및 서버/개인용에 따른 네트워크/위치 섹션 동적 렌더링 구현

- 프론트엔드(state): 분산된 API 호출을 단일 호출로 통합하여 렌더링 성능 최적화

- 레거시 백업 파일 및 불필요한 구형 테이블 완벽 정리 완료
2026-06-08 17:58:48 +09:00
3b9b2ea598 feat: 자산번호 4자리 일련번호 확장 및 날짜 기반 생성 로직 추가
- 백엔드(server.js): 자산번호 자동 생성 시 구매일자(YYYYMM)를 파싱하여 [접두사]-[YYYYMM]-[0000] 형태로 4자리 일련번호를 부여하도록 로직 전면 수정
- 프런트엔드(HWModal.ts): 자산번호 생성 API 호출 시 사용자가 입력한 구매일자 데이터를 파라미터로 함께 전송하도록 연동
- 전체 DB 및 로컬 마스터 데이터의 4자리 일련번호 및 날짜 복구 마이그레이션 반영
2026-06-08 15:18:39 +09:00
05c565552a feat: 자산 현황 고도화 및 데이터 표준화 작업 완료
- UI 개선: 모든 아이콘/이모지 제거(미니멀리즘), 목록 행 하이라이트 추가, 요약 레이아웃 최적화
- 경고 시스템: 위치 부적절/형식 부적절 사유별 세분화 및 상단 통계 배지 적용
- 정보 강화: 우측 패널 '상세 보기' 버튼 복구 및 '자산 유형' 정보 추가 노출
- 표준화: 위치명(기술개발센터, 한맥빌딩) 및 자산번호 접두사(STO/NAS/DAS -> DSS/STM) 통합
- 데이터: realServerData.ts 및 SharedData.ts 내 유형/접두사 표준 체계 전면 반영
- 레이아웃: 헤더 역할 스위처 및 가이드 버튼 일렬 정렬 수정
2026-06-08 11:40:27 +09:00
2ec9261c03 feat: 자산 현황 레이아웃 개선 및 위치 정보 가독성 최적화
- 박스형 디자인 배제 및 선 기반의 미니멀 레이아웃 적용
- 목록 클릭 시 우측 패널에 배치도 및 위치 마커 즉시 표시
- 사진 비율 유지 및 좌표 정밀 보정 (onload/resize 로직 도입)
- 총 보유 자산 통계에 외부(운영)/내부(테스트) 대수 세부 표시 추가
- 빌드 오류 수정을 위한 구문 정리
2026-06-08 09:44:15 +09:00
06f3baaa58 feat: 대시보드 및 자산현황 레이아웃 개편 및 디자인 복원
- 자산현황 대시보드의 그래프를 제거하고 표와 상세정보 패널(5:5 비율)로 레이아웃 개편
- 표에서 '비고' 컬럼을 제거하고 '담당자(정)', '담당자(부)' 컬럼으로 교체 및 너비 조정
- 이중 필터(위치 -> 상세위치) 도입으로 필터링 기능 강화
- 상세정보 패널의 사진 영역을 Flexbox로 최적화하여 위아래 잘림 현상 원천 차단
- 모달창 내 '수정 모드' 폰트 색상을 디자인 가이드(var(--color-dahong))에 맞게 붉은 계열로 원상 복구 및 누락 변수 추가
- ListFactory.ts의 ASSET_SCHEMA.SERVICE_TYPE 참조 시 발생하던 TypeError 픽스
2026-06-05 18:01:26 +09:00
eead43837d feat: PC 맞춤형 대시보드 구현 및 자산 현황 레이아웃 최적화
- PC 자산 관리 화면에 유형별(공용/서버/개인) 통계 및 차트 적용
- 자산 현황 대시보드의 위치 분류 체계 통일 (센터/IDC/한맥빌딩)
- 하단 요약 표의 자산번호 컬럼을 비고(Memo)로 교체 및 말줄임표 적용
- 차트 크기 확대 및 네비게이션 메뉴 레이아웃 안정화
- ListFactory.ts 내 formatInline 미정의 오류 수정
2026-06-05 10:51:29 +09:00
46422e8544 cleanup: remove database migration and utility scripts 2026-06-02 14:44:45 +09:00
a30f99f0ad feat: improve asset code generation and re-sequence assets by year
- Enhanced backend asset code generation logic to handle multiple tables
- Integrated asset code generation button in HWModal
- Included utility scripts for asset code migration and DB synchronization
- Resolved issues with missing purchase dates and duplicate asset codes
2026-06-02 14:40:06 +09:00
9e8ab11f99 feat: implement role-based entry and navigation enhancements
- Replace credential login with Admin/Practitioner role selection
- Add role-switcher toggle in header with automatic reversion for Admin mode
- Implement immediate return to role selection via system logo click
- Integrate role state management into global app state
2026-06-01 17:56:22 +09:00
19d4222470 Merge branch 'main' into login 2026-06-01 16:52:17 +09:00
db5c7a96a6 fix: restore map editor layout and event binding logic 2026-06-01 16:34:57 +09:00
7d3d5ef281 feat: implement initial login UI and entry logic 2026-06-01 16:23:23 +09:00
9cd5d59bf8 refactor: complete modal class-based architecture, design system integration, and map editor modularization 2026-06-01 14:57:07 +09:00
590ddd0e85 feat: enhance map editor, refine location view, and update image assets
- Map Editor: Add box numbering (drawing/placed) and set default file
- Location View: Refine mouse interaction in view mode (readonly)
- Assets: Add MDF room support and update server room directory structure
- Backend: Add map configuration API for real-time saving
2026-06-01 14:00:45 +09:00
91 changed files with 12443 additions and 2901 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
build
.git
.gitignore
.env
npm-debug.log
uploads
*.xlsx
*.log

12
Dockerfile.backend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]

12
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

60
PLAN_ASSET_HISTORY.md Normal file
View File

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

View File

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

BIN
backupDB_20260602.xlsx Normal file

Binary file not shown.

59
backup_db.js Normal file
View File

@@ -0,0 +1,59 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import * as xlsx from 'xlsx';
import fs from 'fs';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function backup() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Starting Database Backup Process...');
const tables = [
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
];
const wb = xlsx.utils.book_new();
for (const table of tables) {
try {
// 1. Create table backup
await connection.query(`DROP TABLE IF EXISTS ${table}_backup`);
await connection.query(`CREATE TABLE ${table}_backup AS SELECT * FROM ${table}`);
console.log(`✅ Table backup created: ${table} -> ${table}_backup`);
// 2. Fetch data for Excel
const [rows] = await connection.query(`SELECT * FROM ${table}`);
if (rows.length > 0) {
const ws = xlsx.utils.json_to_sheet(rows);
// Sheet names max length is 31 chars
const sheetName = table.substring(0, 31);
xlsx.utils.book_append_sheet(wb, ws, sheetName);
}
} catch (e) {
console.warn(`⚠️ Skipped ${table}: ${e.message}`);
}
}
// 3. Write Excel file
const fileName = 'backupDB_20260608.xlsx';
xlsx.writeFile(wb, fileName);
console.log(`✅ Excel data exported successfully to ${fileName}`);
await connection.end();
}
backup().catch(err => {
console.error('❌ Backup Failed:', err);
process.exit(1);
});

28
check_logs.js Normal file
View File

@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRecentLogs() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Recent History Logs ---');
const [rows] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC LIMIT 5');
console.log(JSON.stringify(rows, null, 2));
console.log('\n--- Recent Core Data (to check current_dept) ---');
const [coreRows] = await connection.query('SELECT id, asset_code, current_dept, previous_dept FROM asset_core ORDER BY updated_at DESC LIMIT 5');
console.log(JSON.stringify(coreRows, null, 2));
await connection.end();
}
checkRecentLogs().catch(console.error);

29
check_network.js Normal file
View File

@@ -0,0 +1,29 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRemote() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Checking asset_remote table ---');
const [columns] = await connection.query('DESCRIBE asset_remote');
const cols = columns.map(c => c.Field);
console.log('Columns in asset_remote:', cols.join(', '));
const [count] = await connection.query('SELECT COUNT(*) as count FROM asset_remote WHERE remote_tool IS NOT NULL OR remote_id IS NOT NULL');
console.log(`Rows with remote info (tool or id): ${count[0].count}`);
await connection.end();
}
checkRemote().catch(console.error);

729
doc_readme.md Normal file
View File

@@ -0,0 +1,729 @@
# ITAM 도커라이징 실전 가이드
## 1. 문서 목적
이 문서는 Gitea에 올라가 있는 현재 저장소를 기준으로, 개발 PC에 WSL2와 Ubuntu만 설치되어 있는 상태에서 지금의 Docker 실행 구조를 재현하는 방법을 처음부터 끝까지 설명하는 실전 가이드다.
이 문서는 아래 상황을 가정한다.
1. 소스 코드는 아직 로컬에 없거나, Gitea에서 막 받아올 예정이다.
2. Windows에는 WSL2와 Ubuntu는 설치되어 있다.
3. 그 외 Docker 관련 세팅은 아직 안 되어 있을 수 있다.
4. 최종 목표는 현재 저장소 기준 `frontend + backend + external DB` 구조를 Docker로 재현하는 것이다.
이 문서의 목적은 아래 네 가지다.
1. 현재 시스템 구조와 Docker 구조를 먼저 이해하게 한다.
2. 기존 파일 중 무엇이 새로 추가되었고 무엇이 수정되었는지 정리한다.
3. 각 단계별로 정확히 어디에서 명령을 실행해야 하는지 명시한다.
4. Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 상태까지 도달하게 한다.
---
## 2. 현재 시스템 구조 개요
## 2.1 애플리케이션 원래 구조
현재 저장소의 본래 실행 구조는 다음과 같다.
1. 프런트엔드: Vite 기반 TypeScript 앱
2. 백엔드: Express 기반 Node.js API 서버
3. 데이터베이스: 외부 MySQL 서버
즉, 원래부터 MySQL이 Docker 안에 들어 있던 구조가 아니다.
프런트와 백엔드는 각각 별도 프로세스로 실행되며, 프런트는 `/api` 상대 경로로 백엔드 API를 호출한다.
---
## 2.2 현재 Docker 구조
현재 최종 Docker 구조는 아래와 같다.
1. `frontend` 컨테이너
2. `backend` 컨테이너
3. 외부 MySQL DB
즉, 지금은 내부 `db` 컨테이너가 없고, 내부 `db-bootstrap` 컨테이너도 없다.
현재 구조를 문장으로 풀면 다음과 같다.
1. 브라우저는 `http://localhost:8080`으로 `frontend` 컨테이너에 접속한다.
2. `frontend``/api` 요청을 `backend:3000`으로 프록시한다.
3. `backend``.env`에 적힌 외부 DB 정보로 외부 MySQL에 직접 접속한다.
4. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
간단한 흐름은 아래와 같다.
```text
Browser
-> frontend container :8080
-> Vite proxy (/api)
-> backend container :3000
-> external MySQL (.env)
```
---
## 2.3 왜 이 구조가 맞는가
현재 구조가 적절한 이유는 다음과 같다.
1. 원래 시스템도 외부 MySQL을 쓰는 구조였다.
2. 지금 목표는 운영형 단일 배포가 아니라 현재 개발형 구조를 Docker로 재현하는 것이다.
3. 프런트는 Vite dev server 기반이라 운영형 nginx 정적 배포 구조로 억지로 바꾸는 것보다, 현 구조를 유지하는 편이 안전하다.
4. 실무 표준 관점에서도 앱 컨테이너는 무상태로 유지하고, DB는 외부 인프라를 사용하는 구성이 더 일반적이다.
---
## 3. 이번 도커라이징에서 추가되거나 수정된 파일 정리
아래 파일들은 이번 Docker 재현 구조를 위해 새로 추가되었거나 수정된 핵심 파일이다.
## 3.1 새로 추가된 파일
1. `Dockerfile.frontend`
2. `Dockerfile.backend`
3. `.dockerignore`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `docker_task_plan.md`
11. `doc_readme2.md`
---
## 3.2 기존 파일 중 수정된 핵심 파일
1. `server.js`
2. `vite.config.ts`
3. `doc_readme.md`
---
## 3.3 각 파일의 역할
### `Dockerfile.frontend`
역할:
1. 프런트 Vite 개발 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run dev -- --host 0.0.0.0`를 실행한다.
### `Dockerfile.backend`
역할:
1. 백엔드 Express 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run server`를 실행한다.
### `.dockerignore`
역할:
1. `node_modules`, `build`, `.git`, `.env`, `uploads` 같은 불필요한 파일을 Docker build context에서 제외한다.
### `docker-compose.yaml`
역할:
1. `frontend`, `backend` 두 컨테이너를 동시에 띄운다.
2. `backend``.env`의 외부 DB를 사용한다.
3. `frontend``backend:3000`으로 프록시한다.
### `start_docker_wsl.ps1`
역할:
1. Windows 경로를 WSL 경로로 안전하게 바꾼다.
2. WSL 내부 Docker를 사용해 `docker compose up --build -d`를 실행한다.
3. 한글 경로와 공백 경로에서도 안정적으로 실행되게 한다.
### `stop_docker_wsl.ps1`
역할:
1. 같은 방식으로 WSL 내부에서 `docker compose down`을 실행한다.
### `start_docker_wsl.bat`, `stop_docker_wsl.bat`
역할:
1. PowerShell 스크립트를 쉽게 실행하는 래퍼 역할을 한다.
### `server.js`
중요 수정 사항:
1. `dotenv.config({ override: true })`가 아니라 `dotenv.config()`를 사용한다.
이유:
1. Compose나 실행 환경이 주는 환경변수를 `.env`가 덮어써 버리면 안 된다.
2. 외부 DB 정보와 포트 설정 등 실행 환경 우선 구조를 유지해야 한다.
### `vite.config.ts`
중요 수정 사항:
1. 프록시 타깃을 고정 `localhost:3000`이 아니라 환경변수 기반으로 받도록 바꿨다.
현재 구조:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
이유:
1. 로컬에서 직접 프런트를 띄울 때는 `localhost:3000`이 맞다.
2. Docker 안에서는 `frontend` 컨테이너에서 보는 `localhost`가 백엔드가 아니므로 `backend:3000`을 써야 한다.
---
## 4. 현재 `docker-compose.yaml` 기준 실제 동작 구조
현재 `docker-compose.yaml`은 아래 구조다.
### `backend`
1. `Dockerfile.backend`로 이미지를 빌드한다.
2. `.env`를 읽는다.
3. DB 관련 변수는 `${DB_HOST}`, `${DB_PORT}`, `${DB_USER}`, `${DB_PASS}`, `${DB_NAME}`를 그대로 사용한다.
4. 포트 `3000:3000`으로 노출한다.
5. `uploads`, `map_config.json`을 마운트한다.
### `frontend`
1. `Dockerfile.frontend`로 이미지를 빌드한다.
2. `VITE_DEV_PROXY_TARGET: http://backend:3000` 환경변수를 사용한다.
3. 포트 `8080:8080`으로 노출한다.
4. 브라우저의 `/api` 요청을 `backend`로 프록시한다.
즉, 현재 Compose는 DB를 띄우지 않고 앱 두 개만 띄운다.
---
## 5. 사전 준비 사항
이 섹션은 Gitea에서 코드를 받기 전 또는 받은 직후에 확인해야 한다.
## 5.1 가정하는 기본 상태
이미 설치되어 있다고 가정하는 것:
1. Windows
2. WSL2
3. Ubuntu 배포판
아직 없을 수 있는 것:
1. Docker Desktop 또는 WSL 내부 Docker 사용 환경
2. Git 클라이언트
3. 프로젝트 `.env`
---
## 5.2 권장 Docker 실행 방식
현재 저장소 구조상 가장 권장하는 방식은 다음이다.
1. Windows에 Docker Desktop 설치
2. Docker Desktop에서 WSL2 통합 활성화
3. Ubuntu WSL 내부에서 `docker` 명령을 사용할 수 있게 한다.
이유:
1. 현재 `start_docker_wsl.ps1`가 WSL 내부의 `docker`를 호출하는 구조다.
2. 실제 검증도 WSL 내부 Docker 기준으로 이루어졌다.
---
## 5.3 외부 DB 정보 준비
현재 구조는 외부 MySQL을 사용하므로 `.env` 파일이 반드시 필요하다.
최소한 아래 값이 필요하다.
```env
DB_HOST=<외부 MySQL 호스트>
DB_PORT=3306
DB_USER=<외부 MySQL 계정>
DB_PASS=<외부 MySQL 비밀번호>
DB_NAME=itam
```
필요 시 추가 환경변수는 현재 백엔드 코드 기준으로 함께 넣을 수 있다.
---
## 6. Gitea에서 소스 받기
## 6.1 작업 실행 위치
이 단계는 **Windows PowerShell** 또는 **Windows 터미널의 PowerShell**에서 수행한다.
실행 위치 이유:
1. 이후 `start_docker_wsl.ps1`도 Windows PowerShell에서 실행하는 것이 가장 자연스럽다.
2. 로컬 작업 폴더를 Windows 경로 기준으로 준비할 수 있다.
---
## 6.2 소스 클론
예시:
```powershell
git clone <Gitea 저장소 URL>
cd <클론된 저장소 경로>
```
현재 프로젝트처럼 한글 경로를 사용할 수도 있지만, 가능하면 너무 복잡한 경로는 피하는 것이 좋다.
현재 실제 프로젝트 경로 예시는 아래였다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 경로도 현재 스크립트로는 동작 가능하다.
---
## 7. Docker 환경 준비
## 7.1 작업 실행 위치
이 단계는 **Windows PowerShell**과 **WSL Ubuntu 터미널**을 둘 다 사용한다.
1. 설치 확인은 Windows PowerShell에서 시작
2. 실제 Docker 동작 확인은 WSL Ubuntu에서 수행
---
## 7.2 Docker Desktop 설치 여부 확인
**실행 위치: Windows PowerShell**
```powershell
docker version
```
만약 여기서 바로 안 잡혀도 현재 프로젝트는 WSL 내부 Docker를 쓰므로, 다음 단계로 넘어가 WSL 내부 확인을 한다.
---
## 7.3 WSL 내부 Docker 확인
**실행 위치: Windows PowerShell**
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
정상 기대 결과:
1. Ubuntu가 Running 상태
2. `docker --version`이 정상 출력
만약 `docker --version`이 실패하면, Docker Desktop 설치 및 WSL 통합을 먼저 완료해야 한다.
---
## 8. `.env` 파일 준비
## 8.1 작업 실행 위치
이 단계는 **Windows PowerShell**, **VS Code**, 또는 아무 텍스트 편집기**에서 수행한다.
즉, 프로젝트 루트에 `.env` 파일을 만드는 작업이다.
---
## 8.2 `.env` 작성
프로젝트 루트에 `.env`를 만든다.
예시:
```env
DB_HOST=your-external-db-host
DB_PORT=3306
DB_USER=your-db-user
DB_PASS=your-db-password
DB_NAME=itam
```
주의:
1. 현재 Compose는 내부 DB를 만들지 않는다.
2. 따라서 이 값이 곧 실제 운영/개발 외부 DB 연결 정보다.
3. 이 정보가 틀리면 `backend`는 기동해도 API에서 DB 오류가 난다.
---
## 9. 현재 Docker 파일이 어떻게 동작하는지 이해하기
## 9.1 `Dockerfile.frontend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
의미:
1. Node 20 Alpine 기반
2. 의존성 설치 후 전체 소스 복사
3. Vite 개발 서버 실행
---
## 9.2 `Dockerfile.backend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
의미:
1. Node 20 Alpine 기반
2. Express 서버 실행
---
## 9.3 `vite.config.ts`
**확인 위치: 프로젝트 루트 / VS Code**
현재 핵심:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
그리고 `/api`, `/uploads`가 모두 `proxyTarget`으로 프록시된다.
의미:
1. 로컬 실행 시 기본값은 `localhost:3000`
2. Docker 실행 시 Compose가 `http://backend:3000`을 주입
이 수정이 있어야 Docker 안에서도 화면에 데이터가 표시된다.
---
## 10. Docker Compose 기동
## 10.1 작업 실행 위치
이 단계는 반드시 **Windows PowerShell**에서 수행하는 것을 권장한다.
이유:
1. `start_docker_wsl.ps1`가 Windows 경로를 받아 WSL 경로로 바꾸는 구조다.
2. 한글/공백 경로에서 가장 안전하다.
---
## 10.2 권장 기동 방법
**실행 위치: 프로젝트 루트의 Windows PowerShell**
```powershell
.\start_docker_wsl.ps1
```
또는
```powershell
.\start_docker_wsl.bat
```
이 스크립트는 내부적으로 아래를 수행한다.
1. PowerShell 출력 인코딩을 UTF-8로 설정
2. 현재 Windows 경로를 WSL 경로로 변환
3. WSL 동작 확인
4. WSL 내부 Docker 동작 확인
5. `docker compose up --build -d` 수행
---
## 10.3 직접 기동이 필요할 때
**실행 위치: WSL Ubuntu 터미널**
직접 실행 예시는 아래와 같다.
```bash
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
docker compose up --build -d
```
하지만 현재 프로젝트는 한글 경로 이슈가 있었기 때문에, 특별한 이유가 없으면 `start_docker_wsl.ps1`를 우선 사용한다.
---
## 11. 컨테이너 기동 후 검증
## 11.1 컨테이너 상태 확인
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-backend` -> `Up`
2. `itam-frontend` -> `Up`
현재는 `itam-db`, `itam-db-bootstrap`가 없어야 정상이다.
---
## 11.2 백엔드 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 `backend`가 외부 DB에 정상 연결됐는지 보는 가장 직접적인 검사다.
---
## 11.3 프런트 경유 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 프런트 프록시가 정상인지 확인한다.
예전에 화면에 데이터가 안 보였던 것은 외부 DB 자체가 아니라, 이 프록시 경로가 잘못돼 있었기 때문이다.
---
## 11.4 브라우저 화면 확인
**실행 위치: 브라우저**
```text
http://localhost:8080
```
확인 포인트:
1. 화면이 열리는지
2. 목록/대시보드/테이블 데이터가 비어 있지 않은지
3. 모달 진입 시 데이터가 정상적으로 보이는지
---
## 12. 지금 데이터가 표시되는 원리
현재는 내부 DB로 데이터를 옮겨 담지 않는다.
현재 실제 동작 원리는 다음과 같다.
1. 브라우저가 `frontend`에 접속한다.
2. 프런트가 `/api/...`로 요청한다.
3. Vite 프록시가 `backend:3000`으로 요청을 넘긴다.
4. `backend``.env`의 외부 MySQL에 직접 접속한다.
5. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
즉, 현재는 아래 구조다.
```text
Browser -> frontend -> backend -> external MySQL
```
예전 외부 DB 구조에서 화면에 데이터가 안 보였던 이유는 외부 DB 때문이 아니라, 프런트 컨테이너가 `localhost:3000`을 잘못 바라보고 있었기 때문이다.
지금은 `VITE_DEV_PROXY_TARGET: http://backend:3000`으로 수정되어 있기 때문에 정상 표시된다.
---
## 13. 자주 헷갈리는 포인트
## 13.1 현재는 내부 DB 컨테이너가 없다
현재 `docker-compose.yaml`에는 아래가 없다.
1. `db` 서비스
2. `db-bootstrap` 서비스
3. `itam_mysql_data` 볼륨
즉, DB는 Docker 스택 밖에 있다.
---
## 13.2 현재는 `.env`가 곧 실제 DB 연결 정보다
현재 `backend`는 아래처럼 Compose에서 그대로 받는다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
즉, `.env`를 틀리게 적으면 화면도 데이터가 안 뜬다.
---
## 13.3 `server.js`는 여전히 중요하게 수정된 상태다
현재 `server.js``dotenv.config()`를 사용한다.
이 구조는 이후 Compose나 실행 환경에서 변수를 주입할 때, 애플리케이션이 그 값을 받아들일 수 있게 하기 위해 유지해야 한다.
---
## 14. 스택 중지 방법
## 14.1 작업 실행 위치
**Windows PowerShell / 프로젝트 루트**
---
## 14.2 권장 종료 명령
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
이 스크립트는 내부적으로 WSL 경로 변환 후 `docker compose down`을 수행한다.
---
## 15. 장애 발생 시 점검 순서
## 15.1 `frontend` 화면은 뜨는데 데이터가 없을 때
**실행 위치: Windows PowerShell**
먼저 아래 두 API를 분리해서 본다.
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
판단 기준:
1. `3000`은 200이고 `8080`만 실패 -> 프런트 프록시 문제
2. 둘 다 실패 -> 백엔드 또는 외부 DB 연결 문제
---
## 15.2 백엔드가 외부 DB에 연결되지 않을 때
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
점검 항목:
1. `.env`의 DB 정보가 정확한지
2. 외부 DB 서버 접근이 가능한지
3. 계정/비밀번호가 맞는지
4. 방화벽 또는 네트워크 이슈가 없는지
---
## 15.3 프런트 프록시가 의심될 때
**확인 위치: `vite.config.ts`, `docker-compose.yaml`**
다음 두 설정이 유지되는지 확인한다.
`vite.config.ts`
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
`docker-compose.yaml`
```yaml
VITE_DEV_PROXY_TARGET: http://backend:3000
```
이 둘 중 하나라도 바뀌면 Docker 안에서 화면 데이터가 다시 안 보일 수 있다.
---
## 16. 현재 기준 재현 절차 요약
가장 짧게 정리하면 아래 순서다.
1. Gitea에서 소스를 클론한다.
2. Windows PowerShell에서 프로젝트 루트로 이동한다.
3. `.env`에 외부 MySQL 정보를 작성한다.
4. Docker Desktop + WSL 통합 또는 WSL 내부 Docker 사용 가능 상태를 만든다.
5. `start_docker_wsl.ps1`를 실행한다.
6. `http://localhost:3000/api/assets/master`가 200인지 확인한다.
7. `http://localhost:8080/api/assets/master`가 200인지 확인한다.
8. 브라우저에서 `http://localhost:8080`을 열어 실제 데이터 표시를 확인한다.
---
## 17. 현재 최종 결론
현재 저장소의 도커라이징 구조는 실무 표준에 맞는 `무상태 앱 컨테이너 + 외부 DB` 구조다.
현재 핵심은 아래 세 가지다.
1. `backend`는 외부 MySQL에 직접 연결한다.
2. `frontend``backend:3000`으로 API 프록시한다.
3. WSL 경로 변환 스크립트를 통해 Windows 한글 경로에서도 안정적으로 실행한다.
즉, 이 문서대로 진행하면 Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 구조를 재현할 수 있다.

730
doc_readme2.md Normal file
View File

@@ -0,0 +1,730 @@
# ITAM 도커라이징 최종 재현 가이드
## 1. 문서 목적
이 문서는 현재 Git 저장소에 올라간 파일만 가지고, 지금과 동일한 수준으로 ITAM 시스템을 도커라이징하고 실행하는 절차를 처음부터 끝까지 정리한 최종 가이드다.
이 문서만 읽어도 아래 목표를 달성할 수 있게 작성한다.
1. 현재 저장소 구조를 이해한다.
2. 왜 이렇게 도커라이징했는지 판단 근거를 안다.
3. WSL2 기반으로 실제 스택을 기동한다.
4. 외부 MySQL에서 내부 MySQL 컨테이너로 초기 데이터를 bootstrap 한다.
5. 프런트 8080과 백엔드 3000이 모두 정상 동작하는지 검증한다.
6. 재초기화, 재기동, 장애 확인까지 수행한다.
이 문서는 최종 성공 구조 기준이다. 실패 기록은 `doc_readme.md`를 본다.
---
## 2. 최종 목표 구조
현재 최종 구조는 아래 4개 서비스/역할로 나뉜다.
1. `frontend`: Vite 개발 서버 컨테이너, 포트 8080
2. `backend`: Express API 서버 컨테이너, 포트 3000
3. `db`: MySQL 8 컨테이너, 포트 3306
4. `db-bootstrap`: 외부 MySQL -> 내부 MySQL로 1회성 복제 수행 후 종료되는 도우미 컨테이너
논리 흐름은 다음과 같다.
```text
브라우저 -> frontend:8080 -> Vite proxy -> backend:3000 -> db:3306
\
-> /uploads -> backend 정적 경로
초기 1회 기동 시
외부 MySQL(.env) -> db-bootstrap -> 내부 MySQL(db)
```
---
## 3. 왜 이 구조를 선택했는가
이 저장소는 처음부터 운영형 정적 배포 앱이 아니었다. 실제 구조는 다음과 같았다.
1. 프런트는 Vite 개발 서버가 따로 돈다.
2. 백엔드는 Express API가 따로 돈다.
3. 프런트는 상대 경로 `/api`를 호출한다.
4. 백엔드는 프런트의 `dist`를 서빙하지 않는다.
따라서 내일 바로 시연 가능한 수준까지 빠르게 안정화하려면 아래 전략이 가장 맞다.
1. 프런트를 Vite dev server 그대로 컨테이너화한다.
2. 백엔드를 별도 컨테이너로 유지한다.
3. DB는 MySQL 8 컨테이너로 묶되, 초기 데이터는 외부 DB에서 복제한다.
4. 프런트 프록시는 컨테이너 네트워크 서비스명 `backend`로 붙게 한다.
즉, 현재 구조는 "개발형 구조를 Docker로 재현한 시연/개발용 Compose"다.
---
## 4. 저장소 내 최종 관련 파일 목록
현재 도커라이징과 직접 관련된 핵심 파일은 아래와 같다.
1. `.dockerignore`
2. `Dockerfile.frontend`
3. `Dockerfile.backend`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `server.js`
11. `vite.config.ts`
각 파일 역할은 다음과 같다.
### 4.1 `.dockerignore`
Docker build context에서 제외할 파일을 정의한다.
주요 제외 대상은 다음과 같다.
1. `node_modules`
2. `dist`
3. `build`
4. `.git`
5. `.env`
6. `uploads`
7. `*.xlsx`
8. `*.log`
### 4.2 `Dockerfile.frontend`
프런트 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
이 이미지는 Vite dev server를 컨테이너에서 띄우기 위한 것이다.
### 4.3 `Dockerfile.backend`
백엔드 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
### 4.4 `docker-compose.yaml`
전체 스택의 핵심 파일이다.
현재 최종 구성은 다음 논리를 가진다.
1. `db`는 MySQL 8 내부 DB다.
2. `db-bootstrap`은 외부 DB 데이터를 내부 DB로 1회 복제한다.
3. `backend`는 내부 `db`에 붙는다.
4. `frontend``backend` 서비스명으로 프록시한다.
### 4.5 `start_docker_wsl.ps1`
Windows에서 WSL 경유로 Docker Compose를 안전하게 기동하는 진입점이다.
핵심은 다음 두 가지다.
1. 프로젝트 Windows 경로를 `wslpath`로 WSL 경로로 바꾼다.
2. 그 경로로 이동한 뒤 `docker compose up --build -d`를 수행한다.
### 4.6 `stop_docker_wsl.ps1`
같은 방식으로 WSL 내부에서 `docker compose down`을 수행해 스택을 안전하게 내린다.
### 4.7 `start_docker_wsl.bat`, `stop_docker_wsl.bat`
더블클릭 또는 간단 실행용 래퍼다. 내부적으로 PowerShell 스크립트를 호출한다.
### 4.8 `server.js`
중요 포인트는 다음 두 가지다.
1. `dotenv.config();`를 사용한다.
2. `dotenv.config({ override: true })`를 사용하지 않는다.
이 차이로 Compose 환경변수 `DB_HOST=db``.env`보다 우선하도록 보장한다.
### 4.9 `vite.config.ts`
현재 프록시는 환경변수 기반으로 동작한다.
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
로컬 PC에서 직접 Vite를 띄우면 기본값 `http://localhost:3000`을 쓴다.
컨테이너에서는 Compose가 `http://backend:3000`을 주입한다.
---
## 5. 현재 최종 `docker-compose.yaml` 구조 설명
아래는 실제 동작 관점에서 읽어야 할 핵심 내용이다.
### 5.1 `db` 서비스
역할:
1. 내부 MySQL 데이터 저장소
2. 앱이 최종적으로 붙는 DB
핵심 설정:
1. 이미지: `mysql:8.0`
2. DB 이름: `itam`
3. 앱 계정: `itam_admin`
4. 데이터 볼륨: `itam_mysql_data`
5. healthcheck 사용
healthcheck는 `mysqladmin ping`으로 동작하며, `backend``db-bootstrap`은 이 상태를 기다린다.
### 5.2 `db-bootstrap` 서비스
역할:
1. 외부 원본 DB에서 내부 `db`로 초기 데이터 복제
2. 1회성 작업 후 종료
핵심 포인트:
1. `.env`를 읽어 외부 DB 접속 정보를 가져온다.
2. 내부 `db``asset_core` 테이블이 이미 존재하면 아무 것도 하지 않고 종료한다.
3. 그렇지 않으면 `mysqldump | mysql` 파이프라인으로 복제한다.
4. `restart: "no"` 이므로 정상 종료 후 반복 실행하지 않는다.
또한 source DB와 target DB 변수는 분리돼 있다.
1. source: `SOURCE_DB_*`
2. target: `TARGET_DB_*`
이 구조로 외부 원본 DB 자격증명과 내부 컨테이너 DB 자격증명이 섞이지 않는다.
### 5.3 `backend` 서비스
역할:
1. Express API 제공
2. 내부 `db`에 연결
3. `/uploads` 정적 제공
핵심 포인트:
1. `env_file: .env`를 유지하지만,
2. Compose `environment`에서 `DB_HOST=db`, `DB_PORT=3306`, `DB_USER=itam_admin`, `DB_PASS=itam1234`, `DB_NAME=itam`를 다시 지정한다.
3. `depends_on``db` healthy와 `db-bootstrap` 성공 종료를 모두 기다린다.
즉, 백엔드는 DB bootstrap이 끝난 뒤 시작한다.
### 5.4 `frontend` 서비스
역할:
1. Vite dev server 제공
2. 브라우저 요청 `/api`, `/uploads``backend`로 프록시
핵심 포인트:
1. `VITE_DEV_PROXY_TARGET: http://backend:3000`
2. `CHOKIDAR_USEPOLLING: "true"`
3. `npm run dev -- --host 0.0.0.0`
중요한 이유는 컨테이너 안의 `localhost`가 호스트의 `localhost`가 아니기 때문이다.
---
## 6. 사전 준비 조건
이 저장소를 지금처럼 기동하려면 다음 전제가 필요하다.
### 6.1 운영체제와 런타임
1. Windows
2. WSL2 Ubuntu 설치 및 실행 중
3. Docker CLI가 WSL 내부에서 동작 가능
권장 확인 명령:
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
### 6.2 `.env` 파일
현재 최종 구조는 "첫 기동 시 외부 DB에서 내부 DB로 bootstrap" 하는 방식이므로 `.env`가 반드시 필요하다.
최소한 다음 값은 외부 원본 DB를 가리켜야 한다.
```env
DB_HOST=<external-mysql-host>
DB_PORT=3306
DB_USER=<external-db-user>
DB_PASS=<external-db-password>
DB_NAME=itam
```
주의:
1. `.env``db-bootstrap`이 외부 원본 DB에 접속할 때 사용한다.
2. `backend`는 최종적으로 내부 `db` 컨테이너를 쓰므로, 런타임에서는 Compose `environment`가 우선한다.
### 6.3 한글 경로 주의
현재 프로젝트 경로는 한글과 공백을 포함한다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 때문에 Docker 관련 명령은 수동으로 경로를 조립하지 말고, `start_docker_wsl.ps1` / `stop_docker_wsl.ps1`을 우선 사용해야 한다.
---
## 7. 첫 기동 절차
이 절차는 "Git에서 소스를 받은 뒤 처음 올리는 경우" 기준이다.
### 7.1 저장소 준비
1. 저장소를 받는다.
2. `.env`가 올바른 외부 원본 DB를 가리키는지 확인한다.
3. WSL이 켜져 있는지 확인한다.
### 7.2 권장 실행 방법
Windows PowerShell에서 프로젝트 루트로 이동한 뒤 아래 중 하나를 사용한다.
방법 A:
```powershell
.\start_docker_wsl.ps1
```
방법 B:
```powershell
.\start_docker_wsl.bat
```
### 7.3 내부 실행 순서
스크립트는 내부적으로 다음 순서로 동작한다.
1. 현재 Windows 경로를 WSL 경로로 변환한다.
2. WSL 동작 여부를 확인한다.
3. WSL 내부 Docker 사용 가능 여부를 확인한다.
4. `docker compose up --build -d`를 수행한다.
### 7.4 기대되는 컨테이너 순서
정상이라면 다음 순서로 올라온다.
1. `itam-db`
2. `itam-db-bootstrap`
3. `itam-backend`
4. `itam-frontend`
`itam-db-bootstrap`은 정상이라면 최종 상태가 `Exited (0)`이어야 한다.
---
## 8. 첫 기동 후 검증 절차
기동 후에는 반드시 아래 검증을 수행한다.
### 8.1 컨테이너 상태 확인
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-db` -> `Up ... (healthy)`
2. `itam-db-bootstrap` -> `Exited (0)`
3. `itam-backend` -> `Up`
4. `itam-frontend` -> `Up`
### 8.2 백엔드 API 직접 확인
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.3 프런트 경유 API 확인
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.4 데이터가 실제로 들어왔는지 확인
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES' | head -n 20"
```
정상이라면 아래와 같은 테이블들이 보여야 한다.
1. `asset_core`
2. `asset_remote`
3. `asset_spec`
4. `asset_location`
5. `asset_history`
6. `asset_software_perpetual`
7. `asset_software_subscription`
8. `hardware_components_master`
9. `job_spec_standards`
### 8.5 브라우저 화면 확인
브라우저에서 아래 주소를 연다.
```text
http://localhost:8080
```
목록/대시보드 데이터가 보이면 화면까지 정상 연결된 것이다.
---
## 9. 재기동 절차
코드만 수정됐고 DB는 유지하고 싶다면 다음처럼 하면 된다.
### 9.1 스택 종료
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
### 9.2 스택 재기동
```powershell
.\start_docker_wsl.ps1
```
이 경우 `itam_mysql_data` 볼륨이 유지되므로, `db-bootstrap`은 내부 DB에 `asset_core`가 이미 있음을 감지하고 빠르게 종료한다.
---
## 10. DB를 완전히 다시 초기화하는 절차
외부 원본 DB에서 다시 처음부터 내부 DB를 복제하고 싶다면, MySQL 볼륨을 제거해야 한다.
### 10.1 스택 중지
```powershell
.\stop_docker_wsl.ps1
```
### 10.2 MySQL 데이터 볼륨 삭제
```powershell
wsl sh -lc "docker volume rm -f itam_itam_mysql_data"
```
### 10.3 다시 시작
```powershell
.\start_docker_wsl.ps1
```
이때 `db-bootstrap`이 외부 DB에서 내부 DB로 전체를 다시 복제한다.
---
## 11. 현재 구조에서 꼭 알아야 할 설계 포인트
### 11.1 `server.js`의 `dotenv.config()` 변경 이유
백엔드가 내부 DB로 붙게 하려면 Compose가 준 환경변수가 `.env`보다 우선해야 한다.
만약 아래처럼 `override: true`를 쓰면 안 된다.
```js
dotenv.config({ override: true });
```
이렇게 되면 내부 `db`가 아니라 `.env`의 외부 DB로 다시 붙을 수 있다.
현재는 아래가 맞다.
```js
dotenv.config();
```
### 11.2 왜 `docker-entrypoint-initdb.d` 기반 dump 파일을 안 쓰는가
처음에는 이 방식을 시도했지만, 실제 데이터의 긴 문자열/깨진 텍스트 때문에 import가 line 97에서 중단됐다.
그래서 현재는 더 안정적인 아래 방식을 쓴다.
1. 외부 DB에서 `mysqldump`
2. 파이프로 내부 `db`에 즉시 `mysql` import
즉, 파일 중간 생성물을 신뢰하지 않는 구조다.
### 11.3 왜 프런트 프록시 타깃을 환경변수화했는가
로컬 직접 실행과 컨테이너 실행의 네트워크 기준이 다르기 때문이다.
1. 로컬 직접 실행: `localhost:3000`이 맞다.
2. 컨테이너 내부 실행: `backend:3000`이 맞다.
그래서 `vite.config.ts`는 둘 다 수용할 수 있게 작성됐다.
---
## 12. 문제 발생 시 진단 순서
이 프로젝트에서는 문제를 아래 순서로 자르면 가장 빠르다.
### 12.1 브라우저 화면에 데이터가 없을 때
먼저 다음 둘을 분리해서 본다.
1. `http://localhost:3000/api/assets/master`
2. `http://localhost:8080/api/assets/master`
판단 기준:
1. `3000`은 200이고 `8080`만 실패면 프런트 프록시 문제다.
2. 둘 다 실패면 백엔드 또는 DB 문제다.
### 12.2 DB bootstrap이 성공했는지 확인할 때
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
여기서 `itam-db-bootstrap``Exited (0)`인지 본다.
### 12.3 내부 DB에 실제 데이터가 있는지 확인할 때
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES'"
```
### 12.4 백엔드 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
### 12.5 DB 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-db"
```
### 12.6 프런트 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-frontend"
```
---
## 13. 자주 나올 수 있는 장애와 해석
### 13.1 `docker` 명령이 PowerShell에서 안 보임
의미:
1. Windows 셸이 아니라 WSL에서 Docker를 쓰는 환경이다.
대응:
1. `start_docker_wsl.ps1` 사용
### 13.2 `asset_core` 테이블 없음
의미:
1. 내부 DB 초기화가 안 됐거나 bootstrap이 안 끝났다.
대응:
1. `db-bootstrap` 상태 확인
2. `.env` 외부 DB 접속 정보 확인
3. 필요하면 볼륨 삭제 후 재초기화
### 13.3 `3000` API는 되는데 화면은 비어 있음
의미:
1. DB는 정상이고 프런트 프록시 또는 화면 렌더링 문제다.
대응:
1. `8080/api/assets/master` 상태 먼저 확인
### 13.4 `db-bootstrap`가 실패 종료함
의미 후보:
1. `.env` 외부 DB 접속 정보 오류
2. 외부 DB 네트워크 접근 불가
3. 외부 계정 권한 문제
대응:
1. `docker logs itam-db-bootstrap` 확인
---
## 14. 현재 최종 검증 완료 상태
이 저장소는 아래 상태까지 검증이 완료됐다.
1. WSL2 Ubuntu에서 Docker 실행 가능
2. `start_docker_wsl.ps1`로 전체 스택 기동 가능
3. `db` 컨테이너 healthcheck 통과
4. `db-bootstrap`가 외부 DB에서 내부 DB로 데이터 복제 후 `Exited (0)` 종료
5. `backend`가 내부 `db`를 사용해 API 응답 가능
6. `frontend``backend`를 프록시해 8080 기준 화면/API 동작 가능
7. 내부 MySQL에 실데이터 적재 확인
즉, 현재 Git에 올라간 상태만으로도 WSL2와 외부 원본 DB 정보만 있으면 지금과 같은 수준의 Docker 실행 재현이 가능하다.
---
## 15. 현재 구조의 한계와 다음 단계
현재 구조는 충분히 시연 가능하고 개발 재현도 가능하지만, 다음은 아직 별도 작업이 필요하다.
1. 운영형 정적 배포 구조 전환
2. 외부 DB 없이도 완전 독립 실행 가능한 정식 dump/backup 체계
3. `.env.example` 정리
4. DB bootstrap 전용 계정/권한 최소화
5. 장기적으로 `map_config.json` 파일 저장 정책 정리
하지만 "현재 저장소만으로 지금과 같은 Docker 실행 상태 재현"이라는 목표는 이미 충족한다.
---
## 16. 빠른 실행 요약
가장 짧게 요약하면 다음 순서다.
1. `.env`에 외부 원본 MySQL 접속 정보를 넣는다.
2. WSL2 Ubuntu와 WSL 내부 Docker가 살아 있는지 확인한다.
3. `start_docker_wsl.ps1`를 실행한다.
4. `itam-db-bootstrap``Exited (0)`인지 확인한다.
5. `http://localhost:3000/api/assets/master``http://localhost:8080/api/assets/master`가 모두 200인지 확인한다.
6. 브라우저에서 `http://localhost:8080`을 열어 데이터가 보이는지 확인한다.
이 순서대로 진행하면 현재 저장소 기준 Dockerized ITAM 시연 환경을 재현할 수 있다.
---
## 17. 2026-06-16 최신 정정
이 문서의 상단 본문은 한동안 사용했던 `내부 db + db-bootstrap` 구조를 기준으로 작성됐다. 하지만 오늘 기준 현재 저장소의 실제 `docker-compose.yaml`은 다시 `무상태 앱 컨테이너 + 외부 DB` 구조로 되돌아가 있다.
따라서 현재 시점의 정답 아키텍처는 아래다.
1. `backend` 컨테이너
2. `frontend` 컨테이너
3. 외부 MySQL DB
현재는 더 이상 아래 항목이 없다.
1. `db` 서비스 없음
2. `db-bootstrap` 서비스 없음
3. `itam_mysql_data` 볼륨 없음
### 17.1 현재 실제 `docker-compose.yaml` 기준 backend 동작
현재 backend는 `.env`의 외부 DB 접속 정보를 그대로 사용한다.
즉, 아래 환경변수 매핑이 현재 기준이다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
`PORT: 3000`만 Compose에서 고정한다.
### 17.2 현재 실제 기동 구조
현재 스택 기동 순서는 단순하다.
1. `backend` 기동
2. `frontend` 기동
3. backend는 외부 DB에 직접 접속
4. frontend는 `http://backend:3000`으로 프록시
즉, 현재는 DB 컨테이너 초기화 단계나 bootstrap 단계가 존재하지 않는다.
### 17.3 현재 기준 첫 실행 체크리스트
오늘 기준으로는 아래 순서가 맞다.
1. `.env`에 외부 DB 접속 정보 입력
2. `start_docker_wsl.ps1` 또는 `start_docker_wsl.bat` 실행
3. `http://localhost:3000/api/assets/master`가 200인지 확인
4. `http://localhost:8080/api/assets/master`가 200인지 확인
5. 브라우저에서 `http://localhost:8080` 접속 후 데이터 표시 확인
### 17.4 이 문서에서 현재 유효한 부분과 과거 이력 부분
현재도 그대로 유효한 내용은 아래다.
1. WSL2 기반 실행 방식
2. `start_docker_wsl.ps1` / `stop_docker_wsl.ps1` 사용 방식
3. `server.js`에서 Compose 환경변수가 `.env`보다 우선되도록 `dotenv.config()`를 유지해야 한다는 점
4. `vite.config.ts`에서 프록시 타깃을 환경변수화해야 한다는 점
현재는 과거 이력으로만 읽어야 하는 내용은 아래다.
1. 내부 `db` 서비스 설명
2. `db-bootstrap` 설명
3. `itam_mysql_data` 볼륨 설명
4. 내부 DB 재초기화 절차
5. 내부 테이블 확인 절차
### 17.5 현재 최종 한 줄 요약
오늘 날짜 기준 현재 저장소의 실사용 Compose 구조는 `frontend + backend + external DB`이며, 이전의 내부 DB/bootstrap 구조는 역사적으로 한 번 사용했던 임시 해결책으로만 남아 있다.

48
docker-compose.yaml Normal file
View File

@@ -0,0 +1,48 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: itam-backend
working_dir: /app
env_file:
- .env
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
PORT: 3000
ports:
- "3000:3000"
volumes:
- ./:/app
- backend_node_modules:/app/node_modules
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json
command: npm run server
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: itam-frontend
working_dir: /app
depends_on:
- backend
environment:
CHOKIDAR_USEPOLLING: "true"
VITE_DEV_PROXY_TARGET: http://backend:3000
ports:
- "8080:8080"
volumes:
- ./:/app
- frontend_node_modules:/app/node_modules
command: npm run dev -- --host 0.0.0.0
restart: unless-stopped
volumes:
backend_node_modules:
frontend_node_modules:

View File

@@ -0,0 +1,16 @@
# MySQL init directory
This directory is kept as a legacy hook for file-based MySQL initialization.
Current production path in this repository is not file-based import.
The live Docker flow uses the `db-bootstrap` service in `docker-compose.yaml` to stream data from the external source DB into the internal `db` container.
Use this directory only if you intentionally switch back to `docker-entrypoint-initdb.d` style initialization.
If you do that, typical naming would be:
- `01_schema.sql`
- `02_seed.sql`
- or a single `01_itam_dump.sql`
Remember that files in this directory are executed automatically by the MySQL container only on the first initialization of the data volume.

330
docker_task_plan.md Normal file
View File

@@ -0,0 +1,330 @@
# ITAM 도커라이징 작업 태스크 정리
## 1. 문서 목적
이 문서는 ITAM 자산관리 시스템의 도커라이징 작업을 실제 실행 단위로 쪼개서 정리한 태스크 문서다.
이 문서의 목표는 아래와 같다.
1. 내일까지 보여줄 시연 범위를 기준으로 우선순위를 정한다.
2. 시연용 작업과 운영형 전환 작업을 분리한다.
3. 개발 담당자가 바로 실행할 수 있는 체크리스트를 제공한다.
관련 배경과 구조 분석은 [doc_readme.md](c:/Users/user/Desktop/안건%20파일/itam/doc_readme.md) 문서를 기준으로 한다.
현재 구현/검증 상태:
- `Dockerfile.frontend` 생성 완료
- `Dockerfile.backend` 생성 완료
- `docker-compose.yaml` 생성 완료
- `.dockerignore` 생성 완료
- WSL2 Ubuntu에서 `docker compose up --build -d` 검증 완료
- frontend 8080 응답 확인 완료
- backend `/api/assets/master` 응답 확인 완료
- 현재 DB는 external MySQL 기준이며, DB 컨테이너 추가 작업은 다음 단계로 남아 있음
## 2. 이번 작업의 최우선 목표
이번 도커라이징의 1차 목표는 "운영 배포 완료"가 아니라 아래 상태를 재현하는 것이다.
1. frontend 컨테이너가 정상 기동한다.
2. backend 컨테이너가 정상 기동한다.
3. backend가 기존 외부 MySQL 또는 MySQL 컨테이너에 정상 연결된다.
4. 브라우저에서 화면이 열린다.
5. 핵심 API 호출이 정상 동작한다.
6. 업로드 저장 경로가 유지된다.
7. 필요 시 DB까지 함께 포함된 재현 가능한 스택을 제공한다.
## 3. 작업 범위 구분
### 3.1 이번 시연 범위에 포함
- Dockerfile.frontend 초안 작성
- Dockerfile.backend 초안 작성
- docker-compose.yaml 작성
- `.dockerignore` 작성
- MySQL 컨테이너 추가 설계
- 초기 SQL dump 또는 init SQL 적재 방식 정의
- `uploads` 볼륨 처리
- `map_config.json` 영속성 처리 방식 반영
- 컨테이너 기동 및 접속 확인
- 핵심 API 및 화면 확인
### 3.2 이번 시연 범위에서 제외
- DB 전체 마이그레이션 자동화
- nginx 기반 운영 배포 구조
- 단일 이미지 운영 구조 전환
- CI/CD 연계
## 4. 선행 확인 태스크
아래 태스크는 실제 Docker 파일 작성 전에 먼저 확인해야 한다.
### Task 1. 외부 MySQL 접근 가능 여부 확인
- 목적: 컨테이너에서 외부 DB 접속이 가능한지 확인
- 확인 항목:
- DB_HOST 접근 가능 여부
- DB_PORT 3306 접속 가능 여부
- 계정 권한 정상 여부
- 완료 기준:
- backend 컨테이너 기준 DB 연결 에러가 발생하지 않음
### Task 2. 기준 스키마 상태 확인
- 목적: 현재 앱이 요구하는 테이블 구조가 실제 DB와 맞는지 확인
- 확인 항목:
- `asset_core`
- `asset_spec`
- `asset_location`
- `asset_remote`
- `asset_history`
- `hardware_components_master`
- `job_spec_standards`
- 완료 기준:
- `/api/assets/master` 호출 시 쿼리 에러가 발생하지 않음
### Task 3. 파일 영속성 대상 확인
- 목적: 컨테이너 재시작 이후에도 유지되어야 할 파일/폴더 식별
- 대상:
- `uploads`
- `map_config.json`
- 완료 기준:
- 볼륨 설계 대상이 명확하게 문서화됨
### Task 4. DB 기준 데이터 소스 확정
- 목적: MySQL 컨테이너 최초 기동 시 어떤 데이터로 초기화할지 결정
- 선택지:
- 기존 사내 DB에서 추출한 SQL dump 사용
- 정리된 스키마 SQL + seed SQL 사용
- 수동 import 절차 사용
- 완료 기준:
- `docker/mysql/init` 기준 적재 전략 또는 수동 복원 절차가 확정됨
## 5. 시연용 도커라이징 태스크
### Task 5. 프런트 Dockerfile 작성
- 목적: Vite 개발 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 8080 포트 노출
- `npm run dev -- --host 0.0.0.0` 실행
- 산출물:
- `Dockerfile.frontend`
- 완료 기준:
- 컨테이너에서 8080 포트가 정상 listen 상태가 됨
### Task 6. 백엔드 Dockerfile 작성
- 목적: Express API 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 3000 포트 노출
- `npm run server` 실행
- 산출물:
- `Dockerfile.backend`
- 완료 기준:
- 컨테이너에서 3000 포트가 정상 listen 상태가 됨
### Task 7. MySQL Docker 구성 추가
- 목적: DB까지 포함한 재현 가능한 스택 구성
- 작업 내용:
- `mysql:8.0` 서비스 정의
- `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` 설정
- utf8mb4 문자셋 옵션 반영
- MySQL 데이터 volume 연결
- 초기 SQL 적재용 `docker/mysql/init` 디렉터리 설계
- 산출물:
- `docker-compose.yaml``db` 서비스 또는 별도 DB compose 확장안
- 완료 기준:
- MySQL 컨테이너가 정상 기동하고 3306 포트에서 응답 가능
### Task 8. backend DB 연결 전환
- 목적: backend가 external MySQL 대신 DB 컨테이너를 바라보도록 변경
- 작업 내용:
- `DB_HOST``db`로 전환
- 필요 시 `.env.docker` 또는 compose 내부 환경변수 사용
- backend `depends_on`에 db 추가
- 산출물:
- DB 컨테이너용 backend 환경 정의
- 완료 기준:
- backend 로그에서 DB 연결 성공 확인
### Task 9. docker-compose.yaml 확장
- 목적: frontend/backend를 함께 기동
- 작업 내용:
- frontend 서비스 정의
- backend 서비스 정의
- db 서비스 정의
- 포트 매핑 추가
- `.env` 또는 docker 전용 환경변수 연결
- MySQL 데이터 볼륨 연결
- `uploads` 볼륨 연결
- `map_config.json` 처리 방식 반영
- 산출물:
- `docker-compose.yaml`
- 완료 기준:
- `docker compose up --build` 한 번으로 세 서비스가 모두 올라옴
### Task 10. `.dockerignore` 작성
- 목적: 불필요한 빌드 컨텍스트 제외
- 제외 권장 항목:
- `node_modules`
- `dist`
- `build`
- `.git`
- `uploads`
- `*.xlsx`
- 산출물:
- `.dockerignore`
- 완료 기준:
- 이미지 빌드 컨텍스트가 과도하게 커지지 않음
## 6. 시연 검증 태스크
### Task 11. WSL 컨테이너 기동 검증
- 실행 명령:
```bash
powershell -ExecutionPolicy Bypass -File .\start_docker_wsl.ps1
```
- 확인 항목:
- frontend 로그 에러 여부
- backend 로그 에러 여부
- db 로그 에러 여부
- backend와 db 연결 성공 여부
- 완료 기준:
- 세 컨테이너 모두 종료 없이 유지됨
### Task 12. 웹 접속 검증
- 확인 항목:
- `http://localhost:8080` 접속 가능 여부
- 첫 화면 로딩 여부
- 콘솔 에러 여부
- 완료 기준:
- 브라우저에서 초기 화면이 정상 표시됨
### Task 13. API 검증
- 확인 항목:
- `http://localhost:3000/api/assets/master`
- 프런트에서 `/api/assets/master` 호출 정상 여부
- 완료 기준:
- 200 응답 또는 정상 데이터 응답 확인
### Task 14. DB 초기 데이터 검증
- 확인 항목:
- MySQL 컨테이너 내부에 목표 DB가 생성되었는지
- 기준 테이블이 존재하는지
- 샘플 데이터 또는 실데이터가 적재되었는지
- 완료 기준:
- backend가 기대하는 최소 테이블과 데이터가 실제로 조회됨
### Task 15. 업로드/파일 저장 검증
- 확인 항목:
- `/api/upload` 호출 정상 여부
- 업로드 파일이 `uploads`에 실제 저장되는지
- `map_config.json` 수정 내용이 유지되는지
- 완료 기준:
- 컨테이너 재시작 후에도 저장 데이터가 유지됨
## 7. 시연 후 후속 태스크
### Task 16. 운영형 프런트 배포 구조 전환
- 목표: Vite dev server 대신 정적 빌드 기반 구조로 전환
- 후보:
- nginx 정적 서빙
- Express 정적 서빙
### Task 17. DB 초기화/마이그레이션 전략 통합
- 목표: 기준 스키마와 실행 순서를 단일 정책으로 통일
- 필요 작업:
- 기준 스키마 선정
- 초기화 스크립트 확정
- 마이그레이션 순서 정의
### Task 18. `.env.example` 및 배포 환경 분리
- 목표: 민감정보를 저장소에서 분리하고 배포별 설정 체계화
### Task 19. 운영 볼륨 및 백업 전략 정리
- 목표: 업로드 파일과 설정 파일, MySQL 데이터의 장기 보존 정책 정리
### Task 20. DB 백업/복원 절차 문서화
- 목표: 컨테이너 DB를 기준으로 dump/restore 절차를 문서화
## 8. 우선순위 정리
### P0: 내일까지 반드시 필요한 작업
1. Task 1. 외부 MySQL 접근 가능 여부 확인
2. Task 2. 기준 스키마 상태 확인
3. Task 4. DB 기준 데이터 소스 확정
4. Task 7. MySQL Docker 구성 추가
5. Task 8. backend DB 연결 전환
6. Task 9. docker-compose.yaml 확장
7. Task 11. WSL 컨테이너 기동 검증
8. Task 12. 웹 접속 검증
9. Task 13. API 검증
10. Task 14. DB 초기 데이터 검증
### P1: 시연 안정화를 위해 권장되는 작업
1. Task 3. 파일 영속성 대상 확인
2. Task 10. `.dockerignore` 작성
3. Task 15. 업로드/파일 저장 검증
### P2: 시연 이후 진행할 작업
1. Task 16. 운영형 프런트 배포 구조 전환
2. Task 17. DB 초기화/마이그레이션 전략 통합
3. Task 18. `.env.example` 및 배포 환경 분리
4. Task 19. 운영 볼륨 및 백업 전략 정리
5. Task 20. DB 백업/복원 절차 문서화
## 9. 개발자용 최종 작업 순서 제안
개발 담당자에게는 아래 순서로 진행하라고 전달하면 된다.
1. 외부 DB 연결 가능 여부부터 확인
2. 현재 DB 스키마가 앱 요구사항과 맞는지 확인
3. DB 기준 dump 또는 init SQL 확보
4. MySQL 컨테이너 구성 추가
5. backend의 DB 연결 대상을 `db`로 전환
6. WSL에서 `docker compose config` 확인
7. WSL에서 컨테이너 기동 테스트
8. 웹 접속 및 API 확인
9. 업로드 및 파일 영속성 확인
10. 시연 완료 후 운영형 구조로 분리 작업 진행
## 10. 완료 판단 기준
이번 도커라이징 1차 작업은 아래 조건을 만족하면 완료로 본다.
1. `docker compose up --build`로 프런트, 백엔드, DB가 모두 기동한다.
2. 브라우저에서 8080 화면이 열린다.
3. `/api/assets/master`가 정상 응답한다.
4. backend가 DB 컨테이너와 정상 연결된다.
5. DB 초기 테이블과 데이터가 기대 상태로 적재된다.
6. `uploads`, `map_config.json`, MySQL 데이터가 재시작 후에도 유지된다.
이 문서는 실제 구현 작업의 체크리스트로 사용한다.

44
drop_legacy.js Normal file
View File

@@ -0,0 +1,44 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function dropLegacyTables() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🧹 Starting cleanup of obsolete legacy backup tables...');
const tablesToDrop = [
'asset_pc', 'asset_pc_backup',
'asset_server', 'asset_server_backup',
'asset_storage', 'asset_storage_backup',
'asset_remote_backup', // IMPORTANT: DO NOT drop asset_remote!
'asset_equipment', 'asset_equipment_backup',
'asset_office_supplies', 'asset_office_supplies_backup',
'asset_survey', 'asset_survey_backup',
'asset_vip', 'asset_vip_backup',
'asset_pc_parts'
];
for (const table of tablesToDrop) {
try {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
console.log(`✅ Dropped table: ${table}`);
} catch (err) {
console.warn(`⚠️ Failed to drop table ${table}: ${err.message}`);
}
}
console.log('🎉 Cleanup complete. Database is now lean and mean.');
await connection.end();
}
dropLegacyTables().catch(console.error);

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

@@ -9,6 +9,7 @@
<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" />
<link rel="stylesheet" href="/src/styles/common.css" /> <link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/login.css" />
<link rel="stylesheet" href="/src/styles/guide.css" /> <link rel="stylesheet" href="/src/styles/guide.css" />
<link rel="stylesheet" href="/src/styles/modal.css" /> <link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" /> <link rel="stylesheet" href="/src/styles/dashboard.css" />
@@ -18,7 +19,7 @@
</head> </head>
<body> <body>
<div class="app-layout"> <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">
@@ -33,6 +34,14 @@
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<div class="role-switcher" id="role-switcher">
<span class="role-label user active">실무자</span>
<label class="switch">
<input type="checkbox" id="role-toggle-checkbox">
<span class="slider round"></span>
</label>
<span class="role-label admin">관리자</span>
</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> 가이드

768
map_config.json Normal file
View File

@@ -0,0 +1,768 @@
{
"img/location_photo/IDC/서관205.png": [
{
"x": "50.78",
"y": "1.53",
"w": "45.83",
"h": "6.10"
},
{
"x": "50.67",
"y": "10.35",
"w": "45.95",
"h": "5.99"
},
{
"x": "50.78",
"y": "19.06",
"w": "45.83",
"h": "6.32"
},
{
"x": "50.67",
"y": "27.89",
"w": "46.06",
"h": "6.32"
},
{
"x": "50.78",
"y": "36.71",
"w": "45.95",
"h": "6.21"
},
{
"x": "50.78",
"y": "45.64",
"w": "45.83",
"h": "6.32"
},
{
"x": "50.67",
"y": "54.25",
"w": "46.06",
"h": "6.54"
},
{
"x": "50.90",
"y": "63.29",
"w": "45.72",
"h": "5.99"
},
{
"x": "50.90",
"y": "72.00",
"w": "45.72",
"h": "6.32"
},
{
"x": "50.78",
"y": "81.92",
"w": "18.40",
"h": "15.58"
},
{
"x": "78.67",
"y": "82.03",
"w": "17.94",
"h": "15.25"
}
],
"img/location_photo/IDC/서관202.png": [
{
"x": "56.35",
"y": "64.02",
"w": "40.41",
"h": "5.89"
},
{
"x": "56.35",
"y": "71.57",
"w": "40.66",
"h": "5.89"
},
{
"x": "56.23",
"y": "79.25",
"w": "40.53",
"h": "5.76"
},
{
"x": "55.98",
"y": "86.42",
"w": "41.15",
"h": "6.27"
}
],
"img/location_photo/IDC/서관203.png": [
{
"x": "56.07",
"y": "2.44",
"w": "40.91",
"h": "6.40"
},
{
"x": "56.07",
"y": "10.12",
"w": "40.79",
"h": "6.27"
},
{
"x": "55.95",
"y": "17.80",
"w": "41.04",
"h": "6.14"
},
{
"x": "55.95",
"y": "63.51",
"w": "40.91",
"h": "6.14"
},
{
"x": "55.95",
"y": "71.19",
"w": "41.04",
"h": "6.14"
},
{
"x": "56.07",
"y": "87.70",
"w": "40.91",
"h": "6.02"
}
],
"img/location_photo/IDC/서관204.png": [
{
"x": "48.87",
"y": "2.57",
"w": "47.40",
"h": "6.14"
},
{
"x": "49.01",
"y": "10.38",
"w": "47.40",
"h": "5.89"
},
{
"x": "48.87",
"y": "17.93",
"w": "47.40",
"h": "5.89"
},
{
"x": "48.73",
"y": "25.49",
"w": "47.69",
"h": "6.27"
},
{
"x": "48.87",
"y": "33.17",
"w": "47.40",
"h": "6.02"
},
{
"x": "48.87",
"y": "40.59",
"w": "47.54",
"h": "6.40"
},
{
"x": "48.87",
"y": "48.40",
"w": "47.54",
"h": "6.14"
},
{
"x": "48.73",
"y": "55.95",
"w": "47.69",
"h": "6.14"
},
{
"x": "49.01",
"y": "63.63",
"w": "47.40",
"h": "6.14"
},
{
"x": "48.73",
"y": "71.06",
"w": "47.54",
"h": "6.27"
},
{
"x": "48.87",
"y": "78.74",
"w": "47.40",
"h": "6.27"
},
{
"x": "49.01",
"y": "86.68",
"w": "18.76",
"h": "12.29"
}
],
"img/location_photo/IDC/동관53.png": [
{
"x": "61.62",
"y": "3.08",
"w": "35.63",
"h": "7.55"
},
{
"x": "61.53",
"y": "12.68",
"w": "35.80",
"h": "7.30"
},
{
"x": "61.70",
"y": "21.65",
"w": "35.63",
"h": "7.68"
}
],
"img/location_photo/IDC/동관54.png": [
{
"x": "54.71",
"y": "2.57",
"w": "42.21",
"h": "6.27"
},
{
"x": "54.71",
"y": "10.38",
"w": "42.21",
"h": "6.14"
},
{
"x": "54.71",
"y": "27.15",
"w": "41.97",
"h": "6.27"
},
{
"x": "54.71",
"y": "43.54",
"w": "42.09",
"h": "6.02"
},
{
"x": "54.71",
"y": "54.93",
"w": "42.09",
"h": "6.40"
},
{
"x": "54.83",
"y": "70.16",
"w": "42.09",
"h": "6.27"
},
{
"x": "54.71",
"y": "79.51",
"w": "42.09",
"h": "6.14"
}
],
"img/location_photo/기술개발센터/서버실_1.png": [
{
"x": "69.45",
"y": "1.10",
"w": "8.58",
"h": "11.45"
},
{
"x": "79.21",
"y": "1.10",
"w": "11.65",
"h": "11.45"
},
{
"x": "90.16",
"y": "23.23",
"w": "8.43",
"h": "21.11"
},
{
"x": "52.91",
"y": "53.35",
"w": "8.66",
"h": "21.11"
},
{
"x": "62.36",
"y": "53.47",
"w": "8.43",
"h": "21.11"
},
{
"x": "71.65",
"y": "53.47",
"w": "8.50",
"h": "20.98"
},
{
"x": "80.87",
"y": "53.35",
"w": "8.35",
"h": "21.23"
},
{
"x": "90.08",
"y": "53.35",
"w": "8.58",
"h": "21.11"
},
{
"x": "43.78",
"y": "76.38",
"w": "8.50",
"h": "21.11"
},
{
"x": "53.15",
"y": "76.38",
"w": "8.43",
"h": "21.23"
},
{
"x": "62.44",
"y": "76.51",
"w": "8.35",
"h": "20.98"
},
{
"x": "71.57",
"y": "76.25",
"w": "8.43",
"h": "21.11"
},
{
"x": "81.02",
"y": "76.64",
"w": "8.27",
"h": "20.85"
},
{
"x": "90.24",
"y": "76.64",
"w": "8.50",
"h": "20.98"
}
],
"img/location_photo/기술개발센터/서버실_2.png": [
{
"x": "49.60",
"y": "1.93",
"w": "46.96",
"h": "6.53"
},
{
"x": "49.34",
"y": "11.92",
"w": "47.09",
"h": "6.66"
},
{
"x": "49.34",
"y": "21.39",
"w": "47.35",
"h": "6.40"
},
{
"x": "49.47",
"y": "30.73",
"w": "47.22",
"h": "6.40"
},
{
"x": "49.34",
"y": "39.82",
"w": "47.22",
"h": "6.53"
},
{
"x": "49.47",
"y": "49.68",
"w": "47.09",
"h": "6.91"
},
{
"x": "49.60",
"y": "59.28",
"w": "46.82",
"h": "6.27"
},
{
"x": "49.34",
"y": "68.63",
"w": "47.35",
"h": "6.40"
},
{
"x": "49.47",
"y": "77.84",
"w": "46.82",
"h": "6.40"
},
{
"x": "49.60",
"y": "86.93",
"w": "46.82",
"h": "6.53"
}
],
"img/location_photo/한맥빌딩/MDF실/MDF_1.png": [
{
"x": "49.33",
"y": "14.99",
"w": "7.13",
"h": "11.01"
},
{
"x": "59.23",
"y": "14.73",
"w": "7.13",
"h": "11.14"
},
{
"x": "69.22",
"y": "14.86",
"w": "7.13",
"h": "11.14"
},
{
"x": "78.96",
"y": "14.99",
"w": "7.30",
"h": "11.01"
},
{
"x": "89.03",
"y": "14.99",
"w": "7.05",
"h": "11.14"
},
{
"x": "48.57",
"y": "34.19",
"w": "7.39",
"h": "11.14"
},
{
"x": "56.80",
"y": "34.06",
"w": "7.22",
"h": "11.27"
},
{
"x": "64.94",
"y": "34.19",
"w": "7.30",
"h": "11.01"
},
{
"x": "72.83",
"y": "34.19",
"w": "7.47",
"h": "10.88"
},
{
"x": "81.22",
"y": "34.06",
"w": "7.22",
"h": "11.14"
},
{
"x": "89.36",
"y": "34.19",
"w": "7.13",
"h": "11.01"
},
{
"x": "48.66",
"y": "53.52",
"w": "9.06",
"h": "20.99"
},
{
"x": "58.48",
"y": "53.27",
"w": "9.15",
"h": "21.12"
},
{
"x": "68.55",
"y": "53.27",
"w": "9.06",
"h": "21.12"
},
{
"x": "78.54",
"y": "53.39",
"w": "8.90",
"h": "21.25"
},
{
"x": "89.36",
"y": "53.27",
"w": "7.39",
"h": "9.99"
},
{
"x": "89.36",
"y": "64.92",
"w": "7.39",
"h": "9.60"
},
{
"x": "48.57",
"y": "77.08",
"w": "9.40",
"h": "21.38"
},
{
"x": "58.56",
"y": "77.20",
"w": "9.23",
"h": "21.12"
},
{
"x": "68.63",
"y": "77.33",
"w": "9.06",
"h": "21.12"
},
{
"x": "78.71",
"y": "77.46",
"w": "8.98",
"h": "20.99"
}
],
"img/location_photo/한맥빌딩/MDF실/MDF_2.png": [
{
"x": "56.59",
"y": "44.43",
"w": "40.35",
"h": "6.78"
},
{
"x": "56.71",
"y": "54.80",
"w": "40.24",
"h": "6.53"
},
{
"x": "56.71",
"y": "65.94",
"w": "40.24",
"h": "6.40"
}
],
"img/location_photo/한맥빌딩/MDF실/MDF_3.png": [
{
"x": "56.71",
"y": "13.20",
"w": "40.24",
"h": "6.78"
},
{
"x": "56.48",
"y": "23.57",
"w": "40.58",
"h": "6.53"
},
{
"x": "56.59",
"y": "34.57",
"w": "40.58",
"h": "6.27"
},
{
"x": "56.59",
"y": "44.69",
"w": "40.46",
"h": "6.66"
},
{
"x": "56.71",
"y": "54.80",
"w": "40.24",
"h": "6.66"
},
{
"x": "56.71",
"y": "65.81",
"w": "40.24",
"h": "6.53"
},
{
"x": "56.59",
"y": "76.05",
"w": "40.35",
"h": "6.53"
}
],
"img/location_photo/한맥빌딩/MDF실/MDF_4.png": [
{
"x": "52.36",
"y": "64.02",
"w": "44.38",
"h": "6.53"
}
],
"img/location_photo/기술개발센터/서버실/서버실_1.png": [
{
"x": "69.53",
"y": "1.42",
"w": "8.58",
"h": "11.45"
},
{
"x": "79.21",
"y": "1.55",
"w": "11.97",
"h": "11.32"
},
{
"x": "90.24",
"y": "23.30",
"w": "8.50",
"h": "21.49"
},
{
"x": "53.07",
"y": "53.28",
"w": "8.74",
"h": "21.62"
},
{
"x": "62.28",
"y": "53.41",
"w": "8.82",
"h": "21.49"
},
{
"x": "71.50",
"y": "53.28",
"w": "8.90",
"h": "21.75"
},
{
"x": "80.87",
"y": "53.15",
"w": "8.66",
"h": "21.75"
},
{
"x": "90.08",
"y": "53.54",
"w": "8.90",
"h": "21.49"
},
{
"x": "43.86",
"y": "76.32",
"w": "8.82",
"h": "21.75"
},
{
"x": "53.15",
"y": "76.45",
"w": "8.66",
"h": "21.49"
},
{
"x": "62.52",
"y": "76.57",
"w": "8.58",
"h": "21.62"
},
{
"x": "71.65",
"y": "76.45",
"w": "8.66",
"h": "21.62"
},
{
"x": "80.94",
"y": "76.57",
"w": "8.74",
"h": "21.49"
},
{
"x": "90.24",
"y": "76.57",
"w": "8.50",
"h": "21.36"
}
],
"img/location_photo/기술개발센터/서버실/서버실_2.png": [
{
"x": "49.47",
"y": "1.80",
"w": "47.49",
"h": "7.04"
},
{
"x": "49.47",
"y": "12.04",
"w": "47.49",
"h": "6.91"
},
{
"x": "49.60",
"y": "21.52",
"w": "47.35",
"h": "6.91"
},
{
"x": "49.47",
"y": "30.48",
"w": "47.49",
"h": "7.04"
},
{
"x": "49.60",
"y": "39.82",
"w": "47.49",
"h": "6.91"
},
{
"x": "49.47",
"y": "50.06",
"w": "47.62",
"h": "6.91"
},
{
"x": "49.74",
"y": "59.28",
"w": "47.22",
"h": "6.91"
},
{
"x": "49.34",
"y": "68.37",
"w": "47.75",
"h": "7.04"
},
{
"x": "49.60",
"y": "77.97",
"w": "47.22",
"h": "6.91"
},
{
"x": "49.60",
"y": "86.93",
"w": "47.35",
"h": "7.17"
}
]
}

42
map_editor.html Normal file
View File

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

197
migrate_schema.js Normal file
View File

@@ -0,0 +1,197 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateSchema() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 1: Creating Normalized Tables & Migrating Data...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// --- 1. Drop existing new tables if they exist ---
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
// --- 2. Create New Schema ---
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log('✅ Normalized tables created.');
// --- 3. Migrate Data from Legacy Tables ---
const legacyTables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'];
let totalMigrated = 0;
for (const table of legacyTables) {
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
for (const row of rows) {
// 3.1 Insert into asset_core
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 3.2 Insert into asset_hardware (if hardware fields exist)
if (row.model_name || row.cpu || row.ram || row.hw_status) {
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.hdd_1, row.ssd_2 || row.hdd_2, row.hdd_3, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
}
// 3.3 Insert into asset_location (if location fields exist)
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 3.4 Insert into asset_remote (if network fields exist)
// Handle primary network interface
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Handle secondary network interface (e.g., from server table) if it exists
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
console.log(`- Migrated ${rows.length} records from ${table}`);
} catch (err) {
console.warn(`- Skipping legacy table ${table}: ${err.message}`);
}
}
console.log(`✅ Phase 1 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateSchema();

212
migrate_v2_final.js Normal file
View File

@@ -0,0 +1,212 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateV2() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 2: Final Migration to Normalized V2 Schema...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// 1. Create/Enhance Core Tables
console.log('1. Creating/Enhancing Tables...');
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
current_role VARCHAR(50) DEFAULT 'Normal' COMMENT 'Normal, Server, Personal, etc.',
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
storage4 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ V2 Schema tables created.');
// 2. Migration Logic
const legacyTables = [
{ name: 'asset_pc', defaultRole: 'Personal' },
{ name: 'asset_server', defaultRole: 'Server' },
{ name: 'asset_storage', defaultRole: 'Normal' },
{ name: 'asset_equipment', defaultRole: 'Normal' },
{ name: 'asset_office_supplies', defaultRole: 'Normal' },
{ name: 'asset_survey', defaultRole: 'Normal' },
{ name: 'asset_vip', defaultRole: 'Normal' },
{ name: 'asset_pc_parts', defaultRole: 'Normal' }
];
let totalMigrated = 0;
for (const tableInfo of legacyTables) {
const table = tableInfo.name;
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
console.log(`- Migrating ${rows.length} records from ${table}...`);
for (const row of rows) {
// 2.1 Insert into asset_core
const role = (table === 'asset_pc' && row.asset_type === '서버PC') ? 'Server' : tableInfo.defaultRole;
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, current_role, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, role, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current || row.current_user, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 2.2 Insert into asset_hardware
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, storage4, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.storage1, row.ssd_2 || row.storage2, row.hdd_1 || row.storage3, row.hdd_2, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
// 2.3 Insert into asset_location
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 2.4 Insert into asset_remote
// Primary Network
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Secondary Network (for servers)
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
} catch (err) {
console.warn(`- Skipping table ${table}: ${err.message}`);
}
}
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log(`✅ Phase 2 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateV2();

73
migrate_v4_network.js Normal file
View File

@@ -0,0 +1,73 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Creating asset_remote_v4 table...');
await conn.query(`
CREATE TABLE IF NOT EXISTS asset_remote_v4 (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
net_type VARCHAR(20) NOT NULL, /* 'IP' or 'REMOTE' */
net_name VARCHAR(100), /* e.g., '기본망', 'AnyDesk' */
net_value1 VARCHAR(100), /* IP or ID */
net_value2 VARCHAR(100), /* MAC or PW */
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('2. Migrating data from asset_remote...');
const [oldRows] = await conn.query('SELECT * FROM asset_remote WHERE is_active = 1');
let ipCount = 0;
let remoteCount = 0;
for (const row of oldRows) {
// Migrating IP/MAC
if (row.ip_address || row.mac_address) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'IP', '기본망', row.ip_address, row.mac_address, row.created_at]
);
ipCount++;
}
// Migrating Remote
if (row.remote_tool || row.remote_id || row.remote_pw) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'REMOTE', row.remote_tool, row.remote_id, row.remote_pw, row.created_at]
);
remoteCount++;
}
}
console.log(`Migrated ${ipCount} IP records and ${remoteCount} Remote records.`);
console.log('3. Renaming tables...');
await conn.query('DROP TABLE IF EXISTS asset_remote_legacy');
await conn.query('RENAME TABLE asset_remote TO asset_remote_legacy, asset_remote_v4 TO asset_remote;');
console.log('✅ Migration V4 (Remote) Complete.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

View File

@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Renaming asset_network to asset_remote...');
await conn.query('RENAME TABLE asset_network TO asset_remote');
console.log('✅ Table renamed successfully.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

195
migrate_v6_parts_master.js Normal file
View File

@@ -0,0 +1,195 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config({ override: true });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 기존의 감점 계산 로직을 그대로 이용해 등급과 감점점수를 도출하는 헬퍼 함수
function parseCpu(cpu) {
if (!cpu) return { tier: '기타', deduction: 30 };
const cpuUpper = cpu.toUpperCase().trim();
if (cpuUpper === '-' || cpuUpper === '') return { tier: '기타', deduction: 30 };
let tier = '기타';
let deduction = 30;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
tier = 'i9 / Ryzen 9';
deduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
tier = 'i7 / Ryzen 7';
deduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
tier = 'i5 / Ryzen 5';
deduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
tier = 'i3 / Ryzen 3';
deduction = 25;
}
// CPU 세대 감점 계산 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
// 최종 등급 감점 + 세대 감점 합산
return { tier, deduction: deduction + genDeduction };
}
function parseGpu(gpu) {
if (!gpu) return { tier: 'C', deduction: 25 };
const gpuUpper = gpu.toUpperCase().trim();
if (gpuUpper === '-' || gpuUpper === '') return { tier: 'C', deduction: 25 };
if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
return { tier: 'S', deduction: 0 };
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
return { tier: 'A', deduction: 5 };
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
return { tier: 'B', deduction: 15 };
} else {
return { tier: 'C', deduction: 25 };
}
}
function parseRam(ram) {
if (!ram) return { tier: '부족', deduction: 25 };
const ramUpper = ram.toUpperCase().trim();
if (ramUpper === '-' || ramUpper === '') return { tier: '부족', deduction: 25 };
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) return { tier: '최적', deduction: 0 };
else if (ramVal >= 16) return { tier: '보통', deduction: 10 };
else if (ramVal >= 8) return { tier: '주의', deduction: 20 };
}
return { tier: '부족', deduction: 25 };
}
async function runMigration() {
console.log('🔄 DB 커넥션 연결 중...');
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
try {
console.log('⚙️ 1. hardware_components_master 테이블 생성...');
await connection.query('DROP TABLE IF EXISTS hardware_components_master');
await connection.query(`
CREATE TABLE hardware_components_master (
id INT AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(50) NOT NULL COMMENT 'CPU, GPU, RAM 등',
component_name VARCHAR(255) NOT NULL UNIQUE COMMENT '부품 표준 명칭',
score_tier VARCHAR(50) COMMENT '성능 등급',
deduction INT DEFAULT 0 COMMENT '감점 점수',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 테이블 생성 완료.');
console.log('🔍 2. 기존 asset_spec 테이블에서 부품명 조회...');
const [specRows] = await connection.query('SELECT DISTINCT cpu, ram, gpu FROM asset_spec');
const uniqueCpus = new Set();
const uniqueGpus = new Set();
const uniqueRams = new Set();
specRows.forEach(row => {
if (row.cpu && row.cpu.trim() !== '-' && row.cpu.trim() !== '') uniqueCpus.add(row.cpu.trim());
if (row.gpu && row.gpu.trim() !== '-' && row.gpu.trim() !== '') uniqueGpus.add(row.gpu.trim());
if (row.ram && row.ram.trim() !== '-' && row.ram.trim() !== '') uniqueRams.add(row.ram.trim());
});
// 만약 데이터가 너무 비어있을 경우를 대비하여 기본 대표 부품 몇 개 추가
if (uniqueCpus.size === 0) {
['Intel Core i9-13900K', 'Intel Core i7-14700K', 'Intel Core i5-12400', 'AMD Ryzen 7 7800X3D', 'Intel Core i3-10100'].forEach(c => uniqueCpus.add(c));
}
if (uniqueGpus.size === 0) {
['NVIDIA GeForce RTX 4090', 'NVIDIA GeForce RTX 4070', 'NVIDIA GeForce RTX 3060', 'Intel Iris Xe Graphics', 'NVIDIA GeForce GTX 1660 Super'].forEach(g => uniqueGpus.add(g));
}
if (uniqueRams.size === 0) {
['8GB', '16GB', '32GB', '64GB'].forEach(r => uniqueRams.add(r));
}
console.log(` - 추출된 CPU 개수: ${uniqueCpus.size}`);
console.log(` - 추출된 GPU 개수: ${uniqueGpus.size}`);
console.log(` - 추출된 RAM 개수: ${uniqueRams.size}`);
console.log('💾 3. 마스터 테이블에 부품 데이터 및 감점 정보 삽입...');
// CPU 삽입
for (const cpu of uniqueCpus) {
const { tier, deduction } = parseCpu(cpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['CPU', cpu, tier, deduction]
);
}
// GPU 삽입
for (const gpu of uniqueGpus) {
const { tier, deduction } = parseGpu(gpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['GPU', gpu, tier, deduction]
);
}
// RAM 삽입
for (const ram of uniqueRams) {
const { tier, deduction } = parseRam(ram);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['RAM', ram, tier, deduction]
);
}
console.log('✅ 마이그레이션이 성공적으로 완료되었습니다!');
} catch (error) {
console.error('❌ 마이그레이션 오류 발생:', error);
} finally {
await connection.end();
}
}
runMigration();

36
probe_db.js Normal file
View File

@@ -0,0 +1,36 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function probeDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Database Probe Start ---');
const [tables] = await connection.query('SHOW TABLES');
const tableNames = tables.map(t => Object.values(t)[0]);
console.log('Existing Tables:', tableNames);
for (const table of tableNames) {
const [columns] = await connection.query(`DESCRIBE ${table}`);
console.log(`\n[Table: ${table}]`);
columns.forEach(c => {
console.log(` - ${c.Field} (${c.Type}) ${c.Comment ? '// ' + c.Comment : ''}`);
});
}
await connection.end();
console.log('\n--- Database Probe End ---');
}
probeDB().catch(console.error);

840
server.js
View File

@@ -2,166 +2,746 @@ import express from 'express';
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import cors from 'cors'; import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config(); dotenv.config();
const app = express(); const dbConfig = {
app.use(cors());
app.use(express.json({ limit: '100mb' }));
// Request Logger
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
const pool = mysql.createPool({
host: process.env.DB_HOST, 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')
charset: 'utf8mb4'
});
const handleError = (res, err, context, isGet = false) => {
console.error(`❌ [${context}] Error:`, err.message);
if (isGet) res.json([]);
else res.status(500).json({ error: err.message });
}; };
// --- API Implementation --- const getDbConnectionSummary = () => ({
host: dbConfig.host || '(missing)',
/** port: dbConfig.port,
* Generic Fetcher for Asset Tables user: dbConfig.user || '(missing)',
*/ database: dbConfig.database || '(missing)'
const fetchAssets = async (tableName, res, context) => {
try {
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
res.json(rows);
} catch (err) {
handleError(res, err, context, true);
}
};
/**
* Generic Batch Saver for Asset Tables
*/
const saveAssetsBatch = async (tableName, items, res, context) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
// Get valid columns for this table
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
const validColumns = cols.map(c => c.Field);
// 1. Clear existing (or we could use UPSERT logic, but existing code used DELETE-INSERT pattern)
await connection.query(`DELETE FROM ${tableName}`);
// 2. Insert new items
for (const item of items) {
const filteredRow = {};
validColumns.forEach(col => {
// Exclude auto-managed timestamps from manual insertion
if (col === 'created_at' || col === 'updated_at') return;
if (item[col] !== undefined) filteredRow[col] = item[col];
}); });
// Auto-generate ID if missing const app = express();
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9); app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]); // uploads 폴더가 없으면 생성
if (!fs.existsSync('uploads')) {
fs.mkdirSync('uploads');
} }
await connection.commit(); // MySQL Pool Configuration
res.json({ success: true, count: items.length }); const pool = mysql.createPool({
host: dbConfig.host,
user: dbConfig.user,
password: dbConfig.password,
database: dbConfig.database,
port: dbConfig.port,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Database startup check (ensure job_spec_standards table exists)
(async () => {
let connection;
try {
connection = await pool.getConnection();
await connection.query(`
CREATE TABLE IF NOT EXISTS job_spec_standards (
id INT AUTO_INCREMENT PRIMARY KEY,
job_name VARCHAR(100) UNIQUE NOT NULL,
cpu_standard VARCHAR(255),
ram_standard VARCHAR(100),
gpu_standard VARCHAR(100),
min_score INT DEFAULT 0,
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ job_spec_standards table verification completed.');
} catch (err) { } catch (err) {
await connection.rollback(); console.error('❌ Failed to verify/create job_spec_standards table:', {
handleError(res, err, context); db: getDbConnectionSummary(),
code: err.code,
errno: err.errno,
syscall: err.syscall,
address: err.address,
port: err.port,
message: err.message
});
} finally { } finally {
connection.release(); if (connection) connection.release();
} }
}; })();
// --- Routes --- // Error Handler
const handleError = (res, err, label) => {
const routeMap = { console.error(`❌ [${label}] Error:`, {
'/api/users': { table: 'system_users', context: 'USERS' }, db: getDbConnectionSummary(),
'/api/pc': { table: 'asset_pc', context: 'PC' }, code: err.code,
'/api/server': { table: 'asset_server', context: 'SERVER' }, errno: err.errno,
'/api/storage': { table: 'asset_storage', context: 'STORAGE' }, syscall: err.syscall,
'/api/network': { table: 'asset_network', context: 'NETWORK' }, address: err.address,
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' }, port: err.port,
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' }, message: err.message
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
'/api/cost': { table: 'asset_cost', context: 'COST' },
'/api/vip': { table: 'asset_vip', context: 'VIP' },
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
};
// 동적 라우팅 생성 (Dynamic Routing)
Object.entries(routeMap).forEach(([route, { table, context }]) => {
app.get(route, (req, res) => fetchAssets(table, res, context));
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
}); });
res.status(500).json({ error: err.message });
};
// 4. Legacy/Auxiliary (History & Assignment) // --- Global Constants ---
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY')); const CATEGORY_TABLE_MAP = {
app.post('/api/asset/history/batch', async (req, res) => { pc: 'asset_core',
// Custom logic for history as it might not follow the random-id pattern server: 'asset_core',
const connection = await pool.getConnection(); storage: 'asset_core',
network: 'asset_core',
equipment: 'asset_core',
officeSupplies: 'asset_core',
survey: 'asset_core',
vip: 'asset_core',
pcParts: 'asset_core',
swInternal: 'asset_software_perpetual',
swExternal: 'asset_software_subscription',
swUsers: 'asset_software_assignment',
users: 'system_users',
logs: 'asset_history'
};
const ASSET_TABLES = [
'asset_core'
];
// --- API Endpoints ---
// 1. Generic Batch Save (Dynamic Table Detection)
app.post('/api/:table/batch', async (req, res) => {
const { table } = req.params;
const dbTable = CATEGORY_TABLE_MAP[table] || table;
const data = req.body;
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
let connection;
try { try {
connection = await pool.getConnection();
await connection.beginTransaction(); await connection.beginTransaction();
await connection.query('DELETE FROM asset_history');
for (const item of req.body) { const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
const dbRow = { const validFields = columns.map(c => c.Field);
asset_id: item.assetId,
log_date: item.date, await connection.query(`DELETE FROM ${dbTable}`);
log_user: item.user,
details: item.details, if (data.length > 0) {
cost: item.cost || 0 const placeholders = validFields.map(() => '?').join(', ');
}; const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
for (const item of data) {
const values = validFields.map(field => {
const val = item[field];
return val === undefined ? null : val;
});
await connection.query(sql, values);
} }
}
await connection.commit(); await connection.commit();
res.json({ success: true }); res.json({ success: true, count: data.length });
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); } } catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'BATCH SAVE');
} finally {
if (connection) connection.release();
}
}); });
// 5. Utility // 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
app.get('/api/generate-asset-code', async (req, res) => { app.get('/api/assets/master', async (req, res) => {
let connection;
try { try {
const { prefix } = req.query; connection = await pool.getConnection();
const masterData = {
pc: [], server: [], storage: [], network: [],
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
swInternal: [], swExternal: [], swUsers: [], users: [], logs: [], partsMaster: []
};
// Load from V3 Normalized Schema
const [rows] = await connection.query(`
SELECT
c.*,
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
s.monitoring, s.price, s.monitor_inch, s.serial_num,
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
(
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', net_type, 'name', net_name, 'val1', net_value1, 'val2', net_value2))
FROM asset_remote WHERE asset_id = c.id AND is_active = 1
) as remotes,
(
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no))
FROM asset_volume WHERE asset_id = c.id
) as volumes
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
LEFT JOIN asset_location l ON l.id = (
SELECT id FROM asset_location
WHERE asset_id = c.id AND is_active = 1
ORDER BY created_at DESC LIMIT 1
)
`);
const catMap = {
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
};
rows.forEach(row => {
const key = catMap[row.category] || 'pc';
masterData[key].push(row);
});
const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual');
const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription');
const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment');
const [users] = await connection.query('SELECT * FROM system_users');
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
const [jobSpecs] = await connection.query('SELECT * FROM job_spec_standards ORDER BY job_name');
masterData.swInternal = swInternal;
masterData.swExternal = swExternal;
masterData.swUsers = swUsers;
masterData.users = users;
masterData.logs = logs;
masterData.partsMaster = partsMaster;
masterData.jobSpecs = jobSpecs;
res.json(masterData);
} catch (err) {
handleError(res, err, 'MASTER DATA');
} finally {
if (connection) connection.release();
}
});
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
app.post('/api/asset/:category/save', async (req, res) => {
const asset = req.body;
let connection;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
// 3.0 History Tracking & Auto Field Update
const [oldCoreRows] = await connection.query('SELECT * FROM asset_core WHERE id = ?', [asset.id]);
const [oldSpecRows] = await connection.query('SELECT * FROM asset_spec WHERE asset_id = ?', [asset.id]);
const oldCore = oldCoreRows[0] || {};
const oldSpec = oldSpecRows[0] || {};
const historyLogs = [];
const logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
const logUser = '관리자';
// 3.0.1 Core 변동 감지 (Dept, User)
const oldDept = oldCore.current_dept || '';
const newDept = asset.current_dept || '';
if (newDept !== '' && oldDept !== newDept) {
asset.previous_dept = oldDept;
historyLogs.push({
event_type: 'DEPT_CHANGE',
old_dept: oldDept || null,
new_dept: newDept,
details: `[조직 변동] ${oldDept || '(없음)'} -> ${newDept}`
});
}
const oldUser = oldCore.user_current || '';
const newUser = asset.user_current || '';
if (newUser !== '' && oldUser !== newUser) {
asset.previous_user = oldUser;
historyLogs.push({
event_type: 'USER_CHANGE',
old_user: oldUser || null,
new_user: newUser,
details: `[사용자 변동] ${oldUser || '(없음)'} -> ${newUser}`
});
}
// 3.0.2 Spec 변동 감지 (CPU, RAM, GPU, OS, Mainboard 등)
const specFieldsToTrack = [
{ key: 'cpu', label: 'CPU' },
{ key: 'ram', label: 'RAM' },
{ key: 'gpu', label: 'GPU' },
{ key: 'os', label: 'OS' },
{ key: 'mainboard', label: '메인보드' }
];
specFieldsToTrack.forEach(field => {
const oldVal = String(oldSpec[field.key] || '').trim();
const newVal = String(asset[field.key] || '').trim();
if (newVal !== '' && oldVal !== newVal) {
historyLogs.push({
event_type: 'SPEC_CHANGE',
details: `[사양 변경] ${field.label}: ${oldVal || '(없음)'} -> ${newVal}`
});
}
});
// 3.0.3 상태 변경 감지
const oldStatus = oldSpec.hw_status || '';
const newStatus = asset.hw_status || '';
if (newStatus !== '' && oldStatus !== newStatus) {
historyLogs.push({
event_type: 'STATUS_CHANGE',
details: `[상태 변경] ${oldStatus || '(없음)'} -> ${newStatus}`
});
}
// 로그 일괄 삽입
for (const log of historyLogs) {
await connection.query(
`INSERT INTO asset_history (asset_id, event_type, old_dept, new_dept, old_user, new_user, details, log_date, log_user)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[asset.id, log.event_type, log.old_dept || null, log.new_dept || null, log.old_user || null, log.new_user || null, log.details, logDate, logUser]
);
}
// 3.1 asset_core
const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position'];
const coreData = {};
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
const coreKeys = Object.keys(coreData);
console.log(`[DEBUG] Saving Asset ID: ${asset.id}, Code: ${asset.asset_code}`);
const [existingCore] = await connection.query('SELECT id FROM asset_core WHERE id = ?', [asset.id]);
console.log(`[DEBUG] Existing Core Check for ${asset.id}: Found ${existingCore.length}`);
if (existingCore.length > 0) {
// UPDATE
const updateKeys = coreKeys.filter(k => k !== 'id');
const coreSql = `UPDATE asset_core SET ${updateKeys.map(k => `${k} = ?`).join(', ')} WHERE id = ?`;
const [updRes] = await connection.query(coreSql, [...updateKeys.map(k => coreData[k]), asset.id]);
console.log(`[DEBUG] Core UPDATE result: affectedRows=${updRes.affectedRows}`);
} else {
// INSERT
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')})`;
const [insRes] = await connection.query(coreSql, Object.values(coreData));
console.log(`[DEBUG] Core INSERT result: affectedRows=${insRes.affectedRows}`);
}
// 3.2 asset_spec
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
const specData = { asset_id: asset.id };
specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; });
const specKeys = Object.keys(specData);
const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]);
if (specExists.length > 0) {
const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`;
await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]);
} else {
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
}
// 3.3 asset_volume
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
if (asset.volumes) {
try {
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
if (Array.isArray(vols)) {
for (let i = 0; i < vols.length; i++) {
const v = vols[i];
if (v.type && v.capacity) {
await connection.query(
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
);
}
}
}
} catch(e) { console.error('Volume parse error', e); }
}
// 3.4 asset_location
if (asset.location || asset.location_detail) {
const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]);
const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y;
if (isChanged) {
await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
[asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]);
}
}
// 3.5 asset_remote (Dynamic Array Logic)
if (asset.remotes) {
try {
let nets = typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes;
if (Array.isArray(nets)) {
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
for (const n of nets) {
if (n.type) {
await connection.query(
'INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)',
[asset.id, n.type, n.name || '', n.val1 || '', n.val2 || '']
);
}
}
}
} catch(e) { console.error('Remote data parse error', e); }
} else {
// Fallback for UI that hasn't sent the networks array yet
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
const [netActive] = await connection.query('SELECT * FROM asset_remote WHERE asset_id = ? AND is_active = 1', [asset.id]);
const isChanged = netActive.length === 0 || netActive[0].net_value1 !== asset.ip_address || netActive[0].net_value2 !== asset.mac_address || netActive[0].net_name !== asset.remote_tool;
if (isChanged) {
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
if (asset.ip_address || asset.mac_address) {
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'IP', '기본망', asset.ip_address, asset.mac_address]);
}
if (asset.remote_tool || asset.remote_id || asset.remote_pw) {
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'REMOTE', asset.remote_tool, asset.remote_id, asset.remote_pw]);
}
}
}
}
await connection.commit();
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
res.json({ success: true });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'ASSET SAVE V3');
} finally {
if (connection) connection.release();
}
});
// 3.6 PC Flow Transaction (Checkout, Return, Move)
app.post('/api/pc/flow', async (req, res) => {
const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body;
let connection;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
if (action === 'checkout') {
await connection.query(
`UPDATE asset_core
SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
WHERE id = ?`,
[userName, empNo, dept, position, assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '운영' WHERE asset_id = ?`,
[assetId]
);
} else if (action === 'return') {
await connection.query(
`UPDATE asset_core
SET previous_user = user_current, previous_dept = current_dept,
user_current = '', emp_no = '', user_position = ''
WHERE id = ?`,
[assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '재고' WHERE asset_id = ?`,
[assetId]
);
} else if (action === 'move') {
await connection.query(
`UPDATE asset_core
SET previous_user = user_current, previous_dept = current_dept,
user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
WHERE id = ?`,
[userName, empNo, dept, position, assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '운영' WHERE asset_id = ?`,
[assetId]
);
} else {
throw new Error('Invalid action type');
}
// Insert into asset_history
await connection.query(
`INSERT INTO asset_history (asset_id, log_date, log_user, details)
VALUES (?, ?, ?, ?)`,
[assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details]
);
await connection.commit();
console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`);
res.json({ success: true });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'PC FLOW TRANSACTION');
} finally {
if (connection) connection.release();
}
});
// 4. Asset Delete
app.delete('/api/asset/:category/:id', async (req, res) => {
const { category, id } = req.params;
// Define mapping for which base table handles the delete
const deleteTableMap = {
pc: 'asset_core',
server: 'asset_core',
storage: 'asset_core',
network: 'asset_core',
equipment: 'asset_core',
officeSupplies: 'asset_core',
survey: 'asset_core',
vip: 'asset_core',
pcParts: 'asset_core',
swInternal: 'asset_software_perpetual',
swExternal: 'asset_software_subscription',
swUsers: 'asset_software_assignment',
users: 'system_users'
};
const table = deleteTableMap[category];
if (!table) return res.status(400).json({ error: 'Invalid category for deletion' });
try {
const connection = await pool.getConnection();
// For asset_core, ON DELETE CASCADE will handle spec, location, remote, volume
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
connection.release();
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'ASSET DELETE');
}
});
// 5. Generate Next Asset Code
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix, purchaseDate } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' }); if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try {
// Search in multiple tables if necessary, but typically prefix-based tables are known const connection = await pool.getConnection();
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip']; const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
let lastCode = ''; const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
let maxNum = 0;
for (const table of tables) { for (const table of ASSET_TABLES) {
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]); try {
if (rows.length > 0 && rows[0].asset_code > lastCode) { const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]);
lastCode = rows[0].asset_code; rows.forEach(row => {
const parts = row.asset_code.split('-');
const num = parseInt(parts[parts.length - 1]);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
} catch (err) {}
} }
} const nextNum = maxNum + 1;
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
let nextNum = 1; connection.release();
if (lastCode) { res.json({ nextCode });
const lastNum = parseInt(lastCode.split('-').pop() || '0');
nextNum = lastNum + 1;
}
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
} catch (err) { handleError(res, err, 'GENERATE CODE'); } } catch (err) { handleError(res, err, 'GENERATE CODE'); }
}); });
app.listen(3000, '0.0.0.0', () => { // 6. Map Config API
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)'); app.get('/api/maps', (req, res) => {
try {
if (!fs.existsSync('map_config.json')) return res.json({});
const data = fs.readFileSync('map_config.json', 'utf8');
res.json(JSON.parse(data || '{}'));
} catch (err) { handleError(res, err, 'GET MAPS'); }
});
// 6.5. Get Hardware Components Master List
app.get('/api/hardware-components', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET HARDWARE COMPONENTS');
}
});
// 6.6. Save Hardware Component (Add or Update)
app.post('/api/hardware-components/save', async (req, res) => {
const { id, category, component_name, score_tier, deduction } = req.body;
let connection;
try {
connection = await pool.getConnection();
if (id) {
await connection.query(
'UPDATE hardware_components_master SET category = ?, component_name = ?, score_tier = ?, deduction = ? WHERE id = ?',
[category, component_name, score_tier, deduction, id]
);
} else {
await connection.query(
'INSERT INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
[category, component_name, score_tier, deduction]
);
}
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE HARDWARE COMPONENT');
} finally {
if (connection) connection.release();
}
});
// 6.7. Delete Hardware Component
app.delete('/api/hardware-components/:id', async (req, res) => {
const { id } = req.params;
let connection;
try {
connection = await pool.getConnection();
await connection.query('DELETE FROM hardware_components_master WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'DELETE HARDWARE COMPONENT');
} finally {
if (connection) connection.release();
}
});
// 6.7.1. Get Job Spec Standards
app.get('/api/job-specs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM job_spec_standards ORDER BY job_name');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET JOB SPECS');
}
});
// 6.7.2. Save Job Spec Standard (Add or Update)
app.post('/api/job-specs/save', async (req, res) => {
const { id, job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks } = req.body;
let connection;
try {
connection = await pool.getConnection();
if (id) {
await connection.query(
'UPDATE job_spec_standards SET job_name = ?, cpu_standard = ?, ram_standard = ?, gpu_standard = ?, min_score = ?, remarks = ? WHERE id = ?',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks, id]
);
} else {
await connection.query(
'INSERT INTO job_spec_standards (job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks) VALUES (?, ?, ?, ?, ?, ?)',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks]
);
}
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.7.3. Delete Job Spec Standard
app.delete('/api/job-specs/:id', async (req, res) => {
const { id } = req.params;
let connection;
try {
connection = await pool.getConnection();
await connection.query('DELETE FROM job_spec_standards WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'DELETE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.8. Get System Users List
app.get('/api/system-users', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM system_users ORDER BY user_name');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET SYSTEM USERS');
}
});
// 6.9. Save System User (Add or Update)
app.post('/api/system-users/save', async (req, res) => {
const { id, emp_no, user_name, dept_name, position, status } = req.body;
let connection;
try {
connection = await pool.getConnection();
if (id) {
await connection.query(
'UPDATE system_users SET emp_no = ?, user_name = ?, dept_name = ?, position = ?, status = ? WHERE id = ?',
[emp_no, user_name, dept_name, position, status, id]
);
} else {
const newId = 'USER-' + Math.random().toString(36).substring(2, 9).toUpperCase();
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status) VALUES (?, ?, ?, ?, ?, ?)',
[newId, emp_no, user_name, dept_name, position, status]
);
}
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE SYSTEM USER');
} finally {
if (connection) connection.release();
}
});
// 6.10. Delete System User
app.delete('/api/system-users/:id', async (req, res) => {
const { id } = req.params;
let connection;
try {
connection = await pool.getConnection();
await connection.query('DELETE FROM system_users WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'DELETE SYSTEM USER');
} finally {
if (connection) connection.release();
}
});
app.post('/api/maps/save', (req, res) => {
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
let config = {};
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
config[path] = boxes;
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
res.json({ success: true });
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
});
// 7. File Upload API (Base64)
app.post('/api/upload', (req, res) => {
try {
const { fileName, fileData } = req.body;
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
// base64 데이터에서 실제 바이너리 추출
const base64Data = fileData.replace(/^data:.*;base64,/, "");
const buffer = Buffer.from(base64Data, 'base64');
// 고유한 파일명 생성 (타임스탬프 결합)
const timestamp = Date.now();
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `uploads/${safeFileName}`;
fs.writeFileSync(filePath, buffer);
console.log(`파일 업로드 성공: ${filePath}`);
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
} catch (err) {
handleError(res, err, 'FILE UPLOAD');
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
}); });

View File

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

View File

@@ -1,121 +1,188 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { closeModals, openModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setEditLock } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide'; import { createIcons, X, Save, Database, CalendarClock, Edit2, 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';
import { API_BASE_URL } from '../../core/utils';
let currentItem: any = null; class DomainAssetModal extends BaseModal {
constructor() {
const DOMAIN_MODAL_HTML = ` super('domain', '도메인 정보');
... (rest of DOMAIN_MODAL_HTML remains same) ...
`;
export function initDomainModal() {
if (!document.getElementById('domain-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
} }
const modal = document.getElementById('domain-asset-modal')!; protected renderFrameHTML(): string {
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals()); return `
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals()); <div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">${this.title}</h2>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" />
const saveBtn = document.getElementById('btn-save-domain'); <div class="form-section-title">기본 정보</div>
const revertBtn = document.getElementById('btn-revert-domain'); <div class="form-group">
const deleteBtn = document.getElementById('btn-delete-domain'); <label>구분</label>
const headerEditBtn = document.getElementById('btn-edit-domain-header'); <select id="domain-type" name="type">
<option value="호스팅">호스팅</option>
<option value="도메인">도메인</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required />
</div>
<div class="form-group full-width">
<label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required />
</div>
saveBtn?.addEventListener('click', () => { <div class="form-section-title">계약 및 비용</div>
if (!currentItem) return; <div class="form-group">
if (saveBtn.textContent?.includes('수정')) { <label>계약시작일</label>
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' }); <input type="date" id="domain-start-date" name="start_date" />
</div>
<div class="form-group">
<label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" />
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
<div class="form-section-title">담당자 및 비고</div>
<div class="form-group">
<label>정담당자</label>
<input type="text" id="domain-manager-main" name="manager_main" />
</div>
<div class="form-group">
<label>부담당자</label>
<input type="text" id="domain-manager-sub" name="manager_sub" />
</div>
<div class="form-group full-width">
<label>비고</label>
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="domain-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-domain-asset')!;
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return; return;
} }
saveDomain();
});
headerEditBtn?.addEventListener('click', () => { const formData = new FormData(this.formEl!);
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' }); const updated = { ...this.currentAsset };
}); formData.forEach((value, key) => { updated[key] = value; });
revertBtn?.addEventListener('click', () => { if (!updated.service_name || !updated.domain_name) {
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (currentItem) openDomainModal(currentItem);
});
deleteBtn?.addEventListener('click', async () => {
if (currentItem && confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) {
const success = await deleteAsset('domain', currentItem.id);
if (success) {
alert('성공적으로 삭제되었습니다.');
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
}
}
});
}
export function openDomainModal(item: any = null) {
currentItem = item;
const isEdit = !!item;
const mode = isEdit ? 'view' : 'add';
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 상세' : '신규 도메인 등록';
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) el.value = val || '';
};
setVal('domain-type', item?.type || '호스팅');
setVal('domain-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', formatExcelDate(item?.start_date));
setVal('domain-expiry-date', formatExcelDate(item?.expiry_date));
setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || '');
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
}
async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
const newDomain = {
id: currentItem ? currentItem.id : `DOM-${Date.now()}`,
type: getVal('domain-type'),
corp: getVal('domain-corp'),
service_name: getVal('domain-service-name'),
domain_name: getVal('domain-name'),
start_date: getVal('domain-start-date'),
expiry_date: getVal('domain-expiry-date'),
price: getVal('domain-price'),
manager_main: getVal('domain-manager-main'),
manager_sub: getVal('domain-manager-sub'),
remarks: getVal('domain-remarks')
};
if (!newDomain.service_name || !newDomain.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.'); alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return; return;
} }
const success = await saveAsset('domain', newDomain); if (await saveAsset('domain', updated)) {
if (success) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
closeModals(); onSave(); this.close(); closeModals();
window.dispatchEvent(new CustomEvent('refresh-view')); }
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
if (await deleteAsset('domain', this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
}
protected fillFormData(asset: any): void {
setFieldValue('domain-id', asset.id);
setFieldValue('domain-type', asset.type || '호스팅');
setFieldValue('domain-corp', asset.corp || '');
setFieldValue('domain-service-name', asset.service_name || '');
setFieldValue('domain-name', asset.domain_name || '');
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
setFieldValue('domain-price', asset.price || '');
setFieldValue('domain-manager-main', asset.manager_main || '');
setFieldValue('domain-manager-sub', asset.manager_sub || '');
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
}
private renderHistory(assetId: string) {
const container = document.getElementById('domain-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
} }
} }
export const domainModal = new DomainAssetModal();
export function initDomainModal(onSave: () => void, closeModals: () => void) {
domainModal.init(onSave, closeModals);
}
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
domainModal.open(asset, mode);
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,625 @@
import { state, loadMasterDataFromDB } from '../../core/state';
import { createIcons, Search, Monitor, RefreshCw } from 'lucide';
import { API_BASE_URL } from '../../core/utils';
export class PCFlowModal {
private static instance: PCFlowModal | null = null;
private modalEl: HTMLElement | null = null;
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
// Selected state
private selectedUser: any = null;
private selectedTargetUser: any = null;
private selectedPC: any = null;
private constructor() {}
public static getInstance(): PCFlowModal {
if (!PCFlowModal.instance) {
PCFlowModal.instance = new PCFlowModal();
}
return PCFlowModal.instance;
}
public init(onSave: () => void) {
if (document.getElementById('pc-flow-modal')) return;
// Inject HTML
document.body.insertAdjacentHTML('beforeend', this.renderHTML());
this.modalEl = document.getElementById('pc-flow-modal');
this.setupEventListeners(onSave);
// Set default date to today
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
if (dateInput) {
dateInput.value = new Date().toISOString().split('T')[0];
}
createIcons({ icons: { Search, Monitor, RefreshCw } });
}
public open() {
this.resetState();
if (this.modalEl) {
this.modalEl.classList.remove('hidden');
}
this.updateUI();
}
public close() {
if (this.modalEl) {
this.modalEl.classList.add('hidden');
}
}
private resetState() {
this.selectedUser = null;
this.selectedTargetUser = null;
this.selectedPC = null;
this.currentFlowType = 'checkout';
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
if (radioCheckout) radioCheckout.checked = true;
// Reset text fields
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
if (userSearch) userSearch.value = '';
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
if (targetUserSearch) targetUserSearch.value = '';
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
if (stockSearch) stockSearch.value = '';
const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement;
if (details) details.value = '';
}
private setupEventListeners(onSave: () => void) {
const btnClose = document.getElementById('btn-close-pc-flow-modal');
const btnCancel = document.getElementById('btn-cancel-pc-flow-modal');
const btnSubmit = document.getElementById('btn-submit-pc-flow');
btnClose?.addEventListener('click', () => this.close());
btnCancel?.addEventListener('click', () => this.close());
// Flow Type Radio Buttons
const labels = document.querySelectorAll('.flow-type-label');
labels.forEach(label => {
const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement;
label.addEventListener('click', () => {
labels.forEach(l => l.classList.remove('active'));
label.classList.add('active');
radio.checked = true;
this.currentFlowType = radio.value as any;
// Reset selected PC when switching flow types
this.selectedPC = null;
this.updateUI();
});
});
// 1. Source User Autocomplete Search
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
userSearch?.addEventListener('input', () => {
const query = userSearch.value.trim().toLowerCase();
if (!query) {
userSuggestions.classList.add('hidden');
return;
}
const users = state.masterData.users || [];
const filtered = users.filter((u: any) =>
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
(u.emp_no && u.emp_no.toString().includes(query))
);
const uniqueFiltered: any[] = [];
const seen = new Set();
filtered.forEach((u: any) => {
const key = u.emp_no || u.user_name;
if (!seen.has(key)) {
seen.add(key);
uniqueFiltered.push(u);
}
});
this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => {
this.selectedUser = user;
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
userSuggestions.classList.add('hidden');
// Automatically populate details if return or move
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
this.selectedPC = null; // Reset selection
}
this.updateUI();
});
});
// Close suggestion overlays on clicking outside
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) {
userSuggestions.classList.add('hidden');
}
if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) {
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions');
targetSuggestions?.classList.add('hidden');
}
if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) {
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions');
stockSuggestions?.classList.add('hidden');
}
});
// 2. Target User Autocomplete Search (For Moves)
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
targetUserSearch?.addEventListener('input', () => {
const query = targetUserSearch.value.trim().toLowerCase();
if (!query) {
targetSuggestions.classList.add('hidden');
return;
}
const users = state.masterData.users || [];
const filtered = users.filter((u: any) =>
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
(u.emp_no && u.emp_no.toString().includes(query))
);
const uniqueFiltered: any[] = [];
const seen = new Set();
filtered.forEach((u: any) => {
const key = u.emp_no || u.user_name;
if (!seen.has(key)) {
seen.add(key);
uniqueFiltered.push(u);
}
});
this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => {
this.selectedTargetUser = user;
targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
targetSuggestions.classList.add('hidden');
this.updateUI();
});
});
// 3. Stock PC Autocomplete Search (For Checkout)
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
const showStockSuggestions = () => {
const query = stockSearch.value.trim().toLowerCase();
// Filter available PCs (category PC, status '대기', '미할당', or '재고')
const pcs = state.masterData.pc || [];
const filtered = pcs.filter((p: any) => {
const status = (p.hw_status || '').trim();
const matchesQuery = !query ||
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
(p.cpu && p.cpu.toLowerCase().includes(query));
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
});
this.renderPCSuggestions(filtered, stockSuggestions, (pc) => {
this.selectedPC = pc;
stockSearch.value = `${pc.asset_code} - ${pc.model_name}`;
stockSuggestions.classList.add('hidden');
this.updateUI();
});
};
stockSearch?.addEventListener('input', showStockSuggestions);
stockSearch?.addEventListener('focus', showStockSuggestions);
stockSearch?.addEventListener('click', showStockSuggestions);
// 4. Submit Transaction
btnSubmit?.addEventListener('click', async () => {
if (!this.validateInputs()) return;
const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value;
const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim();
const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자';
// Build Details Message as JSON
const logData = {
type: this.currentFlowType,
user: this.selectedUser ? this.selectedUser.user_name : '',
dept: this.selectedUser ? this.selectedUser.dept_name : '',
targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '',
targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '',
assetCode: this.selectedPC ? this.selectedPC.asset_code : '',
memo: detailsVal
};
const finalDetails = JSON.stringify(logData);
const payload: any = {
action: this.currentFlowType,
assetId: this.selectedPC.id,
date: dateVal,
details: finalDetails,
manager: loginUser
};
if (this.currentFlowType === 'checkout') {
payload.userName = this.selectedUser.user_name;
payload.dept = this.selectedUser.dept_name;
payload.empNo = this.selectedUser.emp_no;
payload.position = this.selectedUser.position || '사원';
} else if (this.currentFlowType === 'move') {
payload.userName = this.selectedTargetUser.user_name;
payload.dept = this.selectedTargetUser.dept_name;
payload.empNo = this.selectedTargetUser.emp_no;
payload.position = this.selectedTargetUser.position || '사원';
}
try {
const response = await fetch(`${API_BASE_URL}/api/pc/flow`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
alert('PC 이동/반납 처리가 완료되었습니다.');
this.close();
onSave(); // Refresh views
} else {
const errData = await response.json();
alert(`오류 발생: ${errData.error || '처리 실패'}`);
}
} catch (err) {
console.error('API Error:', err);
alert('서버 전송 중 오류가 발생했습니다.');
}
});
}
private validateInputs(): boolean {
if (this.currentFlowType === 'checkout') {
if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; }
if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; }
} else if (this.currentFlowType === 'return') {
if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; }
if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; }
} else if (this.currentFlowType === 'move') {
if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; }
if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; }
if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; }
if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) {
alert('인계자와 인수자는 동일할 수 없습니다.');
return false;
}
}
return true;
}
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
container.innerHTML = '';
if (users.length === 0) {
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
container.classList.remove('hidden');
return;
}
users.forEach(u => {
const item = document.createElement('div');
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.innerHTML = `
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
<span>부서: ${u.dept_name}</span>
<span>|</span>
<span>사번: ${u.emp_no || '-'}</span>
</div>
`;
item.addEventListener('click', () => onSelect(u));
container.appendChild(item);
});
container.classList.remove('hidden');
}
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
container.innerHTML = '';
if (pcs.length === 0) {
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
container.classList.remove('hidden');
return;
}
pcs.forEach(p => {
const item = document.createElement('div');
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.innerHTML = `
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div style="font-size: 11px; color: var(--text-muted);">
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
</div>
`;
item.addEventListener('click', () => onSelect(p));
container.appendChild(item);
});
container.classList.remove('hidden');
}
private updateUI() {
// 1. Hide/Show dynamic sections based on flow type
const stockContainer = document.getElementById('stock-pc-search-container')!;
const targetUserContainer = document.getElementById('target-user-search-container')!;
const userPcsContainer = document.getElementById('user-pcs-container')!;
const labelStep2 = document.getElementById('user-search-label')!;
if (this.currentFlowType === 'checkout') {
stockContainer.classList.remove('hidden');
targetUserContainer.classList.add('hidden');
userPcsContainer.classList.add('hidden');
labelStep2.textContent = '2. 불출 대상 사원 검색';
} else if (this.currentFlowType === 'return') {
stockContainer.classList.add('hidden');
targetUserContainer.classList.add('hidden');
userPcsContainer.classList.remove('hidden');
labelStep2.textContent = '2. 반납 대상 사원 검색';
} else if (this.currentFlowType === 'move') {
stockContainer.classList.add('hidden');
targetUserContainer.classList.remove('hidden');
userPcsContainer.classList.remove('hidden');
labelStep2.textContent = '2. 인계 사원 검색';
}
// 2. Update summary panels on the right
const summaryUserName = document.getElementById('summary-user-name')!;
const summaryUserDept = document.getElementById('summary-user-dept')!;
if (this.selectedUser) {
summaryUserName.textContent = this.selectedUser.user_name;
summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`;
} else {
summaryUserName.textContent = '선택된 사원 없음';
summaryUserDept.textContent = '-';
}
const summaryTargetCard = document.getElementById('summary-target-user-card')!;
const summaryTargetUserName = document.getElementById('summary-target-user-name')!;
const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!;
if (this.currentFlowType === 'move') {
summaryTargetCard.classList.remove('hidden');
if (this.selectedTargetUser) {
summaryTargetUserName.textContent = this.selectedTargetUser.user_name;
summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`;
} else {
summaryTargetUserName.textContent = '선택된 사원 없음';
summaryTargetUserDept.textContent = '-';
}
} else {
summaryTargetCard.classList.add('hidden');
}
const summaryPcCode = document.getElementById('summary-pc-code')!;
const summaryPcModel = document.getElementById('summary-pc-model')!;
if (this.selectedPC) {
summaryPcCode.textContent = this.selectedPC.asset_code;
summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`;
} else {
summaryPcCode.textContent = '선택된 PC 없음';
summaryPcModel.textContent = '-';
}
// 3. Render user's active PCs list on the right (For Return & Move)
const userPcsList = document.getElementById('user-pcs-list')!;
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
const allPcs = state.masterData.pc || [];
const userPcs = allPcs.filter((p: any) =>
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
(p.user_current && p.user_current === this.selectedUser.user_name)
);
if (userPcs.length === 0) {
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
} else {
userPcsList.innerHTML = userPcs.map(p => {
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
return `
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
</div>
</div>
`;
}).join('');
// Bind clicks to list items
userPcsList.querySelectorAll('.user-pc-item').forEach(item => {
item.addEventListener('click', () => {
const pcId = item.getAttribute('data-id');
const foundPC = userPcs.find(p => p.id === pcId);
if (foundPC) {
this.selectedPC = foundPC;
this.updateUI();
}
});
});
}
} else {
userPcsList.innerHTML = '';
}
}
private renderHTML(): string {
const overlayStyle = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
z-index: 1000; transition: opacity 0.3s;
`;
const contentStyle = `
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
`;
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
return `
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
<div class="modal-content" style="${contentStyle}">
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
</h2>
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
<!-- 왼쪽 영역: 입력 폼 -->
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
<!-- 1. 처리 유형 -->
<div>
<label style="${labelStyle}">1. 처리 유형 선택</label>
<div style="display: flex; gap: 12px;">
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
불출 (지급)
</label>
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="return" style="display:none;" />
입고 (반납)
</label>
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="move" style="display:none;" />
이동 (이관)
</label>
</div>
</div>
<!-- 2. 대상 사용자 검색 -->
<div style="position: relative;">
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
<div id="target-user-search-container" class="hidden" style="position: relative;">
<label style="${labelStyle}">새 인수 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
<div id="stock-pc-search-container" style="position: relative;">
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<!-- 5. 상세 공통 입력 -->
<div style="display: flex; gap: 16px;">
<div style="flex: 1;">
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
<input type="date" id="pc-flow-date" style="${inputStyle}" />
</div>
<div style="flex: 2;">
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
</div>
</div>
</div>
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
<!-- 사원 요약 카드 -->
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 인수 사원 요약 카드 (이동 전용) -->
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 대상 PC 자산 요약 카드 -->
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
</div>
</div>
</div>
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
</div>
</div>
</div>
<style>
.flow-type-label {
transition: all 0.2s;
border-color: var(--border-color);
background: white;
color: var(--text-muted);
}
.flow-type-label:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.flow-type-label.active {
border-color: var(--primary-color);
background: var(--primary-light);
color: var(--primary-color);
}
.suggestion-item:hover {
background-color: var(--primary-light) !important;
}
</style>
`;
}
}
export const pcFlowModal = PCFlowModal.getInstance();

View File

@@ -0,0 +1,166 @@
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Database, Edit2, Plus } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class PartsMasterModal extends BaseModal {
constructor() {
super('parts-master', '부품 표준 정보');
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
const selectStyle = sharedStyle;
return `
<div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 500px; width: 100%;">
<div class="modal-header">
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
<select id="parts-master-category" name="category" style="${selectStyle}">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
<option value="RAM">RAM</option>
</select>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
</div>
</form>
</div>
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
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 deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
if (!compName || !tier || deductStr === '') {
alert('모든 필드를 올바르게 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
category,
component_name: compName,
score_tier: tier,
deduction: parseInt(deductStr, 10)
};
if (await savePartsMaster(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
}
protected fillFormData(asset: any): void {
setFieldValue('parts-master-id', asset.id || '');
setFieldValue('parts-master-category', asset.category || 'CPU');
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 부품 마스터 등록';
} else {
titleEl.textContent = '부품 마스터 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
// 추가 모드일 때는 삭제 버튼 숨김
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add') {
this.setEditLockMode('edit');
this.isEditMode = true;
saveBtn.textContent = '등록';
saveBtn.style.display = 'block';
} else {
this.setEditLockMode('view');
this.isEditMode = false;
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
}
}
export const partsMasterModal = new PartsMasterModal();
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);
}

View File

@@ -1,7 +1,7 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { openModal, closeModals } from './BaseModal'; import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal'; import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar } from 'lucide'; import { createIcons, History, Plus, X, Save, Edit2, 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';
@@ -9,18 +9,20 @@ import {
generateOptionsHTML, generateOptionsHTML,
setFieldValue, setFieldValue,
getFieldValue, getFieldValue,
setEditLock,
applyDateMask applyDateMask
} from './ModalUtils'; } from './ModalUtils';
let currentSwAsset: any | null = null; class SwAssetModal extends BaseModal {
let isEditMode = false; constructor() {
super('sw', '소프트웨어 상세 정보');
}
const SW_MODAL_HTML = ` protected renderFrameHTML(): string {
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">
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2> <h2 id="sw-modal-title">${this.title}</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -29,10 +31,9 @@ const SW_MODAL_HTML = `
<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" />
<!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div> <div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group"> <div class="form-group">
<label for="sw-asset-type">자산 유형</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>
@@ -40,7 +41,7 @@ const SW_MODAL_HTML = `
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-분야">${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>
@@ -48,50 +49,47 @@ const SW_MODAL_HTML = `
<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 for="sw-법인">${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 for="sw-제품명">${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 for="sw-플랫폼명">${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 for="sw-부서">${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 for="sw-user-current">${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 for="sw-previous-user">${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>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<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 for="sw-수량">${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 for="sw-금액">${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>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label for="sw-계정명">${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 for="sw-결제수단">${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>
@@ -99,10 +97,9 @@ const SW_MODAL_HTML = `
</select> </select>
</div> </div>
<!-- Group 4: 관리 정보 (Management) -->
<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 for="sw-구매일">${ASSET_SCHEMA.PURCHASE_DATE.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" /> <input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
@@ -112,23 +109,23 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-납품업체">${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 for="sw-개발담당자">${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 for="sw-기획담당자">${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 for="sw-영업담당자">${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 for="sw-만료일">${ASSET_SCHEMA.EXPIRED_DATE.ui}</label> <label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" /> <input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
@@ -138,7 +135,7 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="sw-비고">${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>
@@ -172,7 +169,7 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<!-- 계약/유지보수 기간 갱신 및 업데이트 모달 --> <!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;"> <div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;"> <div class="modal-content" style="max-width: 500px;">
<div class="modal-header"> <div class="modal-header">
@@ -213,8 +210,125 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
`; `;
}
function applySwTypeUI(type: string) { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
userAssignBtn.addEventListener('click', () => {
if (this.currentAsset) openSwUserModal(this.currentAsset);
});
// 업데이트 모달 로직
const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden');
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
subModal.classList.remove('hidden');
});
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdate(); onSave();
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
const type = getFieldValue('sw-asset-type');
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = this.currentAsset.asset_type || this.currentAsset.type;
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
}
protected fillFormData(asset: any): void {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
this.renderHistory(asset.id);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
}
private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only'); const 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');
@@ -230,217 +344,28 @@ function applySwTypeUI(type: string) {
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';
// 외부SW에만 현 사용자/직전 사용자 표시 (내부SW는 user tracking 제외 요청됨)
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none'); userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
} }
} }
} }
function fillSwFormData(asset: any) { private renderHistory(swId: string) {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-previous_dept', asset.previous_dept || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-시작일', asset.start_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
renderSwHistory(asset.id);
}
function renderSwHistory(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.assetId === swId); const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) { if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
return;
} }
container.innerHTML = logs.map(l => ` }
<div class="history-item">
<div class="history-date">${l.date}</div> export const swModal = new SwAssetModal();
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details}</div> export function initSwModal(onSave: () => void, closeModals: () => void) {
</div> swModal.init(onSave, closeModals);
`).join('');
} }
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
currentSwAsset = asset; swModal.open(asset, mode);
const modal = document.getElementById('sw-asset-modal')!;
setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = (mode === 'add' || mode === 'edit');
fillSwFormData(asset);
applySwTypeUI(asset.asset_type || asset.type);
modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } });
}
export function initSwModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
const form = document.getElementById('sw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
typeSelect?.addEventListener('change', () => {
applySwTypeUI(typeSelect.value);
});
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
createIcons({ icons: { Calendar } });
const closeModalAction = () => { closeModals(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false;
if (currentSwAsset) fillSwFormData(currentSwAsset);
});
saveBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = true;
return;
}
const type = getFieldValue('sw-asset-type');
const formData = new FormData(form);
const updated: any = { ...currentSwAsset };
formData.forEach((value, key) => {
updated[key] = value;
});
// Mapping for generic saveAsset
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
const success = await saveAsset(categoryKey, updated);
if (success) {
onSave();
closeModalAction();
}
});
deleteBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = currentSwAsset.asset_type || currentSwAsset.type;
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
const success = await deleteAsset(categoryKey, currentSwAsset.id);
if (success) {
alert('성공적으로 삭제되었습니다.');
onSave(); // Refresh list
closeModalAction();
}
});
userAssignBtn.addEventListener('click', () => {
if (currentSwAsset) openSwUserModal(currentSwAsset);
});
// 자산 업데이트(계약 갱신) 모달 로직
const subModal = document.getElementById('sw-update-modal')!;
const btnCloseUpdate = document.getElementById('btn-close-sw-update')!;
const btnCancelUpdate = document.getElementById('btn-cancel-sw-update')!;
const btnSaveUpdate = document.getElementById('btn-save-sw-update')!;
const closeUpdateModal = () => subModal.classList.add('hidden');
btnCloseUpdate?.addEventListener('click', closeUpdateModal);
btnCancelUpdate?.addEventListener('click', closeUpdateModal);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.');
return;
}
subModal.classList.remove('hidden');
});
btnSaveUpdate?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
// Save as log
const log = {
assetId: currentSwAsset.id,
date,
details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`,
user: '관리자'
};
// Call generic API for logs (could be added to state.ts)
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdateModal();
onSave();
});
} }

View File

@@ -1,18 +1,22 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler'; import { BaseModal } from './BaseModal';
import { openModal } from './BaseModal'; import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { createIcons, Edit2, X, Paperclip, Calendar } from 'lucide'; import { ORG_LIST } from './SharedData';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
let currentSwUserAsset: SoftwareAsset | null = null; class SwUserModal extends BaseModal {
let tempSwUsers: any[] = []; private tempSwUsers: any[] = [];
const SW_USER_MODAL_HTML = ` constructor() {
<div id="sw-user-modal" class="modal-overlay hidden"> super('sw-user', '소프트웨어 사용자 관리');
}
protected renderFrameHTML(): string {
return `
<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">소프트웨어 사용자 관리</h2> <h2 id="sw-user-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -39,6 +43,8 @@ const SW_USER_MODAL_HTML = `
<tbody id="sw-user-table-body"></tbody> <tbody id="sw-user-table-body"></tbody>
</table> </table>
</div> </div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<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>
@@ -106,40 +112,74 @@ const SW_USER_MODAL_HTML = `
</div> </div>
</div> </div>
`; `;
}
export function openSwUserModal(asset: SoftwareAsset) { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
currentSwUserAsset = asset; const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const modal = document.getElementById('sw-user-modal')!; const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return;
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
const newMapping = {
sw_id: this.currentAsset!.id,
userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave(); this.close(); closeModals();
});
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
}
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!; const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = ` swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;"> <div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.}</div> <div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset. || ''}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.}</div> <div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset. || ''}</div>
</div> </div>
`; `;
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id); const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
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]
})) : []; })) : [];
renderUserList(); this.renderUserList();
modal.classList.remove('hidden');
createIcons({ icons: { Edit2, X, Paperclip } });
} }
function renderUserList() { protected onAfterOpen(): void {}
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!; const tbody = document.getElementById('sw-user-table-body')!;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
if (tempSwUsers.length === 0) { tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return; return;
} }
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>
@@ -153,15 +193,15 @@ function renderUserList() {
<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 btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button> <button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
</div> </div>
</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')!);
openUserEditSubModal(idx); this.openUserEditSubModal(idx);
}); });
}); });
@@ -169,99 +209,37 @@ function renderUserList() {
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('사용자 할당을 삭제하시겠습니까?')) {
tempSwUsers.splice(idx, 1); this.tempSwUsers.splice(idx, 1); this.renderUserList();
renderUserList();
} }
}); });
}); });
createIcons({ icons: { Paperclip } }); createIcons({ icons: { Paperclip } });
} }
function 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 = 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.);
// 사용기간 파싱 (yyyy-mm-dd ~ yyyy-mm-dd)
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());
} else {
setFieldValue('new-user-시작일', '');
setFieldValue('new-user-종료일', '');
} }
} }
subModal.classList.remove('hidden'); subModal.classList.remove('hidden');
} }
export function initSwUserModal(onSave: () => void, closeModals: () => void) { private saveUserDataToList() {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
}
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
createIcons({ icons: { Calendar } });
addUserBtn.addEventListener('click', () => openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => {
saveUserDataToList();
});
mainSaveBtn.addEventListener('click', () => {
if (!currentSwUserAsset) return;
// 전역 상태 업데이트
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = {
sw_id: currentSwUserAsset!.id,
userData: tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave();
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-edit')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-sub')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
}
function 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 ? 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-조직'),
@@ -271,10 +249,19 @@ function saveUserDataToList() {
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`, : `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
}; };
if (idx === -1) this.tempSwUsers.push(userData);
if (idx === -1) tempSwUsers.push(userData); else this.tempSwUsers[idx] = userData;
else tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden'); document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList(); this.renderUserList();
}
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
swUserModal.init(onSave, closeModals);
}
export function openSwUserModal(asset: any) {
swUserModal.open(asset);
} }

View File

@@ -13,15 +13,15 @@ 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', '서버PC', '스토리지 렉'], '서버': ['서버 렉', '가상서버(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': ['영구', '구독'],
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'], '내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'], '비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'], '내빈/외빈': ['선물'],
'시설자산': ['사무가구'] '시설자산': ['사무가구']
@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
// 설치위치 종속성 데이터 // 설치위치 종속성 데이터
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층', '파고라'],
'기술개발센터': ['서버실', '1층', '기타'], '기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['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']
@@ -38,8 +38,44 @@ export const LOCATION_DATA: Record<string, string[]> = {
// 유형별 자산번호 접두사(Prefix) 매핑 // 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = { export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO', '서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB', '저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET', '드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT' '구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};
// 배치도 이미지 매핑 데이터
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'IDC': {
'서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'],
},
'기술개발센터': {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png'
],
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
},
'한맥빌딩': {
'1층': ['img/location_photo/한맥빌딩/1층.png'],
'2층': ['img/location_photo/한맥빌딩/2층.png'],
'3층': ['img/location_photo/한맥빌딩/3층.png'],
'4층': ['img/location_photo/한맥빌딩/4층.png'],
'5층': ['img/location_photo/한맥빌딩/5층.png'],
'6층': ['img/location_photo/한맥빌딩/6층.png'],
'7층': ['img/location_photo/한맥빌딩/7층.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
]
}
}; };

View File

@@ -0,0 +1,171 @@
import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { createIcons, X, Save } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class UserModal extends BaseModal {
constructor() {
super('user', '임직원 정보');
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
return `
<div id="user-asset-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 500px; width: 100%;">
<div class="modal-header">
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<input type="hidden" id="user-id" name="id" />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
<select id="user-status" name="status" style="\${sharedStyle}">
<option value="재직">재직</option>
<option value="퇴직">퇴직</option>
</select>
</div>
</form>
</div>
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-user-asset')!;
const revertBtn = document.getElementById('btn-revert-user-edit')!;
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const empNo = (document.getElementById('user-emp-no') 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 position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
const status = (document.getElementById('user-status') as HTMLSelectElement).value;
if (!empNo || !userName || !deptName || !position) {
alert('모든 필수 입력 필드를 채워주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
emp_no: empNo,
user_name: userName,
dept_name: deptName,
position: position,
status: status
};
if (await saveSystemUser(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
if (await deleteSystemUser(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
}
protected fillFormData(asset: any): void {
setFieldValue('user-id', asset.id || '');
setFieldValue('user-emp-no', asset.emp_no || '');
setFieldValue('user-name-input', asset.user_name || '');
setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직');
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('user-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 임직원 등록';
} else {
titleEl.textContent = '임직원 정보 수정';
}
}
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
const saveBtn = document.getElementById('btn-save-user-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add') {
this.setEditLockMode('edit');
this.isEditMode = true;
saveBtn.textContent = '등록';
saveBtn.style.display = 'block';
} else {
this.setEditLockMode('view');
this.isEditMode = false;
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
}
}
export const userModal = new UserModal();
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);
}

View File

@@ -3,15 +3,15 @@ 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: ['외부', '내부'] tabs: ['외부SW', '내부SW']
}, },
ops: { ops: {
label: '운영지원', label: '운영지원',
tabs: ['클라우드', '도메인', '비용관리'] tabs: ['클라우드', '도메인', '비용관리', '사용자']
}, },
vip: { vip: {
label: '내빈/외빈', label: '내빈/외빈',
@@ -32,6 +32,23 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
// 기존 메뉴 렌더링 // 기존 메뉴 렌더링
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => { (Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey]; const config = MENU_CONFIG[catKey];
// 역할에 따라 노출할 서브탭 필터링
const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') {
// 관리자(admin)일 경우 대시보드 탭만 노출
return tab === '대시보드';
} else {
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
return tab !== '대시보드';
}
});
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
if (visibleTabs.length === 0) {
return;
}
const isActive = state.activeCategory === catKey; const isActive = state.activeCategory === catKey;
const group = document.createElement('div'); const group = document.createElement('div');
@@ -44,7 +61,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
trigger.addEventListener('click', () => { trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) { if (state.activeCategory !== catKey) {
state.activeCategory = catKey as any; state.activeCategory = catKey as any;
const firstTab = config.tabs[0]; const firstTab = visibleTabs[0] || config.tabs[0];
state.activeSubTab = firstTab; state.activeSubTab = firstTab;
render(); render();
onTabChange(firstTab); onTabChange(firstTab);
@@ -55,7 +72,8 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const shelf = document.createElement('div'); const shelf = document.createElement('div');
shelf.className = 'lnb-shelf'; shelf.className = 'lnb-shelf';
config.tabs.forEach((tab: string) => { visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`; item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
item.textContent = tab; item.textContent = tab;
@@ -73,7 +91,8 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
navContainer.appendChild(group); navContainer.appendChild(group);
}); });
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ─── // ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
if (state.currentUserRole === 'admin') {
const adminGroup = document.createElement('div'); const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group'; adminGroup.className = 'nav-group';
@@ -86,11 +105,12 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
adminTrigger.style.paddingLeft = '1.5rem'; adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => { adminTrigger.addEventListener('click', () => {
alert('준비중입니다.'); window.open('/map_editor.html', '_blank');
}); });
adminGroup.appendChild(adminTrigger); adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup); navContainer.appendChild(adminGroup);
}
}; };
render(); render();

View File

@@ -27,10 +27,15 @@ export interface SWUser {
export interface HardwareLog { export interface HardwareLog {
id: string; id: string;
assetId: string; assetId?: string;
date: string; asset_id?: string;
date?: string;
log_date?: string;
created_at?: string;
details: string; details: string;
user: string; user?: string;
log_user?: string;
event_type?: string;
} }
export interface MasterAssetData { export interface MasterAssetData {

View File

@@ -14,33 +14,61 @@ export interface FilterOptions {
showDept?: boolean; showDept?: boolean;
showLoc?: boolean; showLoc?: boolean;
showField?: boolean; showField?: boolean;
showType?: boolean;
showStatus?: boolean;
extraHTML?: string; extraHTML?: string;
onFilterChange: (filters: any) => void; onFilterChange: (filters: any) => void;
initialFilters?: any;
} }
export function renderFilterBar(container: HTMLElement, options: FilterOptions) { export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, extraHTML = '', onFilterChange } = options; const {
keywordLabel = '통합 검색',
showCorp = false,
showDept = false,
showLoc = false,
showField = false,
showType = false,
showStatus = false,
extraHTML = '',
onFilterChange,
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
} = options;
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"> <input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div> </div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
</select>
</div>` : ''}
${showStatus ? `
<div class="search-item">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="filter-status">
<option value="">전체 상태</option>
</select>
</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="업무공통">업무공통</option> <option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option>
<option value="개발S/W">개발S/W</option> <option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option>
<option value="디자인">디자인</option> <option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option>
<option value="설계S/W">설계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, '', 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">
@@ -66,7 +94,9 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
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 || '',
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
}; };
onFilterChange(filters); onFilterChange(filters);
}; };
@@ -76,9 +106,11 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
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-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'].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 = '';
}); });
@@ -98,7 +130,9 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
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 matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField; return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
}); });
} }

View File

@@ -484,7 +484,7 @@ export const realServerData = [
}, },
{ {
"법인": "삼안", "법인": "삼안",
"자산코드": "sa-das-001", "자산코드": "DSS020",
"storage유형": "서버", "storage유형": "서버",
"용도": "", "용도": "",
"상세": "Satis01, Satis02 광케이블 연결 (물리연결)", "상세": "Satis01, Satis02 광케이블 연결 (물리연결)",
@@ -505,7 +505,7 @@ export const realServerData = [
}, },
{ {
"법인": "삼안", "법인": "삼안",
"자산코드": "sa-nas-001", "자산코드": "DSS019",
"storage유형": "서버", "storage유형": "서버",
"용도": "인트라넷 백업 스토리지", "용도": "인트라넷 백업 스토리지",
"상세": "", "상세": "",
@@ -526,7 +526,7 @@ export const realServerData = [
}, },
{ {
"법인": "삼안", "법인": "삼안",
"자산코드": "sa-nas-002", "자산코드": "DSS018",
"storage유형": "서버", "storage유형": "서버",
"용도": "성과품 스토리지", "용도": "성과품 스토리지",
"상세": "매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요)", "상세": "매니지먼트 접속 확인 불가 (콘솔 연결 후 페이지 오픈 필요)",
@@ -547,7 +547,7 @@ export const realServerData = [
}, },
{ {
"법인": "삼안", "법인": "삼안",
"자산코드": "sa-nas-003", "자산코드": "DSS017",
"storage유형": "서버", "storage유형": "서버",
"용도": "성과품 백업 스토리지", "용도": "성과품 백업 스토리지",
"상세": "", "상세": "",
@@ -568,7 +568,7 @@ export const realServerData = [
}, },
{ {
"법인": "한라", "법인": "한라",
"자산코드": "hl-das-001", "자산코드": "DSS016",
"storage유형": "서버", "storage유형": "서버",
"용도": "", "용도": "",
"상세": "파일서버 정보 없음(접속 불가)", "상세": "파일서버 정보 없음(접속 불가)",
@@ -589,7 +589,7 @@ export const realServerData = [
}, },
{ {
"법인": "한라", "법인": "한라",
"자산코드": "hl-das-002", "자산코드": "DSS015",
"storage유형": "서버", "storage유형": "서버",
"용도": "", "용도": "",
"상세": "파일서버 정보 없음(접속 불가)", "상세": "파일서버 정보 없음(접속 불가)",
@@ -611,7 +611,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "", "자산코드": "",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "GSIM NAS", "용도": "GSIM NAS",
"상세": "팀 내부 자료 저장 , 정사영상 및 지도 데이터 저장 , Gitea 및 Git 내장 NAS", "상세": "팀 내부 자료 저장 , 정사영상 및 지도 데이터 저장 , Gitea 및 Git 내장 NAS",
"위치": "마천사무실", "위치": "마천사무실",
@@ -631,7 +631,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "", "자산코드": "",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "그래픽스개발팀 데이터 백업 NAS", "용도": "그래픽스개발팀 데이터 백업 NAS",
"상세": "그래픽스 개발팀 데이터 백업용 NAS", "상세": "그래픽스 개발팀 데이터 백업용 NAS",
"위치": "마천사무실", "위치": "마천사무실",
@@ -1091,7 +1091,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "1", "자산코드": "1",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 2", "용도": "NAS 2",
"상세": "한라 기업부설연구소 공용 NAS", "상세": "한라 기업부설연구소 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1107,7 +1107,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "2", "자산코드": "2",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 1", "용도": "NAS 1",
"상세": "한라 공용 NAS", "상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1123,7 +1123,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "3", "자산코드": "3",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 4", "용도": "NAS 4",
"상세": "한라 공용 NAS", "상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1139,7 +1139,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "4", "자산코드": "4",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 5", "용도": "NAS 5",
"상세": "한라 환경플랜트사업부 NAS", "상세": "한라 환경플랜트사업부 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1155,7 +1155,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "5", "자산코드": "5",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 6", "용도": "NAS 6",
"상세": "한라 공용 NAS", "상세": "한라 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1171,7 +1171,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "6", "자산코드": "6",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS7", "용도": "NAS7",
"상세": "한라 원주바이오 NAS", "상세": "한라 원주바이오 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1187,7 +1187,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "7", "자산코드": "7",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "총괄기획실 NAS", "용도": "총괄기획실 NAS",
"상세": "총괄기획실 공용 NAS", "상세": "총괄기획실 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1203,7 +1203,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "8", "자산코드": "8",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 1", "용도": "한맥 NAS 1",
"상세": "한맥 공용 NAS", "상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1219,7 +1219,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "9", "자산코드": "9",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 2", "용도": "한맥 NAS 2",
"상세": "한맥 공용 NAS", "상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1235,7 +1235,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "10", "자산코드": "10",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "한맥 NAS 3", "용도": "한맥 NAS 3",
"상세": "한맥 공용 NAS", "상세": "한맥 공용 NAS",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1251,7 +1251,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "11", "자산코드": "11",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "NAS 13", "용도": "NAS 13",
"상세": "환경플랜트사업", "상세": "환경플랜트사업",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1331,7 +1331,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "16", "자산코드": "16",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "디자인팀1 NAS", "용도": "디자인팀1 NAS",
"상세": "", "상세": "",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1347,7 +1347,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "17", "자산코드": "17",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "디자인팀2 NAS", "용도": "디자인팀2 NAS",
"상세": "", "상세": "",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1507,7 +1507,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "27", "자산코드": "27",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "기술개발센터 NAS", "용도": "기술개발센터 NAS",
"상세": "", "상세": "",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",
@@ -1523,7 +1523,7 @@ export const realServerData = [
{ {
"법인": "", "법인": "",
"자산코드": "28", "자산코드": "28",
"storage유형": "NAS", "storage유형": "저장시스템_렉(DAS)",
"용도": "-", "용도": "-",
"상세": "", "상세": "",
"위치": "한맥빌딩(MDF 실)", "위치": "한맥빌딩(MDF 실)",

View File

@@ -17,10 +17,14 @@ export const ASSET_SCHEMA = {
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: '서비스 구분' },
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: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' }, MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ─── // ─── 하드웨어 상세 (Hardware) ───
@@ -117,12 +121,12 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.', description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map' icon: 'map'
}, },
'내부': { '내부SW': {
title: '사내 개발 S/W 관리', title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.', description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code' icon: 'code'
}, },
'외부': { '외부SW': {
title: '외부 상용 S/W 관리', title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.', description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package' icon: 'package'
@@ -151,6 +155,21 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
title: '사무용 가구 관리', title: '사무용 가구 관리',
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.', description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
icon: 'armchair' icon: 'armchair'
},
'사용자': {
title: '임직원 사용자 관리',
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
icon: 'users'
},
'부품 마스터': {
title: '부품 표준 정보 관리',
description: 'PC 사양 적정성 평가의 기준이 되는 부품 표준 정보 및 등급별 감점 점수를 관리합니다.',
icon: 'cpu'
},
'직무별 기준 사양': {
title: '직무별 기준 사양 관리',
description: 'BIM 모델러, 개발자, 엔지니어 등 사내 직무별 권장 하드웨어 기준 및 성능 합격 점수를 관리합니다.',
icon: 'sliders'
} }
}; };

View File

@@ -11,6 +11,7 @@ export interface MasterAssetData {
network: any[]; network: any[];
survey: any[]; survey: any[];
pcParts: any[]; pcParts: any[];
partsMaster: any[];
equipment: any[]; equipment: any[];
officeSupplies: any[]; officeSupplies: any[];
swInternal: any[]; swInternal: any[];
@@ -21,6 +22,7 @@ export interface MasterAssetData {
vip: any[]; vip: any[];
mobile?: any[]; // Legacy mobile support mobile?: any[]; // Legacy mobile support
equip?: any[]; // Backward compat equip?: any[]; // Backward compat
jobSpecs?: any[];
// Backward compatibility // Backward compatibility
subSw: any[]; subSw: any[];
@@ -37,49 +39,58 @@ export interface MasterAssetData {
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';
masterData: MasterAssetData; masterData: MasterAssetData;
activeCharts: any[]; activeCharts: any[];
currentUserRole: 'admin' | 'user';
listFilters?: Record<string, any>;
} }
// 초기 상태 // 초기 상태
export const state: AppState = { export const state: AppState = {
activeCategory: 'hw', activeCategory: 'hw',
activeSubTab: '대시보드', activeSubTab: '대시보드',
viewMode: 'location',
activeCharts: [], activeCharts: [],
currentUserRole: 'user',
listFilters: {},
masterData: { masterData: {
users: [], users: [],
pc: [], server: [], storage: [], network: [], pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], equipment: [], officeSupplies: [], survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [], swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [], cost: [], vip: [],
subSw: [], permSw: [], subSw: [], permSw: [],
hw: [], sw: [], hw: [], sw: [],
swUsers: [], logs: [] swUsers: [], logs: [],
jobSpecs: []
} }
}; };
/** /**
* 신규 14개 테이블 구조에 맞춘 데이터 로드 (Dummy Data) * 통합 V2 스키마에 맞춘 데이터 로드
*/ */
export async function loadMasterDataFromDB() { export async function loadMasterDataFromDB() {
try { try {
state.masterData.pc = dummyPCs || []; const response = await fetch(`${API_BASE_URL}/api/assets/master`);
state.masterData.server = dummyServers || []; if (!response.ok) throw new Error('Failed to fetch master data');
state.masterData.storage = dummyStorages || [];
state.masterData.network = dummyEquips || []; // dummy fallback const data = await response.json();
state.masterData.survey = [];
state.masterData.pcParts = []; // 전역 상태 업데이트
state.masterData.equipment = dummyEquips || []; state.masterData = {
state.masterData.officeSupplies = []; ...state.masterData,
state.masterData.swInternal = dummyPermSw || []; ...data,
state.masterData.swExternal = dummySubSw || []; jobSpecs: data.jobSpecs || [],
state.masterData.cloud = dummyCloud || []; logs: (data.logs || []).map((l: any) => ({
state.masterData.domain = dummyDomain || []; ...l,
state.masterData.cost = []; assetId: l.asset_id || l.assetId,
state.masterData.vip = []; date: l.log_date || l.date,
state.masterData.swUsers = dummySwUsers || []; user: l.log_user || l.user,
state.masterData.logs = dummyLogs || []; log_date: l.log_date || l.date,
state.masterData.users = []; log_user: l.log_user || l.user
}))
};
// Mapping for backward compatibility // Mapping for backward compatibility
state.masterData.equip = state.masterData.equipment; state.masterData.equip = state.masterData.equipment;
@@ -101,10 +112,10 @@ export async function loadMasterDataFromDB() {
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('✅ All dummy data loaded and unified'); console.log('✅ V2 Normalized data loaded successfully');
return true; return true;
} catch (err) { } catch (err) {
console.warn('⚠️ Dummy 로드 실패:', err); console.warn('⚠️ Dummy 로드 실패:', err);
@@ -117,18 +128,21 @@ export function updateState(newState: Partial<AppState>) {
} }
/** /**
* 자산 저장 (Dummy API) * 자산 저장 (V2 Normalized API)
*/ */
export async function saveAsset(category: string, asset: any) { export async function saveAsset(category: string, asset: any) {
try { try {
const currentList = [...(state.masterData as any)[category]]; const url = `${API_BASE_URL}/api/asset/${category}/save`;
const idx = currentList.findIndex(a => a.id === asset.id); const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset)
});
if (idx > -1) currentList[idx] = asset; if (response.ok) {
else currentList.push(asset); await loadMasterDataFromDB(); // 전역 상태 갱신
(state.masterData as any)[category] = currentList;
return true; return true;
}
} catch (err) { } catch (err) {
console.error('자산 저장 실패:', err); console.error('자산 저장 실패:', err);
} }
@@ -136,16 +150,120 @@ export async function saveAsset(category: string, asset: any) {
} }
/** /**
* 자산 삭제 (Dummy API) * 자산 삭제 (V2 API)
*/ */
export async function deleteAsset(category: string, assetId: string) { export async function deleteAsset(category: string, assetId: string) {
try { try {
const currentList = [...(state.masterData as any)[category]]; const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
const filteredList = currentList.filter(a => a.id !== assetId); const response = await fetch(url, { method: 'DELETE' });
(state.masterData as any)[category] = filteredList;
if (response.ok) {
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) {
try {
const url = `${API_BASE_URL}/api/hardware-components/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('부품 마스터 저장 실패:', err);
}
return false;
}
export async function deletePartsMaster(id: number) {
try {
const url = `${API_BASE_URL}/api/hardware-components/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('부품 마스터 삭제 실패:', err);
}
return false;
}
export async function saveSystemUser(user: any) {
try {
const url = `${API_BASE_URL}/api/system-users/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('사용자 정보 저장 실패:', err);
}
return false;
}
export async function deleteSystemUser(id: string) {
try {
const url = `${API_BASE_URL}/api/system-users/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('사용자 정보 삭제 실패:', err);
}
return false;
}
export async function saveJobSpec(spec: any) {
try {
const url = `${API_BASE_URL}/api/job-specs/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 저장 실패:', err);
}
return false;
}
export async function deleteJobSpec(id: number) {
try {
const url = `${API_BASE_URL}/api/job-specs/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 삭제 실패:', err);
}
return false;
}

View File

@@ -1,6 +1,6 @@
import { PAGE_DESCRIPTIONS } from './schema'; import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = `http://${location.hostname}:3000`; export const API_BASE_URL = '';
/** /**
* ITAM 공통 유틸리티 함수 * ITAM 공통 유틸리티 함수
@@ -17,7 +17,7 @@ export function renderPageHeader(container: HTMLElement, pageId: string) {
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"><i data-lucide="${config.icon}"></i> ${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>
`; `;
@@ -153,14 +153,207 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
} }
/** /**
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가) * 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
*/ */
export function getActionButtonsHTML(): string { export function getActionButtonsHTML(): string {
return ` return '';
<div class="search-actions">
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
`;
} }
/**
* 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식)
*/
export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
let score = 100;
if (!cpu) cpu = '';
if (!ram) ram = '';
if (!gpu) gpu = '';
const cpuUpper = cpu.toUpperCase();
const ramUpper = ram.toUpperCase();
const gpuUpper = gpu.toUpperCase();
// 1. CPU 등급 감점 (최대 -30점)
let cpuDeduction = 0;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
cpuDeduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
cpuDeduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
cpuDeduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
cpuDeduction = 25;
} else {
cpuDeduction = 30;
}
score -= cpuDeduction;
// 2. CPU 세대 노후 감점 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
score -= genDeduction;
// 3. RAM 용량 감점 (최대 -25점)
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
let ramDeduction = 25;
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) ramDeduction = 0;
else if (ramVal >= 16) ramDeduction = 10;
else if (ramVal >= 8) ramDeduction = 20;
else ramDeduction = 25;
}
score -= ramDeduction;
// 4. GPU 성능 감점 (최대 -25점)
let gpuDeduction = 25;
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
gpuDeduction = 25;
} else if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
gpuDeduction = 0;
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
gpuDeduction = 5;
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
gpuDeduction = 15;
} else {
gpuDeduction = 25;
}
score -= gpuDeduction;
// 5. 연식(노후도) 감점 (최대 -15점)
let age = 0;
if (purchaseDate && purchaseDate !== '-') {
let normalized = purchaseDate.replace(/\./g, '-').trim();
if (/^\d{6}$/.test(normalized)) {
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
}
const purchase = new Date(normalized);
if (!isNaN(purchase.getTime())) {
// 2026년 5월 31일 기준 경과연수 계산
const mockToday = new Date('2026-05-31');
const diffMs = mockToday.getTime() - purchase.getTime();
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
age = Math.max(0, parseFloat(age.toFixed(1)));
}
}
let ageDeduction = 0;
if (age < 1) ageDeduction = 0;
else if (age < 2) ageDeduction = 3;
else if (age < 3) ageDeduction = 6;
else if (age < 4) ageDeduction = 9;
else if (age < 5) ageDeduction = 12;
else ageDeduction = 15;
score -= ageDeduction;
return Math.max(10, score);
}
/**
* 성능 점수 기준 등급 뱃지 메타 정보 가져오기
*/
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 >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
}
/**
* Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별
*/
export function isWindows11Incompatible(cpu: string, ram: string): boolean {
if (!cpu) return true;
const cpuUpper = cpu.toUpperCase();
// 1. RAM 4GB 미만은 공식 미지원
if (ram) {
const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal < 4) return true;
}
}
// 2. CPU 세대 검사
// Intel CPU 세대 판정
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
let gen = 0;
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 === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750)
if (gen > 0 && gen < 8) return true; // 8세대 미만 불가
return false;
}
// AMD Ryzen CPU 세대 판정
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
if (amdMatch && amdMatch[1]) {
const numStr = amdMatch[1];
let amdGen = 0;
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등
if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가
return false;
}
// Apple Silicon은 지원
if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) {
return false;
}
// 그 외 확실한 구형 CPU 제품군
const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON'];
if (knownOldCpus.some(name => cpuUpper.includes(name))) {
return true;
}
// 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주
if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) {
// i5-620M 처럼 옛날 구형 모바일 칩 등
return true;
}
return false;
}

View File

@@ -2,36 +2,21 @@ 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 { 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 { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
import { initUserModal, openUserModal } from './components/Modal/UserModal';
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 { 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';
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
async function apiBatchSave(url: string, data: any[], label: string) {
try {
console.log(`${label} DB 저장 완료 (Dummy Mode: ${data?.length || 0} items)`);
} catch (err) {
console.error(`${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
}
}
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자');
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그');
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
// 화면 갱신 통합 핸들러 // 화면 갱신 통합 핸들러
function refreshView() { function refreshView() {
@@ -40,18 +25,45 @@ function refreshView() {
if (state.activeSubTab === '대시보드') { if (state.activeSubTab === '대시보드') {
renderDashboard(mainContent); renderDashboard(mainContent);
return;
}
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
state.viewMode = 'list';
}
const isServerTab = state.activeSubTab === '서버';
mainContent.innerHTML = `
<div class="view-header">
<div class="view-toggle-container" style="${isServerTab ? '' : 'display:none;'}">
<button class="mode-toggle-btn ${state.viewMode === 'location' ? 'active' : ''}" data-mode="location">자산현황(위치)</button>
<button class="mode-toggle-btn ${state.viewMode === 'list' ? 'active' : ''}" data-mode="list">자산목록</button>
</div>
</div>
<div id="view-body" style="flex: 1; overflow: hidden; display: flex; flex-direction: column;"></div>
`;
// 이벤트 바인딩
mainContent.querySelectorAll('.mode-toggle-btn').forEach(btn => {
btn.addEventListener('click', () => {
const mode = (btn as HTMLElement).getAttribute('data-mode') as any;
state.viewMode = mode;
refreshView();
});
});
const viewBody = document.getElementById('view-body')!;
if (state.viewMode === 'location') {
renderLocationView(viewBody);
} else { } else {
renderSWTable(mainContent); renderSWTable(viewBody); // 리스트 형식
} }
} }
// 통합 저장 및 갱신 // 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
async function saveAllDataToDB() { async function refreshAllData() {
await Promise.all([
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
]);
await loadMasterDataFromDB(); await loadMasterDataFromDB();
refreshView(); refreshView();
} }
@@ -65,25 +77,24 @@ function initApp() {
try { try {
renderNavigation((tab) => { renderNavigation((tab) => {
if (tab === '대시보드') { refreshView();
renderDashboard(mainContent);
} else {
renderSWTable(mainContent);
}
}); });
initHwModal(() => saveAllDataToDB(), closeAllModals); initHwModal(() => refreshAllData(), closeAllModals);
initSwModal(() => saveAllDataToDB(), closeAllModals); initSwModal(() => refreshAllData(), closeAllModals);
initSwUserModal(() => { initSwUserModal(() => {
saveSwUsersToDB().then(() => {
loadMasterDataFromDB().then(() => refreshView()); loadMasterDataFromDB().then(() => refreshView());
});
}, closeAllModals); }, closeAllModals);
initDomainModal(() => refreshAllData(), closeAllModals);
initPartsMasterModal(() => refreshAllData(), closeAllModals);
initJobSpecModal(() => refreshAllData(), closeAllModals);
initUserModal(() => refreshAllData(), closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
initDomainModal();
initGuide(); initGuide();
pcFlowModal.init(() => {
loadMasterDataFromDB().then(() => refreshView());
});
loadMasterDataFromDB().then((success) => { loadMasterDataFromDB().then((success) => {
if (success) { if (success) {
@@ -104,22 +115,40 @@ function initApp() {
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 === 'users') {
// 사용자 추가는 renderUserList 내부에서 별도로 처리하거나 여기서 호출 가능
// 현재 renderUserList에서 별도로 핸들링하고 있으므로 중복 실행 방지
return;
}
if (cat === 'hw') { if (cat === 'hw') {
if (tab === '부품 마스터') {
if (activePartsMasterSubTab === 'job-spec') {
openJobSpecModal({ id: '' } as any, 'add');
} else {
openPartsMasterModal({ id: '' } as any, 'add');
}
} 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' : (tab === '내부' ? '내부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');
} }
return; return;
} }
// 부품 마스터 탭으로 바로가기 연동
if (target.closest('#btn-goto-parts-master')) {
state.activeCategory = 'hw';
state.activeSubTab = '부품 마스터';
renderNavigation((tab) => { refreshView(); });
refreshView();
return;
}
// PC 이동/반납 모달 열기
if (target.closest('#btn-pc-flow')) {
pcFlowModal.open();
return;
}
}); });
createIcons({ createIcons({
@@ -128,4 +157,91 @@ function initApp() {
window.addEventListener('refresh-view', () => refreshView()); window.addEventListener('refresh-view', () => refreshView());
} }
document.addEventListener('DOMContentLoaded', initApp); /**
* 헤더 역할 전환 토글 로직
*/
function initRoleSwitcher() {
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
if (!checkbox || !userLabel || !adminLabel) return;
checkbox.addEventListener('change', () => {
const mainContent = document.getElementById('main-content')!;
if (checkbox.checked) {
state.currentUserRole = 'admin';
userLabel.classList.remove('active');
adminLabel.classList.add('active');
document.body.classList.add('admin-mode');
// 관리자 모드 전환 시 대시보드로 이동
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
refreshView();
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
} else {
renderSWTable(mainContent);
}
});
} else {
state.currentUserRole = 'user';
adminLabel.classList.remove('active');
userLabel.classList.add('active');
document.body.classList.remove('admin-mode');
// 실무자 모드 전환 시 서버 목록으로 이동
state.activeCategory = 'hw';
state.activeSubTab = '서버';
refreshView();
renderNavigation((tab) => {
if (tab === '대시보드') {
renderDashboard(mainContent);
} else {
renderSWTable(mainContent);
}
});
}
});
}
/**
* 앱 초기화 (로그인 과정 없이 즉시 시작)
*/
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
// 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭
// UI 상태 동기화
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
// 화면 전환
if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화
initRoleSwitcher();
initApp();
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => location.reload();
}
}
document.addEventListener('DOMContentLoaded', initializeAppDirectly);

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

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

View File

@@ -1,5 +1,5 @@
:root { :root {
/* --- System Colors (Added) --- */ /* --- System Colors --- */
--color-red: #F21D0D; --color-red: #F21D0D;
--color-pink: #E8175E; --color-pink: #E8175E;
--color-magenta: #B92ED1; --color-magenta: #B92ED1;
@@ -15,36 +15,6 @@
--color-iron: #7F7F7F; --color-iron: #7F7F7F;
--color-steel: #688897; --color-steel: #688897;
--color-red-light: #FEE9E7;
--color-pink-light: #FDE8EF;
--color-magenta-light: #F8EBFB;
--color-purple-light: #F1ECF9;
--color-navy-light: #EDEEF9;
--color-blue-light: #E7F4FE;
--color-cyan-light: #E6F7FF;
--color-green-light: #EEF8EE;
--color-yellow-light: #FFF9E6;
--color-orange-light: #FFF5E6;
--color-dahong-light: #FFECE6;
--color-brown-light: #F6F1EF;
--color-iron-light: #F3F3F3;
--color-steel-light: #F0F4F5;
--color-red-medium: #FAA59E;
--color-pink-medium: #F6A2BF;
--color-magenta-medium: #E3ABEC;
--color-purple-medium: #C5B1E7;
--color-navy-medium: #B3BBE5;
--color-blue-medium: #9ED1FA;
--color-cyan-medium: #9ADFFE;
--color-green-medium: #B8E0B9;
--color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199;
--color-brown-medium: #D9C6BF;
--color-iron-medium: #CCCCCC;
--color-steel-medium: #C3CFD5;
/* --- Primary Brand Levels --- */ /* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED; --primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB; --primary-lv-1: #D2DCDB;
@@ -57,24 +27,24 @@
--primary-lv-8: #193833; --primary-lv-8: #193833;
--primary-lv-9: #162A27; --primary-lv-9: #162A27;
/* --- Legacy Aliases (Maintained for compatibility) --- */ /* --- Semantic Colors --- */
--primary-color: var(--primary-lv-6); --primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5); --primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0); --primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: rgba(255, 61, 0, 0.1);
--edit-mode-focus: rgba(255, 61, 0, 0.3);
--edit-mode-dark: #cc3100;
--text-main: #111827; --text-main: #111827;
--text-muted: #6B7280; --text-muted: #6B7280;
--border-color: #E5E7EB; --border-color: #E5E7EB;
--bg-color: #F9FAFB; --bg-color: #F9FAFB;
--bg-light: #FAFAFA; --bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF; --white: #FFFFFF;
--danger: var(--color-red); --danger: var(--color-red);
--success: var(--color-green);
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
--header-height: 52px; --header-height: 52px;
} }
@@ -83,15 +53,14 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
letter-spacing: -0.02em; letter-spacing: -0.02em;
/* 모든 요소에 자간 규칙 일괄 적용 */
} }
body { body {
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif; font-family: 'Pretendard Variable', Pretendard, sans-serif;
color: var(--text-main); color: var(--text-main);
background-color: var(--bg-color); background-color: var(--bg-color);
line-height: 1.5; line-height: 1.5;
font-size: 19px; font-size: 14px;
overflow: hidden; overflow: hidden;
} }
@@ -102,12 +71,13 @@ body {
width: 100%; width: 100%;
} }
/* --- Main Header & GNB/LNB --- */ /* --- Header --- */
.main-header { .main-header {
background-color: var(--white); background-color: var(--white);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
z-index: 100; z-index: 100;
height: var(--header-height); height: var(--header-height);
flex-shrink: 0;
} }
.header-container { .header-container {
@@ -118,160 +88,46 @@ body {
gap: 1.5rem; gap: 1.5rem;
} }
.brand { .brand { display: flex; align-items: center; gap: 0.75rem; }
display: flex; .main-logo { height: 34px; width: auto; }
align-items: center; .brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
gap: 0.75rem; .brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
}
.main-logo { .integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
height: 34px; .nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
width: auto; .gnb-trigger { font-size: 14px; font-weight: 700; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; }
} .nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); }
.lnb-shelf { display: none; align-items: center; gap: 0.2rem; padding: 0 0.5rem; height: 60%; border-left: 1px solid var(--border-color); margin-left: 0.2rem; }
.brand h1 { /* 기본적으로 활성 탭의 서브메뉴 표시 */
font-size: 1.1rem; .nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
/* 전체적으로 살짝 축소 */
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
}
.brand h1 .sub-title { /* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
font-size: 0.85rem; .integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
/* 영문 제목은 더 작게 */
color: var(--primary-color);
font-weight: 600;
margin-left: 0.25rem;
}
.integrated-nav { /* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
flex: 1; .nav-group:hover .lnb-shelf { display: flex !important; }
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-group { .lnb-item { font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer; padding: 0.2rem 0.6rem; border-radius: 4px; white-space: nowrap; transition: all 0.2s; }
display: flex; .lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
align-items: center; .lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
height: 100%;
}
.gnb-trigger { .header-actions { display: flex; align-items: center; gap: 1rem; }
font-size: 14px; .role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
font-weight: 700; .role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); }
color: var(--text-main); .role-label.active { color: var(--primary-color); }
padding: 0 1rem; .switch { position: relative; display: inline-block; width: 34px; height: 18px; }
cursor: pointer; .switch input { opacity: 0; width: 0; height: 0; }
height: 100%; .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
display: flex; .slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
align-items: center; input:checked + .slider { background-color: var(--color-orange); }
white-space: nowrap; input:checked + .slider:before { transform: translateX(16px); }
}
.lnb-shelf { /* --- Layout Content --- */
display: none;
align-items: center;
gap: 0.25rem;
padding: 0 0.75rem;
height: 60%;
border-left: 1px solid var(--border-color);
margin-left: 0.25rem;
animation: fadeIn 0.2s ease-out;
}
.nav-group:hover .lnb-shelf,
.nav-group.is-showing-shelf .lnb-shelf {
display: flex;
}
.lnb-item {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.6rem;
border-radius: 4px;
white-space: nowrap;
}
.lnb-item:hover {
color: var(--primary-color);
background-color: var(--bg-color);
}
.lnb-item.active {
color: var(--primary-color);
background-color: var(--primary-light);
font-weight: 700;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* --- Global Actions & Buttons --- */
.header-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0 0.8rem;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
cursor: pointer;
height: 28px;
line-height: 1;
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
flex-shrink: 0; /* 크기 찌그러짐 방지 */
}
.btn i,
.btn svg {
width: 12px !important;
height: 12px !important;
}
.btn-primary {
background-color: var(--primary-color);
color: var(--white);
border: 1px solid var(--primary-color);
}
.btn-outline {
background-color: transparent;
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.btn-danger {
color: var(--danger) !important;
border-color: var(--danger) !important;
}
/* --- Layout Frame --- */
.content-area { .content-area {
flex: 1; flex: 1;
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */ padding: 1.25rem 2rem 0;
overflow: hidden; overflow: hidden;
/* 전체 스크롤 차단 */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -282,9 +138,44 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
/* 내부 스크롤을 유도하기 위해 설정 */
} }
.view-content-wrapper {
flex: 1;
overflow-y: auto;
padding-bottom: 2rem;
}
/* --- View Toggle --- */
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
/* --- System Status List (Docker Style) --- */
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; }
.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; }
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
.col-info { flex: 1.5; }
.col-network { flex: 1; }
.col-remote { flex: 1; display: flex; align-items: center; gap: 0.5rem; }
.col-traffic { flex: 1.2; }
.col-actions { width: 120px; display: flex; justify-content: flex-end; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); }
.status-text { font-size: 11px; font-weight: 600; color: var(--success); }
.asset-primary { font-weight: 700; font-size: 14px; }
.asset-secondary { font-size: 12px; color: var(--text-muted); }
.ip-address { font-weight: 600; font-family: monospace; color: var(--primary-color); }
.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; }
.traffic-info { display: flex; justify-content: space-between; font-size: 11px; }
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--primary-color); }
.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; }
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
/* --- Footer --- */ /* --- Footer --- */
.main-footer { .main-footer {
height: 28px; height: 28px;
@@ -321,6 +212,10 @@ body {
} }
/* --- Utility Styles --- */ /* --- Utility Styles --- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.badge { .badge {
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
@@ -339,6 +234,42 @@ body {
color: white; color: white;
} }
.badge-light {
background: var(--bg-color);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
/* PC 성능 등급 뱃지 컬러 스타일 */
.badge.b-purple {
background-color: #EDE9FE;
color: #7C3AED;
border: 1px solid #DDD6FE;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-primary {
background-color: #DBEAFE;
color: #1D4ED8;
border: 1px solid #BFDBFE;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-green {
background-color: #D1FAE5;
color: #047857;
border: 1px solid #A7F3D0;
font-size: 11px;
padding: 2px 6px;
}
.badge.b-yellow {
background-color: #FEF3C7;
color: #D97706;
border: 1px solid #FDE68A;
font-size: 11px;
padding: 2px 6px;
}
.text-tag { .text-tag {
color: var(--text-muted); color: var(--text-muted);
font-size: 16px; font-size: 16px;
@@ -368,7 +299,6 @@ body {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */ .brand h1 .sub-title { display: none; }
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */ .header-actions .btn span { display: none; }
.header-actions .btn { padding: 0 0.5rem; }
} }

View File

@@ -1,10 +1,13 @@
/* --- Premium Executive Dashboard View Specific Styles --- */ /* --- Premium Executive Dashboard View Specific Styles --- */
.dashboard-section-title { .dashboard-section-title {
padding: 0 0 1rem 0; padding: 0 0 0 8px;
font-size: 1.55rem; font-size: 1.55rem;
font-weight: 800; font-weight: 800;
color: var(--text-main); color: var(--text-main);
letter-spacing: -0.02em; letter-spacing: -0.02em;
border-left: 4px solid var(--primary-color);
margin-bottom: 1rem;
line-height: 1.2;
} }
.dashboard-grid { .dashboard-grid {
@@ -14,23 +17,25 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
/* Premium Glassmorphism Card Style */ /* Premium Executive Divider-based Style (Line-based Division) */
.dashboard-card, .stat-card { .dashboard-card, .stat-card {
background: rgba(255, 255, 255, 0.7); background: transparent;
backdrop-filter: blur(10px); backdrop-filter: none;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: none;
border: 1px solid rgba(255, 255, 255, 0.5); border: none;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.07); border-bottom: 1px solid var(--border-color);
border-radius: 12px; box-shadow: none;
padding: 1.5rem; border-radius: 0;
padding: 1.5rem 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: opacity 0.2s ease;
} }
.dashboard-card:hover, .stat-card:hover { .dashboard-card:hover, .stat-card:hover {
transform: translateY(-5px); transform: none;
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.12); box-shadow: none;
opacity: 0.85;
} }
.dashboard-layout-2col { .dashboard-layout-2col {
@@ -57,7 +62,7 @@
/* Premium KPI Value Styling */ /* Premium KPI Value Styling */
.stat-value { .stat-value {
font-size: 2.2rem; font-size: 2.41rem;
font-weight: 800; font-weight: 800;
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%); background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
@@ -75,7 +80,7 @@
} }
.stat-label { .stat-label {
font-size: 1.15rem; font-size: 1.36rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
@@ -115,7 +120,7 @@
font-weight: 700; font-weight: 700;
padding: 1rem; padding: 1rem;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.75rem; font-size: 0.96rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -123,7 +128,7 @@
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #E2E8F0; border-bottom: 1px solid #E2E8F0;
color: #1E293B; color: #1E293B;
font-size: 13px; font-size: 16px;
} }
.table-premium tr:hover td { .table-premium tr:hover td {
@@ -170,10 +175,30 @@
cursor: not-allowed; cursor: not-allowed;
} }
.page-info {
font-size: 0.96rem;
color: var(--text-muted);
font-weight: 600;
}
.page-btns button {
padding: 0.3rem 0.75rem;
border: 1px solid var(--border-color);
background: var(--white);
border-radius: 4px;
font-size: 0.96rem;
cursor: pointer;
}
.page-btns button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.slider-indicator { .slider-indicator {
font-weight: 700; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
font-size: 1.2rem; font-size: 1.41rem;
} }
.dashboard-slider-viewport { .dashboard-slider-viewport {
@@ -185,11 +210,317 @@
.dashboard-slider-track { .dashboard-slider-track {
display: flex; display: flex;
transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1); transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
width: 200%; /* For 2 pages */ width: 400%; /* For 4 pages */
} }
.dashboard-slide { .dashboard-slide {
width: 50%; /* 100% / 2 pages */ width: 25%; /* 100% / 4 pages */
flex-shrink: 0; flex-shrink: 0;
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */ padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
height: calc(100vh - 150px);
min-height: 520px;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* --- Location View Styles --- */
.location-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 2rem;
height: calc(100vh - 180px);
}
.map-section, .asset-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--text-main);
display: flex;
align-items: center;
}
.map-wrapper {
flex: 1;
background: #f8fafc;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);
}
.location-box {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
user-select: none;
}
.location-box:hover {
background: rgba(30, 81, 73, 0.2) !important;
transform: scale(1.02);
z-index: 10;
}
.location-box:active {
transform: scale(0.98);
}
.asset-section .table-container {
flex: 1;
overflow-y: auto;
}
.status-tag {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: #ecfdf5;
color: #059669;
border: 1px solid #d1fae5;
}
.view-toggle-btn:hover {
border-color: var(--primary-color) !important;
color: var(--primary-color) !important;
}
.view-toggle-btn.active:hover {
color: white !important;
}
/* --- View Toggle Header --- */
.view-header {
padding: 0.5rem 1.5rem;
background: var(--white);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: flex-start;
gap: 1rem;
}
.view-toggle-container {
display: flex;
background: #f1f5f9;
padding: 0.25rem;
border-radius: 8px;
gap: 0.25rem;
}
.mode-toggle-btn {
padding: 0.5rem 1rem;
border: none;
background: transparent;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.mode-toggle-btn:hover {
color: var(--text-main);
}
.mode-toggle-btn.active {
background: var(--white);
color: var(--primary-color);
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* --- Enhanced Location View --- */
.location-view-wrapper {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.location-filter-bar {
padding: 1rem 1.5rem;
background: var(--white);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 2rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-group label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-main);
}
.filter-group select {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.8125rem;
color: var(--text-main);
background: var(--white);
min-width: 140px;
}
.map-pagination {
margin-left: auto;
display: flex;
align-items: center;
gap: 1rem;
}
.location-main-content {
flex: 1;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 1.5rem;
padding: 1.5rem;
overflow: hidden;
}
.map-container-section {
display: flex;
flex-direction: column;
overflow: auto;
}
.location-box-point {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.box-label-text {
font-size: 0.65rem;
font-weight: 800;
color: var(--primary-color);
pointer-events: none;
text-shadow: 0 0 2px white;
}
.asset-list-section {
background: var(--white);
border-radius: 12px;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow: hidden;
}
.asset-list-section .section-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-color);
background: #f8fafc;
}
.asset-list-section h4 {
margin: 0;
font-size: 0.9375rem;
color: var(--text-main);
}
.mini-table-wrapper {
flex: 1;
overflow-y: auto;
}
.compact-table {
width: 100%;
border-collapse: collapse;
}
.compact-table th {
position: sticky;
top: 0;
background: var(--white);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.compact-table td {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.compact-table tr.clickable-row:hover {
background: #f1f5f9;
cursor: pointer;
}
/* --- Asset Detail Sidebar (LocationView) --- */
.asset-detail-sidebar {
padding-top: 1rem;
background: var(--white);
height: 100%;
overflow-y: auto;
}
.detail-section {
margin-bottom: 20px;
padding: 0 1.25rem;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: var(--primary-color);
border-bottom: 1px solid var(--border-color);
padding-bottom: 6px;
margin-bottom: 12px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(80px, auto) 1fr);
gap: 8px 16px;
}
.detail-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
display: flex;
align-items: center;
}
.detail-value {
font-size: 14px;
color: var(--text-main);
font-weight: 500;
word-break: break-all;
display: flex;
align-items: center;
}
.detail-header-actions {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.detail-header-title {
flex: 1;
font-size: 0.95rem;
font-weight: 700;
} }

115
src/styles/login.css Normal file
View File

@@ -0,0 +1,115 @@
/* Login Screen Styles */
.login-layout {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg-color);
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 500px;
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.08);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-logo {
height: 52px;
margin-bottom: 1.25rem;
}
.login-header h2 {
font-size: 1.75rem;
font-weight: 800;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.login-header p {
font-size: 0.9375rem;
color: var(--text-muted);
}
.login-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.role-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2rem 1.5rem;
border: 2px solid var(--bg-light);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background-color: var(--bg-light);
}
.role-card:hover {
border-color: var(--primary-color);
background-color: var(--white);
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(30, 81, 73, 0.08);
}
.role-icon {
width: 56px;
height: 56px;
background-color: var(--white);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
color: var(--primary-color);
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.role-card:hover .role-icon {
background-color: var(--primary-color);
color: var(--white);
border-color: var(--primary-color);
}
.role-card h3 {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.role-card p {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.4;
}
.login-footer {
margin-top: 3rem;
text-align: center;
font-size: 0.75rem;
color: var(--text-muted);
}

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

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

View File

@@ -47,7 +47,7 @@
} }
.modal-header .btn-icon { .modal-header .btn-icon {
color: #FFFFFF !important; color: var(--white) !important;
cursor: pointer; cursor: pointer;
background: none !important; background: none !important;
border: none !important; border: none !important;
@@ -129,7 +129,7 @@
display: none !important; display: none !important;
} }
.grid-form.is-view-mode button { .grid-form.is-view-mode button:not(.btn-loc-action) {
pointer-events: none !important; pointer-events: none !important;
background: none !important; background: none !important;
border: none !important; border: none !important;
@@ -143,7 +143,7 @@
.grid-form.is-edit-mode input, .grid-form.is-edit-mode input,
.grid-form.is-edit-mode select, .grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea { .grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */ color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
@@ -160,8 +160,8 @@
.grid-form.is-edit-mode input:focus, .grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus, .grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus { .grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00; border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1); box-shadow: 0 0 0 2px var(--edit-mode-focus);
} }
.form-section-title:first-child { .form-section-title:first-child {
@@ -379,16 +379,21 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
max-height: 500px; max-height: 500px;
padding-right: 0.5rem; padding-right: 8px;
margin-top: 8px;
} }
.history-item { .history-item {
position: relative; position: relative;
padding-left: 1.25rem; padding-left: 20px;
padding-bottom: 1.5rem; padding-bottom: 20px;
border-left: 2px solid var(--border-color); border-left: 2px solid var(--border-color);
} }
.history-item:last-child {
border-left: 2px solid transparent;
}
.history-item::before { .history-item::before {
content: ''; content: '';
position: absolute; position: absolute;
@@ -399,34 +404,68 @@
border-radius: 50%; border-radius: 50%;
background-color: var(--white); background-color: var(--white);
border: 2px solid var(--primary-color); border: 2px solid var(--primary-color);
z-index: 1;
} }
.history-item:last-child { /* Event Specific Markers */
border-left: 2px solid transparent; .history-item.evt-dept::before { border-color: #3b82f6; }
.history-item.evt-user::before { border-color: #8b5cf6; }
.history-item.evt-role::before { border-color: #10b981; }
.history-item.evt-status::before { border-color: #f59e0b; }
.history-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
} }
.history-date { .history-date {
font-size: 1.05rem; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;
margin-bottom: 0.25rem;
} }
.history-tag {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
}
.tag-dept { background: #eff6ff; color: #3b82f6; }
.tag-user { background: #f5f3ff; color: #8b5cf6; }
.tag-role { background: #ecfdf5; color: #10b981; }
.tag-status { background: #fffbeb; color: #f59e0b; }
.tag-default { background: #f3f4f6; color: #6b7280; }
.history-user { .history-user {
font-size: 1.05rem; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--primary-color); color: var(--text-main);
margin-bottom: 0.25rem; margin-bottom: 6px;
display: block;
} }
.history-details { .history-details {
font-size: 1.1rem; font-size: 12.5px;
color: var(--text-main); color: var(--text-main);
line-height: 1.4; line-height: 1.5;
white-space: pre-wrap; background: #f8fafc;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid #f1f5f9;
word-break: break-all; word-break: break-all;
} }
.history-arrow {
display: inline-block;
margin: 0 4px;
color: var(--text-muted);
font-weight: 400;
}
.empty-history { .empty-history {
padding: 2rem 0; padding: 2rem 0;
text-align: center; text-align: center;
@@ -508,3 +547,283 @@
color: #3b82f6; color: #3b82f6;
background: #eff6ff; background: #eff6ff;
} }
/* Layout Map & Image Picker Styles */
.layout-map-container {
position: relative;
display: inline-block;
cursor: crosshair;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.layout-map-container.readonly {
cursor: default;
}
.layout-map-container.readonly .map-seat-obj {
pointer-events: none;
}
.digital-overlay-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 8;
}
.digital-map-svg {
width: 100%; height: 100%;
}
.map-seat-obj {
fill: rgba(30, 81, 73, 0.02);
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
stroke-width: 0.2;
cursor: pointer;
pointer-events: all;
transition: all 0.2s;
}
.map-seat-obj:hover {
fill: rgba(30, 81, 73, 0.3);
stroke: rgba(30, 81, 73, 0.6);
stroke-width: 0.5;
}
.layout-map-img {
display: block;
max-width: 100%;
max-height: 75vh;
user-select: none;
-webkit-user-drag: none;
}
.layout-marker {
position: absolute;
width: 14px;
height: 14px;
background-color: rgba(30, 81, 73, 0.7);
border: 2px solid #FFFFFF;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 10;
box-shadow: 0 0 8px rgba(0,0,0,0.4);
}
.pulse-marker {
background-color: rgba(255, 61, 0, 0.8) !important;
border-color: #FFFFFF !important;
animation: marker-pulse 1.2s infinite;
}
@keyframes marker-pulse {
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
}
.image-picker-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
z-index: 2500;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.image-picker-header {
width: 100%;
max-width: 900px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.image-picker-header h3 {
color: white;
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.image-picker-content {
background: white;
padding: 8px;
border-radius: 8px;
max-width: 95vw;
max-height: 80vh;
overflow: auto;
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.picker-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 60px;
background: rgba(0,0,0,0.5);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
z-index: 100;
user-select: none;
transition: background 0.2s;
}
.picker-nav:hover { background: rgba(0,0,0,0.8); }
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
.image-picker-footer {
margin-top: 20px;
display: flex;
gap: 12px;
}
.btn-loc-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px;
font-size: 10px !important;
font-weight: 600;
border-radius: 4px;
height: 24px;
min-width: 52px;
white-space: nowrap;
transition: all 0.2s;
}
.btn-loc-view {
background-color: var(--primary-color);
color: white !important;
border: none;
}
.btn-loc-view:hover {
background-color: #163d37;
}
.location-detail-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.location-detail-container select {
flex: 1;
}
/* Dynamic Remote Info Row */
.remote-info-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
border-bottom: 1px dashed var(--border-color);
}
.remote-info-row:last-child {
border-bottom: none;
}
.ri-line {
display: flex;
gap: 8px;
align-items: center;
}
.ri-line select,
.ri-line input {
height: 38px;
box-sizing: border-box;
font-size: 13px;
padding: 0 10px;
border: 1px solid var(--border-color);
border-radius: 4px;
outline: none;
background-color: var(--white);
color: var(--text-main);
transition: border-color 0.2s;
}
.ri-line select:disabled,
.ri-line input[readonly] {
background-color: var(--bg-muted);
border-color: transparent;
cursor: default;
}
.ri-line select:focus,
.ri-line input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
}
.ri-type, .ri-tool {
width: 110px;
flex-shrink: 0;
}
.ri-val1, .ri-id, .ri-pw {
flex: 1;
min-width: 0;
}
.ri-remove-btn {
height: 38px;
width: 38px;
padding: 0;
color: #E11D48;
border: 1px solid #E11D48;
background: transparent;
border-radius: 4px;
cursor: pointer;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.2s;
}
.ri-remove-btn:hover {
background-color: #FFF1F2;
}
.ri-spacer {
width: 46px; /* 38px btn + 8px gap */
flex-shrink: 0;
}
.ri-connector {
width: 24px;
height: 24px;
border-left: 1.5px solid #94a3b8;
border-bottom: 1.5px solid #94a3b8;
margin-top: -24px;
margin-left: 12px;
border-bottom-left-radius: 6px;
flex-shrink: 0;
}
.ri-cred-line {
margin-top: -4px;
}

View File

@@ -10,27 +10,19 @@
} }
.page-title { .page-title {
font-size: 21px; font-size: 16px;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-color);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
margin: 0; margin: 0;
} border-left: 4px solid var(--primary-color);
padding-left: 8px;
.page-title i { line-height: 1.2;
display: flex;
align-items: center;
}
.page-title svg {
width: 18px;
height: 18px;
} }
.page-description { .page-description {
font-size: 17px; font-size: 12px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
@@ -72,7 +64,7 @@
} }
.search-item label { .search-item label {
font-size: 16px; font-size: 11px;
font-weight: 700; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -83,7 +75,7 @@
padding: 0 1rem; padding: 0 1rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
font-size: 19px; font-size: 14px;
outline: none; outline: none;
background-color: var(--white); background-color: var(--white);
} }
@@ -128,7 +120,7 @@ table {
th, td { th, td {
padding: 0.8rem 1.2rem; padding: 0.8rem 1.2rem;
border-bottom: 1px solid #F3F4F6; border-bottom: 1px solid var(--border-color);
text-align: left; /* 기본은 좌측 정렬 */ text-align: left; /* 기본은 좌측 정렬 */
white-space: nowrap; white-space: nowrap;
} }
@@ -140,8 +132,8 @@ thead {
} }
th { th {
background-color: #FAFAFA !important; background-color: var(--bg-light) !important;
font-size: 18px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
position: sticky; position: sticky;
@@ -152,13 +144,13 @@ th {
} }
td { td {
font-size: 18px; font-size: 13px;
color: var(--text-main); color: var(--text-main);
font-weight: 400; font-weight: 400;
} }
tbody tr:hover { tbody tr:hover {
background-color: #F9FAFB; background-color: var(--bg-color);
} }
/* 정렬 클래스 강제 적용 */ /* 정렬 클래스 강제 적용 */

File diff suppressed because it is too large Load Diff

View File

@@ -38,17 +38,17 @@ export function renderSwDashboard(container: HTMLElement) {
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;"> <div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;"> <div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span> <span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div> <div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div> <div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;"> <div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div> <div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div> </div>
</div> </div>
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;"> <div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span> <span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div> <div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div> <div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;"> <div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div> <div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
</div> </div>
@@ -59,12 +59,12 @@ export function renderSwDashboard(container: HTMLElement) {
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;"> <div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span> <span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div> <div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
</div> </div>
<div class="dashboard-card" style="min-height:auto;"> <div class="dashboard-card" style="min-height:auto;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span> <span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
<div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div> <div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -8,17 +8,19 @@ export function renderCloudList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '클라우드', title: '클라우드',
dataSource: () => state.masterData.cloud || [], dataSource: () => state.masterData.cloud || [],
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR'], searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openSwModal(asset, 'view'), onRowClick: (asset) => openSwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' }, { header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.key] || '' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ {
header: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, header: ASSET_SCHEMA.PURCHASE_AMOUNT.ui,
sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key, sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key,

View File

@@ -7,15 +7,16 @@ export function renderCostList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '비용관리', title: '비용관리',
dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []), dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []),
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT'], searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: () => alert('상세 정보 준비 중입니다.'), onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [ columns: [
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
{ header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' }, { header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' },
{ {

View File

@@ -12,24 +12,20 @@ export function renderDomainList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '도메인', title: '도메인',
dataSource: () => state.masterData.domain || [], dataSource: () => state.masterData.domain || [],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME'], searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
persistentSortState, persistentSortState,
emptyMessage: '등록된 도메인 정보가 없습니다.', emptyMessage: '등록된 도메인 정보가 없습니다.',
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (item) => openDomainModal(item), onRowClick: (item) => openDomainModal(item),
columns: [ columns: [
{ header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' }, { header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
{ { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
header: ASSET_SCHEMA.ASSET_TYPE.ui,
sortKey: ASSET_SCHEMA.ASSET_TYPE.key,
align: 'center',
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.ASSET_TYPE.key] === '관리중' ? 'primary' : 'muted'}">${a[ASSET_SCHEMA.ASSET_TYPE.key] || '-'}</span>`
},
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' }, { header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } { header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }

View File

@@ -8,11 +8,12 @@ export function renderEquipmentList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '업무지원장비', title: '업무지원장비',
dataSource: () => sortAssets(state.masterData.equipment || []), dataSource: () => sortAssets(state.masterData.equipment || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -23,7 +24,7 @@ export function renderEquipmentList(container: HTMLElement) {
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
}, },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' }, { header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

View File

@@ -8,11 +8,12 @@ export function renderFacilityList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '사무가구', title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -22,7 +23,7 @@ export function renderFacilityList(container: HTMLElement) {
align: 'center', align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
}, },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ {

View File

@@ -7,15 +7,17 @@ export function renderGiftList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '선물', title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME'], searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: () => alert('상세 정보 준비 중입니다.'), onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [ columns: [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' }, { header: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' }, { header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,18 @@ export function renderMobileList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: 'PC', // Legacy support title: 'PC', // Legacy support
dataSource: () => sortAssets(state.masterData.mobile || []), dataSource: () => sortAssets(state.masterData.mobile || []),
searchKeys: ['MODEL_NAME'], searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' }, { header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' },
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' },
{ {
header: ASSET_SCHEMA.LOCATION.ui, header: ASSET_SCHEMA.LOCATION.ui,

View File

@@ -8,11 +8,12 @@ export function renderNetworkList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '네트워크', title: '네트워크',
dataSource: () => sortAssets(state.masterData.network || []), dataSource: () => sortAssets(state.masterData.network || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -23,7 +24,7 @@ export function renderNetworkList(container: HTMLElement) {
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</span>`
}, },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' }, { header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

View File

@@ -0,0 +1,171 @@
import { state } from '../../core/state';
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
import { openJobSpecModal } from '../../components/Modal/JobSpecModal';
import { formatInline } from '../../core/utils';
import { createListView } from './ListFactory';
export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master';
export function renderPartsMasterList(container: HTMLElement) {
if (activePartsMasterSubTab === 'parts-master') {
createListView(container, {
title: '부품 마스터',
dataSource: () => state.masterData.partsMaster || [],
searchKeys: ['component_name', 'category', 'score_tier'],
filterOptions: {
keywordLabel: '부품명 / 등급 검색',
showLoc: false,
showDept: false,
showType: false
},
onRowClick: (component) => openPartsMasterModal(component, 'view'),
columns: [
{
header: 'ID',
sortKey: 'id',
align: 'center',
width: '5%',
render: c => c.id.toString()
},
{
header: '분류',
sortKey: 'category',
align: 'center',
width: '15%',
render: c => {
let badgeClass = 'badge-primary';
if (c.category === 'CPU') badgeClass = 'b-primary';
else if (c.category === 'GPU') badgeClass = 'b-purple';
else if (c.category === 'RAM') badgeClass = 'b-green';
return `<span class="badge ${badgeClass}">${c.category}</span>`;
}
},
{
header: '부품 표준 명칭',
sortKey: 'component_name',
render: c => formatInline(c.component_name || '-')
},
{
header: '성능 등급',
sortKey: 'score_tier',
align: 'center',
width: '15%',
render: c => c.score_tier || '-'
},
{
header: '감점 점수',
sortKey: 'deduction',
align: 'center',
width: '15%',
render: c => {
const score = c.deduction || 0;
let color = '#3b82f6'; // blue
if (score >= 20) color = '#ef4444'; // red
else if (score >= 10) color = '#f59e0b'; // orange
return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
}
}
]
});
} else {
createListView(container, {
title: '직무별 기준 사양',
dataSource: () => state.masterData.jobSpecs || [],
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
filterOptions: {
keywordLabel: '직무명 / 사양 검색',
showLoc: false,
showDept: false,
showType: false
},
onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'),
columns: [
{
header: 'ID',
sortKey: 'id',
align: 'center',
width: '5%',
render: j => j.id.toString()
},
{
header: '직무명',
sortKey: 'job_name',
width: '15%',
render: j => `<strong style="color: var(--primary-color); font-size: 14px;">${formatInline(j.job_name || '-')}</strong>`
},
{
header: '권장 CPU 사양',
sortKey: 'cpu_standard',
render: j => formatInline(j.cpu_standard || '-')
},
{
header: '권장 RAM 사양',
sortKey: 'ram_standard',
width: '12%',
render: j => formatInline(j.ram_standard || '-')
},
{
header: '권장 GPU 사양',
sortKey: 'gpu_standard',
render: j => formatInline(j.gpu_standard || '-')
},
{
header: '기준 점수',
sortKey: 'min_score',
align: 'center',
width: '10%',
render: j => `<span style="font-weight: 700;">${j.min_score || 0}점 이상</span>`
},
{
header: '비고',
sortKey: 'remarks',
width: '20%',
render: j => formatInline(j.remarks || '-')
}
]
});
}
renderSubTabs(container);
}
function renderSubTabs(container: HTMLElement) {
const header = container.querySelector('.page-header');
if (!header) return;
const tabContainer = document.createElement('div');
tabContainer.className = 'sub-tab-container';
tabContainer.style.cssText = 'display: flex; gap: 16px; margin-top: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 0;';
const tab1Active = activePartsMasterSubTab === 'parts-master';
const tab2Active = activePartsMasterSubTab === 'job-spec';
tabContainer.innerHTML = `
<button id="tab-parts-master" class="sub-tab-btn ${tab1Active ? 'active' : ''}" style="padding: 10px 16px; border: none; background: none; font-size: 14px; font-weight: 600; cursor: pointer; color: ${tab1Active ? 'var(--primary-color)' : 'var(--text-muted)'}; position: relative; border-bottom: 3px solid ${tab1Active ? 'var(--primary-color)' : 'transparent'};">
부품 표준 등급
</button>
<button id="tab-job-spec" class="sub-tab-btn ${tab2Active ? 'active' : ''}" style="padding: 10px 16px; border: none; background: none; font-size: 14px; font-weight: 600; cursor: pointer; color: ${tab2Active ? 'var(--primary-color)' : 'var(--text-muted)'}; position: relative; border-bottom: 3px solid ${tab2Active ? 'var(--primary-color)' : 'transparent'};">
직무별 기준 사양
</button>
`;
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
tabPartsMaster.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'parts-master') {
activePartsMasterSubTab = 'parts-master';
renderPartsMasterList(container);
}
});
tabJobSpec.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'job-spec') {
activePartsMasterSubTab = 'job-spec';
renderPartsMasterList(container);
}
});
}

View File

@@ -1,39 +1,114 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils'; import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema'; import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory'; import { createListView } from './ListFactory';
import { SortState } from '../../core/tableHandler';
let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' };
export function renderPcList(container: HTMLElement) { export function renderPcList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: 'PC', title: 'PC',
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')), persistentSortState,
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN'], dataSource: () => {
const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC');
list.forEach((a: any) => {
a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
});
// 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로)
return list.sort((a: any, b: any) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
if (dateA < dateB) return 1;
if (dateA > dateB) return -1;
return 0;
});
},
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true,
showStatus: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
width: '8%',
render: a => {
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '재고';
let badgeClass = 'badge-light';
if (status === '운영') badgeClass = 'b-green';
else if (status === '재고') badgeClass = 'b-yellow';
else if (status === '수리') badgeClass = 'b-purple';
else if (status === '폐기') badgeClass = 'badge-muted';
return `<span class="badge ${badgeClass}">${status}</span>`;
}
},
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' }, { header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.USER_POSITION.ui, sortKey: ASSET_SCHEMA.USER_POSITION.key, align: 'center', render: a => a[ASSET_SCHEMA.USER_POSITION.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' }, { header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },
{ header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' }, { header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' },
{ header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' }, { header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' },
{ header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' }, { header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
{ header: 'SSD1', sortKey: ASSET_SCHEMA.SSD1.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD1.key] || '-' }, {
{ header: 'SSD2', sortKey: ASSET_SCHEMA.SSD2.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD2.key] || '-' }, header: 'SSD',
{ header: 'HDD1', sortKey: ASSET_SCHEMA.HDD1.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD1.key] || '-' }, align: 'center',
{ header: 'HDD2', sortKey: ASSET_SCHEMA.HDD2.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD2.key] || '-' }, width: '8%',
{ header: 'HDD3', sortKey: ASSET_SCHEMA.HDD3.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD3.key] || '-' }, render: a => {
{ header: 'HDD4', sortKey: ASSET_SCHEMA.HDD4.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD4.key] || '-' }, try {
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
if (Array.isArray(vols)) {
const ssds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'SSD');
if (ssds.length > 0) {
return ssds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
}
}
} catch (e) {}
return '-';
}
},
{
header: 'HDD',
align: 'center',
width: '12%',
render: a => {
try {
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
if (Array.isArray(vols)) {
const hdds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'HDD');
if (hdds.length > 0) {
return hdds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
}
}
} catch (e) {}
return '-';
}
},
{ {
header: ASSET_SCHEMA.MAC_ADDR.ui, header: ASSET_SCHEMA.MAC_ADDR.ui,
sortKey: ASSET_SCHEMA.MAC_ADDR.key, sortKey: ASSET_SCHEMA.MAC_ADDR.key,
align: 'center', align: 'center',
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>` render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
}, },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } {
header: '성능 등급',
sortKey: '_pc_score',
align: 'center',
width: '8%',
render: a => {
const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
const isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]);
const grade = getPcGrade(score, isWin11Incompatible);
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
}
}
] ]
}); });
} }

View File

@@ -12,7 +12,8 @@ export function renderPcPartList(container: HTMLElement) {
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -22,7 +23,7 @@ export function renderPcPartList(container: HTMLElement) {
align: 'center', align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
}, },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' }, { header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },

View File

@@ -12,11 +12,12 @@ export function renderServerList(container: HTMLElement) {
const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC'); const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC');
return sortAssets([...serverList, ...serverPcList]); return sortAssets([...serverList, ...serverPcList]);
}, },
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE'], searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [

View File

@@ -8,11 +8,12 @@ export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '공간정보장비', title: '공간정보장비',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER'], searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -24,7 +25,7 @@ export function renderSpaceInfoList(container: HTMLElement) {
}, },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' }, { header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') }, { header: ASSET_SCHEMA.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ {
header: ASSET_SCHEMA.LOCATION.ui, header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key, sortKey: ASSET_SCHEMA.LOCATION.key,

View File

@@ -8,17 +8,18 @@ export function renderStorageList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '스토리지', title: '스토리지',
dataSource: () => sortAssets(state.masterData.storage || []), dataSource: () => sortAssets(state.masterData.storage || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' }, { header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' }, { header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' }, { header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' }, { header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },

View File

@@ -5,29 +5,31 @@ import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory'; import { createListView } from './ListFactory';
export function renderSwList(container: HTMLElement) { export function renderSwList(container: HTMLElement) {
const isInternal = state.activeSubTab === '내부'; const isInternal = state.activeSubTab === '내부SW';
createListView(container, { createListView(container, {
title: isInternal ? '내부' : '외부', title: isInternal ? '내부SW' : '외부SW',
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal), dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT'], searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
emptyMessage: '검색 결과가 없습니다.', emptyMessage: '검색 결과가 없습니다.',
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
showField: true, showField: true,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openSwModal(asset, 'view'), onRowClick: (asset) => openSwModal(asset, 'view'),
columns: isInternal ? [ columns: isInternal ? [
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' }, { header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' }, { header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' },
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '보유중' }, { header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '보유중' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' }, { header: ASSET_SCHEMA.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } { header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
] : [ ] : [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' }, { header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: '유형', sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '외부' }, { header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' }, { header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' },
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' }, { header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' }, { header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' },

View File

@@ -0,0 +1,60 @@
import { state } from '../../core/state';
import { openUserModal } from '../../components/Modal/UserModal';
import { formatInline } from '../../core/utils';
import { createListView } from './ListFactory';
export function renderUserList(container: HTMLElement) {
createListView(container, {
title: '사용자',
dataSource: () => state.masterData.users || [],
searchKeys: ['emp_no', 'user_name', 'dept_name', 'position', 'status'],
filterOptions: {
keywordLabel: '사번/이름/부서/직급 검색',
showCorp: false,
showDept: true,
showType: false
},
onRowClick: (user) => openUserModal(user, 'view'),
columns: [
{
header: '사번',
sortKey: 'emp_no',
align: 'center',
width: '15%',
render: u => formatInline(u.emp_no || '-')
},
{
header: '이름',
sortKey: 'user_name',
align: 'center',
width: '15%',
render: u => formatInline(u.user_name || '-')
},
{
header: '조직 (부서)',
sortKey: 'dept_name',
align: 'left',
width: '25%',
render: u => formatInline(u.dept_name || '-')
},
{
header: '직급 (직무)',
sortKey: 'position',
align: 'left',
width: '25%',
render: u => formatInline(u.position || '-')
},
{
header: '상태',
sortKey: 'status',
align: 'center',
width: '10%',
render: u => {
const status = u.status || '재직';
const badgeClass = status === '퇴직' ? 'badge-danger' : 'badge-success';
return `<span class="badge ${badgeClass}">${status}</span>`;
}
}
]
});
}

244
src/views/LocationView.ts Normal file
View File

@@ -0,0 +1,244 @@
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Refined)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
// 로컬 상태 (UI 제어용)
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 자산이 등록된(좌표가 일치하는) 구역만 필터링하여 표시
const allBoxes = mapConfig[mapPath] || [];
const boxes = allBoxes.filter((box: any) =>
state.masterData.hw.some(a =>
a.location === currentLoc &&
a.location_detail === currentDetail &&
String(a.loc_x) === String(box.x) &&
String(a.loc_y) === String(box.y)
)
);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 2단계 필터 바 -->
<div class="location-filter-bar">
<div class="filter-group">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="filter-group">
<label>상세 위치</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션을 상세 위치 바로 옆으로 이동 -->
${locImages.length > 1 ? `
<div class="map-pagination" style="margin-left: 0; padding-left: 0.5rem; border-left: 1px solid var(--border-color); display: flex; align-items: center; gap: 0.5rem;">
<div class="page-btns">
<button id="btn-prev-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" style="height: 28px; padding: 0 8px;" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content" style="height: calc(100vh - 180px); align-items: stretch; gap: 1rem; padding: 1rem; overflow: hidden; display: grid; grid-template-columns: 1.4fr 1fr;">
<!-- 지도 섹션: 상단 고정 정렬로 밀림 방지 -->
<div class="map-container-section" style="position: relative; overflow: hidden; border-radius: 8px; border: 1px solid var(--border-color); background: #f1f5f9; display: flex; align-items: flex-start; justify-content: center;">
<div class="map-frame-wrapper" style="position: relative; width: 100%; height: 100%; display: flex; align-items: flex-start; justify-content: center;">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" style="max-width: 100%; max-height: 100%; object-fit: contain; display: block;">
<div id="box-overlay" style="position: absolute; pointer-events: none; transition: none;">
${boxes.map((box: any, idx: number) => {
const name = box.name || `#${idx+1}`;
return `
<div class="location-box-point"
data-name="${name}"
data-x="${box.x}"
data-y="${box.y}"
style="position: absolute; left:${box.x}%; top:${box.y}%; width:${box.w}%; height:${box.h}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto;">
</div>
`}).join('')}
</div>
` : '<div style="padding: 5rem; text-align:center; color: #999;">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션: 내부 스크롤만 허용 -->
<div class="asset-list-section" style="display: flex; flex-direction: column; height: 100%; overflow: hidden; background: #fff; border-radius: 8px; border: 1px solid var(--border-color);">
<div class="section-header" style="flex-shrink: 0; background: #f8fafc; border-bottom: 1px solid var(--border-color); padding: 1rem;">
<h4 id="loc-list-title" style="margin:0; font-size: 0.95rem; font-weight: 700;">📍 구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper" style="flex: 1; overflow-y: auto; padding: 0;">
<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
<div style="padding: 0 1.5rem 0.5rem; flex-shrink: 0;">
<p style="font-size:0.75rem; color:var(--text-muted); margin: 0;">* 지도 위의 구역을 클릭하면 자산 상세 정보가 표시됩니다.</p>
</div>
</div>
`;
// 이미지 로드 및 윈도우 리사이즈 시 오버레이 크기와 위치를 이미지에 정확히 맞춤
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50); // 레이아웃 안정화 대기
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
// 이벤트 바인딩
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
container.querySelectorAll('.location-box-point').forEach(box => {
box.addEventListener('click', () => {
const x = box.getAttribute('data-x');
const y = box.getAttribute('data-y');
const targetAsset = state.masterData.hw.find(a =>
a.location === currentLoc &&
a.location_detail === currentDetail &&
String(a.loc_x) === String(x) &&
String(a.loc_y) === String(y)
);
if (targetAsset) {
renderAssetDetail(targetAsset);
}
container.querySelectorAll('.location-box-point').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<button id="btn-back-to-list" class="btn-icon" style="background: none; border: none; cursor: pointer; color: var(--primary-color); font-size: 1.2rem; padding: 0 4px;">←</button>
<span class="detail-header-title">자산 상세 정보</span>
<button id="btn-edit-from-loc" class="btn btn-primary btn-sm" style="font-size: 11px; height: 28px;">수정</button>
</div>
`;
const renderSection = (title: string, fields: { label: string; value: any }[]) => `
<div class="detail-section">
<div class="detail-section-title">${title}</div>
<div class="detail-grid">
${fields.map(f => `
<div class="detail-label">${f.label}</div>
<div class="detail-value">${f.value || '-'}</div>
`).join('')}
</div>
</div>
`;
const sectionsHTML = [
renderSection('기본 관리 정보', [
{ label: ASSET_SCHEMA.ASSET_CODE.ui, value: asset.asset_code },
{ label: ASSET_SCHEMA.PURCHASE_CORP.ui, value: asset.purchase_corp },
{ label: ASSET_SCHEMA.CATEGORY.ui, value: asset.category },
{ label: ASSET_SCHEMA.ASSET_TYPE.ui, value: asset.asset_type },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status }
]),
renderSection('시스템 사양', [
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu }
]),
renderSection('네트워크 정보', [
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool }
]),
renderSection('구매 및 기타', [
{ label: ASSET_SCHEMA.PURCHASE_DATE.ui, value: asset.purchase_date },
{ label: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, value: asset.purchase_amount ? `${Number(asset.purchase_amount).toLocaleString()}` : '-' },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo }
])
].join('');
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-back-to-list')?.addEventListener('click', () => {
title.textContent = `📍 구역을 선택하세요`;
tableContainer.innerHTML = `<div class="empty-state" style="padding: 3rem 1rem;">지도에서 자산 위치를 클릭하세요.</div>`;
});
container.querySelector('#btn-edit-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'edit');
});
};
render();
}

222
src/views/MapEditor.ts Normal file
View File

@@ -0,0 +1,222 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
export class MapEditor {
private container: HTMLElement;
private wrapper: HTMLElement;
private img: HTMLImageElement;
private boxListEl: HTMLElement;
private pathLabel: HTMLElement;
private statusEl: HTMLElement;
private saveBtn: HTMLButtonElement;
private fileSidebar: HTMLElement;
private allMapConfig: Record<string, any[]> = {};
private boxes: any[] = [];
private isDrawing: boolean = false;
private startX: number = 0;
private startY: number = 0;
private currentBox: HTMLElement | null = null;
private currentPath: string = '';
constructor() {
this.container = document.getElementById('container')!;
this.wrapper = document.getElementById('wrapper')!;
this.img = document.getElementById('target-img') as HTMLImageElement;
this.boxListEl = document.getElementById('box-list')!;
this.pathLabel = document.getElementById('current-path')!;
this.statusEl = document.getElementById('save-status')!;
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
this.fileSidebar = document.getElementById('file-sidebar')!;
}
public async init() {
this.renderFileSidebar();
await this.loadConfig();
this.bindEvents();
this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
}
private renderFileSidebar() {
let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
html += `<div class="folder-item">${bldg}</div>`;
Object.entries(details).forEach(([detail, paths]) => {
paths.forEach(path => {
const fileName = path.split('/').pop() || path;
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
});
});
});
this.fileSidebar.innerHTML = html;
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.renderCurrentFile();
});
});
}
private selectFirstFile() {
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
if (firstItem) {
firstItem.classList.add('active');
this.renderCurrentFile();
}
}
private async loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
}
}
private renderCurrentFile() {
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
if (!activeItem) return;
this.currentPath = activeItem.dataset.path || '';
this.boxes = this.allMapConfig[this.currentPath] || [];
this.pathLabel.textContent = this.currentPath;
this.img.src = this.currentPath;
this.render();
}
private bindEvents() {
this.wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
this.isDrawing = true;
const rect = this.wrapper.getBoundingClientRect();
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
this.currentBox = document.createElement('div');
this.currentBox.className = 'draw-box';
this.currentBox.style.left = this.startX + 'px';
this.currentBox.style.top = this.startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (this.boxes.length + 1);
this.currentBox.appendChild(label);
this.wrapper.appendChild(this.currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!this.isDrawing || !this.currentBox) return;
const rect = this.wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - this.startX;
const height = currentY - this.startY;
this.currentBox.style.width = Math.abs(width) + 'px';
this.currentBox.style.height = Math.abs(height) + 'px';
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
});
window.addEventListener('mouseup', () => {
if (!this.isDrawing || !this.currentBox) return;
this.isDrawing = false;
const width = parseFloat(this.currentBox.style.width);
const height = parseFloat(this.currentBox.style.height);
if (width > 3 && height > 3) {
const rect = this.wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2)
};
this.boxes.push(boxData);
this.render();
}
this.currentBox.remove();
this.currentBox = null;
});
(window as any).removeBox = (index: number) => {
this.boxes.splice(index, 1);
this.render();
};
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
if(confirm('모든 박스를 삭제할까요?')) {
this.boxes = [];
this.render();
}
});
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
}
private async saveToServer() {
if (!this.currentPath) return;
try {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
});
if (res.ok) {
this.allMapConfig[this.currentPath] = [...this.boxes];
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => this.statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.textContent = '서버에 즉시 저장';
}
}
private render() {
this.boxListEl.innerHTML = '';
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
this.boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
this.wrapper.appendChild(div);
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<span>#${i+1}: [${box.x}, ${box.y}]</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
`;
this.boxListEl.appendChild(item);
});
}
}

View File

@@ -9,11 +9,13 @@ import { renderCloudList } from './List/CloudListView';
import { renderDomainList } from './List/DomainListView'; import { renderDomainList } from './List/DomainListView';
import { renderNetworkList } from './List/NetworkListView'; import { renderNetworkList } from './List/NetworkListView';
import { renderPcPartList } from './List/PcPartListView'; import { renderPcPartList } from './List/PcPartListView';
import { renderPartsMasterList } from './List/PartsMasterListView';
import { renderSpaceInfoList } from './List/SpaceInfoListView'; import { renderSpaceInfoList } from './List/SpaceInfoListView';
import { renderGiftList } from './List/GiftListView'; import { renderGiftList } from './List/GiftListView';
import { renderFacilityList } from './List/FacilityListView'; import { renderFacilityList } from './List/FacilityListView';
import { renderCostList } from './List/CostListView'; import { renderCostList } from './List/CostListView';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide'; import { renderUserList } from './List/UserListView';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings } from 'lucide';
/** /**
* 자산 목록 테이블 렌더링 통합 허브 * 자산 목록 테이블 렌더링 통합 허브
@@ -36,12 +38,13 @@ export function renderSWTable(mainContent: HTMLElement) {
else if (tab === '업무지원장비') renderEquipmentList(container); else if (tab === '업무지원장비') renderEquipmentList(container);
else if (tab === '네트워크') renderNetworkList(container); else if (tab === '네트워크') renderNetworkList(container);
else if (tab === 'PC부품') renderPcPartList(container); else if (tab === 'PC부품') renderPcPartList(container);
else if (tab === '부품 마스터') renderPartsMasterList(container);
else if (tab === '공간정보장비') renderSpaceInfoList(container); else if (tab === '공간정보장비') renderSpaceInfoList(container);
else { else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`; container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
} }
} else if (state.activeCategory === 'sw') { } else if (state.activeCategory === 'sw') {
if (tab === '외부' || tab === '내부') { if (tab === '외부SW' || tab === '내부SW') {
renderSwList(container); renderSwList(container);
} else { } else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`; container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
@@ -50,6 +53,7 @@ export function renderSWTable(mainContent: HTMLElement) {
if (tab === '도메인') renderDomainList(container); if (tab === '도메인') renderDomainList(container);
else if (tab === '클라우드') renderCloudList(container); else if (tab === '클라우드') renderCloudList(container);
else if (tab === '비용관리') renderCostList(container); else if (tab === '비용관리') renderCostList(container);
else if (tab === '사용자') renderUserList(container);
else { else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영지원 리스트 뷰가 정의되지 않았습니다.</div>`; container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영지원 리스트 뷰가 정의되지 않았습니다.</div>`;
} }
@@ -69,7 +73,7 @@ export function renderSWTable(mainContent: HTMLElement) {
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지) // 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
createIcons({ createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings }
}); });
} catch (err: any) { } catch (err: any) {
console.error('❌ Error rendering table view:', err); console.error('❌ Error rendering table view:', err);

10
start_docker_wsl.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0start_docker_wsl.ps1"
if errorlevel 1 (
echo.
echo [ERROR] start_docker_wsl.ps1 failed.
pause
exit /b %errorlevel%
)

107
start_docker_wsl.ps1 Normal file
View File

@@ -0,0 +1,107 @@
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$projectWindowsPath = $PSScriptRoot
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
$envFilePath = Join-Path $PSScriptRoot '.env'
function Get-EnvValue {
param(
[string]$FilePath,
[string]$Key
)
if (-not (Test-Path $FilePath)) {
return $null
}
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
if (-not $line) {
return $null
}
return ($line -split '=', 2)[1].Trim()
}
function Test-TcpPortFast {
param(
[string]$HostName,
[int]$Port,
[int]$TimeoutMs = 3000
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
$client.Close()
return $false
}
$client.EndConnect($asyncResult)
$client.Close()
return $true
}
catch {
$client.Close()
return $false
}
}
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " HM ITAM WSL Docker Start" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "[INFO] Checking WSL..."
wsl -l -v
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] WSL is not available." -ForegroundColor Red
exit 1
}
Write-Host "[INFO] Checking Docker in WSL..."
wsl sh -lc "docker --version"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Docker is not available inside WSL." -ForegroundColor Red
exit 1
}
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
if (-not $dbPort) {
$dbPort = '3306'
}
if (-not $dbHost) {
Write-Host "[WARN] .env is missing DB_HOST. Containers will still start, but backend DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
}
if ($dbHost) {
Write-Host "[INFO] Checking external DB reachability..."
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
if (-not $dbReachable) {
Write-Host "[WARN] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
Write-Host "[HINT] Containers will still start. Check VPN/private network connection, firewall rules, DB host/port in .env, or whether the DB server is running." -ForegroundColor Yellow
}
}
Write-Host "[INFO] Starting ITAM containers in WSL..."
wsl sh -lc "cd '$wslProjectPath' && docker compose up --build -d --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[WARN] Build-based startup failed. Retrying with cached images/containers..." -ForegroundColor Yellow
wsl sh -lc "cd '$wslProjectPath' && docker compose up -d --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to start containers." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "============================================" -ForegroundColor Green
Write-Host " [OK] WSL Docker stack started." -ForegroundColor Green
Write-Host " [INFO] Frontend: http://localhost:8080"
Write-Host " [INFO] Backend : http://localhost:3000/api/assets/master"
Write-Host "============================================" -ForegroundColor Green
Start-Process "http://localhost:8080"

View File

@@ -1,6 +1,49 @@
# HM ITAM Server Start Script # HM ITAM Server Start Script
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Get-EnvValue {
param(
[string]$FilePath,
[string]$Key
)
if (-not (Test-Path $FilePath)) {
return $null
}
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
if (-not $line) {
return $null
}
return ($line -split '=', 2)[1].Trim()
}
function Test-TcpPortFast {
param(
[string]$HostName,
[int]$Port,
[int]$TimeoutMs = 3000
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
$client.Close()
return $false
}
$client.EndConnect($asyncResult)
$client.Close()
return $true
}
catch {
$client.Close()
return $false
}
}
Write-Host "============================================" -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan
Write-Host " HM ITAM System Start" -ForegroundColor Cyan Write-Host " HM ITAM System Start" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan
@@ -21,6 +64,13 @@ if (-not (Test-Path "node_modules")) {
Write-Host "[INFO] Checking ports..." Write-Host "[INFO] Checking ports..."
$backendPort = 3000 $backendPort = 3000
$frontendPort = 8080 $frontendPort = 8080
$envFilePath = Join-Path $PSScriptRoot '.env'
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
if (-not $dbPort) {
$dbPort = '3306'
}
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) { if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
@@ -30,6 +80,21 @@ if (Get-NetTCPConnection -LocalPort $frontendPort -ErrorAction SilentlyContinue)
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
} }
if (-not $dbHost) {
Write-Host "[WARNING] .env is missing DB_HOST. Backend and frontend will still start, but DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
}
else {
Write-Host "[INFO] Checking external DB reachability..."
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
if ($dbReachable) {
Write-Host "[INFO] External DB reachable: $dbHost`:$dbPort"
}
else {
Write-Host "[WARNING] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
Write-Host "[WARNING] Backend and frontend will still start, but DB-backed screens and APIs may fail." -ForegroundColor Yellow
}
}
Write-Host "" Write-Host ""
Write-Host "[INFO] Starting Backend [Port: 3000]..." Write-Host "[INFO] Starting Backend [Port: 3000]..."
Start-Process cmd -ArgumentList "/k npm run server" Start-Process cmd -ArgumentList "/k npm run server"

4
stop_docker_wsl.bat Normal file
View File

@@ -0,0 +1,4 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0stop_docker_wsl.ps1"

13
stop_docker_wsl.ps1 Normal file
View File

@@ -0,0 +1,13 @@
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$projectWindowsPath = $PSScriptRoot
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
Write-Host "[INFO] Stopping ITAM WSL Docker stack..."
wsl sh -lc "cd '$wslProjectPath' && docker compose down --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to stop containers." -ForegroundColor Red
exit 1
}
Write-Host "[OK] WSL Docker stack stopped." -ForegroundColor Green

71
test_data_generator.js Normal file
View File

@@ -0,0 +1,71 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import crypto from 'crypto';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
const CATEGORIES = ['PC', '서버', '노트북', '모니터', '업무지원장비'];
const DEPTS = ['기술개발센터', '총괄기획실', '한맥', '삼안', '장헌', '한라'];
const USERS = ['홍길동', '김철수', '이영희', '박지성', '손흥민', '봉준호', '싸이'];
const STATUSES = ['운영', '재고', '수리', '폐기', '기타'];
const CORPS = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
async function generateTestData() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 무작위 테스트 데이터 생성을 시작합니다 (Crypto UUID 방식)...');
for (let i = 1; i <= 20; i++) {
const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)];
const dept = DEPTS[Math.floor(Math.random() * DEPTS.length)];
const user = USERS[Math.floor(Math.random() * USERS.length)];
const status = STATUSES[Math.floor(Math.random() * STATUSES.length)];
const corp = CORPS[Math.floor(Math.random() * CORPS.length)];
// Crypto UUID 생성
const id = crypto.randomUUID();
const assetCode = `TEST-${Date.now().toString().slice(-6)}-${String(i).padStart(3, '0')}`;
try {
// 1. asset_core 삽입 (id 수동 지정)
await connection.query(
`INSERT INTO asset_core
(id, asset_code, category, asset_type, purchase_corp, current_dept, user_current, purchase_date, service_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, assetCode, category, category, corp, dept, user, '2026-06-10', '내부']
);
// 2. asset_spec 삽입
await connection.query(
`INSERT INTO asset_spec
(asset_id, hw_status, model_name, cpu, ram)
VALUES (?, ?, ?, ?, ?)`,
[id, status, `${category} Model ${i}`, 'Intel i7', '16GB']
);
// 3. 초기 이력 삽입
await connection.query(
`INSERT INTO asset_history (asset_id, event_type, details, log_date, log_user)
VALUES (?, ?, ?, ?, ?)`,
[id, 'STATUS_CHANGE', `[최초 등록] 테스트 데이터 생성 (${status})`, '2026-06-10', '시스템']
);
console.log(`✅ 생성 완료: ${assetCode} (${category} / ${dept} / ${user})`);
} catch (err) {
console.error(`❌ 생성 실패 (${i}):`, err.message);
}
}
await connection.end();
console.log('\n✨ 20개의 테스트 데이터 생성이 완료되었습니다.');
}
generateTestData().catch(console.error);

View File

@@ -1,8 +1,20 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
export default defineConfig({ export default defineConfig({
server: { server: {
port: 8080, port: 8080,
host: true, // Listen on all local IPs host: true, // Listen on all local IPs
proxy: {
'/api': {
target: proxyTarget,
changeOrigin: true,
},
'/uploads': {
target: proxyTarget,
changeOrigin: true,
}
}
}, },
}); });