22 Commits

Author SHA1 Message Date
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
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
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
57 changed files with 6265 additions and 1809 deletions

View File

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

BIN
backupDB_20260602.xlsx Normal file

Binary file not shown.

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"
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/login.css" />
<link rel="stylesheet" href="/src/styles/guide.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" />
@@ -18,7 +19,37 @@
</head>
<body>
<div class="app-layout">
<!-- Login Screen -->
<div id="login-container" class="login-layout">
<div class="login-card">
<div class="login-header">
<img src="/image 92.png" alt="Logo" class="login-logo" />
<h2>ITAM 시스템</h2>
<p>자산 관리 포털에 오신 것을 환영합니다</p>
</div>
<div id="login-selection" class="login-selection">
<div class="role-card" data-role="admin">
<div class="role-icon">
<i data-lucide="settings"></i>
</div>
<h3>관리자</h3>
<p>시스템 설정 및 자산 마스터 관리</p>
</div>
<div class="role-card" data-role="user">
<div class="role-icon">
<i data-lucide="monitor"></i>
</div>
<h3>실무자</h3>
<p>자산 조회 및 현황 확인</p>
</div>
</div>
<div class="login-footer">
<p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
</div>
</div>
</div>
<div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
@@ -33,6 +64,14 @@
</nav>
<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-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
<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>

505
server.js
View File

@@ -2,166 +2,423 @@ import express from 'express';
import mysql from 'mysql2/promise';
import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json({ limit: '100mb' }));
app.use(express.json({ limit: '50mb' }));
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
// Request Logger
app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
});
// uploads 폴더가 없으면 생성
if (!fs.existsSync('uploads')) {
fs.mkdirSync('uploads');
}
// MySQL Pool Configuration
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
charset: 'utf8mb4'
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
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 });
// Error Handler
const handleError = (res, err, label) => {
console.error(`❌ [${label}] Error:`, err);
res.status(500).json({ error: err.message });
};
// --- API Implementation ---
/**
* Generic Fetcher for Asset Tables
*/
const fetchAssets = async (tableName, res, context) => {
try {
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
res.json(rows);
} catch (err) {
handleError(res, err, context, true);
}
// --- Global Constants ---
const CATEGORY_TABLE_MAP = {
pc: 'asset_pc',
server: 'asset_server',
storage: 'asset_storage',
network: 'asset_network',
equipment: 'asset_equipment',
officeSupplies: 'asset_office_supplies',
survey: 'asset_survey',
vip: 'asset_vip',
swInternal: 'sw_internal',
swExternal: 'sw_external',
cloud: 'asset_cloud',
users: 'system_users',
swUsers: 'sw_assignment',
logs: 'asset_history'
};
/**
* Generic Batch Saver for Asset Tables
*/
const saveAssetsBatch = async (tableName, items, res, context) => {
const connection = await pool.getConnection();
const ASSET_TABLES = [
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
];
// --- API Endpoints ---
// 1. Generic Batch Save (Dynamic Table Detection)
app.post('/api/:table/batch', async (req, res) => {
const { table } = req.params;
const dbTable = CATEGORY_TABLE_MAP[table] || table;
const data = req.body;
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
let connection;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
// 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;
const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
const validFields = columns.map(c => c.Field);
await connection.query(`DELETE FROM ${dbTable}`);
if (data.length > 0) {
const placeholders = validFields.map(() => '?').join(', ');
const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
if (item[col] !== undefined) filteredRow[col] = item[col];
});
// Auto-generate ID if missing
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
for (const item of data) {
const values = validFields.map(field => {
const val = item[field];
return val === undefined ? null : val;
});
await connection.query(sql, values);
}
}
await connection.commit();
res.json({ success: true, count: items.length });
res.json({ success: true, count: data.length });
} catch (err) {
await connection.rollback();
handleError(res, err, context);
if (connection) await connection.rollback();
handleError(res, err, 'BATCH SAVE');
} finally {
connection.release();
if (connection) connection.release();
}
};
// --- Routes ---
const routeMap = {
'/api/users': { table: 'system_users', context: 'USERS' },
'/api/pc': { table: 'asset_pc', context: 'PC' },
'/api/server': { table: 'asset_server', context: 'SERVER' },
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
'/api/network': { table: 'asset_network', context: 'NETWORK' },
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
'/api/cost': { table: 'asset_cost', context: 'COST' },
'/api/vip': { table: 'asset_vip', context: 'VIP' },
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
};
// 동적 라우팅 생성 (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`));
});
// 4. Legacy/Auxiliary (History & Assignment)
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
app.post('/api/asset/history/batch', async (req, res) => {
// Custom logic for history as it might not follow the random-id pattern
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM asset_history');
for (const item of req.body) {
const dbRow = {
asset_id: item.assetId,
log_date: item.date,
log_user: item.user,
details: item.details,
cost: item.cost || 0
};
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
}
await connection.commit();
res.json({ success: true });
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
});
// 5. Utility
app.get('/api/generate-asset-code', async (req, res) => {
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
app.get('/api/assets/master', async (req, res) => {
try {
const { prefix } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
const connection = await pool.getConnection();
const masterData = {
pc: [], server: [], storage: [], network: [],
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
};
const [rows] = await connection.query(`
SELECT
c.*,
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
s.monitoring, s.price, s.monitor_inch, s.serial_num,
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
(
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no))
FROM asset_volume WHERE asset_id = c.id
) as volumes
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
LEFT JOIN asset_location l ON l.id = (
SELECT id FROM asset_location
WHERE asset_id = c.id AND is_active = 1
ORDER BY created_at DESC LIMIT 1
)
LEFT JOIN asset_network n ON n.id = (
SELECT id FROM asset_network
WHERE asset_id = c.id AND is_active = 1
ORDER BY created_at DESC LIMIT 1
)
`);
const catMap = {
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
};
rows.forEach(row => {
const key = catMap[row.category] || 'pc';
masterData[key].push(row);
});
const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual');
const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription');
const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment');
const [users] = await connection.query('SELECT * FROM system_users');
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
// Search in multiple tables if necessary, but typically prefix-based tables are known
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
let lastCode = '';
for (const table of tables) {
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
if (rows.length > 0 && rows[0].asset_code > lastCode) {
lastCode = rows[0].asset_code;
masterData.swInternal = swInternal;
masterData.swExternal = swExternal;
masterData.swUsers = swUsers;
masterData.users = users;
masterData.logs = logs;
connection.release();
res.json(masterData);
} catch (err) {
handleError(res, err, 'MASTER DATA');
}
});
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
app.post('/api/asset/:category/save', async (req, res) => {
const asset = req.body;
let connection;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
// 3.1 asset_core
const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position'];
const coreData = {};
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
const coreKeys = Object.keys(coreData);
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')}) ON DUPLICATE KEY UPDATE ${coreKeys.map(k => `${k} = VALUES(${k})`).join(', ')}`;
await connection.query(coreSql, Object.values(coreData));
// 3.2 asset_spec
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
const specData = { asset_id: asset.id };
specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; });
const specKeys = Object.keys(specData);
const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]);
if (specExists.length > 0) {
const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`;
await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]);
} else {
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
}
// 3.3 asset_volume
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
if (asset.volumes) {
try {
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
if (Array.isArray(vols)) {
for (let i = 0; i < vols.length; i++) {
const v = vols[i];
if (v.type && v.capacity) {
await connection.query(
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
);
}
}
}
} catch(e) { console.error('Volume parse error', e); }
}
// 3.4 asset_location
if (asset.location || asset.location_detail) {
const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]);
const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y;
if (isChanged) {
await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
[asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]);
}
}
let nextNum = 1;
if (lastCode) {
const lastNum = parseInt(lastCode.split('-').pop() || '0');
nextNum = lastNum + 1;
// 3.5 asset_network
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]);
const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw;
if (isChanged) {
await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
[asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]);
}
}
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
await connection.commit();
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
res.json({ success: true });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'ASSET SAVE V3');
} finally {
if (connection) connection.release();
}
});
// 3.6 PC Flow Transaction (Checkout, Return, Move)
app.post('/api/pc/flow', async (req, res) => {
const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body;
let connection;
try {
connection = await pool.getConnection();
await connection.beginTransaction();
if (action === 'checkout') {
await connection.query(
`UPDATE asset_core
SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
WHERE id = ?`,
[userName, empNo, dept, position, assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
[assetId]
);
} else if (action === 'return') {
await connection.query(
`UPDATE asset_core
SET previous_user = user_current, previous_dept = current_dept,
user_current = '', emp_no = '', current_dept = '재고창고', user_position = ''
WHERE id = ?`,
[assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '대기' WHERE asset_id = ?`,
[assetId]
);
} else if (action === 'move') {
await connection.query(
`UPDATE asset_core
SET previous_user = user_current, previous_dept = current_dept,
user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
WHERE id = ?`,
[userName, empNo, dept, position, assetId]
);
await connection.query(
`UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`,
[assetId]
);
} else {
throw new Error('Invalid action type');
}
// Insert into asset_history
await connection.query(
`INSERT INTO asset_history (asset_id, log_date, log_user, details)
VALUES (?, ?, ?, ?)`,
[assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details]
);
await connection.commit();
console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`);
res.json({ success: true });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'PC FLOW TRANSACTION');
} finally {
if (connection) connection.release();
}
});
// 4. Asset Delete
app.delete('/api/asset/:category/:id', async (req, res) => {
const { category, id } = req.params;
// Define mapping for which base table handles the delete
const deleteTableMap = {
pc: 'asset_core',
server: 'asset_core',
storage: 'asset_core',
network: 'asset_core',
equipment: 'asset_core',
officeSupplies: 'asset_core',
survey: 'asset_core',
vip: 'asset_core',
pcParts: 'asset_core',
swInternal: 'asset_software_perpetual',
swExternal: 'asset_software_subscription',
swUsers: 'asset_software_assignment',
users: 'system_users'
};
const table = deleteTableMap[category];
if (!table) return res.status(400).json({ error: 'Invalid category for deletion' });
try {
const connection = await pool.getConnection();
// For asset_core, ON DELETE CASCADE will handle spec, location, network, volume
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
connection.release();
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'ASSET DELETE');
}
});
// 5. Generate Next Asset Code
app.get('/api/generate-asset-code', async (req, res) => {
const { prefix, purchaseDate } = req.query;
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
try {
const connection = await pool.getConnection();
const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
let maxNum = 0;
for (const table of ASSET_TABLES) {
try {
const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]);
rows.forEach(row => {
const parts = row.asset_code.split('-');
const num = parseInt(parts[parts.length - 1]);
if (!isNaN(num) && num > maxNum) maxNum = num;
});
} catch (err) {}
}
const nextNum = maxNum + 1;
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
connection.release();
res.json({ nextCode });
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
// 6. Map Config API
app.get('/api/maps', (req, res) => {
try {
if (!fs.existsSync('map_config.json')) return res.json({});
const data = fs.readFileSync('map_config.json', 'utf8');
res.json(JSON.parse(data || '{}'));
} catch (err) { handleError(res, err, 'GET MAPS'); }
});
app.post('/api/maps/save', (req, res) => {
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
let config = {};
if (fs.existsSync('map_config.json')) config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
config[path] = boxes;
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
res.json({ success: true });
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
});
// 7. File Upload API (Base64)
app.post('/api/upload', (req, res) => {
try {
const { fileName, fileData } = req.body;
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
// base64 데이터에서 실제 바이너리 추출
const base64Data = fileData.replace(/^data:.*;base64,/, "");
const buffer = Buffer.from(base64Data, 'base64');
// 고유한 파일명 생성 (타임스탬프 결합)
const timestamp = Date.now();
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
const filePath = `uploads/${safeFileName}`;
fs.writeFileSync(filePath, buffer);
console.log(`파일 업로드 성공: ${filePath}`);
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
} catch (err) {
handleError(res, err, 'FILE UPLOAD');
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
});

View File

@@ -1,5 +1,106 @@
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');
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() {
const modals = document.querySelectorAll('.modal-overlay');
@@ -7,26 +108,14 @@ export function closeModals() {
}
export function initBaseModal() {
// ESC 키로 닫기
// ESC 키로 모든 모달 닫기
window.addEventListener('keydown', (e) => {
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 };
}
/**
* 특정 모달을 엽니다.
* @param modalId 모달 엘리먼트의 ID
*/
export function openModal(modalId: string) {
const modal = document.getElementById(modalId);
if (modal) {

View File

@@ -1,121 +1,188 @@
import { state, saveAsset, deleteAsset } from '../../core/state';
import { closeModals, openModal } from './BaseModal';
import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setEditLock } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils';
let currentItem: any = null;
const DOMAIN_MODAL_HTML = `
... (rest of DOMAIN_MODAL_HTML remains same) ...
`;
export function initDomainModal() {
if (!document.getElementById('domain-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
class DomainAssetModal extends BaseModal {
constructor() {
super('domain', '도메인 정보');
}
const modal = document.getElementById('domain-asset-modal')!;
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals());
const saveBtn = document.getElementById('btn-save-domain');
const revertBtn = document.getElementById('btn-revert-domain');
const deleteBtn = document.getElementById('btn-delete-domain');
const headerEditBtn = document.getElementById('btn-edit-domain-header');
protected renderFrameHTML(): string {
return `
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">${this.title}</h2>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" />
<div class="form-section-title">기본 정보</div>
<div class="form-group">
<label>구분</label>
<select id="domain-type" name="type">
<option value="호스팅">호스팅</option>
<option value="도메인">도메인</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required />
</div>
<div class="form-group full-width">
<label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required />
</div>
saveBtn?.addEventListener('click', () => {
if (!currentItem) return;
if (saveBtn.textContent?.includes('수정')) {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
return;
}
saveDomain();
});
<div class="form-section-title">계약 및 비용</div>
<div class="form-group">
<label>계약시작일</label>
<input type="date" id="domain-start-date" name="start_date" />
</div>
<div class="form-group">
<label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" />
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
headerEditBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
});
<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>
`;
}
revertBtn?.addEventListener('click', () => {
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (currentItem) openDomainModal(currentItem);
});
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')!;
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'));
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
}
});
}
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 ? '도메인 정보 상세' : '신규 도메인 등록';
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (!updated.service_name || !updated.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
}
const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) el.value = val || '';
};
if (await saveAsset('domain', updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
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 || '');
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
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();
}
});
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('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
}
const success = await saveAsset('domain', newDomain);
if (success) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
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,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

@@ -1,7 +1,7 @@
import { state, saveAsset, deleteAsset } from '../../core/state';
import { openModal, closeModals } from './BaseModal';
import { BaseModal } from './BaseModal';
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 { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils';
@@ -9,438 +9,363 @@ import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
setEditLock,
applyDateMask
} from './ModalUtils';
let currentSwAsset: any | null = null;
let isEditMode = false;
class SwAssetModal extends BaseModal {
constructor() {
super('sw', '소프트웨어 상세 정보');
}
const SW_MODAL_HTML = `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" />
<!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label for="sw-asset-type">자산 유형</label>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label for="sw-분야">${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
protected renderFrameHTML(): string {
return `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title">${this.title}</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" />
</div>
<div class="form-group">
<label for="sw-법인">${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label for="sw-제품명">${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required />
</div>
<div class="form-group cloud-only">
<label for="sw-플랫폼명">${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div>
<div class="form-group">
<label for="sw-부서">${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" />
</div>
<div class="form-group sw-user-tracking">
<label for="sw-user-current">${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" />
</div>
<div class="form-group sw-user-tracking">
<label for="sw-previous-user">${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" />
</div>
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<label for="sw-수량">${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-금액">${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only">
<label for="sw-계정명">${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" />
</div>
<div class="form-group cloud-only">
<label for="sw-결제수단">${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea>
</div>
</form>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label for="sw-구매일">${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
</button>
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group sw-standard-field">
<label for="sw-납품업체">${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-개발담당자">${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-기획담당자">${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" />
</div>
<div class="form-group sw-standard-field">
<label for="sw-영업담당자">${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
<div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
</button>
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div>
<div id="sw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 계약 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
<span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
</div>
</div>
<div class="form-group full-width">
<label for="sw-비고">${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea>
<div class="form-group">
<label>발생 비용</label>
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
</form>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
</button>
</div>
</div>
<div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="sw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<!-- 계약/유지보수 기간 갱신 및 업데이트 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h2>계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 계약 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
<span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div>
</div>
<div class="form-group">
<label>발생 비용</label>
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
`;
`;
}
function applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
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')!;
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
['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; });
// 외부SW에만 현 사용자/직전 사용자 표시 (내부SW는 user tracking 제외 요청됨)
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
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 swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
}
}
}
private renderHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
}
}
function fillSwFormData(asset: any) {
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 || '');
export const swModal = new SwAssetModal();
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');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) {
container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>';
return;
}
container.innerHTML = logs.map(l => `
<div class="history-item">
<div class="history-date">${l.date}</div>
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details}</div>
</div>
`).join('');
export function initSwModal(onSave: () => void, closeModals: () => void) {
swModal.init(onSave, closeModals);
}
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
currentSwAsset = asset;
const modal = document.getElementById('sw-asset-modal')!;
setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = (mode === 'add' || 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();
});
swModal.open(asset, mode);
}

View File

@@ -1,280 +1,267 @@
import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler';
import { openModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar } from 'lucide';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { BaseModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
let currentSwUserAsset: SoftwareAsset | null = null;
let tempSwUsers: any[] = [];
class SwUserModal extends BaseModal {
private tempSwUsers: any[] = [];
const SW_USER_MODAL_HTML = `
<div id="sw-user-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-title">소프트웨어 사용자 관리</h2>
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<th>관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
</div>
</div>
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index:1100;">
<div class="modal-content" style="width:400px;">
<div class="modal-header">
<h3 id="sw-user-edit-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용 시작일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-시작일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>사용 종료일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-종료일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>신청서 (증빙)</label>
<input type="file" id="new-user-신청서" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
</div>
</div>
</div>
`;
export function openSwUserModal(asset: SoftwareAsset) {
currentSwUserAsset = asset;
const modal = document.getElementById('sw-user-modal')!;
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.}</div>
</div>
`;
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
renderUserList();
modal.classList.remove('hidden');
createIcons({ icons: { Edit2, X, Paperclip } });
}
function renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
tbody.innerHTML = '';
if (tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return;
constructor() {
super('sw-user', '소프트웨어 사용자 관리');
}
tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td style="text-align:center;">${user. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
protected renderFrameHTML(): string {
return `
<div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<th>관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
</table>
</div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<form id="sw-user-asset-form" class="hidden"></form>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
</div>
</div>
`;
tbody.appendChild(tr);
});
</div>
// 이벤트 연결
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
openUserEditSubModal(idx);
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="width: 400px;">
<div class="modal-header">
<h3 id="sw-user-edit-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용 시작일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-시작일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>사용 종료일</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="new-user-종료일" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
</button>
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>신청서 (증빙)</label>
<input type="file" id="new-user-신청서" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
});
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
tempSwUsers.splice(idx, 1);
renderUserList();
}
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();
});
});
createIcons({ icons: { Paperclip } });
}
function openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset();
setFieldValue('edit-user-index', idx);
if (idx > -1) {
const user = tempSwUsers[idx];
setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
// 사용기간 파싱 (yyyy-mm-dd ~ yyyy-mm-dd)
if (user. && user..includes('~')) {
const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim());
} else {
setFieldValue('new-user-시작일', '');
setFieldValue('new-user-종료일', '');
}
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 } });
}
subModal.classList.remove('hidden');
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset. || ''}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset. || ''}</div>
</div>
`;
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
this.renderUserList();
}
protected onAfterOpen(): void {}
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return;
}
this.tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td style="text-align:center;">${user. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
</div>
</td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
this.openUserEditSubModal(idx);
});
});
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
this.tempSwUsers.splice(idx, 1); this.renderUserList();
}
});
});
createIcons({ icons: { Paperclip } });
}
private openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset();
setFieldValue('edit-user-index', idx);
if (idx > -1) {
const user = this.tempSwUsers[idx];
setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
if (user. && user..includes('~')) {
const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim());
}
}
subModal.classList.remove('hidden');
}
private saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement;
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx]. : '');
const userData: any = {
조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
};
if (idx === -1) this.tempSwUsers.push(userData);
else this.tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
this.renderUserList();
}
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
}
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['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');
});
swUserModal.init(onSave, closeModals);
}
function saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement;
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : '');
const userData: any = {
조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
};
if (idx === -1) tempSwUsers.push(userData);
else tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList();
export function openSwUserModal(asset: any) {
swUserModal.open(asset);
}

View File

@@ -13,15 +13,15 @@ export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', 'NAS', 'DAS', '서버PC', '스토리지 렉'],
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'스토리지': ['SSD', 'HDD', '외장HDD'],
'저장매체': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부': ['영구', '구독'],
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'외부SW': ['영구', '구독'],
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'],
'시설자산': ['사무가구']
@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
// 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', '1층', '기타'],
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
'유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
@@ -38,8 +38,37 @@ export const LOCATION_DATA: Record<string, string[]> = {
// 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : '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'
]
},
'한맥빌딩': {
'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

@@ -7,7 +7,7 @@ const MENU_CONFIG: any = {
},
sw: {
label: '소프트웨어',
tabs: ['외부', '내부']
tabs: ['외부SW', '내부SW']
},
ops: {
label: '운영지원',
@@ -32,6 +32,23 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
// 기존 메뉴 렌더링
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey];
// 역할에 따라 노출할 서브탭 필터링
const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') {
// 관리자(admin)일 경우 대시보드 탭만 노출
return tab === '대시보드';
} else {
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
return tab !== '대시보드';
}
});
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
if (visibleTabs.length === 0) {
return;
}
const isActive = state.activeCategory === catKey;
const group = document.createElement('div');
@@ -40,11 +57,11 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const trigger = document.createElement('div');
trigger.className = 'gnb-trigger';
trigger.textContent = config.label;
trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) {
state.activeCategory = catKey as any;
const firstTab = config.tabs[0];
const firstTab = visibleTabs[0] || config.tabs[0];
state.activeSubTab = firstTab;
render();
onTabChange(firstTab);
@@ -55,7 +72,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
config.tabs.forEach((tab: string) => {
visibleTabs.forEach((tab: string) => {
const item = document.createElement('div');
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
item.textContent = tab;
@@ -73,24 +90,26 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
navContainer.appendChild(group);
});
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger';
adminTrigger.innerHTML = '관리자';
adminTrigger.style.color = 'var(--text-muted)';
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
adminTrigger.style.marginLeft = '1rem';
adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => {
alert('준비중입니다.');
});
adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup);
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
if (state.currentUserRole === 'admin') {
const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger';
adminTrigger.innerHTML = '관리자';
adminTrigger.style.color = 'var(--text-muted)';
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
adminTrigger.style.marginLeft = '1rem';
adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => {
window.open('/map_editor.html', '_blank');
});
adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup);
}
};
render();

View File

@@ -14,18 +14,26 @@ export interface FilterOptions {
showDept?: boolean;
showLoc?: boolean;
showField?: boolean;
showType?: boolean;
extraHTML?: string;
onFilterChange: (filters: any) => void;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, extraHTML = '', onFilterChange } = options;
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
</select>
</div>` : ''}
${showField ? `
<div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
@@ -66,7 +74,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || ''
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
};
onFilterChange(filters);
};
@@ -76,9 +85,10 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field'].forEach(id => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
const el = container.querySelector(`#${id}`);
if (el) (el as any).value = '';
});
@@ -98,7 +108,8 @@ 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 matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
});
}

View File

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

View File

@@ -21,6 +21,9 @@ export const ASSET_SCHEMA = {
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ───
@@ -117,12 +120,12 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map'
},
'내부': {
'내부SW': {
title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code'
},
'외부': {
'외부SW': {
title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package'

View File

@@ -39,6 +39,7 @@ export interface AppState {
activeSubTab: string;
masterData: MasterAssetData;
activeCharts: any[];
currentUserRole: 'admin' | 'user';
}
// 초기 상태
@@ -46,6 +47,7 @@ export const state: AppState = {
activeCategory: 'hw',
activeSubTab: '대시보드',
activeCharts: [],
currentUserRole: 'user',
masterData: {
users: [],
pc: [], server: [], storage: [], network: [],
@@ -59,27 +61,28 @@ export const state: AppState = {
};
/**
* 신규 14개 테이블 구조에 맞춘 데이터 로드 (Dummy Data)
* 통합 V2 스키마에 맞춘 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
state.masterData.pc = dummyPCs || [];
state.masterData.server = dummyServers || [];
state.masterData.storage = dummyStorages || [];
state.masterData.network = dummyEquips || []; // dummy fallback
state.masterData.survey = [];
state.masterData.pcParts = [];
state.masterData.equipment = dummyEquips || [];
state.masterData.officeSupplies = [];
state.masterData.swInternal = dummyPermSw || [];
state.masterData.swExternal = dummySubSw || [];
state.masterData.cloud = dummyCloud || [];
state.masterData.domain = dummyDomain || [];
state.masterData.cost = [];
state.masterData.vip = [];
state.masterData.swUsers = dummySwUsers || [];
state.masterData.logs = dummyLogs || [];
state.masterData.users = [];
const response = await fetch(`${API_BASE_URL}/api/assets/master`);
if (!response.ok) throw new Error('Failed to fetch master data');
const data = await response.json();
// 전역 상태 업데이트
state.masterData = {
...state.masterData,
...data,
logs: (data.logs || []).map((l: any) => ({
...l,
assetId: l.asset_id || l.assetId,
date: l.log_date || l.date,
user: l.log_user || l.user,
log_date: l.log_date || l.date,
log_user: l.log_user || l.user
}))
};
// Mapping for backward compatibility
state.masterData.equip = state.masterData.equipment;
@@ -101,10 +104,10 @@ export async function loadMasterDataFromDB() {
state.masterData.sw = [
...state.masterData.swInternal,
...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;
} catch (err) {
console.warn('⚠️ Dummy 로드 실패:', err);
@@ -117,18 +120,21 @@ export function updateState(newState: Partial<AppState>) {
}
/**
* 자산 저장 (Dummy API)
* 자산 저장 (V2 Normalized API)
*/
export async function saveAsset(category: string, asset: any) {
try {
const currentList = [...(state.masterData as any)[category]];
const idx = currentList.findIndex(a => a.id === asset.id);
const url = `${API_BASE_URL}/api/asset/${category}/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset)
});
if (idx > -1) currentList[idx] = asset;
else currentList.push(asset);
(state.masterData as any)[category] = currentList;
return true;
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 저장 실패:', err);
}
@@ -136,14 +142,17 @@ export async function saveAsset(category: string, asset: any) {
}
/**
* 자산 삭제 (Dummy API)
* 자산 삭제 (V2 API)
*/
export async function deleteAsset(category: string, assetId: string) {
try {
const currentList = [...(state.masterData as any)[category]];
const filteredList = currentList.filter(a => a.id !== assetId);
(state.masterData as any)[category] = filteredList;
return true;
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 삭제 실패:', err);
}

View File

@@ -153,14 +153,8 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
}
/**
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가)
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
*/
export function getActionButtonsHTML(): string {
return `
<div class="search-actions">
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus"></i> 자산추가
</button>
</div>
`;
return '';
}

View File

@@ -9,12 +9,18 @@ import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
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';
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
async function apiBatchSave(url: string, data: any[], label: string) {
try {
console.log(`${label} DB 저장 완료 (Dummy Mode: ${data?.length || 0} items)`);
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`${label} DB 저장 실패`);
console.log(`${label} DB 저장 완료`);
} catch (err) {
console.error(`${label} DB 저장 오류:`, err);
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
@@ -26,11 +32,11 @@ const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/
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 saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swInternal/batch`, state.masterData.swInternal, '내부SW');
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swExternal/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 saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swUsers/batch`, state.masterData.swUsers, 'SW사용자');
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/logs/batch`, state.masterData.logs, '자산 로그');
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
// 화면 갱신 통합 핸들러
@@ -74,16 +80,18 @@ function initApp() {
initHwModal(() => saveAllDataToDB(), closeAllModals);
initSwModal(() => saveAllDataToDB(), closeAllModals);
initSwUserModal(() => {
saveSwUsersToDB().then(() => {
loadMasterDataFromDB().then(() => refreshView());
});
}, closeAllModals);
initDomainModal(() => saveAllDataToDB(), closeAllModals);
initDashboardDetailModal();
initDomainModal();
initGuide();
pcFlowModal.init(() => {
loadMasterDataFromDB().then(() => refreshView());
});
loadMasterDataFromDB().then((success) => {
if (success) {
@@ -113,13 +121,19 @@ function initApp() {
if (cat === 'hw') {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
} 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');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
}
return;
}
// PC 이동/반납 모달 열기
if (target.closest('#btn-pc-flow')) {
pcFlowModal.open();
return;
}
});
createIcons({
@@ -128,4 +142,117 @@ function initApp() {
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 handleLogin() {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
const roleCards = document.querySelectorAll('.role-card');
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
if (!loginContainer || !appLayout || roleCards.length === 0) return;
roleCards.forEach(card => {
card.addEventListener('click', () => {
const role = card.getAttribute('data-role');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
if (role === 'admin') {
console.log('🔓 Entering as Admin');
state.currentUserRole = 'admin';
state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; // 관리자는 대시보드로 진입
if (checkbox) checkbox.checked = true;
if (userLabel) userLabel.classList.remove('active');
if (adminLabel) adminLabel.classList.add('active');
document.body.classList.add('admin-mode');
} else if (role === 'user') {
console.log('🔓 Entering as Practitioner');
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자는 서버 목록으로 진입
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
} else {
return;
}
// UI 전환
loginContainer.style.display = 'none';
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', handleLogin);

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 {
/* --- System Colors (Added) --- */
/* --- System Colors --- */
--color-red: #F21D0D;
--color-pink: #E8175E;
--color-magenta: #B92ED1;
@@ -15,36 +15,6 @@
--color-iron: #7F7F7F;
--color-steel: #688897;
--color-red-light: #FEE9E7;
--color-pink-light: #FDE8EF;
--color-magenta-light: #F8EBFB;
--color-purple-light: #F1ECF9;
--color-navy-light: #EDEEF9;
--color-blue-light: #E7F4FE;
--color-cyan-light: #E6F7FF;
--color-green-light: #EEF8EE;
--color-yellow-light: #FFF9E6;
--color-orange-light: #FFF5E6;
--color-dahong-light: #FFECE6;
--color-brown-light: #F6F1EF;
--color-iron-light: #F3F3F3;
--color-steel-light: #F0F4F5;
--color-red-medium: #FAA59E;
--color-pink-medium: #F6A2BF;
--color-magenta-medium: #E3ABEC;
--color-purple-medium: #C5B1E7;
--color-navy-medium: #B3BBE5;
--color-blue-medium: #9ED1FA;
--color-cyan-medium: #9ADFFE;
--color-green-medium: #B8E0B9;
--color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199;
--color-brown-medium: #D9C6BF;
--color-iron-medium: #CCCCCC;
--color-steel-medium: #C3CFD5;
/* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED;
--primary-lv-1: #D2DCDB;
@@ -57,24 +27,24 @@
--primary-lv-8: #193833;
--primary-lv-9: #162A27;
/* --- Legacy Aliases (Maintained for compatibility) --- */
/* --- Semantic Colors --- */
--primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: rgba(255, 61, 0, 0.1);
--edit-mode-focus: rgba(255, 61, 0, 0.3);
--edit-mode-dark: #cc3100;
--text-main: #111827;
--text-muted: #6B7280;
--border-color: #E5E7EB;
--bg-color: #F9FAFB;
--bg-light: #FAFAFA;
--sidebar-bg: #ffffff;
--white: #FFFFFF;
--danger: var(--color-red);
--dash-primary: #6cc020;
--dash-light: #f2f9ec;
--dash-danger: #cf222e;
--success: var(--color-green);
--header-height: 52px;
}
@@ -83,11 +53,10 @@
margin: 0;
padding: 0;
letter-spacing: -0.02em;
/* 모든 요소에 자간 규칙 일괄 적용 */
}
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);
background-color: var(--bg-color);
line-height: 1.5;
@@ -102,12 +71,13 @@ body {
width: 100%;
}
/* --- Main Header & GNB/LNB --- */
/* --- Header --- */
.main-header {
background-color: var(--white);
border-bottom: 1px solid var(--border-color);
z-index: 100;
height: var(--header-height);
flex-shrink: 0;
}
.header-container {
@@ -118,160 +88,46 @@ body {
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand { display: flex; align-items: center; gap: 0.75rem; }
.main-logo { height: 34px; width: auto; }
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; }
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; }
.main-logo {
height: 34px;
width: auto;
}
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; }
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
.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;
/* 전체적으로 살짝 축소 */
font-weight: 800;
color: var(--text-main);
white-space: nowrap;
}
/* 기본적으로 활성 탭의 서브메뉴 표시 */
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
.brand h1 .sub-title {
font-size: 0.85rem;
/* 영문 제목은 더 작게 */
color: var(--primary-color);
font-weight: 600;
margin-left: 0.25rem;
}
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
.integrated-nav {
flex: 1;
height: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
.nav-group:hover .lnb-shelf { display: flex !important; }
.nav-group {
display: flex;
align-items: center;
height: 100%;
}
.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; }
.lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
.lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
.gnb-trigger {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
padding: 0 1rem;
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
white-space: nowrap;
}
.header-actions { display: flex; align-items: center; gap: 1rem; }
.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
.role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); }
.role-label.active { color: var(--primary-color); }
.switch { position: relative; display: inline-block; width: 34px; height: 18px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: var(--color-orange); }
input:checked + .slider:before { transform: translateX(16px); }
.lnb-shelf {
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 --- */
/* --- Layout Content --- */
.content-area {
flex: 1;
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
padding: 1.25rem 2rem 0;
overflow: hidden;
/* 전체 스크롤 차단 */
display: flex;
flex-direction: column;
}
@@ -282,9 +138,44 @@ body {
display: flex;
flex-direction: column;
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 --- */
.main-footer {
height: 28px;
@@ -321,6 +212,10 @@ body {
}
/* --- 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 {
padding: 2px 6px;
border-radius: 4px;
@@ -339,6 +234,12 @@ body {
color: white;
}
.badge-light {
background: var(--bg-color);
color: var(--text-muted);
border: 1px solid var(--border-color);
}
.text-tag {
color: var(--text-muted);
font-size: 16px;
@@ -368,7 +269,6 @@ body {
}
@media (max-width: 768px) {
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
.header-actions .btn { padding: 0 0.5rem; }
}
.brand h1 .sub-title { display: none; }
.header-actions .btn span { display: none; }
}

View File

@@ -185,11 +185,16 @@
.dashboard-slider-track {
display: flex;
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 {
width: 50%; /* 100% / 2 pages */
width: 25%; /* 100% / 4 pages */
flex-shrink: 0;
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;
}

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 {
color: #FFFFFF !important;
color: var(--white) !important;
cursor: pointer;
background: none !important;
border: none !important;
@@ -129,7 +129,7 @@
display: none !important;
}
.grid-form.is-view-mode button {
.grid-form.is-view-mode button:not(.btn-loc-action) {
pointer-events: none !important;
background: none !important;
border: none !important;
@@ -143,7 +143,7 @@
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
@@ -160,8 +160,8 @@
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00;
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1);
border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px var(--edit-mode-focus);
}
.form-section-title:first-child {
@@ -508,3 +508,186 @@
color: #3b82f6;
background: #eff6ff;
}
/* Layout Map & Image Picker Styles */
.layout-map-container {
position: relative;
display: inline-block;
cursor: crosshair;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.layout-map-container.readonly {
cursor: default;
}
.layout-map-container.readonly .map-seat-obj {
pointer-events: none;
}
.digital-overlay-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 8;
}
.digital-map-svg {
width: 100%; height: 100%;
}
.map-seat-obj {
fill: rgba(30, 81, 73, 0.02);
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
stroke-width: 0.2;
cursor: pointer;
pointer-events: all;
transition: all 0.2s;
}
.map-seat-obj:hover {
fill: rgba(30, 81, 73, 0.3);
stroke: rgba(30, 81, 73, 0.6);
stroke-width: 0.5;
}
.layout-map-img {
display: block;
max-width: 100%;
max-height: 75vh;
user-select: none;
-webkit-user-drag: none;
}
.layout-marker {
position: absolute;
width: 14px;
height: 14px;
background-color: rgba(30, 81, 73, 0.7);
border: 2px solid #FFFFFF;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 10;
box-shadow: 0 0 8px rgba(0,0,0,0.4);
}
.pulse-marker {
background-color: rgba(255, 61, 0, 0.8) !important;
border-color: #FFFFFF !important;
animation: marker-pulse 1.2s infinite;
}
@keyframes marker-pulse {
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
}
.image-picker-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
z-index: 2500;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.image-picker-header {
width: 100%;
max-width: 900px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.image-picker-header h3 {
color: white;
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.image-picker-content {
background: white;
padding: 8px;
border-radius: 8px;
max-width: 95vw;
max-height: 80vh;
overflow: auto;
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.picker-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 60px;
background: rgba(0,0,0,0.5);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
z-index: 100;
user-select: none;
transition: background 0.2s;
}
.picker-nav:hover { background: rgba(0,0,0,0.8); }
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
.image-picker-footer {
margin-top: 20px;
display: flex;
gap: 12px;
}
.btn-loc-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px;
font-size: 10px !important;
font-weight: 600;
border-radius: 4px;
height: 24px;
min-width: 52px;
white-space: nowrap;
transition: all 0.2s;
}
.btn-loc-view {
background-color: var(--primary-color);
color: white !important;
border: none;
}
.btn-loc-view:hover {
background-color: #163d37;
}
.location-detail-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.location-detail-container select {
flex: 1;
}

View File

@@ -128,7 +128,7 @@ table {
th, td {
padding: 0.8rem 1.2rem;
border-bottom: 1px solid #F3F4F6;
border-bottom: 1px solid var(--border-color);
text-align: left; /* 기본은 좌측 정렬 */
white-space: nowrap;
}
@@ -140,7 +140,7 @@ thead {
}
th {
background-color: #FAFAFA !important;
background-color: var(--bg-light) !important;
font-size: 18px;
font-weight: 600;
color: var(--text-muted);
@@ -158,7 +158,7 @@ td {
}
tbody tr:hover {
background-color: #F9FAFB;
background-color: var(--bg-color);
}
/* 정렬 클래스 강제 적용 */

View File

@@ -10,6 +10,90 @@ declare global {
}
let jobChartInstance: any = null;
let totalPcMismatchByCorpChartInstance: any = null;
let totalServerMismatchByPurposeChartInstance: any = null;
// 4p charts
let jobChartInstance4p: any = null;
let corpChartInstance4p: any = null;
let totalServerMismatchByPurposeChartInstance4p: any = null;
let serverServiceChartInstance4p: any = null;
let serverStatusChartInstance4p: any = null;
let pcFlowChartInstance: any = null;
// ─── 서버 용도별 카테고리 분류 헬퍼 ───
function categorizePurpose(purpose: string): string {
if (!purpose) return '기타/일반';
const lower = purpose.toLowerCase();
if (
lower.includes('해석') ||
lower.includes('abaqus') ||
lower.includes('ai') ||
lower.includes('시뮬레이션') ||
lower.includes('processing') ||
lower.includes('매핑') ||
lower.includes('측량') ||
lower.includes('렌더링') ||
lower.includes('gpu')
) {
return '해석/분석/AI';
}
if (
lower.includes('개발') ||
lower.includes('test') ||
lower.includes('테스트') ||
lower.includes('dev') ||
lower.includes('unity') ||
lower.includes('유니티')
) {
return '개발/테스트';
}
if (
lower.includes('was') ||
lower.includes('web') ||
lower.includes('웹') ||
lower.includes('배포') ||
lower.includes('nginx') ||
lower.includes('apache') ||
lower.includes('홈페이지') ||
lower.includes('서비스')
) {
return '서비스/웹/WAS';
}
if (
lower.includes('postgresql') ||
lower.includes('postgres') ||
lower.includes('db') ||
lower.includes('데이터') ||
lower.includes('스토리지') ||
lower.includes('storage') ||
lower.includes('mysql') ||
lower.includes('sql') ||
lower.includes('oracle')
) {
return 'DB/스토리지';
}
if (
lower.includes('백업') ||
lower.includes('backup') ||
lower.includes('ids') ||
lower.includes('ips') ||
lower.includes('crowdsec') ||
lower.includes('opnsense') ||
lower.includes('관리') ||
lower.includes('인증') ||
lower.includes('보안')
) {
return '백업/관리/보안';
}
return '기타/일반';
}
// ─── 네트워크 트래픽 문자열을 숫자(GB)로 파싱하는 헬퍼 ───
function parseTrafficToGb(trafficStr: string): number {
@@ -480,12 +564,82 @@ function buildServerStatusTableRows(list: any[]): string {
export function renderHwDashboard(container: HTMLElement) {
const allHw = state.masterData.hw || [];
// --- PC FLOW LOGS DATA PREP ---
const logs = state.masterData.logs || [];
const now = new Date();
const currentYearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
let currentMonthCheckout = 0;
let currentMonthReturn = 0;
let currentMonthMove = 0;
let totalCheckout = 0;
let totalReturn = 0;
let totalMove = 0;
const flowLogs = logs.filter((log: any) => {
const details = log.details || '';
const isFlow = details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
if (isFlow) {
const logDate = log.log_date || '';
const isCurrentMonth = logDate.startsWith(currentYearMonth);
if (details.includes('[불출]')) {
totalCheckout++;
if (isCurrentMonth) currentMonthCheckout++;
} else if (details.includes('[반납]') || details.includes('[입고]')) {
totalReturn++;
if (isCurrentMonth) currentMonthReturn++;
} else if (details.includes('[이동]') || details.includes('[이관]')) {
totalMove++;
if (isCurrentMonth) currentMonthMove++;
}
return true;
}
return false;
});
const recentFlowLogs = flowLogs.slice(0, 5);
let recentFlowLogsHtml = '';
if (recentFlowLogs.length === 0) {
recentFlowLogsHtml = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8; font-size:12px;">최근 유동 이력이 없습니다.</td></tr>';
} else {
recentFlowLogs.forEach((log: any) => {
const details = log.details || '';
let badgeHtml = '';
if (details.includes('[불출]')) {
badgeHtml = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
} else if (details.includes('[반납]') || details.includes('[입고]')) {
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
} else if (details.includes('[이동]') || details.includes('[이관]')) {
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
}
const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
recentFlowLogsHtml += `
<tr style="border-bottom: 1px solid #F1F5F9; font-size: 13px;">
<td style="padding: 8px; color: #64748B;">${log.log_date || '-'}</td>
<td style="padding: 8px;">${badgeHtml}</td>
<td style="padding: 8px; font-weight: 600; color: #334155;">${log.log_user || '시스템'}</td>
<td style="padding: 8px; color: #475569;" title="${details}">${cleanDetails}</td>
</tr>
`;
});
}
// --- PC DATA PREP ---
const pcs = allHw.filter(a => {
const cat = a[ASSET_SCHEMA.CATEGORY.key] || '';
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && job !== '재고PC';
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') &&
job !== '재고PC' &&
status === '사용중' &&
user.trim() !== '';
});
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
@@ -635,6 +789,87 @@ export function renderHwDashboard(container: HTMLElement) {
.filter(a => a['_server_status'] === '방치 의심')
.slice(0, 5);
// --- TOTAL EXEC DASHBOARD STATS (종합 대시보드 통계 연산) ---
let totalAssetValue = 0;
allHw.forEach(a => {
const amt = parseInt(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
totalAssetValue += amt;
});
let costSavingPotential = 0;
servers.forEach(a => {
const status = a['_server_status'];
if (status === '자원 과잉' || status === '방치 의심') {
const amt = parseInt(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
costSavingPotential += amt;
}
});
pcs.forEach(pc => {
if (pc['_spec_status'] === '오버스펙') {
const amt = parseInt(String(pc[ASSET_SCHEMA.PURCHASE_AMOUNT.key] || '0').replace(/[^0-9]/g, ''), 10) || 0;
costSavingPotential += amt;
}
});
const totalEvaluatedDevices = pcs.length + servers.length;
let optimalDevicesCount = 0;
pcs.forEach(pc => { if (pc['_spec_status'] === '적정') optimalDevicesCount++; });
servers.forEach(s => { if (s['_server_status'] === '적정') optimalDevicesCount++; });
const assetOptimizationRate = totalEvaluatedDevices > 0 ? Math.round((optimalDevicesCount / totalEvaluatedDevices) * 100) : 0;
let pcOver5YearsCount = 0;
pcs.forEach(pc => {
const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key];
if (pDate && calculateAssetAge(pDate) >= 5) pcOver5YearsCount++;
});
let totalOver5YearsCount = pcOver5YearsCount;
servers.forEach(s => {
const pDate = s[ASSET_SCHEMA.PURCHASE_DATE.key] || s.purchase_date;
if (pDate && calculateAssetAge(pDate) >= 5) totalOver5YearsCount++;
});
let totalResourceBottleneckCount = 0;
servers.forEach(s => {
if (s['_server_status'] === '자원 부족') totalResourceBottleneckCount++;
});
let totalInactiveCount = 0;
servers.forEach(s => {
if (s['_server_status'] === '방치 의심') totalInactiveCount++;
});
// 용도별 서버 자원 과부족 대수 집계
const PURPOSE_CATEGORIES = ['개발/테스트', '서비스/웹/WAS', 'DB/스토리지', '해석/분석/AI', '백업/관리/보안', '기타/일반'];
const purposeServerUnders = PURPOSE_CATEGORIES.map(cat =>
servers.filter(s => categorizePurpose(s[ASSET_SCHEMA.ASSET_PURPOSE.key] || s.asset_purpose) === cat && s['_server_status'] === '자원 부족').length
);
const purposeServerOvers = PURPOSE_CATEGORIES.map(cat =>
servers.filter(s => categorizePurpose(s[ASSET_SCHEMA.ASSET_PURPOSE.key] || s.asset_purpose) === cat && s['_server_status'] === '자원 과잉').length
);
// 차트용 데이터
const assetTypesCount = {
pc: pcs.length,
server: servers.filter(s => s.asset_type.includes('서버') || s.asset_type.includes('VM')).length,
storage: servers.filter(s => s.asset_type.toUpperCase().includes('NAS') || s.asset_type.toUpperCase().includes('스토리지') || s.asset_type.toUpperCase().includes('STO')).length,
other: allHw.length - pcs.length - servers.length
};
const pcStatusSummary = {
optimal: pcs.filter(p => p._spec_status === '적정').length,
over: pcs.filter(p => p._spec_status === '오버스펙').length,
under: pcs.filter(p => p._spec_status === '사양 부족').length,
inactive: 0
};
const serverStatusSummary = {
optimal: serverStatusGroups.optimal,
over: serverStatusGroups.overSpec,
under: serverStatusGroups.underSpec,
inactive: serverStatusGroups.inactive
};
// --- PRE-BUILD HTML ---
const corpScores = buildCorpScores(pcs);
@@ -660,7 +895,7 @@ export function renderHwDashboard(container: HTMLElement) {
'</div>' +
'<div class="slider-controls">' +
'<button id="slider-prev" class="slider-nav-btn" disabled><i data-lucide="chevron-left"></i></button>' +
'<span id="slider-indicator" class="slider-indicator">1 / 2</span>' +
'<span id="slider-indicator" class="slider-indicator">1 / 5</span>' +
'<button id="slider-next" class="slider-nav-btn"><i data-lucide="chevron-right"></i></button>' +
'</div>' +
'</div>' +
@@ -668,7 +903,52 @@ export function renderHwDashboard(container: HTMLElement) {
'<div class="dashboard-slider-viewport">' +
'<div class="dashboard-slider-track" id="dashboard-slider-track">' +
// ── SLIDE 1: PC DASHBOARD ──
// ── SLIDE 1: TOTAL EXECUTIVE DASHBOARD ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:1.5rem;">📊 전사 IT 자산 및 자원 최적화 요약</h3>' +
// KPI Row
'<div class="dashboard-grid" style="grid-template-columns: repeat(4, 1fr);">' +
'<div class="stat-card">' +
'<div class="stat-icon icon-red"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">PC 사양 부족 장비</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + underSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((underSpecCount / pcs.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-yellow"><i data-lucide="monitor"></i></div>' +
'<span class="stat-label">PC 오버스펙 장비</span>' +
'<div class="stat-value" style="color:#F59E0B; font-size:1.8rem;">' + overSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">개인용 PC 대비 ' + (pcs.length > 0 ? Math.round((overSpecCount / pcs.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-red"><i data-lucide="activity"></i></div>' +
'<span class="stat-label">서버 자원 부족 장비</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + serverStatusGroups.underSpec + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.underSpec / servers.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-icon icon-yellow"><i data-lucide="activity"></i></div>' +
'<span class="stat-label">서버 자원 과잉 장비</span>' +
'<div class="stat-value" style="color:#D97706; font-size:1.8rem;">' + serverStatusGroups.overSpec + '<span style="font-size:1rem; font-weight:600; color:#64748B;">대</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">서버/NAS 대비 ' + (servers.length > 0 ? Math.round((serverStatusGroups.overSpec / servers.length) * 100) : 0) + '% 비율</div>' +
'</div>' +
'</div>' +
// Charts
'<div class="dashboard-layout-2col" style="margin-bottom: 2rem;">' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title" style="color:#EF4444;">가족사별 PC 사양 과부족 현황</h4>' +
'<div style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="chart-total-pc-mismatch-by-corp"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">용도별 서버 자원 과부족 현황</h4>' +
'<div style="height:280px;display:flex;align-items:center;justify-content:center;"><canvas id="chart-total-server-mismatch-by-purpose"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
// ── SLIDE 2: PC DASHBOARD ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:1.5rem;">💻 PC 사양 적정성 분석</h3>' +
@@ -692,9 +972,9 @@ export function renderHwDashboard(container: HTMLElement) {
'<div class="stat-value" style="color:#F59E0B;">' + overSpecCount + '<span style="font-size:1rem; font-weight:600; color:#64748B;">명</span></div>' +
'<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top: 0.5rem;">직무 평균 대비 30% 이상 초과 &nbsp;▸ 클릭하여 상세보기</div>' +
'</div>' +
'<div class="stat-card">' +
'<div class="stat-card" style="border: 1px solid rgba(239,68,68,0.3); background: rgba(254,226,226,0.15);">' +
'<div class="stat-icon" style="background:rgba(239,68,68,0.1);color:#EF4444;"><i data-lucide="alert-triangle"></i></div>' +
'<span class="stat-label">교체/회수 대상 비율</span>' +
'<span class="stat-label" style="color:#EF4444;">교체/회수 대상 비율</span>' +
'<div class="stat-value stat-value-danger" style="font-size:1.8rem;">' + (pcs.length > 0 ? Math.round(((underSpecCount + overSpecCount) / pcs.length) * 100) : 0) + '<span style="font-size:1rem; font-weight:600; color:#64748B;">%</span></div>' +
'<div style="width:100%;height:4px;background:#E2E8F0;border-radius:2px;overflow:hidden;margin-top:1rem;">' +
'<div style="width:' + (pcs.length > 0 ? Math.round(((underSpecCount + overSpecCount) / pcs.length) * 100) : 0) + '%;height:100%;background:linear-gradient(90deg,#F59E0B,#E11D48);"></div>' +
@@ -763,53 +1043,117 @@ export function renderHwDashboard(container: HTMLElement) {
'<div><canvas id="chart-server-service"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">서버/공용PC 적정성 분석</h4>' +
'<div><canvas id="chart-server-status"></canvas></div>' +
'<h4 class="dashboard-section-title">용도별 서버 자원 과부족 현황</h4>' +
'<div><canvas id="chart-total-server-mismatch-by-purpose"></canvas></div>' +
'</div>' +
'<div class="dashboard-card">' +
'<h4 class="dashboard-section-title">서버/스토리지 노후도</h4>' +
'<div><canvas id="chart-server-aging"></canvas></div>' +
'<h4 class="dashboard-section-title">서버 적정성</h4>' +
'<div><canvas id="chart-server-status"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
// 테이블 영역 (2열 레이아웃)
'<div style="display:grid; grid-template-columns:1fr 1fr; gap:1.5rem; margin-bottom:2rem;">' +
'<div class="dashboard-card" style="padding:1.25rem 1.5rem;">' +
'<h4 class="dashboard-section-title" style="display:flex; align-items:center; gap:0.5rem; color:#D97706;">' +
'⚠️ 자원 과잉 장비 (TOP 5)' +
// ── SLIDE 4: ALL DETAILS EXECUTIVE CARDS ──
'<div class="dashboard-slide">' +
'<h3 class="dashboard-section-title" style="color:#0F172A; border-bottom:2px solid #E2E8F0; display:inline-block; margin-bottom:0.5rem; font-size:1.3rem; padding-bottom:0.25rem;">📋 전사 PC 및 서버 상세 현황 (종합 상황판)</h3>' +
// 세로로 분할된 2열 구조 컨테이너 (140% 내용 확대)
'<div style="display: grid; grid-template-columns: 1fr 1.2fr; gap: 0.75rem; flex: 1; min-height: 0; zoom: 1.4;">' +
// 왼쪽 열: PC 현황 (height: 100% 적용)
'<div style="display: flex; flex-direction: column; gap: 0.5rem; background: rgba(255,255,255,0.45); border-radius: 12px; padding: 0.5rem; border: 1px solid rgba(99,102,241,0.12); box-sizing: border-box; min-height: 0; height: 100%;">' +
'<h4 style="font-size: 0.9rem; font-weight: 800; color: #1E293B; margin: 0 0 2px 0; display: flex; align-items: center; gap: 0.25rem;">' +
'<i data-lucide="monitor" style="width:15px; height:15px; color:#3B82F6;"></i> PC 현황 요약' +
'</h4>' +
'<div class="table-premium">' +
'<table>' +
'<thead><tr><th>순위</th><th>장비명</th><th>서비스</th><th>사양 요약</th><th>사용 리소스 (CPU/RAM)</th><th>일일 전송량</th><th>점수</th><th>상태</th></tr></thead>' +
'<tbody>' + buildServerStatusTableRows(overSpecList) + '</tbody>' +
'</table>' +
// 1행: PC KPI 카드 3개 가로 배치
'<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; margin-bottom: 0.2rem;">' +
'<div class="stat-card" style="padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(99,102,241,0.05);">' +
'<div style="background: rgba(59,130,246,0.1); color: #3B82F6; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="monitor" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #64748B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">평균 PC 점수</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #1E293B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + overallPcAvg + '<span style="font-size:0.75rem; font-weight:600; color:#64748B; margin-left: 2px;">점</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-under-spec-4p" style="cursor:pointer; padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(239,68,68,0.1);">' +
'<div style="background: rgba(239,68,68,0.1); color: #EF4444; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-down" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #EF4444; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">사양 부족(교체)</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #EF4444; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + underSpecCount + '<span style="font-size:0.75rem; font-weight:600; color:#EF4444; margin-left: 2px;">명</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-over-spec-4p" style="cursor:pointer; padding: 0.55rem 0.65rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.45rem; border: 1px solid rgba(245,158,11,0.1);">' +
'<div style="background: rgba(245,158,11,0.1); color: #F59E0B; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-up" style="width: 15px; height: 15px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.68rem; color: #F59E0B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">오버스펙(회수)</span>' +
'<div class="stat-value" style="font-size: 1.15rem; font-weight: 800; color: #F59E0B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + overSpecCount + '<span style="font-size:0.75rem; font-weight:600; color:#64748B; margin-left: 2px;">명</span></div>' +
'</div>' +
'</div>' +
'</div>' +
// 2행: PC 그래프 2개 가로 배치
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; flex: 1; min-height: 0;">' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">직무별 평균 PC 사양 점수</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-job-scores-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">가족사별 PC 사양 현황</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-corp-scores-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="dashboard-card" style="padding:1.25rem 1.5rem;">' +
'<h4 class="dashboard-section-title" style="display:flex; align-items:center; gap:0.5rem; color:#EF4444;">' +
'🔻 자원 부족 장비 (TOP 5)' +
// 오른쪽 열: 서버 현황 (height: 100% 적용)
'<div style="display: flex; flex-direction: column; gap: 0.5rem; background: rgba(255,255,255,0.45); border-radius: 12px; padding: 0.5rem; border: 1px solid rgba(16,185,129,0.12); box-sizing: border-box; min-height: 0; height: 100%;">' +
'<h4 style="font-size: 0.9rem; font-weight: 800; color: #1E293B; margin: 0 0 2px 0; display: flex; align-items: center; gap: 0.25rem;">' +
'<i data-lucide="activity" style="width:15px; height:15px; color:#10B981;"></i> 서버 및 인프라 현황 요약' +
'</h4>' +
'<div class="table-premium">' +
'<table>' +
'<thead><tr><th>순위</th><th>장비명</th><th>서비스</th><th>사양 요약</th><th>사용 리소스 (CPU/RAM)</th><th>일일 전송량</th><th>점수</th><th>상태</th></tr></thead>' +
'<tbody>' + buildServerStatusTableRows(underSpecList) + '</tbody>' +
'</table>' +
// 1행: 서버 KPI 카드 4개 가로 배치
'<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.4rem; margin-bottom: 0.2rem;">' +
'<div class="stat-card" id="kpi-server-total-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(16,185,129,0.05);">' +
'<div style="background: rgba(59,130,246,0.1); color: #3B82F6; padding: 0.3rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="monitor" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #64748B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">총 서버 수량</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #1E293B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + servers.length + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-external-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(16,185,129,0.05);">' +
'<div style="background: rgba(16,185,129,0.1); color: #10B981; padding: 0.3rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="activity" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #10B981; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">외부 서비스</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #10B981; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + Math.round((serverServiceGroups.external / servers.length) * 100) + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">%</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-overspec-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(245,158,11,0.1);">' +
'<div style="background: rgba(245,158,11,0.1); color: #F59E0B; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-up" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #F59E0B; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">자원 과잉</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #F59E0B; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + serverStatusGroups.overSpec + '<span style="font-size:0.7rem; font-weight:600; color:#64748B; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'<div class="stat-card" id="kpi-server-critical-4p" style="cursor:pointer; padding: 0.55rem 0.6rem; min-height: unset; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,0.02); background: rgba(255, 255, 255, 0.85); display: flex; align-items: center; gap: 0.4rem; border: 1px solid rgba(239,68,68,0.1);">' +
'<div style="background: rgba(239,68,68,0.1); color: #EF4444; padding: 0.35rem; border-radius: 6px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;"><i data-lucide="trending-down" style="width: 14px; height: 14px;"></i></div>' +
'<div style="display: flex; flex-direction: column; min-width: 0;">' +
'<span class="stat-label" style="font-size: 0.65rem; color: #EF4444; font-weight: 600; margin-bottom: 1px; letter-spacing: 0;">자원 부족</span>' +
'<div class="stat-value" style="font-size: 1.1rem; font-weight: 800; color: #EF4444; margin-top: 0; line-height: 1.2; display: flex; align-items: baseline; background: none; -webkit-text-fill-color: initial;">' + (serverStatusGroups.underSpec + serverStatusGroups.inactive) + '<span style="font-size:0.7rem; font-weight:600; color:#EF4444; margin-left: 2px;">대</span></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
// 방치 장비 목록 (Full-width)
'<div class="dashboard-card" style="padding:1.25rem 1.5rem; margin-bottom:2rem;">' +
'<h4 class="dashboard-section-title" style="display:flex; align-items:center; gap:0.5rem; color:#475569;">' +
'🔍 미사용 방치 의심 장비 (회수/철수 권장)' +
'</h4>' +
'<div class="table-premium">' +
'<table>' +
'<thead><tr><th>순위</th><th>장비명</th><th>서비스</th><th>사양 요약</th><th>사용 리소스 (CPU/RAM)</th><th>일일 전송량</th><th>점수</th><th>상태</th></tr></thead>' +
'<tbody>' + buildServerStatusTableRows(inactiveList) + '</tbody>' +
'</table>' +
'</div>' +
'</div>' +
// 2행: 서버 그래프 2개 가로 배치
'<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; flex: 1; min-height: 0;">' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">용도별 서버 자원 과부족 현황</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-total-server-mismatch-by-purpose-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'<div class="dashboard-card" style="min-height: unset; height: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; display:flex; flex-direction:column; background: rgba(255, 255, 255, 0.85); margin-bottom: 0;">' +
'<h5 style="font-size: 0.75rem; font-weight: 800; margin: 0 0 2px 0; color: #475569;">서버 적정성 분석</h5>' +
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-server-status-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
@@ -825,7 +1169,19 @@ export function renderHwDashboard(container: HTMLElement) {
createIcons({ icons: { DollarSign, Monitor, AlertTriangle, Activity, ChevronLeft, ChevronRight, UserCheck, TrendingUp, TrendingDown, Building2, X, FileText } });
}
initCharts(jobScores, recommendedScores, corpScores, serverAgeGroups, serverServiceGroups, serverStatusGroups);
initCharts(
jobScores,
recommendedScores,
corpScores,
serverAgeGroups,
serverServiceGroups,
serverStatusGroups,
purposeServerUnders,
purposeServerOvers,
totalCheckout,
totalReturn,
totalMove
);
// 기획서 보기 버튼 클릭 이벤트 바인딩
const btnProposal = document.getElementById('btn-open-proposal');
@@ -867,10 +1223,10 @@ export function renderHwDashboard(container: HTMLElement) {
const btnNext = document.getElementById('slider-next') as HTMLButtonElement;
const indicator = document.getElementById('slider-indicator') as HTMLElement;
let currentSlide = 0;
const totalSlides = 2;
const totalSlides = 4;
const updateSlider = () => {
track.style.transform = 'translateX(-' + (currentSlide * 50) + '%)';
track.style.transform = 'translateX(-' + (currentSlide * 25) + '%)';
btnPrev.disabled = currentSlide === 0;
btnNext.disabled = currentSlide === totalSlides - 1;
indicator.textContent = (currentSlide + 1) + ' / ' + totalSlides;
@@ -878,6 +1234,22 @@ export function renderHwDashboard(container: HTMLElement) {
if (btnPrev) btnPrev.addEventListener('click', () => { if (currentSlide > 0) { currentSlide--; updateSlider(); } });
if (btnNext) btnNext.addEventListener('click', () => { if (currentSlide < totalSlides - 1) { currentSlide++; updateSlider(); } });
// 4p KPI 카드 클릭 → 모달 연동
const kpiUnder4p = document.getElementById('kpi-under-spec-4p');
const kpiOver4p = document.getElementById('kpi-over-spec-4p');
if (kpiUnder4p) kpiUnder4p.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '사양 부족'));
if (kpiOver4p) kpiOver4p.addEventListener('click', () => showSpecMismatchModal(criticalPcList, jobScores, allHw, '오버스펙'));
const kpiSvrTotal4p = document.getElementById('kpi-server-total-4p');
const kpiSvrExternal4p = document.getElementById('kpi-server-external-4p');
const kpiSvrOverspec4p = document.getElementById('kpi-server-overspec-4p');
const kpiSvrCritical4p = document.getElementById('kpi-server-critical-4p');
if (kpiSvrTotal4p) kpiSvrTotal4p.addEventListener('click', () => showServerStatusModal(servers, allHw, '전체 서버 및 공용 장비 목록'));
if (kpiSvrExternal4p) kpiSvrExternal4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s.service_type === '외부서비스'), allHw, '외부 운영 서비스 장비 목록'));
if (kpiSvrOverspec4p) kpiSvrOverspec4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 과잉'), allHw, '자원 과잉 장비 목록'));
if (kpiSvrCritical4p) kpiSvrCritical4p.addEventListener('click', () => showServerStatusModal(servers.filter(s => s._server_status === '자원 부족' || s._server_status === '방치 의심'), allHw, '자원 부족 및 방치 의심 장비 목록'));
}, 100);
}
@@ -889,7 +1261,12 @@ function initCharts(
corpScores: any,
ageGroups: any,
serviceGroups: any,
statusGroups: any
statusGroups: any,
purposeServerUnders?: any,
purposeServerOvers?: any,
totalCheckout?: number,
totalReturn?: number,
totalMove?: number
) {
// 직무별 점수
const jobCtx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
@@ -1048,4 +1425,343 @@ function initCharts(
}
});
}
// ─── 종합 대시보드 차트 초기화 ───
// 1. 가족사별 PC 사양 과부족 현황 (Grouped Bar Chart)
const totalPcMismatchCtx = document.getElementById('chart-total-pc-mismatch-by-corp') as HTMLCanvasElement;
if (totalPcMismatchCtx && typeof Chart !== 'undefined' && corpScores) {
if (totalPcMismatchByCorpChartInstance) {
totalPcMismatchByCorpChartInstance.destroy();
totalPcMismatchByCorpChartInstance = null;
}
totalPcMismatchByCorpChartInstance = new Chart(totalPcMismatchCtx, {
type: 'bar',
data: {
labels: corpScores.labels,
datasets: [
{
label: '사양 부족 (명)',
data: corpScores.unders,
backgroundColor: '#E11D48',
borderRadius: 4
},
{
label: '오버스펙 (명)',
data: corpScores.overs,
backgroundColor: '#F59E0B',
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 10,
usePointStyle: true,
boxWidth: 10
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false }
},
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#F1F5F9' },
border: { display: false },
title: {
display: true,
text: '인원(명)',
color: '#94A3B8',
font: { size: 11 }
}
}
},
animation: {
duration: 1200,
easing: 'easeOutQuart'
}
}
});
}
// 2. 용도별 서버 자원 과부족 현황 (Grouped Bar Chart)
const totalServerMismatchCtx = document.getElementById('chart-total-server-mismatch-by-purpose') as HTMLCanvasElement;
if (totalServerMismatchCtx && typeof Chart !== 'undefined' && purposeServerUnders && purposeServerOvers) {
if (totalServerMismatchByPurposeChartInstance) {
totalServerMismatchByPurposeChartInstance.destroy();
totalServerMismatchByPurposeChartInstance = null;
}
totalServerMismatchByPurposeChartInstance = new Chart(totalServerMismatchCtx, {
type: 'bar',
data: {
labels: ['개발/테스트', '서비스/웹/WAS', 'DB/스토리지', '해석/분석/AI', '백업/관리/보안', '기타/일반'],
datasets: [
{
label: '자원 부족 (대)',
data: purposeServerUnders,
backgroundColor: '#EF4444',
borderRadius: 4
},
{
label: '자원 과잉 (대)',
data: purposeServerOvers,
backgroundColor: '#F59E0B',
borderRadius: 4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 10,
usePointStyle: true,
boxWidth: 10
}
}
},
scales: {
x: {
grid: { display: false },
border: { display: false }
},
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#F1F5F9' },
border: { display: false },
title: {
display: true,
text: '장비 수(대)',
color: '#94A3B8',
font: { size: 11 }
}
}
},
animation: {
duration: 1200,
easing: 'easeOutQuart'
}
}
});
}
// ─── 4페이지(종합 카드판) 차트 초기화 ───
// 1. 직무별 평균 PC 사양 점수 (4p)
const jobCtx4p = document.getElementById('chart-job-scores-4p') as HTMLCanvasElement;
if (jobCtx4p && typeof Chart !== 'undefined') {
const labels = Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg);
const avgData = labels.map(l => Math.round(jobScores[l].avg));
const recomData = labels.map(l => recommendedScores[l] || 0);
if (jobChartInstance4p) {
jobChartInstance4p.destroy();
jobChartInstance4p = null;
}
jobChartInstance4p = new Chart(jobCtx4p, {
type: 'bar',
data: {
labels: labels,
datasets: [
{
type: 'line',
label: '권장 목표',
data: recomData,
borderColor: '#EF4444',
borderWidth: 1.5,
borderDash: [3, 3],
fill: false,
pointBackgroundColor: '#EF4444',
pointRadius: 2,
order: 1
},
{
type: 'bar',
label: '평균 PC 점수',
data: avgData,
backgroundColor: '#6366F1',
borderRadius: 4,
order: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: { boxWidth: 8, usePointStyle: true, font: { size: 9 }, padding: 4 }
}
},
scales: {
y: { beginAtZero: true, max: 100, ticks: { font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
},
animation: { duration: 800, easing: 'easeOutQuart' }
}
});
}
// 2. 가족사별 PC 사양 현황 (4p)
const corpCtx4p = document.getElementById('chart-corp-scores-4p') as HTMLCanvasElement;
if (corpCtx4p && typeof Chart !== 'undefined') {
if (corpChartInstance4p) {
corpChartInstance4p.destroy();
corpChartInstance4p = null;
}
corpChartInstance4p = new Chart(corpCtx4p, {
type: 'bar',
data: {
labels: corpScores.labels,
datasets: [
{ label: '부족', data: corpScores.unders, backgroundColor: '#E11D48', borderRadius: 3 },
{ label: '과잉', data: corpScores.overs, backgroundColor: '#F59E0B', borderRadius: 3 }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 4, usePointStyle: true, boxWidth: 6, font: { size: 9 } } }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
},
animation: { duration: 1000, easing: 'easeOutQuart' }
}
});
}
// 3. 용도별 서버 자원 과부족 현황 (4p)
const totalServerMismatchCtx4p = document.getElementById('chart-total-server-mismatch-by-purpose-4p') as HTMLCanvasElement;
if (totalServerMismatchCtx4p && typeof Chart !== 'undefined' && purposeServerUnders && purposeServerOvers) {
if (totalServerMismatchByPurposeChartInstance4p) {
totalServerMismatchByPurposeChartInstance4p.destroy();
totalServerMismatchByPurposeChartInstance4p = null;
}
totalServerMismatchByPurposeChartInstance4p = new Chart(totalServerMismatchCtx4p, {
type: 'bar',
data: {
labels: ['개발/테스트', '웹/WAS', 'DB/스토리지', '해석/AI', '백업/보안', '기타'],
datasets: [
{
label: '부족',
data: purposeServerUnders,
backgroundColor: '#EF4444',
borderRadius: 3
},
{
label: '과잉',
data: purposeServerOvers,
backgroundColor: '#F59E0B',
borderRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { padding: 4, usePointStyle: true, boxWidth: 6, font: { size: 9 } }
}
},
scales: {
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } },
y: {
beginAtZero: true,
ticks: { stepSize: 1, font: { size: 8.5 } },
grid: { color: '#F1F5F9' },
border: { display: false }
}
},
animation: { duration: 1000, easing: 'easeOutQuart' }
}
});
}
// 5. 서버/공용PC 적정성 분석 (4p)
const statusCtx4p = document.getElementById('chart-server-status-4p') as HTMLCanvasElement;
if (statusCtx4p && typeof Chart !== 'undefined') {
if (serverStatusChartInstance4p) {
serverStatusChartInstance4p.destroy();
serverStatusChartInstance4p = null;
}
serverStatusChartInstance4p = new Chart(statusCtx4p, {
type: 'bar',
data: {
labels: ['적정', '부족', '과잉', '방치'],
datasets: [{
label: '수량',
data: [statusGroups.optimal, statusGroups.underSpec, statusGroups.overSpec, statusGroups.inactive],
backgroundColor: ['#10B981', '#EF4444', '#F59E0B', '#64748B'],
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: { beginAtZero: true, ticks: { stepSize: 5, font: { size: 8.5 } }, grid: { color: '#F1F5F9' }, border: { display: false } },
x: { ticks: { font: { size: 8.5 } }, grid: { display: false }, border: { display: false } }
}
}
});
}
// PC 유동 비율 도넛 차트
const flowCtx = document.getElementById('chart-pc-flow-stats') as HTMLCanvasElement;
if (flowCtx && typeof Chart !== 'undefined') {
const tCheckout = totalCheckout || 0;
const tReturn = totalReturn || 0;
const tMove = totalMove || 0;
if (pcFlowChartInstance) {
pcFlowChartInstance.destroy();
pcFlowChartInstance = null;
}
pcFlowChartInstance = new Chart(flowCtx, {
type: 'doughnut',
data: {
labels: ['불출', '입고(반납)', '이동(이관)'],
datasets: [{
data: [tCheckout, tReturn, tMove],
backgroundColor: ['#3B82F6', '#10B981', '#F59E0B'],
borderWidth: 0,
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
},
cutout: '75%',
animation: { animateScale: true, animateRotate: true }
}
});
}
}

View File

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

View File

@@ -7,15 +7,16 @@ export function renderCostList(container: HTMLElement) {
createListView(container, {
title: '비용관리',
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: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
showCorp: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
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: '현 사용자', 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, {
title: '도메인',
dataSource: () => state.masterData.domain || [],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME'],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
persistentSortState,
emptyMessage: '등록된 도메인 정보가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (item) => openDomainModal(item),
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.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',
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.ASSET_TYPE.key] === '관리중' ? 'primary' : 'muted'}">${a[ASSET_SCHEMA.ASSET_TYPE.key] || '-'}</span>`
},
{ 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_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.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, {
title: '업무지원장비',
dataSource: () => sortAssets(state.masterData.equipment || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'],
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
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>`
},
{ 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.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' },

View File

@@ -8,11 +8,12 @@ export function renderFacilityList(container: HTMLElement) {
createListView(container, {
title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR'],
searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
@@ -22,7 +23,7 @@ export function renderFacilityList(container: HTMLElement) {
align: 'center',
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.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, {
title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME'],
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [
{ 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: 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' },

File diff suppressed because it is too large Load Diff

View File

@@ -8,16 +8,18 @@ export function renderMobileList(container: HTMLElement) {
createListView(container, {
title: 'PC', // Legacy support
dataSource: () => sortAssets(state.masterData.mobile || []),
searchKeys: ['MODEL_NAME'],
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
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.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.LOCATION.ui,

View File

@@ -8,11 +8,12 @@ export function renderNetworkList(container: HTMLElement) {
createListView(container, {
title: '네트워크',
dataSource: () => sortAssets(state.masterData.network || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'],
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
@@ -23,7 +24,7 @@ export function renderNetworkList(container: HTMLElement) {
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.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.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' },

View File

@@ -8,32 +8,40 @@ export function renderPcList(container: HTMLElement) {
createListView(container, {
title: 'PC',
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')),
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN'],
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ 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', 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.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.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: 'HDD1', sortKey: ASSET_SCHEMA.HDD1.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD1.key] || '-' },
{ header: 'HDD2', sortKey: ASSET_SCHEMA.HDD2.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD2.key] || '-' },
{ header: 'HDD3', sortKey: ASSET_SCHEMA.HDD3.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD3.key] || '-' },
{ header: 'HDD4', sortKey: ASSET_SCHEMA.HDD4.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD4.key] || '-' },
{
header: 'SSD',
align: 'center',
width: '8%',
render: a => [a[ASSET_SCHEMA.SSD1.key], a[ASSET_SCHEMA.SSD2.key]].filter(Boolean).join(' / ') || '-'
},
{
header: 'HDD',
align: 'center',
width: '12%',
render: a => [a[ASSET_SCHEMA.HDD1.key], a[ASSET_SCHEMA.HDD2.key], a[ASSET_SCHEMA.HDD3.key], a[ASSET_SCHEMA.HDD4.key]].filter(Boolean).join(' / ') || '-'
},
{
header: ASSET_SCHEMA.MAC_ADDR.ui,
sortKey: ASSET_SCHEMA.MAC_ADDR.key,
align: 'center',
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: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', width: '30%', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -12,7 +12,8 @@ export function renderPcPartList(container: HTMLElement) {
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
@@ -22,7 +23,7 @@ export function renderPcPartList(container: HTMLElement) {
align: 'center',
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.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] || '-' },

View File

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

View File

@@ -8,11 +8,12 @@ export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, {
title: '공간정보장비',
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: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
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.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,
sortKey: ASSET_SCHEMA.LOCATION.key,

View File

@@ -8,17 +8,18 @@ export function renderStorageList(container: HTMLElement) {
createListView(container, {
title: '스토리지',
dataSource: () => sortAssets(state.masterData.storage || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM'],
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
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.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.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] || '-' },

View File

@@ -5,29 +5,31 @@ import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSwList(container: HTMLElement) {
const isInternal = state.activeSubTab === '내부';
const isInternal = state.activeSubTab === '내부SW';
createListView(container, {
title: isInternal ? '내부' : '외부',
title: isInternal ? '내부SW' : '외부SW',
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: '검색 결과가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
showField: true,
showCorp: true,
showDept: true
showDept: true,
showType: true
},
onRowClick: (asset) => openSwModal(asset, 'view'),
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.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.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.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.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_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] || '' },

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

@@ -41,7 +41,7 @@ export function renderSWTable(mainContent: HTMLElement) {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'sw') {
if (tab === '외부' || tab === '내부') {
if (tab === '외부SW' || tab === '내부SW') {
renderSwList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;