Compare commits
22 Commits
34d99dc4b6
...
565802f55b
| Author | SHA1 | Date | |
|---|---|---|---|
| 565802f55b | |||
| 525dbd77d4 | |||
| 35c5b1e0fa | |||
| b87ca2854b | |||
| 2f88a0fae7 | |||
| 9a2c35e652 | |||
| 2b9c965c91 | |||
| 4b408b0640 | |||
| 3ab587d342 | |||
| 3b9b2ea598 | |||
| 05c565552a | |||
| 2ec9261c03 | |||
| 06f3baaa58 | |||
| eead43837d | |||
| 46422e8544 | |||
| a30f99f0ad | |||
| 9e8ab11f99 | |||
| 19d4222470 | |||
| db5c7a96a6 | |||
| 7d3d5ef281 | |||
| 9cd5d59bf8 | |||
| 590ddd0e85 |
@@ -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
BIN
img/location_photo/IDC/동관53.png
Normal file
|
After Width: | Height: | Size: 10 MiB |
BIN
img/location_photo/IDC/동관54.png
Normal file
|
After Width: | Height: | Size: 6.3 MiB |
BIN
img/location_photo/IDC/서관202.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
BIN
img/location_photo/IDC/서관203.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
BIN
img/location_photo/IDC/서관204.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
img/location_photo/IDC/서관205.png
Normal file
|
After Width: | Height: | Size: 3.9 MiB |
BIN
img/location_photo/기술개발센터/서버실/서버실_1.png
Normal file
|
After Width: | Height: | Size: 11 MiB |
BIN
img/location_photo/기술개발센터/서버실/서버실_2.png
Normal file
|
After Width: | Height: | Size: 6.1 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_1.png
Normal file
|
After Width: | Height: | Size: 9.5 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_2.png
Normal file
|
After Width: | Height: | Size: 9.8 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_3.png
Normal file
|
After Width: | Height: | Size: 8.1 MiB |
BIN
img/location_photo/한맥빌딩/MDF실/MDF_4.png
Normal file
|
After Width: | Height: | Size: 5.8 MiB |
41
index.html
@@ -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>© 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
@@ -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
@@ -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
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
625
src/components/Modal/PCFlowModal.ts
Normal 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;">×</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();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 실)",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
149
src/main.ts
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 정렬 클래스 강제 적용 */
|
||||
|
||||
@@ -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% 이상 초과 ▸ 클릭하여 상세보기</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 }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] || '-' },
|
||||
{
|
||||
|
||||
@@ -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] || '-') }
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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] || '-') },
|
||||
{
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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] || '-') }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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] || '-' },
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] || '-' },
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>`;
|
||||
|
||||