feat: enhance map editor, refine location view, and update image assets
- Map Editor: Add box numbering (drawing/placed) and set default file - Location View: Refine mouse interaction in view mode (readonly) - Assets: Add MDF room support and update server room directory structure - Backend: Add map configuration API for real-time saving
@@ -51,6 +51,6 @@
|
||||
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
||||
* **Modal (모달 공통 규칙)**:
|
||||
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
||||
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay)을 클릭하면 모달이 닫히도록 구현합니다.
|
||||
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
|
||||
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
||||
|
||||
|
||||
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 |
620
map_config.json
Normal file
@@ -0,0 +1,620 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
307
map_editor.html
Normal file
@@ -0,0 +1,307 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ITAM Map Coordinate Editor v3.0</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #1E5149;
|
||||
--bg: #f5f5f5;
|
||||
}
|
||||
body { font-family: sans-serif; margin: 0; display: flex; height: 100vh; background: var(--bg); overflow: hidden; }
|
||||
|
||||
/* Left Sidebar: File Explorer */
|
||||
.file-sidebar { width: 260px; background: white; border-right: 1px solid #ddd; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.folder-item { padding: 10px 15px; background: #eee; font-weight: bold; font-size: 13px; border-bottom: 1px solid #ddd; color: var(--primary); }
|
||||
.file-item { padding: 8px 25px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #f9f9f9; transition: background 0.2s; }
|
||||
.file-item:hover { background: #f0f0f0; }
|
||||
.file-item.active { background: var(--primary); color: 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: white; line-height: 0; }
|
||||
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: white; border-left: 1px solid #ddd; display: flex; flex-direction: column; padding: 20px; box-shadow: -5px 0 15px rgba(0,0,0,0.05); }
|
||||
h2 { margin-top: 0; color: var(--primary); font-size: 1.2rem; }
|
||||
p { font-size: 0.85rem; color: #666; line-height: 1.4; margin-bottom: 20px; }
|
||||
|
||||
.current-path { font-size: 11px; color: #888; margin-bottom: 10px; word-break: break-all; font-family: monospace; }
|
||||
|
||||
.box-list { flex: 1; overflow-y: auto; margin-bottom: 15px; border: 1px solid #eee; border-radius: 4px; padding: 10px; background: #fafafa; }
|
||||
.box-item { font-family: monospace; font-size: 11px; padding: 6px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
|
||||
.box-item:hover { background: #fff; }
|
||||
.btn-del { cursor: pointer; color: #ff4444; border: none; background: none; font-size: 16px; padding: 0 5px; }
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: 8px; }
|
||||
button { padding: 12px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; transition: all 0.2s; }
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-secondary { background: #f0f0f0; color: #333; border: 1px solid #ccc; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
button:active { transform: scale(0.98); }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
|
||||
/* Drawing Elements */
|
||||
.draw-box { position: absolute; border: 2px solid #FF3D00; background: rgba(255, 61, 0, 0.2); pointer-events: none; z-index: 100; }
|
||||
.placed-box { position: absolute; border: 1.5px solid var(--primary); 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 #FF3D00; 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);
|
||||
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: #FF3D00;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
#save-status { margin-top: 8px; font-size: 11px; color: #27ae60; text-align: center; font-weight: bold; height: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Left: File Selector -->
|
||||
<div class="file-sidebar" id="file-sidebar">
|
||||
<div class="folder-item">IDC</div>
|
||||
<div class="file-item active" data-path="img/location_photo/IDC/서관202.png">서관202.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/IDC/서관203.png">서관203.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/IDC/서관204.png">서관204.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/IDC/서관205.png">서관205.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/IDC/동관53.png">동관53.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/IDC/동관54.png">동관54.png</div>
|
||||
|
||||
<div class="folder-item">기술개발센터</div>
|
||||
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_1.png">서버실_1.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_2.png">서버실_2.png</div>
|
||||
|
||||
<div class="folder-item">한맥빌딩</div>
|
||||
<div class="file-item" data-path="img/location_photo/한맥빌딩/7층_로비.png">7층_배치도(예시)</div>
|
||||
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_1.png">MDF_1.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_2.png">MDF_2.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_3.png">MDF_3.png</div>
|
||||
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_4.png">MDF_4.png</div>
|
||||
</div>
|
||||
|
||||
<!-- Center: Main Editor -->
|
||||
<div class="editor-container" id="container">
|
||||
<div class="img-wrapper" id="wrapper">
|
||||
<img src="img/location_photo/IDC/서관202.png" 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">img/location_photo/IDC/서관202.png</div>
|
||||
<p>
|
||||
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||
</p>
|
||||
|
||||
<div class="box-list" id="box-list"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-secondary" onclick="clearAll()">전체 삭제</button>
|
||||
<button id="btn-save-server" class="btn-primary" onclick="saveToServer()">서버에 즉시 저장</button>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const wrapper = document.getElementById('wrapper');
|
||||
const img = document.getElementById('target-img');
|
||||
const boxListEl = document.getElementById('box-list');
|
||||
const pathLabel = document.getElementById('current-path');
|
||||
const fileItems = document.querySelectorAll('.file-item');
|
||||
const statusEl = document.getElementById('save-status');
|
||||
const saveBtn = document.getElementById('btn-save-server');
|
||||
|
||||
let allMapConfig = {};
|
||||
let boxes = [];
|
||||
let isDrawing = false;
|
||||
let startX, startY;
|
||||
let currentBox = null;
|
||||
|
||||
// 1. 서버에서 기존 설정 로드
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
allMapConfig = await res.json();
|
||||
renderCurrentFile();
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCurrentFile() {
|
||||
const activeItem = document.querySelector('.file-item.active');
|
||||
const activeFile = activeItem.dataset.path;
|
||||
boxes = allMapConfig[activeFile] || [];
|
||||
pathLabel.textContent = activeFile;
|
||||
img.src = activeFile;
|
||||
render();
|
||||
}
|
||||
|
||||
// File Selection
|
||||
fileItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
fileItems.forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
renderCurrentFile();
|
||||
});
|
||||
});
|
||||
|
||||
// 2. 서버에 저장
|
||||
async function saveToServer() {
|
||||
const activeFile = document.querySelector('.file-item.active').dataset.path;
|
||||
|
||||
try {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = '저장 중...';
|
||||
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: activeFile, boxes: boxes })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
allMapConfig[activeFile] = [...boxes];
|
||||
statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
|
||||
setTimeout(() => statusEl.textContent = '', 3000);
|
||||
} else {
|
||||
alert('저장 실패!');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('서버 연결 오류!');
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = '서버에 즉시 저장';
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
isDrawing = true;
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
startX = e.clientX - rect.left;
|
||||
startY = e.clientY - rect.top;
|
||||
|
||||
currentBox = document.createElement('div');
|
||||
currentBox.className = 'draw-box';
|
||||
currentBox.style.left = startX + 'px';
|
||||
currentBox.style.top = startY + 'px';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'box-label';
|
||||
label.textContent = '#' + (boxes.length + 1);
|
||||
currentBox.appendChild(label);
|
||||
|
||||
wrapper.appendChild(currentBox);
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!isDrawing) return;
|
||||
const rect = 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 - startX;
|
||||
const height = currentY - startY;
|
||||
|
||||
currentBox.style.width = Math.abs(width) + 'px';
|
||||
currentBox.style.height = Math.abs(height) + 'px';
|
||||
currentBox.style.left = (width > 0 ? startX : currentX) + 'px';
|
||||
currentBox.style.top = (height > 0 ? startY : currentY) + 'px';
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
const width = parseFloat(currentBox.style.width);
|
||||
const height = parseFloat(currentBox.style.height);
|
||||
|
||||
if (width > 3 && height > 3) {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const boxData = {
|
||||
x: (parseFloat(currentBox.style.left) / rect.width * 100).toFixed(2),
|
||||
y: (parseFloat(currentBox.style.top) / rect.height * 100).toFixed(2),
|
||||
w: (width / rect.width * 100).toFixed(2),
|
||||
h: (height / rect.height * 100).toFixed(2)
|
||||
};
|
||||
boxes.push(boxData);
|
||||
render();
|
||||
}
|
||||
|
||||
currentBox.remove();
|
||||
currentBox = null;
|
||||
});
|
||||
|
||||
function removeBox(index) {
|
||||
boxes.splice(index, 1);
|
||||
render();
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
if(confirm('모든 박스를 삭제할까요?')) {
|
||||
boxes = [];
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
boxListEl.innerHTML = '';
|
||||
const oldBoxes = wrapper.querySelectorAll('.placed-box');
|
||||
oldBoxes.forEach(b => b.remove());
|
||||
|
||||
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);
|
||||
|
||||
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>
|
||||
`;
|
||||
boxListEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
51
server.js
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import mysql from 'mysql2/promise';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -57,22 +58,18 @@ const saveAssetsBatch = async (tableName, items, res, context) => {
|
||||
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)
|
||||
// 1. Clear existing
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
|
||||
// 2. Insert new items
|
||||
for (const item of items) {
|
||||
const filteredRow = {};
|
||||
validColumns.forEach(col => {
|
||||
// Exclude auto-managed timestamps from manual insertion
|
||||
if (col === 'created_at' || col === 'updated_at') return;
|
||||
|
||||
if (item[col] !== undefined) filteredRow[col] = item[col];
|
||||
});
|
||||
|
||||
// Auto-generate ID if missing
|
||||
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
|
||||
}
|
||||
|
||||
@@ -107,16 +104,13 @@ const routeMap = {
|
||||
'/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();
|
||||
@@ -136,23 +130,16 @@ app.post('/api/asset/history/batch', async (req, res) => {
|
||||
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
|
||||
});
|
||||
|
||||
// 5. Utility
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
|
||||
// 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;
|
||||
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
|
||||
}
|
||||
}
|
||||
|
||||
let nextNum = 1;
|
||||
if (lastCode) {
|
||||
const lastNum = parseInt(lastCode.split('-').pop() || '0');
|
||||
@@ -162,6 +149,38 @@ app.get('/api/generate-asset-code', async (req, res) => {
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
// 6. Map Config API (Real-time Save)
|
||||
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));
|
||||
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
|
||||
});
|
||||
|
||||
@@ -12,13 +12,15 @@ export function initBaseModal() {
|
||||
if (e.key === 'Escape') closeModals();
|
||||
});
|
||||
|
||||
// 배경(Overlay) 클릭 시 닫기
|
||||
// 배경(Overlay) 클릭 시 닫기 (요청에 의해 비활성화됨)
|
||||
/*
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('modal-overlay')) {
|
||||
closeModals();
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
return { closeAllModals: closeModals };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,64 @@ import { createIcons, X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu,
|
||||
|
||||
let currentHwAsset: any | null = null;
|
||||
let isEditMode = false;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
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_topic/기술개발센터/서버실/서버실_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'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const getImagesForLocation = (bldg: string, detail: string): string[] | null => {
|
||||
if (!bldg || !detail) return null;
|
||||
const b = bldg.trim();
|
||||
const d = detail.trim();
|
||||
return IMAGE_LOCATIONS[b]?.[d] || null;
|
||||
};
|
||||
|
||||
async function fetchMapConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
dynamicMapConfig = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch map config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function generateDynamicSVG(imagePath: string): string {
|
||||
const boxes = dynamicMapConfig[imagePath] || [];
|
||||
if (boxes.length === 0) return '';
|
||||
return `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="digital-map-svg">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => `
|
||||
<rect class="map-seat-obj" data-id="seat-${i+1}"
|
||||
x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" />
|
||||
`).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
const HW_MODAL_HTML = `
|
||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||
@@ -29,7 +87,6 @@ const HW_MODAL_HTML = `
|
||||
<form id="hw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="hw-id" name="id" />
|
||||
|
||||
<!-- Group 1: 기본 및 관리 정보 -->
|
||||
<div class="form-section-title">기본 및 관리 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||
@@ -92,7 +149,6 @@ const HW_MODAL_HTML = `
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="예: DB서버, 웹서버, 백업용 등" />
|
||||
</div>
|
||||
|
||||
<!-- Group 2: 설치 위치 -->
|
||||
<div class="form-section-title">설치 위치</div>
|
||||
<div class="form-group">
|
||||
<label>건물/위치</label>
|
||||
@@ -100,12 +156,22 @@ const HW_MODAL_HTML = `
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<div class="location-detail-container">
|
||||
<select id="hw-location_detail" name="location_detail">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn-loc-action btn-loc-view hidden" style="background-color: var(--primary-color);">
|
||||
위치등록
|
||||
</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn-loc-action btn-loc-view hidden">
|
||||
위치보기
|
||||
</button>
|
||||
<input type="hidden" id="hw-loc_x" name="loc_x" />
|
||||
<input type="hidden" id="hw-loc_y" name="loc_y" />
|
||||
<input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 3: 시스템 사양 -->
|
||||
<div class="form-section-title">시스템 사양</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
@@ -160,7 +226,6 @@ const HW_MODAL_HTML = `
|
||||
<input type="text" id="hw-mac_address" name="mac_address" />
|
||||
</div>
|
||||
|
||||
<!-- Group 4: 네트워크 및 접속 정보 -->
|
||||
<div class="form-section-title">네트워크 및 접속 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR.ui}</label>
|
||||
@@ -190,7 +255,6 @@ const HW_MODAL_HTML = `
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group 5: 구매 정보 -->
|
||||
<div class="form-section-title">구매 및 증빙</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
@@ -248,11 +312,49 @@ const HW_MODAL_HTML = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* 전역적으로 버튼 가시성을 업데이트하는 공용 함수
|
||||
*/
|
||||
function updateMapButtonVisibility(asset?: any) {
|
||||
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
|
||||
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
|
||||
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
|
||||
|
||||
if (!bldgSelect || !detailSelect) return;
|
||||
|
||||
// 인자로 넘어온 asset이 있으면 우선 사용, 없으면 DOM 필드에서 읽음
|
||||
const bldg = asset ? (asset.location || '') : bldgSelect.value;
|
||||
const detail = asset ? (asset.location_detail || '') : detailSelect.value;
|
||||
|
||||
const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x');
|
||||
const y = asset ? (asset.loc_y || '') : getFieldValue('hw-loc_y');
|
||||
|
||||
const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null');
|
||||
const hasImage = !!getImagesForLocation(bldg, detail);
|
||||
|
||||
// 위치등록 버튼: 오직 편집 모드일 때만 노출
|
||||
if (hasImage && isEditMode) {
|
||||
regLocBtn.classList.remove('hidden');
|
||||
} else {
|
||||
regLocBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
// 위치보기 버튼: 좌표가 있고 이미지가 있는 위치라면 모드 상관없이 노출
|
||||
if (hasImage && hasCoords) {
|
||||
viewLocBtn.classList.remove('hidden');
|
||||
} else {
|
||||
viewLocBtn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
if (!document.getElementById('hw-asset-modal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
|
||||
}
|
||||
|
||||
fetchMapConfig();
|
||||
|
||||
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
|
||||
const saveBtn = document.getElementById('btn-save-hw-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-hw-edit')!;
|
||||
@@ -263,7 +365,6 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
||||
|
||||
// Category -> Asset Type Cascading
|
||||
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||
|
||||
@@ -279,16 +380,176 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
btnCloseHeader.addEventListener('click', closeModalAction);
|
||||
btnCancelFooter.addEventListener('click', closeModalAction);
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentHwAsset) return;
|
||||
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
|
||||
|
||||
bldgSelect.addEventListener('change', () => {
|
||||
setTimeout(() => updateMapButtonVisibility(), 100);
|
||||
});
|
||||
detailSelect.addEventListener('change', () => updateMapButtonVisibility());
|
||||
|
||||
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
|
||||
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
|
||||
|
||||
regLocBtn.addEventListener('click', async () => {
|
||||
await fetchMapConfig();
|
||||
const images = getImagesForLocation(bldgSelect.value, detailSelect.value);
|
||||
if (images) openImagePicker(images, `${detailSelect.value} 위치 등록`);
|
||||
});
|
||||
|
||||
viewLocBtn.addEventListener('click', async () => {
|
||||
await fetchMapConfig();
|
||||
const bldg = bldgSelect.value;
|
||||
const detail = detailSelect.value;
|
||||
const images = getImagesForLocation(bldg, detail);
|
||||
const x = getFieldValue('hw-loc_x');
|
||||
const y = getFieldValue('hw-loc_y');
|
||||
const savedImg = getFieldValue('hw-location_photo');
|
||||
|
||||
if (images) {
|
||||
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
|
||||
openImagePreview(imgPath, `${detail} 위치 확인`, x, y);
|
||||
}
|
||||
});
|
||||
|
||||
function openImagePicker(imagePaths: string[], title: string) {
|
||||
let currentIdx = 0;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
|
||||
const renderContent = () => {
|
||||
const imgPath = imagePaths[currentIdx];
|
||||
const isMulti = imagePaths.length > 1;
|
||||
const digitalMap = generateDynamicSVG(imgPath);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header">
|
||||
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
${isMulti ? `
|
||||
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}">◀</div>
|
||||
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}">▶</div>
|
||||
` : ''}
|
||||
<div class="layout-map-container" id="picker-container">
|
||||
<img src="${imgPath}" class="layout-map-img" />
|
||||
<div id="picker-marker" class="layout-marker hidden"></div>
|
||||
<div class="digital-overlay-layer">${digitalMap}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer">
|
||||
<p style="color:#ddd; font-size:12px; margin:0; flex:1;">배치도의 네모 칸을 클릭하면 위치가 자동으로 지정됩니다.</p>
|
||||
<button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button>
|
||||
<button id="btn-picker-save" class="btn btn-primary">위치 확정</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
createIcons({ icons: { X } });
|
||||
|
||||
let selectedX = '';
|
||||
let selectedY = '';
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
|
||||
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
|
||||
seat.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const target = e.currentTarget as SVGRectElement;
|
||||
selectedX = target.getAttribute('x') || '';
|
||||
selectedY = target.getAttribute('y') || '';
|
||||
const w = target.getAttribute('width') || '0';
|
||||
const h = target.getAttribute('height') || '0';
|
||||
marker.style.left = `${parseFloat(selectedX) + parseFloat(w)/2}%`;
|
||||
marker.style.top = `${parseFloat(selectedY) + parseFloat(h)/2}%`;
|
||||
marker.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
if (!digitalMap) {
|
||||
container.addEventListener('click', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
selectedX = x.toFixed(2);
|
||||
selectedY = y.toFixed(2);
|
||||
marker.style.left = `${selectedX}%`;
|
||||
marker.style.top = `${selectedY}%`;
|
||||
marker.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
|
||||
if (isMulti) {
|
||||
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
||||
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
||||
}
|
||||
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX);
|
||||
setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
updateMapButtonVisibility();
|
||||
overlay.remove();
|
||||
});
|
||||
};
|
||||
renderContent();
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const digitalMap = generateDynamicSVG(imagePath);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header">
|
||||
<h3>${title}</h3>
|
||||
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
<div class="layout-map-container readonly">
|
||||
<img src="${imagePath}" class="layout-map-img" />
|
||||
<div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div>
|
||||
<div class="digital-overlay-layer">${digitalMap}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
createIcons({ icons: { X } });
|
||||
|
||||
if (digitalMap) {
|
||||
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
|
||||
const sx = seat.getAttribute('x');
|
||||
const sy = seat.getAttribute('y');
|
||||
if (sx === x && sy === y) {
|
||||
(seat as SVGRectElement).style.fill = 'rgba(255, 61, 0, 0.4)';
|
||||
(seat as SVGRectElement).style.stroke = '#FF3D00';
|
||||
(seat as SVGRectElement).style.strokeWidth = '0.8';
|
||||
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
|
||||
const w = seat.getAttribute('width') || '0';
|
||||
const h = seat.getAttribute('height') || '0';
|
||||
marker.style.left = `${parseFloat(sx!) + parseFloat(w)/2}%`;
|
||||
marker.style.top = `${parseFloat(sy!) + parseFloat(h)/2}%`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
|
||||
}
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentHwAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
let categoryKey = 'pc';
|
||||
const cat = currentHwAsset.category;
|
||||
const type = currentHwAsset.asset_type;
|
||||
const code = currentHwAsset.asset_code || '';
|
||||
|
||||
if (type === '서버PC') categoryKey = 'pc';
|
||||
if (currentHwAsset.asset_type === '서버PC') categoryKey = 'pc';
|
||||
else if (cat === '서버' || code.startsWith('SVR')) categoryKey = 'server';
|
||||
else if (cat === '스토리지' || code.startsWith('STO')) categoryKey = 'storage';
|
||||
else if (cat === '네트워크' || code.startsWith('NET')) categoryKey = 'network';
|
||||
@@ -298,11 +559,8 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
else if (cat === '사무가구' || cat === '사무소모품') categoryKey = 'officeSupplies';
|
||||
else if (cat === 'PC' || code.startsWith('PC')) categoryKey = 'pc';
|
||||
|
||||
const success = await deleteAsset(categoryKey, currentHwAsset.id);
|
||||
if (success) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); // Refresh list
|
||||
closeModalAction();
|
||||
if (await deleteAsset(categoryKey, currentHwAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.'); onSave(); closeModalAction();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,6 +568,7 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
|
||||
isEditMode = false;
|
||||
if (currentHwAsset) fillHwFormData(currentHwAsset);
|
||||
updateMapButtonVisibility();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
@@ -317,40 +576,23 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
if (!isEditMode) {
|
||||
setEditLock('hw-asset-form', 'edit', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
|
||||
isEditMode = true;
|
||||
updateMapButtonVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const updated: any = { ...currentHwAsset };
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id') updated[key] = value;
|
||||
});
|
||||
|
||||
// Handle location columns:
|
||||
// 'location' stores only the building name
|
||||
// 'location_detail' is already handled via the dynamic FormData loop
|
||||
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
|
||||
updated.location = getFieldValue('hw-bldg-select');
|
||||
|
||||
let categoryKey = 'pc';
|
||||
if (updated.asset_type === '서버PC') categoryKey = 'pc';
|
||||
else if (updated.asset_code?.startsWith('SVR') || updated.category === '서버') categoryKey = 'server';
|
||||
else if (updated.asset_code?.startsWith('STO') || updated.category === '스토리지') categoryKey = 'storage';
|
||||
else if (updated.asset_code?.startsWith('EQP') || updated.category === '업무지원장비') categoryKey = 'equipment';
|
||||
else if (updated.category === '공간정보장비') categoryKey = 'survey';
|
||||
else if (updated.category === 'PC부품') categoryKey = 'pcParts';
|
||||
|
||||
// 서버PC인 경우 category는 PC이지만 UI상 서버로 취급되므로,
|
||||
// 저장은 반드시 'pc' 엔드포인트(/api/pc)로 되어야 함.
|
||||
if (updated.asset_type === '서버PC') {
|
||||
categoryKey = 'pc';
|
||||
} else if (updated.asset_code?.startsWith('SVR') || updated.category === '서버') {
|
||||
categoryKey = 'server';
|
||||
} else if (updated.asset_code?.startsWith('STO') || updated.category === '스토리지') {
|
||||
categoryKey = 'storage';
|
||||
} else if (updated.asset_code?.startsWith('EQP') || updated.category === '업무지원장비') {
|
||||
categoryKey = 'equipment';
|
||||
} else if (updated.category === '공간정보장비') {
|
||||
categoryKey = 'survey';
|
||||
} else if (updated.category === 'PC부품') {
|
||||
categoryKey = 'pcParts';
|
||||
}
|
||||
|
||||
const success = await saveAsset(categoryKey, updated);
|
||||
if (success) {
|
||||
if (await saveAsset(categoryKey, updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave();
|
||||
closeModalAction();
|
||||
@@ -363,31 +605,21 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
currentHwAsset = asset;
|
||||
const modal = document.getElementById('hw-asset-modal')!;
|
||||
|
||||
setEditLock('hw-asset-form', mode, {
|
||||
saveBtnId: 'btn-save-hw-asset',
|
||||
revertBtnId: 'btn-revert-hw-edit',
|
||||
addLogBtnId: 'btn-add-hw-log'
|
||||
});
|
||||
|
||||
setEditLock('hw-asset-form', mode, { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit', addLogBtnId: 'btn-add-hw-log' });
|
||||
isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
fillHwFormData(asset);
|
||||
|
||||
// Show/Hide category specific fields
|
||||
const serverOnly = document.querySelectorAll('.server-only');
|
||||
const nonServer = document.querySelectorAll('.non-server');
|
||||
const pcOnly = document.querySelectorAll('.pc-only');
|
||||
const userFields = document.querySelectorAll('.user-tracking-field');
|
||||
// 조회 모드에서도 확실히 버튼을 노출시키기 위해 asset 데이터를 직접 전달
|
||||
updateMapButtonVisibility(asset);
|
||||
|
||||
const isServer = asset.category === '서버' || asset.asset_code?.startsWith('SVR') || asset.asset_type === '서버PC';
|
||||
const isPc = asset.category === 'PC' || asset.asset_code?.startsWith('PC');
|
||||
const isVip = asset.category === '선물' || asset.category === 'VIP';
|
||||
|
||||
serverOnly.forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
|
||||
nonServer.forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
|
||||
pcOnly.forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
|
||||
userFields.forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
|
||||
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
|
||||
document.querySelectorAll('.non-server').forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
|
||||
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
|
||||
document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
@@ -397,17 +629,11 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-asset_code', asset.asset_code || '');
|
||||
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
||||
setFieldValue('hw-category', asset.category || '');
|
||||
|
||||
// Populate asset_type options based on category
|
||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||
const types = CATEGORY_TYPE_MAP[asset.category] || [];
|
||||
if (typeSelect) {
|
||||
typeSelect.innerHTML = types.length > 0
|
||||
? generateOptionsHTML(types, asset.asset_type, true)
|
||||
: '<option value="">구분을 먼저 선택하세요</option>';
|
||||
}
|
||||
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||
if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
|
||||
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||
setFieldValue('hw-hw_status', asset.hw_status || '운영');
|
||||
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||
setFieldValue('hw-previous_dept', asset.previous_dept || '');
|
||||
@@ -417,7 +643,6 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-user_position', asset.user_position || '');
|
||||
setFieldValue('hw-previous_user', asset.previous_user || '');
|
||||
setFieldValue('hw-asset_purpose', asset.asset_purpose || '');
|
||||
|
||||
setFieldValue('hw-model_name', asset.model_name || '');
|
||||
setFieldValue('hw-cpu', asset.cpu || '');
|
||||
setFieldValue('hw-ram', asset.ram || '');
|
||||
@@ -431,21 +656,21 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||
setFieldValue('hw-os', asset.os || '');
|
||||
setFieldValue('hw-mac_address', asset.mac_address || '');
|
||||
|
||||
setFieldValue('hw-ip_address', asset.ip_address || '');
|
||||
setFieldValue('hw-ip_address_2', asset.ip_address_2 || '');
|
||||
setFieldValue('hw-remote_tool', asset.remote_tool || '');
|
||||
setFieldValue('hw-remote_id', asset.remote_id || '');
|
||||
setFieldValue('hw-remote_pw', asset.remote_pw || '');
|
||||
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
||||
|
||||
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
(document.getElementById('hw-approval_document_name') as HTMLElement).textContent = asset.approval_document || '';
|
||||
|
||||
setFieldValue('hw-memo', asset.memo || '');
|
||||
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||
setFieldValue('hw-loc_y', asset.loc_y || '');
|
||||
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
|
||||
|
||||
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||
renderHwHistory(asset.id);
|
||||
@@ -455,15 +680,6 @@ function renderHwHistory(assetId: string) {
|
||||
const container = document.getElementById('hw-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('');
|
||||
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('');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||