Compare commits
7 Commits
ux_setting
...
QR_setting
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eca0ede91 | ||
|
|
f36e8e93e2 | ||
|
|
9f165faf13 | ||
|
|
237ac9ee25 | ||
| f41f2378d7 | |||
| 41406f56e8 | |||
| b9d28736e2 |
2
.env
2
.env
@@ -3,4 +3,4 @@ DB_PORT=3306
|
||||
DB_USER=itam_admin
|
||||
DB_PASS=itam1234
|
||||
DB_NAME=itam
|
||||
PORT=3000
|
||||
PORT=3001
|
||||
22
QR_system.md
Normal file
22
QR_system.md
Normal file
@@ -0,0 +1,22 @@
|
||||
목적
|
||||
- 정기적인 실물자산 점검을 실시하여 시스템 내 자산정보의 정확성을 확보하고, 실제 자산의 위치 및 상태를 체계적으로 파악·관리할 수 있는 관리체계를 구축
|
||||
- QR 스캔 시스템을 통해 자산별 관리 이력 및 관리 책임자 정보를 즉시 확인할 수 있으며, 자산의 이동·변경 이력 추적과 안정적인 운영 관리를 추구
|
||||
|
||||
구조 구성안
|
||||
A. 실제 위치 정보를 가진 마스터 테이블 구축
|
||||
- 현재 DB의 위치 정보는 건물 및 호수 정보(예: 기술개발센터 / 서버실)와 이미지 파일 내 픽셀 좌표 정보로 관리되고 있으며, 실제 서버가 설치된 랙(Rack) 및 물리적 위치 정보를 관리하는 항목은 존재하지 않음
|
||||
- 이미지 좌표 데이터와 실제 자산 위치 데이터를 연결하는 별도 마스터 테이블을 생성하여, 좌표 정보와 물리적 위치 정보 간 관계 정의 필요
|
||||
|
||||
B. 기존 테이블 개편
|
||||
- 픽셀 좌표 정보는 마스터 테이블에서 통합관리하고, 기존 테이블은 마스터 코드를상속받는 구조로 변경하여 유지 보수성을 확보
|
||||
|
||||
QR코드 정보
|
||||
- 자산 QR : 시스템에 등록된 자산 고유의 자산번호
|
||||
- 위치 QR : 물리적 위치 테이블에 저장된 마스터 코드
|
||||
|
||||
현장실사 시나리오
|
||||
① 담당자가 서버 렉 전면에 부착된 위치 QR을 스캔
|
||||
② 위치 QR에 저장된 주소로 접속하여 세션에 현재 위치를 저장
|
||||
③ 자산에 부착된 자산 QR을 스캔하여 주소에 접속하게 되면 정보를 매칭하여 API로 전송
|
||||
④ 결합된 정보를 받아 기존 위치를 확인 혹은 업데이트
|
||||
⑤ 시스템에서 관리자가 확인하여 승인하게 되면 시스템에도 업데이트 완료
|
||||
@@ -9,7 +9,10 @@
|
||||
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
||||
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
||||
5. **RED–GREEN–Refactor 개발 원칙**:
|
||||
5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**:
|
||||
- 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다.
|
||||
- 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다.
|
||||
6. **RED–GREEN–Refactor 개발 원칙**:
|
||||
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
|
||||
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
|
||||
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
|
||||
<script src="/qrcode.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
BIN
label/DevExpress.Data.v14.1.dll
Normal file
BIN
label/DevExpress.Data.v14.1.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.Printing.v14.1.Core.dll
Normal file
BIN
label/DevExpress.Printing.v14.1.Core.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.Utils.v14.1.dll
Normal file
BIN
label/DevExpress.Utils.v14.1.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.XtraEditors.v14.1.dll
Normal file
BIN
label/DevExpress.XtraEditors.v14.1.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.XtraGrid.v14.1.dll
Normal file
BIN
label/DevExpress.XtraGrid.v14.1.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.XtraLayout.v14.1.dll
Normal file
BIN
label/DevExpress.XtraLayout.v14.1.dll
Normal file
Binary file not shown.
BIN
label/DevExpress.XtraPrinting.v14.1.dll
Normal file
BIN
label/DevExpress.XtraPrinting.v14.1.dll
Normal file
Binary file not shown.
BIN
label/LabelPrinter.exe
Normal file
BIN
label/LabelPrinter.exe
Normal file
Binary file not shown.
BIN
label/Newtonsoft.Json.dll
Normal file
BIN
label/Newtonsoft.Json.dll
Normal file
Binary file not shown.
BIN
label/WebQuery.dll
Normal file
BIN
label/WebQuery.dll
Normal file
Binary file not shown.
4
label/config.ini
Normal file
4
label/config.ini
Normal file
@@ -0,0 +1,4 @@
|
||||
[PRINT]
|
||||
FONT=8
|
||||
LEFT=143
|
||||
TOP=40
|
||||
BIN
label/de/DevExpress.Data.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.Data.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
BIN
label/de/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.Utils.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.Utils.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/de/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
BIN
label/de/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.Data.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.Data.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
BIN
label/es/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.Utils.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.Utils.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/es/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
BIN
label/es/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.Data.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.Data.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
BIN
label/ja/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.Utils.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.Utils.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ja/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
BIN
label/ja/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.Data.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.Data.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
BIN
label/ru/DevExpress.Printing.v14.1.Core.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.Utils.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.Utils.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.XtraEditors.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.XtraGrid.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.XtraLayout.v14.1.resources.dll
Normal file
Binary file not shown.
BIN
label/ru/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
BIN
label/ru/DevExpress.XtraPrinting.v14.1.resources.dll
Normal file
Binary file not shown.
7
label/tmp/file_1.txt
Normal file
7
label/tmp/file_1.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
자산번호 : 210312
|
||||
자산명 : 가을-PC(i5-12400F)
|
||||
공급사 : (주)가을디에스
|
||||
자산위치 : 지반부
|
||||
관리부서 : 전산
|
||||
사용자 : 박노석
|
||||
취득일자 : 2024-08-05
|
||||
BIN
label/tmp/file_1.txt - 바로 가기.lnk
Normal file
BIN
label/tmp/file_1.txt - 바로 가기.lnk
Normal file
Binary file not shown.
@@ -112,7 +112,7 @@
|
||||
"y": "32.01",
|
||||
"w": "40.87",
|
||||
"h": "6.24",
|
||||
"asset_id": null
|
||||
"asset_id": "9pvkqyi"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/서관203.png": [
|
||||
@@ -722,5 +722,21 @@
|
||||
"h": "6.75",
|
||||
"asset_id": "server_1779761946023_64"
|
||||
}
|
||||
],
|
||||
"img/location_photo/TDD_TEST_MAP.png": [
|
||||
{
|
||||
"x": "30.50",
|
||||
"y": "40.25",
|
||||
"w": "10.00",
|
||||
"h": "12.00",
|
||||
"asset_id": null
|
||||
},
|
||||
{
|
||||
"x": "50.00",
|
||||
"y": "60.00",
|
||||
"w": "5.00",
|
||||
"h": "5.00",
|
||||
"asset_id": null
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
<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" />
|
||||
<script src="/qrcode.min.js"></script>
|
||||
</head>
|
||||
<body class="editor-body">
|
||||
|
||||
@@ -30,8 +31,9 @@
|
||||
|
||||
<div class="box-list" id="box-list"></div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="actions" style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
|
||||
<button id="btn-print-map-qrs" class="btn btn-outline btn-primary">이 도면 QR 일괄인쇄</button>
|
||||
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
|
||||
299
mobile.html
Normal file
299
mobile.html
Normal file
@@ -0,0 +1,299 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>ITAM 모바일 실사 점검</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--card: #18181b;
|
||||
--card-border: #27272a;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--text: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: var(--card);
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
overflow-y: auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Scanner Viewport */
|
||||
.scanner-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 2px dashed var(--card-border);
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
#reader {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
#reader video {
|
||||
object-fit: cover !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Scan Laser Line Animation */
|
||||
.scan-laser {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, var(--primary), transparent);
|
||||
animation: scan 2s linear infinite;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { top: 0%; }
|
||||
50% { top: 100%; }
|
||||
100% { top: 0%; }
|
||||
}
|
||||
|
||||
/* Bottom Info Card */
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.badge-lock {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge-empty {
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background-color: var(--primary);
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-action.btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-action.btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
/* Manual Input Section */
|
||||
.manual-toggle {
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.manual-form {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
width: 100%;
|
||||
background-color: var(--bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
/* Feedbacks Overlay */
|
||||
.feedback-message {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feedback-success {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: var(--success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.feedback-error {
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>ITAM 모바일 실사</h1>
|
||||
<div class="status-dot"></div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="scanner-container">
|
||||
<div id="reader"></div>
|
||||
<div class="scan-laser"></div>
|
||||
</div>
|
||||
|
||||
<div class="info-panel">
|
||||
<!-- 1. 위치 락 정보 -->
|
||||
<div class="info-section">
|
||||
<span class="info-label">현재 점검 위치 (Location)</span>
|
||||
<div class="info-value">
|
||||
<span id="loc-display" class="badge-empty">위치 QR 코드를 먼저 스캔하세요.</span>
|
||||
<button id="btn-unlock-loc" class="btn-action btn-danger" style="display: none;">해제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border: 0; border-top: 1px solid var(--card-border); margin: 0.25rem 0;" />
|
||||
|
||||
<!-- 2. 자산 스캔 결과 및 피드백 -->
|
||||
<div id="scan-feedback" class="feedback-message"></div>
|
||||
|
||||
<!-- 3. 수동 입력 토글 및 양식 -->
|
||||
<div class="info-section">
|
||||
<span id="btn-toggle-manual" class="manual-toggle">카메라가 안 되나요? 수동 코드로 입력</span>
|
||||
<div id="manual-form" class="manual-form">
|
||||
<input type="text" id="manual-code-input" class="input-field" placeholder="위치 또는 자산 코드 입력" />
|
||||
<button id="btn-submit-manual" class="btn-action w-full">입력 확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/src/mobile-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
291
package-lock.json
generated
291
package-lock.json
generated
@@ -14,9 +14,11 @@
|
||||
"iconv-lite": "^0.7.2",
|
||||
"lucide": "^0.364.0",
|
||||
"mysql2": "^3.22.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
@@ -774,11 +776,19 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -801,6 +811,28 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-ssl-profiles": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
@@ -872,6 +904,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
@@ -885,6 +925,16 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
@@ -894,6 +944,22 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@@ -980,6 +1046,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -998,6 +1072,11 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
@@ -1030,6 +1109,11 @@
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -1187,6 +1271,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1247,6 +1343,14 @@
|
||||
"is-property": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1371,6 +1475,14 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-promise": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||
@@ -1383,6 +1495,17 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -1575,6 +1698,39 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1584,6 +1740,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
@@ -1601,6 +1765,14 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||
@@ -1643,6 +1815,22 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
@@ -1682,6 +1870,19 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
@@ -1794,6 +1995,11 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -1918,6 +2124,30 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -1959,8 +2189,7 @@
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
@@ -2040,6 +2269,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
@@ -2058,6 +2292,19 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -2084,6 +2331,44 @@
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"db-init": "node db_init.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
@@ -21,6 +22,7 @@
|
||||
"iconv-lite": "^0.7.2",
|
||||
"lucide": "^0.364.0",
|
||||
"mysql2": "^3.22.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
1
public/qrcode.min.js
vendored
Normal file
1
public/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
scratch/analyze_codes.cjs
Normal file
24
scratch/analyze_codes.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function analyzeCodes() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
// 새 자산들의 연도 분포 확인
|
||||
const [years] = await connection.query('SELECT DISTINCT purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"');
|
||||
console.log('New assets years:', years.map(y => y.purchase_date));
|
||||
|
||||
// 기존 자산 코드 패턴 확인
|
||||
const [existing] = await connection.query('SELECT asset_code FROM asset_core WHERE asset_code LIKE "PC-%" LIMIT 5');
|
||||
console.log('Existing code sample:', existing);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
analyzeCodes().catch(console.error);
|
||||
11
scratch/check_backup_excel.cjs
Normal file
11
scratch/check_backup_excel.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
const XLSX = require('xlsx');
|
||||
const workbook = XLSX.readFile('backupDB_20260602.xlsx');
|
||||
console.log('Sheet Names:', workbook.SheetNames);
|
||||
if (workbook.SheetNames.includes('system_users')) {
|
||||
const sheet = workbook.Sheets['system_users'];
|
||||
const data = XLSX.utils.sheet_to_json(sheet);
|
||||
console.log('system_users found! Count:', data.length);
|
||||
console.log('Sample:', data.slice(0, 2));
|
||||
} else {
|
||||
console.log('system_users sheet not found in backupDB_20260602.xlsx');
|
||||
}
|
||||
24
scratch/check_codes.cjs
Normal file
24
scratch/check_codes.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function checkCodes() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('--- Asset Codes Sample ---');
|
||||
const [rows] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
|
||||
console.log(rows);
|
||||
|
||||
console.log('\n--- Other Asset Codes Sample ---');
|
||||
const [rows2] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id NOT LIKE "PC_20260615_%" AND asset_code IS NOT NULL LIMIT 5');
|
||||
console.log(rows2);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
checkCodes().catch(console.error);
|
||||
40
scratch/check_public_pcs.cjs
Normal file
40
scratch/check_public_pcs.cjs
Normal file
@@ -0,0 +1,40 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function checkPublicPCs() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('🔍 공용 PC(Public PC)로 추정되는 자산 조회 중...');
|
||||
|
||||
// 사번이 없거나, 사용자명에 '공용'이 포함된 데이터 조회
|
||||
const [rows] = await connection.query(`
|
||||
SELECT id, asset_code, user_current, emp_no, current_dept, asset_type
|
||||
FROM asset_core
|
||||
WHERE (emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%')
|
||||
AND id LIKE 'PC_20260615_%'
|
||||
`);
|
||||
|
||||
console.log(`📊 발견된 공용 PC 후보: ${rows.length}건`);
|
||||
|
||||
if (rows.length > 0) {
|
||||
console.table(rows.slice(0, 20)); // 상위 20개 샘플 출력
|
||||
|
||||
// 요약 통계
|
||||
const summary = {
|
||||
only_no_emp: rows.filter(r => (!r.emp_no) && !r.user_current.includes('공용')).length,
|
||||
only_public_name: rows.filter(r => r.emp_no && r.user_current.includes('공용')).length,
|
||||
both: rows.filter(r => (!r.emp_no) && r.user_current.includes('공용')).length
|
||||
};
|
||||
console.log('\n📈 요약 통계:', summary);
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
checkPublicPCs().catch(console.error);
|
||||
77
scratch/compare_and_cleanup.cjs
Normal file
77
scratch/compare_and_cleanup.cjs
Normal file
@@ -0,0 +1,77 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function updateAndCompare() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('🚀 [Step 1 & 2] "undefined" 사번 및 빈 사용자명 정리 중...');
|
||||
const [updateResult] = await connection.query(`
|
||||
UPDATE asset_core
|
||||
SET user_current = '공용', emp_no = NULL
|
||||
WHERE id LIKE "PC_20260615_%" AND (emp_no = 'undefined' OR emp_no IS NULL OR emp_no = '')
|
||||
`);
|
||||
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}건`);
|
||||
|
||||
console.log('\n🔍 [Step 3] 엑셀 데이터와 DB asset_type 비교 분석 중...');
|
||||
const XLSX = require('xlsx');
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const excelData = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
// DB 데이터 로드
|
||||
const [dbRows] = await connection.query('SELECT id, asset_type, user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%"');
|
||||
const dbMap = new Map();
|
||||
dbRows.forEach(r => dbMap.set(r.id, r));
|
||||
|
||||
const mismatches = [];
|
||||
const publicButExcelPersonal = [];
|
||||
|
||||
for (let i = 0; i < excelData.length; i++) {
|
||||
const excelRow = excelData[i];
|
||||
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
const dbRow = dbMap.get(assetId);
|
||||
|
||||
if (!dbRow) continue;
|
||||
|
||||
const excelType = excelRow.asset_type || '개인PC';
|
||||
|
||||
// 1. 단순 타입 불일치 체크
|
||||
if (dbRow.asset_type !== excelType) {
|
||||
mismatches.push({
|
||||
id: assetId,
|
||||
excel_type: excelType,
|
||||
db_type: dbRow.asset_type,
|
||||
user: dbRow.user_current
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 엑셀은 '개인PC'인데 데이터는 공용(사번없음)인 경우 탐색
|
||||
if (excelType === '개인PC' && (!dbRow.emp_no || dbRow.user_current === '공용')) {
|
||||
publicButExcelPersonal.push({
|
||||
id: assetId,
|
||||
excel_user: excelRow.user_current,
|
||||
excel_dept: excelRow.current_dept,
|
||||
db_user: dbRow.user_current
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n📊 분석 결과:`);
|
||||
console.log(`- 엑셀과 DB의 asset_type 불일치: ${mismatches.length}건`);
|
||||
console.log(`- 엑셀은 '개인PC'이나 사번이 없어 '공용'으로 잡힌 항목: ${publicButExcelPersonal.length}건`);
|
||||
|
||||
if (publicButExcelPersonal.length > 0) {
|
||||
console.log('\n⚠️ 엑셀은 개인PC이나 데이터가 미비한 항목 (상위 10개):');
|
||||
console.table(publicButExcelPersonal.slice(0, 10));
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
updateAndCompare().catch(console.error);
|
||||
189
scratch/db_migrate.cjs
Normal file
189
scratch/db_migrate.cjs
Normal file
@@ -0,0 +1,189 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config();
|
||||
|
||||
function getCleanMapKey(path) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
clean = clean.replace('서관', 'W').replace('동관', 'E');
|
||||
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
|
||||
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
|
||||
clean = clean.replace(/\//g, '-');
|
||||
return clean;
|
||||
}
|
||||
|
||||
function getLocationName(path) {
|
||||
if (path.includes('IDC')) return 'IDC';
|
||||
if (path.includes('한맥빌딩')) return '한맥빌딩';
|
||||
if (path.includes('기술개발센터')) return '기술개발센터';
|
||||
return '기타';
|
||||
}
|
||||
|
||||
function getLocationDetail(path, idx) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
let parts = clean.split('/');
|
||||
let lastPart = parts[parts.length - 1]; // e.g. "서관205", "MDF_1", "서버실_1"
|
||||
return `${lastPart} 구역 자리 #${idx + 1}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🏁 Starting DB migration...');
|
||||
|
||||
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: process.env.DB_PORT
|
||||
});
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// 1. Create physical_locations table
|
||||
console.log('⏳ Creating physical_locations table...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS physical_locations (
|
||||
location_code VARCHAR(50) NOT NULL COMMENT '위치 식별 코드 (예: LOC-IDC-W205-001)',
|
||||
location_name VARCHAR(100) NOT NULL COMMENT '물리 위치 대분류 (예: IDC 서관)',
|
||||
location_detail VARCHAR(100) NOT NULL COMMENT '상세 위치/랙 번호 (예: 205호 1번 랙)',
|
||||
map_image VARCHAR(150) NOT NULL COMMENT '해당 도면 파일 경로 (예: img/location_photo/IDC/서관205.png)',
|
||||
map_x DECIMAL(5,2) NOT NULL COMMENT '도면 내 X 백분율 좌표',
|
||||
map_y DECIMAL(5,2) NOT NULL COMMENT '도면 내 Y 백분율 좌표',
|
||||
map_w DECIMAL(5,2) NOT NULL DEFAULT 4.00 COMMENT '도면 내 박스 너비(%)',
|
||||
map_h DECIMAL(5,2) NOT NULL DEFAULT 4.00 COMMENT '도면 내 박스 높이(%)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (location_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
`);
|
||||
console.log('✅ physical_locations table ready.');
|
||||
|
||||
// 2. Create asset_audit_pending table
|
||||
console.log('⏳ Creating asset_audit_pending table...');
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS asset_audit_pending (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_code VARCHAR(50) NOT NULL COMMENT '스캔된 자산 고유번호 (예: server_1779761946023_14)',
|
||||
physical_location_code VARCHAR(50) NOT NULL COMMENT '스캔된 위치 마스터 코드 (예: LOC-IDC-W205-001)',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '상태: PENDING(대기), APPROVED(승인), REJECTED(반려)',
|
||||
scanned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
processed_at TIMESTAMP NULL COMMENT '승인/반려 처리 일시',
|
||||
processed_by VARCHAR(50) NULL COMMENT '처리한 관리자',
|
||||
CONSTRAINT fk_audit_physical FOREIGN KEY (physical_location_code) REFERENCES physical_locations(location_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
`);
|
||||
console.log('✅ asset_audit_pending table ready.');
|
||||
|
||||
// 3. Add physical_location_code to asset_location
|
||||
console.log('⏳ Checking physical_location_code column in asset_location...');
|
||||
const [cols] = await connection.query('DESCRIBE asset_location');
|
||||
const hasCol = cols.some(c => c.Field === 'physical_location_code');
|
||||
if (!hasCol) {
|
||||
await connection.query(`
|
||||
ALTER TABLE asset_location
|
||||
ADD COLUMN physical_location_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT 'physical_locations의 location_code FK'
|
||||
`);
|
||||
console.log('✅ physical_location_code column added with utf8mb4_unicode_ci collation.');
|
||||
} else {
|
||||
console.log('ℹ️ physical_location_code column already exists. Enforcing collation...');
|
||||
await connection.query(`
|
||||
ALTER TABLE asset_location
|
||||
MODIFY COLUMN physical_location_code VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT 'physical_locations의 location_code FK'
|
||||
`);
|
||||
console.log('✅ physical_location_code column collation enforced.');
|
||||
}
|
||||
|
||||
// Add constraint if not exists
|
||||
console.log('⏳ Checking foreign key constraint fk_asset_loc_physical...');
|
||||
const [constraints] = await connection.query(`
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_NAME = 'asset_location'
|
||||
AND CONSTRAINT_NAME = 'fk_asset_loc_physical'
|
||||
AND TABLE_SCHEMA = DATABASE()
|
||||
`);
|
||||
|
||||
if (constraints.length === 0) {
|
||||
console.log('⏳ Adding foreign key constraint...');
|
||||
await connection.query(`
|
||||
ALTER TABLE asset_location
|
||||
ADD CONSTRAINT fk_asset_loc_physical
|
||||
FOREIGN KEY (physical_location_code) REFERENCES physical_locations(location_code)
|
||||
`);
|
||||
console.log('✅ Foreign key constraint added.');
|
||||
} else {
|
||||
console.log('ℹ️ Foreign key constraint already exists.');
|
||||
}
|
||||
|
||||
// 4. Load map_config.json and migrate
|
||||
console.log('⏳ Migrating map_config.json data to physical_locations...');
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
const mapConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
let insertCount = 0;
|
||||
let syncCount = 0;
|
||||
|
||||
for (const [mapPath, boxes] of Object.entries(mapConfig)) {
|
||||
const cleanKey = getCleanMapKey(mapPath);
|
||||
const locName = getLocationName(mapPath);
|
||||
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const box = boxes[i];
|
||||
const padIdx = String(i + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(mapPath, i);
|
||||
|
||||
const bx = parseFloat(box.x);
|
||||
const by = parseFloat(box.y);
|
||||
const bw = parseFloat(box.w || 4.00);
|
||||
const bh = parseFloat(box.h || 4.00);
|
||||
|
||||
// Insert into physical_locations (ignore if duplicate)
|
||||
await connection.query(`
|
||||
INSERT INTO physical_locations
|
||||
(location_code, location_name, location_detail, map_image, map_x, map_y, map_w, map_h)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
location_name = VALUES(location_name),
|
||||
location_detail = VALUES(location_detail),
|
||||
map_image = VALUES(map_image),
|
||||
map_x = VALUES(map_x),
|
||||
map_y = VALUES(map_y),
|
||||
map_w = VALUES(map_w),
|
||||
map_h = VALUES(map_h)
|
||||
`, [locCode, locName, locDetail, mapPath, bx, by, bw, bh]);
|
||||
|
||||
insertCount++;
|
||||
|
||||
// Sync database asset if box.asset_id exists
|
||||
if (box.asset_id) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT id FROM asset_location WHERE asset_id = ? AND is_active = 1',
|
||||
[box.asset_id]
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await connection.query(
|
||||
'UPDATE asset_location SET physical_location_code = ? WHERE asset_id = ? AND is_active = 1',
|
||||
[locCode, box.asset_id]
|
||||
);
|
||||
syncCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`✅ Migrated ${insertCount} physical locations and synced ${syncCount} existing assets.`);
|
||||
} else {
|
||||
console.log('⚠️ map_config.json not found, skipping initial migration.');
|
||||
}
|
||||
|
||||
console.log('🎉 DB Migration successfully completed!');
|
||||
} catch (err) {
|
||||
console.error('❌ Migration failed:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
connection.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
process.exit(1);
|
||||
});
|
||||
25
scratch/debug_public.cjs
Normal file
25
scratch/debug_public.cjs
Normal file
@@ -0,0 +1,25 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function debugPublic() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
const [rows] = await connection.query(`
|
||||
SELECT user_current, emp_no, COUNT(*) as count
|
||||
FROM asset_core
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
GROUP BY user_current, emp_no
|
||||
HAVING emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%' OR user_current = ''
|
||||
`);
|
||||
|
||||
console.table(rows);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
debugPublic().catch(console.error);
|
||||
69
scratch/deep_audit.cjs
Normal file
69
scratch/deep_audit.cjs
Normal file
@@ -0,0 +1,69 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function deepAudit() {
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const excelData = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
console.log('📊 [Excel Audit] Total Rows:', excelData.length);
|
||||
|
||||
// 1. 엑셀 내 asset_type 종류 확인
|
||||
const excelTypes = new Set();
|
||||
excelData.forEach(r => excelTypes.add(r.asset_type));
|
||||
console.log('Excel Asset Types:', Array.from(excelTypes));
|
||||
|
||||
// 2. '공용' 키워드가 들어간 모든 행 추출
|
||||
const publicKeywords = ['공용', '공통', '테스트', 'TEST'];
|
||||
const potentialPublicInExcel = excelData.filter(r => {
|
||||
const name = String(r.user_current || '');
|
||||
const type = String(r.asset_type || '');
|
||||
const memo = String(r.memo || '');
|
||||
return publicKeywords.some(k => name.includes(k) || type.includes(k) || memo.includes(k)) || !r.emp_no;
|
||||
});
|
||||
|
||||
console.log(`\n🔍 [Potential Public/Issue Rows in Excel]: ${potentialPublicInExcel.length}건`);
|
||||
console.table(potentialPublicInExcel.slice(0, 30).map(r => ({
|
||||
emp_no: r.emp_no,
|
||||
user: r.user_current,
|
||||
dept: r.current_dept,
|
||||
type: r.asset_type,
|
||||
memo: r.memo
|
||||
})));
|
||||
|
||||
// 3. DB와 대조 (특히 엑셀엔 사번이 있는데 DB엔 공용으로 된 게 있는지)
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
const [dbRows] = await connection.query('SELECT id, user_current, emp_no, asset_type FROM asset_core WHERE id LIKE "PC_20260615_%"');
|
||||
|
||||
// 엑셀은 개인PC인데 DB는 공용인 경우 (또는 그 반대)
|
||||
const issues = [];
|
||||
for (let i = 0; i < excelData.length; i++) {
|
||||
const ex = excelData[i];
|
||||
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
const db = dbRows.find(r => r.id === id);
|
||||
|
||||
if (!db) continue;
|
||||
|
||||
const isExcelPublic = !ex.emp_no || String(ex.user_current).includes('공용');
|
||||
const isDbPublic = !db.emp_no || String(db.user_current).includes('공용');
|
||||
|
||||
if (isExcelPublic !== isDbPublic) {
|
||||
issues.push({ id, excel_user: ex.user_current, db_user: db.user_current, excel_emp: ex.emp_no, db_emp: db.emp_no });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n⚠️ [Consistency Issues]: ${issues.length}건`);
|
||||
if (issues.length > 0) console.table(issues);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
deepAudit().catch(console.error);
|
||||
61
scratch/extract_pc_failures.cjs
Normal file
61
scratch/extract_pc_failures.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function extractFailures() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🔍 실패 데이터 추출 중...');
|
||||
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rawData = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
// 현재 DB에 존재하는 모든 asset_core ID 조회
|
||||
const [existingRows] = await connection.query('SELECT id FROM asset_core');
|
||||
const existingIds = new Set(existingRows.map(r => r.id));
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const row = rawData[i];
|
||||
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
|
||||
// DB에 해당 ID가 없는 경우 = 실패(충돌 등의 이유로 입력되지 않음) 또는 스킵된 데이터
|
||||
// 하지만 이전 로그에서 'Duplicate entry'로 에러가 났던 항목들을 찾는 것이 목적
|
||||
// 로직상 ID 생성 규칙에 따라 해당 ID가 DB에 없으면 입력에 실패한 행임
|
||||
if (!existingIds.has(assetId)) {
|
||||
failures.push({
|
||||
excel_row: i + 2,
|
||||
generated_id: assetId,
|
||||
...row
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const newWb = XLSX.utils.book_new();
|
||||
const newWs = XLSX.utils.json_to_sheet(failures);
|
||||
XLSX.utils.book_append_sheet(newWb, newWs, 'Failures');
|
||||
const fileName = 'asset_pc_failures_20260615.xlsx';
|
||||
XLSX.writeFile(newWb, fileName);
|
||||
console.log(`✅ 추출 완료: ${failures.length}건의 실패 데이터를 ${fileName}에 저장했습니다.`);
|
||||
} else {
|
||||
console.log('입력되지 않은 데이터가 없습니다.');
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
extractFailures().catch(console.error);
|
||||
29
scratch/find_public.cjs
Normal file
29
scratch/find_public.cjs
Normal file
@@ -0,0 +1,29 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function findPotentialPublic() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('--- Searching for rows with no emp_no or "공용" in user_current ---');
|
||||
|
||||
// 사번이 'undefined', 'null', 빈값, 또는 사용자명에 '공용'이 들어간 데이터
|
||||
const [rows] = await connection.query(`
|
||||
SELECT id, user_current, emp_no
|
||||
FROM asset_core
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
AND (emp_no IS NULL OR emp_no = '' OR emp_no = 'undefined' OR user_current LIKE '%공용%')
|
||||
`);
|
||||
|
||||
console.log('Count:', rows.length);
|
||||
if (rows.length > 0) console.table(rows);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
findPotentialPublic().catch(console.error);
|
||||
47
scratch/fix_asset_types_final.cjs
Normal file
47
scratch/fix_asset_types_final.cjs
Normal file
@@ -0,0 +1,47 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function fixAssetTypes() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('🚀 [데이터 정상화] 사번 기준 자산 유형 재설정 시작...');
|
||||
|
||||
// 1. 사번이 있는 모든 신규 자산을 '개인PC'로 강제 전환
|
||||
const [personalResult] = await connection.query(`
|
||||
UPDATE asset_core
|
||||
SET asset_type = '개인PC'
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
AND emp_no IS NOT NULL
|
||||
AND emp_no != ''
|
||||
`);
|
||||
console.log(`✅ 개인PC 정상화 완료: ${personalResult.affectedRows}건 (사번 존재 항목)`);
|
||||
|
||||
// 2. 사번이 없는 모든 신규 자산을 '공용PC'로 강제 전환
|
||||
const [publicResult] = await connection.query(`
|
||||
UPDATE asset_core
|
||||
SET asset_type = '공용PC', user_current = '공용'
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
AND (emp_no IS NULL OR emp_no = '')
|
||||
`);
|
||||
console.log(`✅ 공용PC 정상화 완료: ${publicResult.affectedRows}건 (사번 부재 항목)`);
|
||||
|
||||
// 3. 최종 결과 확인
|
||||
const [rows] = await connection.query(`
|
||||
SELECT asset_type, COUNT(*) as count
|
||||
FROM asset_core
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
GROUP BY asset_type
|
||||
`);
|
||||
console.log('\n📊 최종 자산 유형 분포:');
|
||||
console.table(rows);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
fixAssetTypes().catch(console.error);
|
||||
122
scratch/import_pc_assets.cjs
Normal file
122
scratch/import_pc_assets.cjs
Normal file
@@ -0,0 +1,122 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function importAssets() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비...');
|
||||
|
||||
// 1. 엑셀 파일 로드
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rawData = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
// 2. system_users 데이터 맵 생성 (사번 기준 빠른 조회를 위함)
|
||||
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
|
||||
const userMap = new Map();
|
||||
userRows.forEach(u => userMap.set(String(u.emp_no), u));
|
||||
|
||||
// 3. 기존 자산 중복 체크용 맵 생성 (emp_no + asset_type + category)
|
||||
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category FROM asset_core');
|
||||
const existingSet = new Set();
|
||||
existingAssets.forEach(a => {
|
||||
existingSet.add(`${a.emp_no}|${a.asset_type}|${a.category}`);
|
||||
});
|
||||
|
||||
console.log(`📊 처리 대상 데이터: ${rawData.length}건`);
|
||||
|
||||
let skipCount = 0;
|
||||
let insertCount = 0;
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const row = rawData[i];
|
||||
const empNo = String(row.emp_no);
|
||||
const assetType = row.asset_type || '개인PC';
|
||||
const category = row.category || 'PC';
|
||||
|
||||
// 중복 체크
|
||||
if (existingSet.has(`${empNo}|${assetType}|${category}`)) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [Step 2] 데이터 정제
|
||||
// 1. 사용자 정보 매칭
|
||||
const matchedUser = userMap.get(empNo);
|
||||
const userName = matchedUser ? matchedUser.user_name : row.user_current;
|
||||
const deptName = matchedUser ? matchedUser.dept_name : row.current_dept;
|
||||
const position = matchedUser ? matchedUser.position : '';
|
||||
|
||||
// 2. 날짜 최적화 (purchase_date_1, purchase_date_2 중 최신값)
|
||||
const d1 = parseInt(row.purchase_date_1) || 0;
|
||||
const d2 = parseInt(row.purchase_date_2) || 0;
|
||||
const latestDate = Math.max(d1, d2);
|
||||
const purchaseDate = latestDate > 0 ? String(latestDate) : '';
|
||||
|
||||
// 3. 고유 ID 생성
|
||||
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
try {
|
||||
// [Step 3] DB 입력
|
||||
// A. asset_core 입력
|
||||
await connection.query(
|
||||
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
|
||||
purchase_corp, purchase_date, memo, manager_primary, current_dept, user_current, emp_no, user_position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[assetId, assetId, category, assetType, row.current_role, row.asset_purpose, row.service_type,
|
||||
'', purchaseDate, row.memo || '', '', deptName, userName, empNo, position, now, now]
|
||||
);
|
||||
|
||||
// B. asset_spec 입력
|
||||
await connection.query(
|
||||
`INSERT INTO asset_spec (asset_id, model_name, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[assetId, '', row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
|
||||
);
|
||||
|
||||
// C. asset_volume 입력 (SSD1, SSD2, HDD1~4)
|
||||
const volumes = [
|
||||
{ type: 'SSD', cap: row.SDD1, slot: 1 },
|
||||
{ type: 'SSD', cap: row.SDD2, slot: 2 },
|
||||
{ type: 'HDD', cap: row.HDD1, slot: 3 },
|
||||
{ type: 'HDD', cap: row.HDD2, slot: 4 },
|
||||
{ type: 'HDD', cap: row.HDD3, slot: 5 },
|
||||
{ type: 'HDD', cap: row.HDD4, slot: 6 }
|
||||
];
|
||||
|
||||
for (const vol of volumes) {
|
||||
if (vol.cap && vol.cap !== '0' && vol.cap !== 0) {
|
||||
await connection.query(
|
||||
`INSERT INTO asset_volume (asset_id, disk_type, capacity, slot_no) VALUES (?, ?, ?, ?)`,
|
||||
[assetId, vol.type, String(vol.cap), vol.slot]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
insertCount++;
|
||||
existingSet.add(`${empNo}|${assetType}|${category}`); // 실시간 중복 방지 추가
|
||||
} catch (err) {
|
||||
console.error(`❌ [${empNo}] 처리 중 오류:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ 작업 완료!`);
|
||||
console.log(`- 신규 입력: ${insertCount}건`);
|
||||
console.log(`- 중복 스킵: ${skipCount}건`);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
importAssets().catch(console.error);
|
||||
164
scratch/import_pc_assets_v2.cjs
Normal file
164
scratch/import_pc_assets_v2.cjs
Normal file
@@ -0,0 +1,164 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
// 용량 정제 함수
|
||||
function parseCapacity(val) {
|
||||
if (!val || val === '0' || val === 0) return null;
|
||||
|
||||
let str = String(val).toUpperCase();
|
||||
|
||||
// 1. 괄호와 그 안의 내용 제거
|
||||
str = str.replace(/\(.*\)/g, '').trim();
|
||||
|
||||
// 2. 숫자와 단위 분리
|
||||
const numMatch = str.match(/[\d.]+/);
|
||||
if (!numMatch) return null;
|
||||
|
||||
let num = parseFloat(numMatch[0]);
|
||||
let unit = 'GB'; // 기본 단위
|
||||
|
||||
if (str.includes('TB')) {
|
||||
unit = 'TB';
|
||||
} else if (str.includes('GB')) {
|
||||
// 4자리수 GB인 경우 TB로 전환 (지시사항 1번)
|
||||
if (num >= 1000) {
|
||||
num = num / 1000;
|
||||
unit = 'TB';
|
||||
} else {
|
||||
unit = 'GB';
|
||||
}
|
||||
} else {
|
||||
// 단위가 명시되지 않은 경우 숫자의 크기로 판단
|
||||
if (num >= 1000) {
|
||||
num = num / 1000;
|
||||
unit = 'TB';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
capacity: parseFloat(num.toFixed(2)),
|
||||
unit: unit
|
||||
};
|
||||
}
|
||||
|
||||
async function importAssets() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비 (정제 로직 강화)...');
|
||||
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rawData = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
// system_users 데이터 맵
|
||||
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
|
||||
const userMap = new Map();
|
||||
userRows.forEach(u => userMap.set(String(u.emp_no), u));
|
||||
|
||||
// 기존 자산 중복 체크용 (emp_no + asset_type + category + user_current)
|
||||
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category, user_current FROM asset_core');
|
||||
const existingSet = new Set();
|
||||
existingAssets.forEach(a => {
|
||||
existingSet.add(`${a.emp_no || ''}|${a.asset_type}|${a.category}|${a.user_current}`);
|
||||
});
|
||||
|
||||
console.log(`📊 처리 대상 데이터: ${rawData.length}건`);
|
||||
|
||||
let skipCount = 0;
|
||||
let insertCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (let i = 0; i < rawData.length; i++) {
|
||||
const row = rawData[i];
|
||||
const empNo = row.emp_no ? String(row.emp_no) : ''; // 사번 없는 행 처리 (지시사항 3번)
|
||||
const assetType = row.asset_type || '개인PC';
|
||||
const category = row.category || 'PC';
|
||||
const userCurrent = row.user_current || '';
|
||||
|
||||
// 중복 체크
|
||||
const dupKey = `${empNo}|${assetType}|${category}|${userCurrent}`;
|
||||
if (existingSet.has(dupKey)) {
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [Step 2] 데이터 정제
|
||||
const matchedUser = empNo ? userMap.get(empNo) : null;
|
||||
const userName = matchedUser ? matchedUser.user_name : userCurrent;
|
||||
const deptName = matchedUser ? matchedUser.dept_name : (row.current_dept || '');
|
||||
const position = matchedUser ? matchedUser.position : '';
|
||||
|
||||
const d1 = parseInt(row.purchase_date_1) || 0;
|
||||
const d2 = parseInt(row.purchase_date_2) || 0;
|
||||
const purchaseDate = Math.max(d1, d2) > 0 ? String(Math.max(d1, d2)) : '';
|
||||
|
||||
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
try {
|
||||
// [Step 3] DB 입력
|
||||
// A. asset_core
|
||||
await connection.query(
|
||||
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
|
||||
purchase_date, memo, current_dept, user_current, emp_no, user_position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[assetId, assetId, category, assetType, row.current_role || '', row.asset_purpose || '', row.service_type || '',
|
||||
purchaseDate, row.memo || '', deptName, userName, empNo, position, now, now]
|
||||
);
|
||||
|
||||
// B. asset_spec
|
||||
await connection.query(
|
||||
`INSERT INTO asset_spec (asset_id, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?)`,
|
||||
[assetId, row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
|
||||
);
|
||||
|
||||
// C. asset_volume
|
||||
const volCols = [
|
||||
{ key: 'SDD1', type: 'SSD', slot: 1 },
|
||||
{ key: 'SDD2', type: 'SSD', slot: 2 },
|
||||
{ key: 'HDD1', type: 'HDD', slot: 3 },
|
||||
{ key: 'HDD2', type: 'HDD', slot: 4 },
|
||||
{ key: 'HDD3', type: 'HDD', slot: 5 },
|
||||
{ key: 'HDD4', type: 'HDD', slot: 6 }
|
||||
];
|
||||
|
||||
for (const col of volCols) {
|
||||
const rawVol = row[col.key];
|
||||
const parsed = parseCapacity(rawVol);
|
||||
if (parsed) {
|
||||
await connection.query(
|
||||
`INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)`,
|
||||
[assetId, col.type, parsed.capacity, parsed.unit, col.slot]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
insertCount++;
|
||||
existingSet.add(dupKey);
|
||||
} catch (err) {
|
||||
errorCount++;
|
||||
console.error(`❌ [Row ${i + 2}] ${empNo || 'Public'}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ 작업 완료!`);
|
||||
console.log(`- 신규 입력: ${insertCount}건`);
|
||||
console.log(`- 중복 스킵: ${skipCount}건`);
|
||||
console.log(`- 오류 실패: ${errorCount}건`);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
importAssets().catch(console.error);
|
||||
61
scratch/import_system_users.cjs
Normal file
61
scratch/import_system_users.cjs
Normal file
@@ -0,0 +1,61 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function importUsers() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 Excel 데이터 로드 중...');
|
||||
const workbook = XLSX.readFile('system_User (20260615).xlsx');
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
console.log(`📊 총 ${data.length}개의 데이터를 찾았습니다.`);
|
||||
|
||||
// 기존 데이터 삭제 여부 (사용자 요구사항에 따라 결정 가능하지만, 보통 초기화 후 재입입)
|
||||
// 여기서는 중복 방지를 위해 기존 데이터를 삭제하고 새로 넣는 방식을 취하겠습니다.
|
||||
console.log('🧹 기존 system_users 데이터 삭제 중...');
|
||||
await connection.query('DELETE FROM system_users');
|
||||
|
||||
console.log('📥 데이터 삽입 중...');
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
const { emp_no, user_name, dept_name, position, status } = row;
|
||||
|
||||
// ID 생성 (USR_ + 인덱스 001 형식)
|
||||
const id = `USR_${String(i + 1).padStart(3, '0')}`;
|
||||
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
try {
|
||||
await connection.query(
|
||||
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, String(emp_no), user_name, dept_name, position, status, createdAt]
|
||||
);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error(`❌ 삽입 실패 (Row ${i + 2}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 완료: ${successCount}개의 사용자가 성공적으로 등록되었습니다.`);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
importUsers().catch(err => {
|
||||
console.error('❌ 작업 중 오류 발생:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
7
scratch/peek_asset_pc.cjs
Normal file
7
scratch/peek_asset_pc.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const XLSX = require('xlsx');
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
console.log('Headers:', JSON.stringify(data[0], null, 2));
|
||||
console.log('Sample Row 1:', JSON.stringify(data[1], null, 2));
|
||||
6
scratch/peek_excel.cjs
Normal file
6
scratch/peek_excel.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
const XLSX = require('xlsx');
|
||||
const workbook = XLSX.readFile('system_User (20260615).xlsx');
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
||||
console.log(JSON.stringify(data.slice(0, 5), null, 2));
|
||||
18
scratch/raw_check.cjs
Normal file
18
scratch/raw_check.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function rawCheck() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
const [rows] = await connection.query('SELECT user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
|
||||
console.log(rows);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
rawCheck().catch(console.error);
|
||||
85
scratch/rebuild_asset_codes_final.cjs
Normal file
85
scratch/rebuild_asset_codes_final.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function rebuildAssetCodes() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('🚀 [Step 1] 신규 자산 구매일 업데이트 (YYYY-12-01)...');
|
||||
|
||||
// 1. 오늘 입력한 자산들 조회
|
||||
const [rows] = await connection.query(
|
||||
'SELECT id, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"'
|
||||
);
|
||||
console.log(`대상 자산: ${rows.length}건`);
|
||||
|
||||
// 2. 구매일자 업데이트 (연도만 있는 경우 -12-01 추가)
|
||||
for (const row of rows) {
|
||||
if (row.purchase_date && row.purchase_date.length === 4) {
|
||||
const newDate = `${row.purchase_date}-12-01`;
|
||||
await connection.query(
|
||||
'UPDATE asset_core SET purchase_date = ? WHERE id = ?',
|
||||
[newDate, row.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log('✅ 구매일 업데이트 완료.');
|
||||
|
||||
console.log('\n🚀 [Step 2] 자산번호(asset_code) 재매핑 시작...');
|
||||
|
||||
// 3. 연도별로 그룹화하여 자산번호 부여
|
||||
// 연도 목록 추출
|
||||
const [yearRows] = await connection.query(
|
||||
'SELECT DISTINCT LEFT(purchase_date, 4) as year FROM asset_core WHERE id LIKE "PC_20260615_%" ORDER BY year'
|
||||
);
|
||||
|
||||
for (const yRow of yearRows) {
|
||||
const year = yRow.year;
|
||||
const yearMonth = `${year}12`;
|
||||
const pattern = `PC-${yearMonth}-%`;
|
||||
|
||||
console.log(`--- [${year}년] 처리 중 ---`);
|
||||
|
||||
// 해당 연도/월의 기존 최대 순번 조회
|
||||
const [maxRows] = await connection.query(
|
||||
'SELECT asset_code FROM asset_core WHERE asset_code LIKE ? AND id NOT LIKE "PC_20260615_%"',
|
||||
[pattern]
|
||||
);
|
||||
|
||||
let maxSeq = 0;
|
||||
maxRows.forEach(r => {
|
||||
const parts = r.asset_code.split('-');
|
||||
const seq = parseInt(parts[2]);
|
||||
if (seq > maxSeq) maxSeq = seq;
|
||||
});
|
||||
|
||||
console.log(`기존 최대 순번: ${maxSeq}`);
|
||||
|
||||
// 해당 연도 자산들 순차적으로 번호 부여
|
||||
const [assetsOfYear] = await connection.query(
|
||||
'SELECT id FROM asset_core WHERE id LIKE "PC_20260615_%" AND purchase_date LIKE ? ORDER BY id',
|
||||
[`${year}-12%`]
|
||||
);
|
||||
|
||||
let currentSeq = maxSeq + 1;
|
||||
for (const asset of assetsOfYear) {
|
||||
const newCode = `PC-${yearMonth}-${String(currentSeq).padStart(4, '0')}`;
|
||||
await connection.query(
|
||||
'UPDATE asset_core SET asset_code = ? WHERE id = ?',
|
||||
[newCode, asset.id]
|
||||
);
|
||||
currentSeq++;
|
||||
}
|
||||
console.log(`신규 부여 완료: ${assetsOfYear.length}건 (순번 ${maxSeq + 1} ~ ${currentSeq - 1})`);
|
||||
}
|
||||
|
||||
console.log('\n✨ 모든 작업이 완료되었습니다.');
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
rebuildAssetCodes().catch(console.error);
|
||||
85
scratch/reexamine_full.cjs
Normal file
85
scratch/reexamine_full.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function reexamineData() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log('🧐 [전수 조사] 엑셀 vs DB 데이터 비교 분석...');
|
||||
|
||||
// 1. 엑셀 데이터 로드
|
||||
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const excelRows = XLSX.utils.sheet_to_json(sheet);
|
||||
|
||||
// 2. DB 데이터 로드
|
||||
const [dbRows] = await connection.query(`
|
||||
SELECT id, asset_code, asset_type, user_current, emp_no, current_dept
|
||||
FROM asset_core
|
||||
WHERE id LIKE "PC_20260615_%"
|
||||
`);
|
||||
const dbMap = new Map();
|
||||
dbRows.forEach(r => dbMap.set(r.id, r));
|
||||
|
||||
const report = {
|
||||
total: excelRows.length,
|
||||
publicInExcelWithEmpNo: [], // 엑셀은 공용PC인데 사번이 있는 경우
|
||||
personalInExcelNoEmpNo: [], // 엑셀은 개인PC인데 사번이 없는 경우
|
||||
typeMismatch: [], // 엑셀과 DB의 asset_type이 다른 경우
|
||||
userMismatch: [] // 사용자명이 크게 다른 경우
|
||||
};
|
||||
|
||||
for (let i = 0; i < excelRows.length; i++) {
|
||||
const ex = excelRows[i];
|
||||
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
|
||||
const db = dbMap.get(id);
|
||||
|
||||
if (!db) continue;
|
||||
|
||||
const exType = ex.asset_type || '개인PC';
|
||||
const exEmpNo = ex.emp_no ? String(ex.emp_no) : null;
|
||||
const exUser = ex.user_current || '';
|
||||
|
||||
// A. 공용PC인데 사번이 있는 경우 (가장 큰 혼란 포인트)
|
||||
if (exType === '공용PC' && exEmpNo) {
|
||||
report.publicInExcelWithEmpNo.push({ id, exUser, exEmpNo, exDept: ex.current_dept });
|
||||
}
|
||||
|
||||
// B. 개인PC인데 사번이 없는 경우
|
||||
if (exType === '개인PC' && !exEmpNo) {
|
||||
report.personalInExcelNoEmpNo.push({ id, exUser, exDept: ex.current_dept });
|
||||
}
|
||||
|
||||
// C. DB와의 타입 불일치 (현재 DB 상태 체크)
|
||||
if (db.asset_type !== exType) {
|
||||
report.typeMismatch.push({ id, exType, dbType: db.asset_type, user: db.user_current });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n================================================');
|
||||
console.log(`📊 전수 조사 요약 (총 ${report.total}건)`);
|
||||
console.log(`1. 엑셀은 '공용PC'이나 '사번'이 있는 항목: ${report.publicInExcelWithEmpNo.length}건`);
|
||||
console.log(`2. 엑셀은 '개인PC'이나 '사번'이 없는 항목: ${report.personalInExcelNoEmpNo.length}건`);
|
||||
console.log(`3. 현재 DB와 엑셀의 '자산유형' 불일치: ${report.typeMismatch.length}건`);
|
||||
console.log('================================================\n');
|
||||
|
||||
if (report.publicInExcelWithEmpNo.length > 0) {
|
||||
console.log('⚠️ [그룹 1] 공용PC인데 실사용자/관리자가 지정된 사례 (샘플 15건):');
|
||||
console.table(report.publicInExcelWithEmpNo.slice(0, 15));
|
||||
}
|
||||
|
||||
if (report.personalInExcelNoEmpNo.length > 0) {
|
||||
console.log('\n⚠️ [그룹 2] 개인PC인데 사번 정보가 누락된 사례 (샘플 15건):');
|
||||
console.table(report.personalInExcelNoEmpNo.slice(0, 15));
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
reexamineData().catch(console.error);
|
||||
92
scratch/restore_and_merge_users.cjs
Normal file
92
scratch/restore_and_merge_users.cjs
Normal file
@@ -0,0 +1,92 @@
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function restoreAndMerge() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🔄 데이터 복구 및 병합 시작...');
|
||||
|
||||
// 1. 백업 파일에서 기존 데이터(212건) 로드
|
||||
const workbookBackup = XLSX.readFile('backupDB_20260602.xlsx');
|
||||
const oldUsers = XLSX.utils.sheet_to_json(workbookBackup.Sheets['system_users']);
|
||||
|
||||
// 2. 신규 파일에서 데이터(987건) 로드
|
||||
const workbookNew = XLSX.readFile('system_User (20260615).xlsx');
|
||||
const newUsers = XLSX.utils.sheet_to_json(workbookNew.Sheets[workbookNew.SheetNames[0]]);
|
||||
|
||||
console.log(`기본 백업 데이터: ${oldUsers.length}건`);
|
||||
console.log(`신규 추가 데이터: ${newUsers.length}건`);
|
||||
|
||||
// 테이블 비우기 (실수를 바로잡기 위해 다시 시작)
|
||||
await connection.query('DELETE FROM system_users');
|
||||
|
||||
const insertedEmpNos = new Set();
|
||||
let restoreCount = 0;
|
||||
let addCount = 0;
|
||||
|
||||
// 3. 기존 데이터 복구 (ID 보존 시도)
|
||||
for (const user of oldUsers) {
|
||||
const { id, emp_no, user_name, dept_name, position, status, created_at } = user;
|
||||
|
||||
// 엑셀 날짜 처리 (숫자로 되어 있을 경우)
|
||||
let finalCreatedAt = created_at;
|
||||
if (typeof created_at === 'number') {
|
||||
const date = new Date((created_at - 25569) * 86400 * 1000);
|
||||
finalCreatedAt = date.toISOString().replace('T', ' ').substring(0, 19);
|
||||
}
|
||||
|
||||
try {
|
||||
await connection.query(
|
||||
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, String(emp_no), user_name, dept_name, position, status, finalCreatedAt]
|
||||
);
|
||||
insertedEmpNos.add(String(emp_no));
|
||||
restoreCount++;
|
||||
} catch (err) {
|
||||
console.error(`❌ 복구 실패 (emp_no: ${emp_no}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 신규 데이터 추가 (중복 제외)
|
||||
for (let i = 0; i < newUsers.length; i++) {
|
||||
const user = newUsers[i];
|
||||
const { emp_no, user_name, dept_name, position, status } = user;
|
||||
const strEmpNo = String(emp_no);
|
||||
|
||||
if (insertedEmpNos.has(strEmpNo)) {
|
||||
continue; // 이미 복구된 데이터는 스킵
|
||||
}
|
||||
|
||||
// 신규 데이터용 ID 생성 (기존 ID와 겹치지 않게 'NEW_' 접두어 또는 시퀀스 사용)
|
||||
// 여기서는 단순히 시퀀스로 처리 (최대 ID 확인 후 +1 하는 방식이 좋으나 여기선 간단히)
|
||||
const id = `USR_N_${String(i + 1).padStart(4, '0')}`;
|
||||
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
|
||||
try {
|
||||
await connection.query(
|
||||
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, strEmpNo, user_name, dept_name, position, status, createdAt]
|
||||
);
|
||||
addCount++;
|
||||
} catch (err) {
|
||||
console.error(`❌ 추가 실패 (emp_no: ${emp_no}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 복구 완료: 기존 ${restoreCount}건 복구, 신규 ${addCount}건 추가 (총 ${restoreCount + addCount}건)`);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
restoreAndMerge().catch(console.error);
|
||||
231
scratch/test_audit.cjs
Normal file
231
scratch/test_audit.cjs
Normal file
@@ -0,0 +1,231 @@
|
||||
const assert = require('assert');
|
||||
const http = require('http');
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
const BASE_URL = 'http://localhost:3001';
|
||||
|
||||
function request(method, path, body = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
const req = http.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
} catch (e) {
|
||||
resolve({ status: res.statusCode, body: data });
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => reject(err));
|
||||
if (body) {
|
||||
req.write(JSON.stringify(body));
|
||||
}
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function runTests() {
|
||||
console.log('🧪 Starting Audit TDD Tests...');
|
||||
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: process.env.DB_PORT
|
||||
});
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
|
||||
try {
|
||||
// Clean up any test records
|
||||
console.log('🧹 Cleaning up test records...');
|
||||
await connection.query("DELETE FROM asset_audit_pending WHERE asset_code LIKE 'TEST-ASSET-%'");
|
||||
|
||||
// Check if test assets exist in asset_core & asset_location
|
||||
// We will use an existing asset or insert a dummy test asset
|
||||
const [testAssets] = await connection.query("SELECT id FROM asset_core WHERE asset_code = 'TEST-ASSET-001'");
|
||||
let testAssetId;
|
||||
if (testAssets.length === 0) {
|
||||
console.log('⏳ Inserting dummy test asset...');
|
||||
testAssetId = 'test_asset_uuid_123456';
|
||||
await connection.query(`
|
||||
INSERT INTO asset_core (id, asset_code, category, asset_type, asset_purpose)
|
||||
VALUES (?, 'TEST-ASSET-001', 'server', 'Server', 'TDD Test Server')
|
||||
`, [testAssetId]);
|
||||
await connection.query(`
|
||||
INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active)
|
||||
VALUES (?, 'Initial Location', 'Initial Detail', 'initial.png', '10.00', '10.00', 1)
|
||||
`, [testAssetId]);
|
||||
} else {
|
||||
testAssetId = testAssets[0].id;
|
||||
}
|
||||
|
||||
// 1. Test GET /api/physical-locations
|
||||
console.log('👉 Test 1: GET /api/physical-locations');
|
||||
const res1 = await request('GET', '/api/physical-locations');
|
||||
assert.strictEqual(res1.status, 200, 'GET /api/physical-locations should return 200');
|
||||
assert(Array.isArray(res1.body), 'Response should be an array of physical locations');
|
||||
assert(res1.body.length > 0, 'Should return at least one physical location');
|
||||
console.log(`✅ Test 1 Passed: Found ${res1.body.length} physical locations.`);
|
||||
|
||||
const sampleLocation = res1.body[0].location_code;
|
||||
|
||||
// 2. Test POST /api/audit/scan
|
||||
console.log(`👉 Test 2: POST /api/audit/scan (Location: ${sampleLocation}, Asset: TEST-ASSET-001)`);
|
||||
const res2 = await request('POST', '/api/audit/scan', {
|
||||
asset_code: 'TEST-ASSET-001',
|
||||
physical_location_code: sampleLocation
|
||||
});
|
||||
assert.strictEqual(res2.status, 200, 'POST /api/audit/scan should return 200');
|
||||
assert.strictEqual(res2.body.success, true, 'Response success should be true');
|
||||
assert(res2.body.pending_id, 'Response should contain pending_id');
|
||||
console.log(`✅ Test 2 Passed: Pending scan registered with ID: ${res2.body.pending_id}`);
|
||||
|
||||
const pendingId = res2.body.pending_id;
|
||||
|
||||
// 3. Test GET /api/audit/pending
|
||||
console.log('👉 Test 3: GET /api/audit/pending');
|
||||
const res3 = await request('GET', '/api/audit/pending');
|
||||
assert.strictEqual(res3.status, 200, 'GET /api/audit/pending should return 200');
|
||||
assert(Array.isArray(res3.body), 'Response should be an array');
|
||||
const pendingItem = res3.body.find(item => item.id === pendingId);
|
||||
assert(pendingItem, 'Pending list should contain the newly registered scan');
|
||||
assert.strictEqual(pendingItem.asset_code, 'TEST-ASSET-001', 'Asset code should match');
|
||||
assert.strictEqual(pendingItem.physical_location_code, sampleLocation, 'Location code should match');
|
||||
assert.strictEqual(pendingItem.status, 'PENDING', 'Status should be PENDING');
|
||||
console.log('✅ Test 3 Passed: Newly registered scan found in pending list with correct details.');
|
||||
|
||||
// 4. Test POST /api/audit/approve
|
||||
console.log(`👉 Test 4: POST /api/audit/approve (Pending ID: ${pendingId})`);
|
||||
const res4 = await request('POST', '/api/audit/approve', {
|
||||
pending_ids: [pendingId],
|
||||
processed_by: 'TDD-TESTER'
|
||||
});
|
||||
assert.strictEqual(res4.status, 200, 'POST /api/audit/approve should return 200');
|
||||
assert.strictEqual(res4.body.success, true, 'Response success should be true');
|
||||
console.log('✅ Test 4 Passed: Audit approved.');
|
||||
|
||||
// Verify database updates
|
||||
console.log('🔍 Verifying updates in database...');
|
||||
const [pendingCheck] = await connection.query(
|
||||
'SELECT status, processed_by FROM asset_audit_pending WHERE id = ?',
|
||||
[pendingId]
|
||||
);
|
||||
assert.strictEqual(pendingCheck[0].status, 'APPROVED', 'Pending record status should be APPROVED');
|
||||
assert.strictEqual(pendingCheck[0].processed_by, 'TDD-TESTER', 'Processed by should match');
|
||||
|
||||
const [locationCheck] = await connection.query(
|
||||
'SELECT physical_location_code, location_photo, loc_x, loc_y FROM asset_location WHERE asset_id = ? AND is_active = 1',
|
||||
[testAssetId]
|
||||
);
|
||||
const [physLoc] = await connection.query(
|
||||
'SELECT map_image, map_x, map_y FROM physical_locations WHERE location_code = ?',
|
||||
[sampleLocation]
|
||||
);
|
||||
assert.strictEqual(locationCheck[0].physical_location_code, sampleLocation, 'Asset location code should be updated');
|
||||
assert.strictEqual(locationCheck[0].location_photo, physLoc[0].map_image, 'Asset map_image should be updated');
|
||||
assert.strictEqual(parseFloat(locationCheck[0].loc_x).toFixed(2), parseFloat(physLoc[0].map_x).toFixed(2), 'Asset map_x should be updated');
|
||||
assert.strictEqual(parseFloat(locationCheck[0].loc_y).toFixed(2), parseFloat(physLoc[0].map_y).toFixed(2), 'Asset map_y should be updated');
|
||||
console.log('✅ Database verification passed: Asset location and map coordinates updated successfully!');
|
||||
|
||||
// 5. Test GET /api/maps (Before modification)
|
||||
console.log('👉 Test 5: GET /api/maps');
|
||||
const res5 = await request('GET', '/api/maps');
|
||||
assert.strictEqual(res5.status, 200, 'GET /api/maps should return 200');
|
||||
assert(typeof res5.body === 'object' && res5.body !== null, 'Response should be a map config object');
|
||||
console.log('✅ Test 5 Passed: GET /api/maps returned valid object.');
|
||||
|
||||
// 6. Test POST /api/maps/save
|
||||
console.log('👉 Test 6: POST /api/maps/save');
|
||||
const testMapPath = 'img/location_photo/TDD_TEST_MAP.png';
|
||||
const testBoxes = [
|
||||
{
|
||||
x: '30.50',
|
||||
y: '40.25',
|
||||
w: '10.00',
|
||||
h: '12.00',
|
||||
asset_id: testAssetId
|
||||
},
|
||||
{
|
||||
x: '50.00',
|
||||
y: '60.00',
|
||||
w: '5.00',
|
||||
h: '5.00',
|
||||
asset_id: null
|
||||
}
|
||||
];
|
||||
|
||||
const res6 = await request('POST', '/api/maps/save', {
|
||||
path: testMapPath,
|
||||
boxes: testBoxes
|
||||
});
|
||||
assert.strictEqual(res6.status, 200, 'POST /api/maps/save should return 200');
|
||||
assert.strictEqual(res6.body.success, true, 'Save should be successful');
|
||||
console.log('✅ Test 6 Passed: Map coordinate save triggered successfully.');
|
||||
|
||||
// Verify DB update directly for physical_locations
|
||||
console.log('🔍 Verifying physical_locations update in database...');
|
||||
const [physLocCheck] = await connection.query(
|
||||
'SELECT location_code, map_x, map_y, map_w, map_h FROM physical_locations WHERE map_image = ? ORDER BY location_code',
|
||||
[testMapPath]
|
||||
);
|
||||
assert.strictEqual(physLocCheck.length, 2, 'Should create 2 physical locations for the test map');
|
||||
|
||||
// First location has asset_id mapped
|
||||
assert.strictEqual(parseFloat(physLocCheck[0].map_x).toFixed(2), '30.50', 'First location X coord match');
|
||||
assert.strictEqual(parseFloat(physLocCheck[0].map_y).toFixed(2), '40.25', 'First location Y coord match');
|
||||
assert.strictEqual(parseFloat(physLocCheck[0].map_w).toFixed(2), '10.00', 'First location W size match');
|
||||
assert.strictEqual(parseFloat(physLocCheck[0].map_h).toFixed(2), '12.00', 'First location H size match');
|
||||
|
||||
// Asset location coordinates sync check
|
||||
console.log('🔍 Verifying asset_location coordination sync in database...');
|
||||
const [assetLocSyncCheck] = await connection.query(
|
||||
'SELECT loc_x, loc_y, physical_location_code FROM asset_location WHERE asset_id = ? AND is_active = 1',
|
||||
[testAssetId]
|
||||
);
|
||||
assert(assetLocSyncCheck.length > 0, 'Asset location should be active');
|
||||
assert.strictEqual(parseFloat(assetLocSyncCheck[0].loc_x).toFixed(2), '30.50', 'Asset location X should sync');
|
||||
assert.strictEqual(parseFloat(assetLocSyncCheck[0].loc_y).toFixed(2), '40.25', 'Asset location Y should sync');
|
||||
assert.strictEqual(assetLocSyncCheck[0].physical_location_code, physLocCheck[0].location_code, 'Physical location code should match');
|
||||
console.log('✅ DB Verification for save: physical_locations and asset_location coordinates synced.');
|
||||
|
||||
// 7. Test GET /api/maps (After modification)
|
||||
console.log('👉 Test 7: GET /api/maps (After saving)');
|
||||
const res7 = await request('GET', '/api/maps');
|
||||
assert.strictEqual(res7.status, 200, 'GET /api/maps should return 200');
|
||||
assert(res7.body[testMapPath], 'Returned config should contain the newly saved test map');
|
||||
const savedBoxes = res7.body[testMapPath];
|
||||
assert.strictEqual(savedBoxes.length, 2, 'Saved boxes count match');
|
||||
assert.strictEqual(savedBoxes[0].asset_id, testAssetId, 'First box asset_id match');
|
||||
assert.strictEqual(savedBoxes[0].x, '30.50', 'First box X match');
|
||||
assert.strictEqual(savedBoxes[1].asset_id, null, 'Second box asset_id is null');
|
||||
console.log('✅ Test 7 Passed: GET /api/maps returned updated configuration.');
|
||||
|
||||
// Clean up
|
||||
console.log('🧹 Cleaning up test assets...');
|
||||
await connection.query("DELETE FROM asset_audit_pending WHERE asset_code = 'TEST-ASSET-001'");
|
||||
await connection.query("DELETE FROM asset_location WHERE asset_id = ?", [testAssetId]);
|
||||
await connection.query("DELETE FROM asset_core WHERE id = ?", [testAssetId]);
|
||||
await connection.query("DELETE FROM physical_locations WHERE map_image = ?", [testMapPath]);
|
||||
|
||||
console.log('🎉 All TDD tests passed successfully!');
|
||||
} catch (err) {
|
||||
console.error('❌ TDD Test Suite Failed:', err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
connection.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
runTests().catch(() => process.exit(1));
|
||||
32
scratch/update_dept_saman.cjs
Normal file
32
scratch/update_dept_saman.cjs
Normal file
@@ -0,0 +1,32 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
async function updateDepartments() {
|
||||
const connection = await mysql.createConnection({
|
||||
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')
|
||||
});
|
||||
|
||||
console.log("🚀 부서명 '삼안' 통합 업데이트 시작...");
|
||||
|
||||
const [result] = await connection.query(`
|
||||
UPDATE asset_core
|
||||
SET current_dept = '삼안'
|
||||
WHERE current_dept NOT IN ('총괄기획실', '기술개발센터', '현타', '장헌', '한맥', 'PTC', '', '삼안')
|
||||
AND current_dept IS NOT NULL
|
||||
`);
|
||||
|
||||
console.log(`✅ 업데이트 완료: ${result.affectedRows}건의 부서명이 '삼안'으로 변경되었습니다.`);
|
||||
|
||||
// 최종 확인용 카운트
|
||||
const [rows] = await connection.query('SELECT current_dept, COUNT(*) as count FROM asset_core GROUP BY current_dept');
|
||||
console.log('\n📊 최종 부서 분포:');
|
||||
console.table(rows);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
updateDepartments().catch(console.error);
|
||||
348
server.js
348
server.js
@@ -4,7 +4,7 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config({ override: true });
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -515,13 +515,67 @@ app.get('/api/generate-asset-code', async (req, res) => {
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
function getCleanMapKey(path) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
clean = clean.replace('서관', 'W').replace('동관', 'E');
|
||||
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
|
||||
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
|
||||
clean = clean.replace(/\//g, '-');
|
||||
return clean;
|
||||
}
|
||||
|
||||
function getLocationName(path) {
|
||||
if (path.includes('IDC')) return 'IDC';
|
||||
if (path.includes('한맥빌딩')) return '한맥빌딩';
|
||||
if (path.includes('기술개발센터')) return '기술개발센터';
|
||||
return '기타';
|
||||
}
|
||||
|
||||
function getLocationDetail(path, idx) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
let parts = clean.split('/');
|
||||
let lastPart = parts[parts.length - 1];
|
||||
return `${lastPart} 구역 자리 #${idx + 1}`;
|
||||
}
|
||||
|
||||
// 6. Map Config API
|
||||
app.get('/api/maps', (req, res) => {
|
||||
app.get('/api/maps', async (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'); }
|
||||
const query = `
|
||||
SELECT
|
||||
pl.location_code,
|
||||
pl.location_name,
|
||||
pl.location_detail,
|
||||
pl.map_image,
|
||||
pl.map_x,
|
||||
pl.map_y,
|
||||
pl.map_w,
|
||||
pl.map_h,
|
||||
al.asset_id
|
||||
FROM physical_locations pl
|
||||
LEFT JOIN asset_location al ON al.physical_location_code = pl.location_code AND al.is_active = 1
|
||||
`;
|
||||
const [rows] = await pool.query(query);
|
||||
|
||||
const mapConfig = {};
|
||||
rows.forEach(row => {
|
||||
const mapPath = row.map_image;
|
||||
if (!mapConfig[mapPath]) {
|
||||
mapConfig[mapPath] = [];
|
||||
}
|
||||
mapConfig[mapPath].push({
|
||||
x: parseFloat(row.map_x).toFixed(2),
|
||||
y: parseFloat(row.map_y).toFixed(2),
|
||||
w: parseFloat(row.map_w).toFixed(2),
|
||||
h: parseFloat(row.map_h).toFixed(2),
|
||||
asset_id: row.asset_id
|
||||
});
|
||||
});
|
||||
|
||||
res.json(mapConfig);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET MAPS');
|
||||
}
|
||||
});
|
||||
|
||||
// 6.5. Get Hardware Components Master List
|
||||
@@ -680,38 +734,284 @@ app.post('/api/maps/save', async (req, res) => {
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||
if (!Array.isArray(boxes)) return res.status(400).json({ error: 'Boxes must be an array' });
|
||||
|
||||
// 1. Get old config to track movements
|
||||
let oldConfig = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
oldConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
const oldBoxes = oldConfig[path] || [];
|
||||
|
||||
// 2. Save new config to file
|
||||
oldConfig[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(oldConfig, null, 2));
|
||||
|
||||
// 3. Sync Database Assets (asset_location table)
|
||||
connection = await pool.getConnection();
|
||||
for (const box of boxes) {
|
||||
await connection.beginTransaction();
|
||||
|
||||
const cleanKey = getCleanMapKey(path);
|
||||
const locName = getLocationName(path);
|
||||
|
||||
// 1. Get old location codes for this map
|
||||
const [oldLocs] = await connection.query(
|
||||
'SELECT location_code FROM physical_locations WHERE map_image = ?',
|
||||
[path]
|
||||
);
|
||||
const oldLocCodes = oldLocs.map(r => r.location_code);
|
||||
|
||||
// 2. Deactivate and clear foreign key references in asset_location to these old location codes
|
||||
if (oldLocCodes.length > 0) {
|
||||
await connection.query(
|
||||
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW(), physical_location_code = NULL WHERE physical_location_code IN (?)',
|
||||
[oldLocCodes]
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Delete old physical locations for this map
|
||||
await connection.query(
|
||||
'DELETE FROM physical_locations WHERE map_image = ?',
|
||||
[path]
|
||||
);
|
||||
|
||||
// 4. Insert new physical locations and setup asset_location mappings
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const box = boxes[i];
|
||||
const padIdx = String(i + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(path, i);
|
||||
|
||||
// Insert physical location
|
||||
await connection.query(`
|
||||
INSERT INTO physical_locations
|
||||
(location_code, location_name, location_detail, map_image, map_x, map_y, map_w, map_h)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [locCode, locName, locDetail, path, box.x, box.y, box.w, box.h]);
|
||||
|
||||
// If asset_id is mapped, update asset_location
|
||||
if (box.asset_id) {
|
||||
console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`);
|
||||
// Deactivate old active locations for this asset
|
||||
await connection.query(
|
||||
'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1',
|
||||
[box.x, box.y, box.asset_id]
|
||||
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1',
|
||||
[box.asset_id]
|
||||
);
|
||||
|
||||
// Insert new active location mapping
|
||||
await connection.query(`
|
||||
INSERT INTO asset_location
|
||||
(asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`, [box.asset_id, locName, locDetail, path, box.x, box.y, locCode]);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, message: 'Map and Database synced successfully' });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'SAVE MAPS SYNC');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// 8. QR Asset Audit & Scan APIs
|
||||
// ==========================================
|
||||
|
||||
// GET all physical locations
|
||||
app.get('/api/physical-locations', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM physical_locations ORDER BY location_code');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET PHYSICAL LOCATIONS');
|
||||
}
|
||||
});
|
||||
|
||||
// POST register scan (mobile)
|
||||
app.post('/api/audit/scan', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
const { asset_code, physical_location_code } = req.body;
|
||||
if (!asset_code || !physical_location_code) {
|
||||
return res.status(400).json({ error: 'asset_code and physical_location_code are required' });
|
||||
}
|
||||
|
||||
connection = await pool.getConnection();
|
||||
|
||||
// Verify if asset exists
|
||||
const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]);
|
||||
if (assets.length === 0) {
|
||||
return res.status(404).json({ error: `Asset with code ${asset_code} not found` });
|
||||
}
|
||||
|
||||
// Insert pending audit record
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO asset_audit_pending (asset_code, physical_location_code, status) VALUES (?, ?, ?)',
|
||||
[asset_code, physical_location_code, 'PENDING']
|
||||
);
|
||||
|
||||
res.json({ success: true, pending_id: result.insertId });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'REGISTER SCAN');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// GET pending audits list (admin)
|
||||
app.get('/api/audit/pending', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query(`
|
||||
SELECT
|
||||
ap.*,
|
||||
c.id AS asset_id,
|
||||
c.asset_purpose,
|
||||
c.asset_type,
|
||||
pl.location_name,
|
||||
pl.location_detail,
|
||||
pl.map_image,
|
||||
l.location AS old_location,
|
||||
l.location_detail AS old_location_detail
|
||||
FROM asset_audit_pending ap
|
||||
JOIN asset_core c ON c.asset_code = ap.asset_code
|
||||
JOIN physical_locations pl ON pl.location_code = ap.physical_location_code
|
||||
LEFT JOIN asset_location l ON l.asset_id = c.id AND l.is_active = 1
|
||||
ORDER BY ap.scanned_at DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET PENDING AUDITS');
|
||||
}
|
||||
});
|
||||
|
||||
// POST approve audits (admin)
|
||||
app.post('/api/audit/approve', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
const { pending_ids, processed_by } = req.body;
|
||||
if (!Array.isArray(pending_ids) || pending_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'pending_ids must be a non-empty array' });
|
||||
}
|
||||
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
let mapConfigChanged = false;
|
||||
let mapConfig = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
mapConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
|
||||
for (const pendingId of pending_ids) {
|
||||
// 1. Get pending scan details
|
||||
const [pendings] = await connection.query(
|
||||
'SELECT asset_code, physical_location_code FROM asset_audit_pending WHERE id = ? AND status = ?',
|
||||
[pendingId, 'PENDING']
|
||||
);
|
||||
if (pendings.length === 0) continue;
|
||||
|
||||
const { asset_code, physical_location_code } = pendings[0];
|
||||
|
||||
// 2. Get asset ID
|
||||
const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]);
|
||||
if (assets.length === 0) continue;
|
||||
const assetId = assets[0].id;
|
||||
|
||||
// 3. Get physical location details
|
||||
const [locations] = await connection.query(
|
||||
'SELECT location_name, location_detail, map_image, map_x, map_y FROM physical_locations WHERE location_code = ?',
|
||||
[physical_location_code]
|
||||
);
|
||||
if (locations.length === 0) continue;
|
||||
const loc = locations[0];
|
||||
|
||||
// 4. Deactivate old active locations for this asset
|
||||
await connection.query(
|
||||
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1',
|
||||
[assetId]
|
||||
);
|
||||
|
||||
// 5. Insert new active location
|
||||
await connection.query(`
|
||||
INSERT INTO asset_location
|
||||
(asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
`, [assetId, loc.location_name, loc.location_detail, loc.map_image, loc.map_x, loc.map_y, physical_location_code]);
|
||||
|
||||
// 6. Update pending audit status
|
||||
await connection.query(
|
||||
'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ?',
|
||||
['APPROVED', processed_by || 'ADMIN', pendingId]
|
||||
);
|
||||
|
||||
// 7. Sync map_config.json
|
||||
// Remove asset from any other map coordinates
|
||||
for (const [mapPath, boxes] of Object.entries(mapConfig)) {
|
||||
let changed = false;
|
||||
const newBoxes = boxes.map(b => {
|
||||
if (b.asset_id === assetId) {
|
||||
changed = true;
|
||||
return { ...b, asset_id: null };
|
||||
}
|
||||
return b;
|
||||
});
|
||||
if (changed) {
|
||||
mapConfig[mapPath] = newBoxes;
|
||||
mapConfigChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add asset to the new map coordinate box matching map_image, map_x, map_y
|
||||
if (mapConfig[loc.map_image]) {
|
||||
const ax = parseFloat(loc.map_x);
|
||||
const ay = parseFloat(loc.map_y);
|
||||
const boxes = mapConfig[loc.map_image];
|
||||
const matchedBox = boxes.find(b => {
|
||||
const bx = parseFloat(b.x);
|
||||
const by = parseFloat(b.y);
|
||||
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
|
||||
});
|
||||
if (matchedBox) {
|
||||
matchedBox.asset_id = assetId;
|
||||
mapConfigChanged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mapConfigChanged) {
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(mapConfig, null, 2));
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, message: 'Audits approved successfully' });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'APPROVE AUDITS');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST reject audits (admin)
|
||||
app.post('/api/audit/reject', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
const { pending_ids, processed_by } = req.body;
|
||||
if (!Array.isArray(pending_ids) || pending_ids.length === 0) {
|
||||
return res.status(400).json({ error: 'pending_ids must be a non-empty array' });
|
||||
}
|
||||
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
for (const pendingId of pending_ids) {
|
||||
await connection.query(
|
||||
'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ? AND status = ?',
|
||||
['REJECTED', processed_by || 'ADMIN', pendingId, 'PENDING']
|
||||
);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, message: 'Audits rejected successfully' });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'REJECT AUDITS');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 7. File Upload API (Base64)
|
||||
app.post('/api/upload', (req, res) => {
|
||||
try {
|
||||
@@ -736,6 +1036,6 @@ app.post('/api/upload', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
||||
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
|
||||
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import { calculatePcScoreDeductive, getPcGrade, API_BASE_URL } from '../../core/utils';
|
||||
import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from './ModalUtils';
|
||||
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { QRPrinter } from '../../core/qr_print';
|
||||
|
||||
/**
|
||||
* 하드웨어 자산 상세 모달 (Styled Main Edition)
|
||||
@@ -30,9 +31,10 @@ class HwAssetModal extends BaseModal {
|
||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
|
||||
<div id="hw-header-identity" class="header-identity"></div>
|
||||
<div class="header-left" style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<h2 id="hw-modal-title" class="modal-title" style="display: none;">${this.title}</h2>
|
||||
<div id="hw-header-identity" class="header-identity" style="display: inline-flex; gap: 0.5rem; align-items: center;"></div>
|
||||
<button id="btn-print-hw-qr" class="btn btn-outline btn-primary hidden" style="padding: 2px 8px; font-size: 11px; height: 22px; margin: 0; line-height: 1; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;">QR 인쇄</button>
|
||||
</div>
|
||||
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
@@ -264,6 +266,21 @@ class HwAssetModal extends BaseModal {
|
||||
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||
|
||||
this.fetchMapConfig();
|
||||
|
||||
const qrPrintBtn = document.getElementById('btn-print-hw-qr');
|
||||
qrPrintBtn?.addEventListener('click', () => {
|
||||
if (this.currentAsset && this.currentAsset.asset_code) {
|
||||
QRPrinter.print([{
|
||||
type: 'asset',
|
||||
code: this.currentAsset.asset_code,
|
||||
title: '[ HM IT ASSET ]',
|
||||
subtitle: this.currentAsset.model_name || this.currentAsset.asset_purpose || this.currentAsset.category || 'IT 자산',
|
||||
dept: this.currentAsset.current_dept || '-',
|
||||
user: this.currentAsset.user_current || '-'
|
||||
}]);
|
||||
}
|
||||
});
|
||||
|
||||
this.fetchMasterComponents().then(() => {
|
||||
this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU');
|
||||
this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM');
|
||||
@@ -299,7 +316,7 @@ class HwAssetModal extends BaseModal {
|
||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode);
|
||||
} catch (err) { console.error('코드 생성 실패:', err); }
|
||||
@@ -317,7 +334,7 @@ class HwAssetModal extends BaseModal {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/upload`, {
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName: file.name, fileData: reader.result })
|
||||
@@ -326,7 +343,7 @@ class HwAssetModal extends BaseModal {
|
||||
if (data.success) {
|
||||
setFieldValue('hw-approval_document', data.filePath);
|
||||
if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${data.filePath}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
||||
fileLinkContainer.innerHTML = `<a href="${data.filePath}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); }
|
||||
@@ -377,6 +394,30 @@ class HwAssetModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
// 자산코드가 비어있는 경우 자동 생성 처리
|
||||
let assetCode = getFieldValue('hw-asset_code').trim();
|
||||
if (!assetCode) {
|
||||
const cat = categorySelect.value;
|
||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
try {
|
||||
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
if (data.nextCode) {
|
||||
setFieldValue('hw-asset_code', data.nextCode);
|
||||
assetCode = data.nextCode;
|
||||
} else {
|
||||
alert('자산코드 자동 생성에 실패했습니다. 수동으로 생성 버튼을 눌러주세요.');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('코드 자동 생성 실패:', err);
|
||||
alert('자산코드 자동 생성 중 오류가 발생했습니다.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 동적 볼륨 데이터 수집
|
||||
const vols: any[] = [];
|
||||
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
||||
@@ -597,7 +638,7 @@ class HwAssetModal extends BaseModal {
|
||||
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
|
||||
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||
if (fileLinkContainer && asset.approval_document) {
|
||||
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${asset.approval_document}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
||||
fileLinkContainer.innerHTML = `<a href="${asset.approval_document}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
||||
} else if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = '';
|
||||
}
|
||||
@@ -618,6 +659,13 @@ class HwAssetModal extends BaseModal {
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const genBtn = document.getElementById('btn-gen-hw-code');
|
||||
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
|
||||
|
||||
const qrBtn = document.getElementById('btn-print-hw-qr');
|
||||
if (qrBtn) {
|
||||
const hasCode = asset && asset.asset_code && asset.asset_code.trim() !== '';
|
||||
qrBtn.classList.toggle('hidden', mode !== 'view' || !hasCode);
|
||||
}
|
||||
|
||||
this.toggleFileUploadUI(mode !== 'view');
|
||||
this.toggleEditOnlyBtns(mode !== 'view');
|
||||
this.updateMapButtonVisibility();
|
||||
@@ -704,7 +752,7 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
private async fetchMapConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
const res = await fetch(`${API_BASE_URL}/api/maps`);
|
||||
this.dynamicMapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||
}
|
||||
@@ -901,7 +949,7 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
private async fetchMasterComponents(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/hardware-components`);
|
||||
const res = await fetch(`${API_BASE_URL}/api/hardware-components`);
|
||||
this.masterComponents = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch master components:', err); }
|
||||
}
|
||||
|
||||
@@ -125,10 +125,13 @@ export function setEditLock(
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지
|
||||
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지 (disabled는 해제하되 readOnly를 적용하여 폼 데이터 수집 가능하게 함)
|
||||
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
|
||||
el.disabled = false;
|
||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
|
||||
} else {
|
||||
el.disabled = false;
|
||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -537,6 +537,8 @@
|
||||
background-color: var(--canvas-soft-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly {
|
||||
@@ -546,12 +548,15 @@
|
||||
.image-marker-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.layout-map-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
@@ -59,11 +59,15 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
Object.keys(MENU_CONFIG).forEach(catKey => {
|
||||
const config = MENU_CONFIG[catKey];
|
||||
|
||||
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||
let visibleTabs = config.tabs.filter((tab: string) => {
|
||||
if (state.currentUserRole === 'admin') return tab === '대시보드';
|
||||
return tab !== '대시보드';
|
||||
});
|
||||
|
||||
if (state.currentUserRole === 'admin' && catKey === 'hw') {
|
||||
visibleTabs = ['대시보드', '실사 승인'];
|
||||
}
|
||||
|
||||
if (visibleTabs.length === 0) return;
|
||||
|
||||
visibleTabs.forEach((tab: string) => {
|
||||
|
||||
250
src/core/qr_print.ts
Normal file
250
src/core/qr_print.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
|
||||
export interface QRPrintItem {
|
||||
type: 'asset' | 'location';
|
||||
code: string;
|
||||
title: string; // e.g. "[ HM IT ASSET ]" or "[ HM LOCATION ]"
|
||||
subtitle?: string; // e.g. "가을-PC(i5-12400F)" or "기술개발센터 서버실"
|
||||
dept?: string; // e.g. "전산" or "B-03 랙"
|
||||
user?: string; // e.g. "박노석"
|
||||
date?: string; // e.g. "2024-08-05"
|
||||
}
|
||||
|
||||
/**
|
||||
* QR 라벨 인쇄 유틸리티 클래스
|
||||
*/
|
||||
export class QRPrinter {
|
||||
private static styleId = 'qr-print-style';
|
||||
private static containerId = 'label-print-container';
|
||||
|
||||
/**
|
||||
* 인쇄 전용 CSS 스타일 주입
|
||||
*/
|
||||
private static injectStyles() {
|
||||
if (document.getElementById(this.styleId)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = this.styleId;
|
||||
style.innerHTML = `
|
||||
/* 화면에서는 인쇄 컨테이너 숨김 */
|
||||
#${this.containerId} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* 화면 내 모든 요소 숨김 */
|
||||
body > *:not(#${this.containerId}) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 인쇄 전용 컨테이너 표시 */
|
||||
#${this.containerId} {
|
||||
display: block !important;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 50mm;
|
||||
height: 30mm;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 페이지 규격 설정 */
|
||||
@page {
|
||||
size: 50mm 30mm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 개별 라벨 스타일 */
|
||||
.print-label-item {
|
||||
display: flex !important;
|
||||
flex-direction: row;
|
||||
width: 50mm;
|
||||
height: 30mm;
|
||||
box-sizing: border-box;
|
||||
padding: 2.5mm;
|
||||
page-break-after: always;
|
||||
font-family: 'Pretendard Variable', sans-serif;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.print-label-item:last-child {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
/* 왼쪽 명세 영역 */
|
||||
.label-details {
|
||||
width: 30mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
font-size: 6.5pt;
|
||||
line-height: 1.25;
|
||||
text-align: left;
|
||||
padding-right: 1mm;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.label-header {
|
||||
font-size: 7.5pt;
|
||||
font-weight: 800;
|
||||
border-bottom: 0.5px solid #000;
|
||||
padding-bottom: 0.5mm;
|
||||
margin-bottom: 0.5mm;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.label-row {
|
||||
display: flex;
|
||||
margin-bottom: 0.2mm;
|
||||
}
|
||||
|
||||
.label-row .row-title {
|
||||
font-weight: 700;
|
||||
width: 9.5mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.label-row .row-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 오른쪽 QR 영역 */
|
||||
.label-qr-wrapper {
|
||||
width: 15mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label-qr-canvas {
|
||||
width: 14mm !important;
|
||||
height: 14mm !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.label-qr-code-text {
|
||||
font-size: 5.5pt;
|
||||
font-weight: 700;
|
||||
margin-top: 1mm;
|
||||
text-align: center;
|
||||
width: 15mm;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* 라벨 인쇄 실행
|
||||
*/
|
||||
public static async print(items: QRPrintItem[]): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
|
||||
this.injectStyles();
|
||||
|
||||
// 기존 컨테이너 제거
|
||||
const oldContainer = document.getElementById(this.containerId);
|
||||
if (oldContainer) oldContainer.remove();
|
||||
|
||||
// 새 인쇄 컨테이너 생성
|
||||
const container = document.createElement('div');
|
||||
container.id = this.containerId;
|
||||
document.body.appendChild(container);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const labelDiv = document.createElement('div');
|
||||
labelDiv.className = 'print-label-item';
|
||||
|
||||
// QR 접속 URL 정의
|
||||
const paramName = item.type === 'asset' ? 'asset' : 'loc';
|
||||
const qrUrl = `${window.location.origin}/mobile?${paramName}=${encodeURIComponent(item.code)}`;
|
||||
|
||||
// HTML 구성
|
||||
if (item.type === 'asset') {
|
||||
labelDiv.innerHTML = `
|
||||
<div class="label-details">
|
||||
<div class="label-header">${item.title}</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">자산번호 :</span>
|
||||
<span class="row-value">${item.code}</span>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">자 산 명 :</span>
|
||||
<span class="row-value">${item.subtitle || '-'}</span>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">부 서 :</span>
|
||||
<span class="row-value">${item.dept || '-'}</span>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">사 용 자 :</span>
|
||||
<span class="row-value">${item.user || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label-qr-wrapper">
|
||||
<canvas class="label-qr-canvas" id="qr-canvas-${i}"></canvas>
|
||||
<div class="label-qr-code-text">${item.code}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Location 라벨 레이아웃
|
||||
labelDiv.innerHTML = `
|
||||
<div class="label-details" style="justify-content: center; gap: 1mm;">
|
||||
<div class="label-header">${item.title}</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">위치코드 :</span>
|
||||
<span class="row-value" style="font-weight: 700;">${item.code}</span>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">구 역 :</span>
|
||||
<span class="row-value">${item.subtitle || '-'}</span>
|
||||
</div>
|
||||
<div class="label-row">
|
||||
<span class="row-title">상세위치 :</span>
|
||||
<span class="row-value">${item.dept || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="label-qr-wrapper">
|
||||
<canvas class="label-qr-canvas" id="qr-canvas-${i}"></canvas>
|
||||
<div class="label-qr-code-text">${item.code}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.appendChild(labelDiv);
|
||||
|
||||
// QR 코드 렌더링
|
||||
const canvas = document.getElementById(`qr-canvas-${i}`) as HTMLCanvasElement;
|
||||
if (canvas) {
|
||||
const qrLib = (window as any).QRCode;
|
||||
if (qrLib) {
|
||||
await qrLib.toCanvas(canvas, qrUrl, {
|
||||
margin: 0,
|
||||
width: 100,
|
||||
errorCorrectionLevel: 'H'
|
||||
});
|
||||
} else {
|
||||
console.error("QRCode library is not loaded on window.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 약간의 딜레이를 주어 QR 코드가 완전히 렌더링되도록 함
|
||||
setTimeout(() => {
|
||||
window.print();
|
||||
// 인쇄 완료 후 컨테이너 정리
|
||||
window.onafterprint = () => {
|
||||
container.remove();
|
||||
};
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { renderNavigation } from './components/Navigation';
|
||||
import { renderDashboard } from './views/DashboardView';
|
||||
import { renderSWTable } from './views/SW_Table';
|
||||
import { renderLocationView } from './views/LocationView';
|
||||
import { renderAuditApprovalView } from './views/AuditApprovalView';
|
||||
import { initBaseModal } from './components/Modal/BaseModal';
|
||||
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
||||
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
||||
@@ -32,6 +33,11 @@ function refreshView(tab?: string) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === '실사 승인') {
|
||||
renderAuditApprovalView(mainContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
|
||||
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
|
||||
const isServerTab = activeTab === '서버';
|
||||
|
||||
183
src/mobile-main.ts
Normal file
183
src/mobile-main.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// ITAM Mobile Audit Scanner Main Business Logic
|
||||
|
||||
const SESSION_LOC_KEY = 'itam_audit_locked_location';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const locDisplay = document.getElementById('loc-display')!;
|
||||
const unlockBtn = document.getElementById('btn-unlock-loc') as HTMLButtonElement;
|
||||
const feedbackEl = document.getElementById('scan-feedback')!;
|
||||
const manualToggleBtn = document.getElementById('btn-toggle-manual')!;
|
||||
const manualForm = document.getElementById('manual-form')!;
|
||||
const manualInput = document.getElementById('manual-code-input') as HTMLInputElement;
|
||||
const manualSubmitBtn = document.getElementById('btn-submit-manual') as HTMLButtonElement;
|
||||
|
||||
let html5QrcodeScanner: any = null;
|
||||
|
||||
// Initialize UI based on current session lock
|
||||
updateLocationUI();
|
||||
|
||||
// Parse URL parameters for immediate processing (convenience for direct QR scans)
|
||||
parseUrlParams();
|
||||
|
||||
// Initialize HTML5 QR Code Scanner
|
||||
initScanner();
|
||||
|
||||
// Bind UI Events
|
||||
unlockBtn.addEventListener('click', () => {
|
||||
sessionStorage.removeItem(SESSION_LOC_KEY);
|
||||
showFeedback('위치 잠금이 해제되었습니다.', 'success');
|
||||
updateLocationUI();
|
||||
});
|
||||
|
||||
manualToggleBtn.addEventListener('click', () => {
|
||||
const isHidden = window.getComputedStyle(manualForm).display === 'none';
|
||||
manualForm.style.display = isHidden ? 'flex' : 'none';
|
||||
manualToggleBtn.textContent = isHidden ? '스캐너 카메라로 스캔하기' : '카메라가 안 되나요? 수동 코드로 입력';
|
||||
});
|
||||
|
||||
manualSubmitBtn.addEventListener('click', () => {
|
||||
const code = manualInput.value.trim();
|
||||
if (!code) return;
|
||||
processScannedCode(code);
|
||||
manualInput.value = '';
|
||||
});
|
||||
|
||||
// --- Core Scanner Functions ---
|
||||
|
||||
function initScanner() {
|
||||
try {
|
||||
// Create Html5Qrcode instance
|
||||
// Using Html5Qrcode directly instead of Html5QrcodeScanner for customized viewport control
|
||||
const html5QrCode = new (window as any).Html5Qrcode("reader");
|
||||
|
||||
const config = {
|
||||
fps: 10,
|
||||
qrbox: (width: number, height: number) => {
|
||||
const size = Math.min(width, height) * 0.7;
|
||||
return { width: size, height: size };
|
||||
},
|
||||
aspectRatio: 1.0
|
||||
};
|
||||
|
||||
// Start scanning using the rear camera
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText: string) => {
|
||||
processScannedCode(decodedText);
|
||||
},
|
||||
(errorMessage: string) => {
|
||||
// Silent failure during continuous scanning to avoid log flooding
|
||||
}
|
||||
).catch((err: any) => {
|
||||
console.error("Camera startup failed:", err);
|
||||
showFeedback("카메라 시작 실패: 권한을 허용했는지 확인하세요.", "error");
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize html5-qrcode:", e);
|
||||
showFeedback("QR 라이브러리 로드 오류", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function processScannedCode(rawCode: string) {
|
||||
// QR 코드 인쇄 폼 등으로 인한 개행 문자(\r, \n) 및 모든 공백 문자(\s)를 제거
|
||||
const code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim();
|
||||
|
||||
// 1. Check if the code is a physical location code
|
||||
if (code.startsWith('LOC-')) {
|
||||
sessionStorage.setItem(SESSION_LOC_KEY, code);
|
||||
showFeedback(`위치 [${code}] 잠금 설정 완료!`, 'success');
|
||||
updateLocationUI();
|
||||
vibrateDevice(100);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Otherwise treat it as an asset code
|
||||
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
|
||||
if (!lockedLoc) {
|
||||
showFeedback('위치 QR 코드를 먼저 스캔하여 잠금을 설정해야 자산을 스캔할 수 있습니다.', 'error');
|
||||
vibrateDevice([100, 50, 100]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit matching info to server
|
||||
submitAssetAudit(code, lockedLoc);
|
||||
}
|
||||
|
||||
async function submitAssetAudit(assetCode: string, locationCode: string) {
|
||||
showFeedback(`자산 ${assetCode} 전송 중...`, 'success');
|
||||
|
||||
try {
|
||||
// Request is sent relative to host, which resolves dynamically through server proxy
|
||||
const res = await fetch('/api/audit/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
asset_code: assetCode,
|
||||
physical_location_code: locationCode
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
showFeedback(`자산 [${assetCode}] 실사 전송 성공! (관리자 승인 대기)`, 'success');
|
||||
vibrateDevice([200]);
|
||||
} else {
|
||||
showFeedback(`전송 실패: ${data.error || '알 수 없는 서버 오류'}`, 'error');
|
||||
vibrateDevice([100, 100, 100]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to submit scan:", err);
|
||||
showFeedback('서버 전송 중 통신 네트워크 오류가 발생했습니다.', 'error');
|
||||
vibrateDevice([100, 100, 100]);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocationUI() {
|
||||
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
|
||||
if (lockedLoc) {
|
||||
locDisplay.textContent = lockedLoc;
|
||||
locDisplay.className = 'badge-lock';
|
||||
unlockBtn.style.display = 'inline-block';
|
||||
} else {
|
||||
locDisplay.textContent = '위치 QR 코드를 먼저 스캔하세요.';
|
||||
locDisplay.className = 'badge-empty';
|
||||
unlockBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrlParams() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const loc = params.get('loc');
|
||||
const asset = params.get('asset');
|
||||
|
||||
if (loc) {
|
||||
processScannedCode(loc);
|
||||
// Clean query parameters to avoid re-triggering on page refresh
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
} else if (asset) {
|
||||
processScannedCode(asset);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, type: 'success' | 'error') {
|
||||
feedbackEl.textContent = msg;
|
||||
feedbackEl.style.display = 'block';
|
||||
feedbackEl.className = `feedback-message ${type === 'success' ? 'feedback-success' : 'feedback-error'}`;
|
||||
|
||||
// Auto-hide feedback after 4 seconds
|
||||
setTimeout(() => {
|
||||
if (feedbackEl.textContent === msg) {
|
||||
feedbackEl.style.display = 'none';
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
function vibrateDevice(pattern: number | number[]) {
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(pattern);
|
||||
}
|
||||
}
|
||||
});
|
||||
427
src/views/AuditApprovalView.ts
Normal file
427
src/views/AuditApprovalView.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { state, loadMasterDataFromDB } from '../core/state';
|
||||
import { openHwModal } from '../components/Modal/HWModal';
|
||||
|
||||
/**
|
||||
* 실사 점검 승인 대시보드 뷰 (Vercel Style Clean layout)
|
||||
*/
|
||||
export async function renderAuditApprovalView(container: HTMLElement) {
|
||||
if (!container) return;
|
||||
|
||||
// 1. CSS Stylesheet Injection
|
||||
const styleId = 'audit-approval-view-style';
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.innerHTML = `
|
||||
.audit-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height) - 48px);
|
||||
background-color: var(--canvas);
|
||||
color: var(--text-main);
|
||||
padding: 1.5rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.audit-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.audit-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.audit-badge {
|
||||
background-color: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.audit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.audit-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid var(--hairline);
|
||||
background-color: var(--canvas-soft);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.audit-btn:hover {
|
||||
background-color: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
.audit-btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.audit-btn-primary:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.audit-btn-danger {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
border-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.audit-btn-danger:hover {
|
||||
background-color: rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Data Table Custom Vercel layout */
|
||||
.audit-table-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
background-color: var(--canvas-soft);
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
font-size: 0.825rem;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
background-color: var(--canvas-soft-2);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.audit-table td {
|
||||
padding: 0.75rem 0.8rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.audit-table tr:hover td {
|
||||
background-color: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
.audit-checkbox {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.link-asset-code {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-asset-code:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.location-badge-diff {
|
||||
background-color: rgba(245, 158, 11, 0.12);
|
||||
color: #d97706;
|
||||
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.location-badge-same {
|
||||
background-color: rgba(16, 185, 129, 0.08);
|
||||
color: #059669;
|
||||
border: 1px solid rgba(16, 185, 129, 0.18);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Empty State Illustration Layout */
|
||||
.audit-empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.audit-empty-icon {
|
||||
font-size: 3rem;
|
||||
color: var(--hairline);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
let pendingData: any[] = [];
|
||||
|
||||
// Function to load data and render layout
|
||||
async function loadAndRender() {
|
||||
try {
|
||||
container.innerHTML = `
|
||||
<div class="audit-container">
|
||||
<div class="audit-header">
|
||||
<div class="audit-title-area">
|
||||
<span class="audit-title">실사 점검 승인 관리</span>
|
||||
<span id="audit-count-badge" class="audit-badge">조회 중...</span>
|
||||
</div>
|
||||
<div class="audit-actions">
|
||||
<button id="btn-audit-refresh" class="audit-btn"><i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 새로고침</button>
|
||||
<button id="btn-audit-reject" class="audit-btn audit-btn-danger" disabled>선택 반려</button>
|
||||
<button id="btn-audit-approve" class="audit-btn audit-btn-primary" disabled>선택 승인</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="audit-content-area" class="audit-table-wrapper">
|
||||
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">실사 내역을 불러오고 있습니다...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
bindHeaderEvents();
|
||||
await fetchPendingList();
|
||||
} catch (err) {
|
||||
console.error('Failed to init audit view:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPendingList() {
|
||||
try {
|
||||
const res = await fetch('/api/audit/pending');
|
||||
pendingData = await res.json();
|
||||
renderTable();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch pending audits:', err);
|
||||
const contentArea = document.getElementById('audit-content-area')!;
|
||||
contentArea.innerHTML = `<div style="padding: 3rem; text-align: center; color: var(--danger); font-weight: 600;">데이터를 불러오는 중 네트워크 에러가 발생했습니다.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const badge = document.getElementById('audit-count-badge')!;
|
||||
badge.textContent = `대기 ${pendingData.length}건`;
|
||||
|
||||
const contentArea = document.getElementById('audit-content-area')!;
|
||||
if (pendingData.length === 0) {
|
||||
contentArea.innerHTML = `
|
||||
<div class="audit-empty-state">
|
||||
<div class="audit-empty-icon">✓</div>
|
||||
<div style="font-size: 1.05rem; font-weight: 700; color: var(--text-main);">대기 중인 실사 내역이 없습니다</div>
|
||||
<div style="font-size: 0.8rem;">현장에서 스캐너로 자산을 스캔하면 실시간으로 여기에 등록됩니다.</div>
|
||||
</div>
|
||||
`;
|
||||
updateActionButtons();
|
||||
return;
|
||||
}
|
||||
|
||||
let tbodyRows = '';
|
||||
pendingData.forEach((row, i) => {
|
||||
// Format scanned date
|
||||
const dateStr = new Date(row.scanned_at).toLocaleString('ko-KR');
|
||||
|
||||
// Check if location actually changed
|
||||
const oldLocFull = row.old_location ? `${row.old_location} ${row.old_location_detail || ''}`.trim() : '미배치';
|
||||
const newLocFull = `${row.location_name} ${row.location_detail || ''}`.trim();
|
||||
const isDiff = oldLocFull !== newLocFull;
|
||||
|
||||
tbodyRows += `
|
||||
<tr>
|
||||
<td style="width: 40px; text-align: center;">
|
||||
<input type="checkbox" class="audit-checkbox row-select" data-id="${row.id}" />
|
||||
</td>
|
||||
<td>
|
||||
<span class="link-asset-code" data-index="${i}">${row.asset_code}</span>
|
||||
</td>
|
||||
<td>${row.asset_purpose || '-'}</td>
|
||||
<td><span class="badge" style="font-size: 11px;">${row.asset_type || 'IT자산'}</span></td>
|
||||
<td><span style="color: var(--text-muted);">${oldLocFull}</span></td>
|
||||
<td>
|
||||
<span class="${isDiff ? 'location-badge-diff' : 'location-badge-same'}">${newLocFull}</span>
|
||||
</td>
|
||||
<td style="color: var(--text-muted); font-size: 11px;">${dateStr}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
contentArea.innerHTML = `
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px; text-align: center;">
|
||||
<input type="checkbox" class="audit-checkbox" id="chk-audit-all" />
|
||||
</th>
|
||||
<th>자산번호</th>
|
||||
<th>자산용도</th>
|
||||
<th>자산유형</th>
|
||||
<th>기존 위치</th>
|
||||
<th>실사 위치</th>
|
||||
<th>스캔 일시</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tbodyRows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
bindTableEvents();
|
||||
updateActionButtons();
|
||||
}
|
||||
|
||||
function bindHeaderEvents() {
|
||||
document.getElementById('btn-audit-refresh')?.addEventListener('click', () => fetchPendingList());
|
||||
|
||||
document.getElementById('btn-audit-approve')?.addEventListener('click', () => handleAction('approve'));
|
||||
document.getElementById('btn-audit-reject')?.addEventListener('click', () => handleAction('reject'));
|
||||
}
|
||||
|
||||
function bindTableEvents() {
|
||||
// Select All Checkbox
|
||||
const selectAllChk = document.getElementById('chk-audit-all') as HTMLInputElement;
|
||||
const rowCheckboxes = document.querySelectorAll('.row-select') as NodeListOf<HTMLInputElement>;
|
||||
|
||||
selectAllChk?.addEventListener('change', () => {
|
||||
rowCheckboxes.forEach(chk => {
|
||||
chk.checked = selectAllChk.checked;
|
||||
});
|
||||
updateActionButtons();
|
||||
});
|
||||
|
||||
rowCheckboxes.forEach(chk => {
|
||||
chk.addEventListener('change', () => {
|
||||
updateActionButtons();
|
||||
// Sync selectAll checkbox state
|
||||
const allChecked = Array.from(rowCheckboxes).every(c => c.checked);
|
||||
if (selectAllChk) selectAllChk.checked = allChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// Asset Detail Modal linkage
|
||||
const assetLinks = document.querySelectorAll('.link-asset-code');
|
||||
assetLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.target as HTMLElement).dataset.index!);
|
||||
const row = pendingData[idx];
|
||||
if (!row) return;
|
||||
|
||||
// Compile master array from state data to find full asset object
|
||||
const allHwAssets = [
|
||||
...(state.masterData.pc || []),
|
||||
...(state.masterData.server || []),
|
||||
...(state.masterData.storage || []),
|
||||
...(state.masterData.network || []),
|
||||
...(state.masterData.equipment || []),
|
||||
...(state.masterData.survey || []),
|
||||
...(state.masterData.officeSupplies || []),
|
||||
...(state.masterData.pcParts || [])
|
||||
];
|
||||
|
||||
const targetAsset = allHwAssets.find(a => a.asset_code === row.asset_code);
|
||||
if (targetAsset) {
|
||||
openHwModal(targetAsset, 'view');
|
||||
} else {
|
||||
alert(`자산 코드 [${row.asset_code}] 에 매칭되는 마스터 데이터가 존재하지 않습니다.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateActionButtons() {
|
||||
const selected = document.querySelectorAll('.row-select:checked');
|
||||
const approveBtn = document.getElementById('btn-audit-approve') as HTMLButtonElement;
|
||||
const rejectBtn = document.getElementById('btn-audit-reject') as HTMLButtonElement;
|
||||
|
||||
if (approveBtn && rejectBtn) {
|
||||
const isDisabled = selected.length === 0;
|
||||
approveBtn.disabled = isDisabled;
|
||||
rejectBtn.disabled = isDisabled;
|
||||
|
||||
approveBtn.textContent = `선택 승인 (${selected.length})`;
|
||||
rejectBtn.textContent = `선택 반려 (${selected.length})`;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAction(actionType: 'approve' | 'reject') {
|
||||
const selected = document.querySelectorAll('.row-select:checked') as NodeListOf<HTMLInputElement>;
|
||||
const ids = Array.from(selected).map(chk => parseInt(chk.dataset.id!));
|
||||
if (ids.length === 0) return;
|
||||
|
||||
const actionText = actionType === 'approve' ? '승인' : '반려';
|
||||
if (!confirm(`선택한 ${ids.length}건의 실사 내역을 최종 ${actionText} 처리할까요?`)) return;
|
||||
|
||||
const endpoint = actionType === 'approve' ? '/api/audit/approve' : '/api/audit/reject';
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
pending_ids: ids,
|
||||
processed_by: 'ADMIN'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
alert(`성공적으로 ${actionText} 완료되었습니다.`);
|
||||
// Reload dashboard state to sync map_config/db coordinates changes
|
||||
await loadMasterDataFromDB();
|
||||
await fetchPendingList();
|
||||
} else {
|
||||
alert(`${actionText} 실패: ${data.error || '알 수 없는 서버 오류'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to trigger audit ${actionType}:`, err);
|
||||
alert(`네트워크 통신 오류로 ${actionText} 처리가 실패했습니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Run initial loading
|
||||
await loadAndRender();
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
const fetchMapConfig = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
const res = await fetch('/api/maps');
|
||||
dynamicMapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||
};
|
||||
|
||||
@@ -25,10 +25,6 @@ export async function renderLocationView(container: HTMLElement) {
|
||||
: [];
|
||||
const mapPath = locImages[currentPage] || '';
|
||||
|
||||
// 조회 모드: 설정 파일에 정의된 asset_id를 기준으로 자산 데이터 매핑
|
||||
const allBoxes = mapConfig[mapPath] || [];
|
||||
const boxes = allBoxes.filter((box: any) => box.asset_id != null);
|
||||
|
||||
// 모든 하드웨어 카테고리에서 자산 검색
|
||||
const allHwAssets = [
|
||||
...state.masterData.pc,
|
||||
@@ -41,6 +37,50 @@ export async function renderLocationView(container: HTMLElement) {
|
||||
...state.masterData.pcParts
|
||||
];
|
||||
|
||||
// map_config.json에 설정된 모든 박스를 복사해서 작업용으로 사용
|
||||
const tempBoxes = (mapConfig[mapPath] || []).map((b: any) => ({ ...b }));
|
||||
|
||||
// DB 데이터에서 현재 지도(mapPath) 및 위치와 좌표 정보(loc_x, loc_y)가 일치하는 자산 추출
|
||||
allHwAssets.forEach((asset: any) => {
|
||||
const photoPath = asset.location_photo || asset.loc_img || '';
|
||||
const hasCoords = asset.loc_x != null && asset.loc_y != null && asset.loc_x !== '' && asset.loc_y !== '' && asset.loc_x !== 'null' && asset.loc_y !== 'null';
|
||||
|
||||
if (hasCoords && photoPath.trim() === mapPath.trim()) {
|
||||
const ax = parseFloat(asset.loc_x);
|
||||
const ay = parseFloat(asset.loc_y);
|
||||
|
||||
// map_config.json에서 읽어온 박스들 중 x, y 좌표가 일치하는 빈 박스가 있는지 찾음 (오차범위 0.1 고려)
|
||||
const matchedBox = tempBoxes.find((b: any) => {
|
||||
const bx = parseFloat(b.x);
|
||||
const by = parseFloat(b.y);
|
||||
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
|
||||
});
|
||||
|
||||
if (matchedBox) {
|
||||
// 이미 매칭된 박스가 존재하고 asset_id가 비어있다면 해당 박스에 asset_id를 주입
|
||||
if (matchedBox.asset_id == null) {
|
||||
matchedBox.asset_id = asset.id;
|
||||
}
|
||||
} else {
|
||||
// 일치하는 기존 박스가 없을 때만 4x4 크기의 임시 박스로 동적 생성
|
||||
const alreadyMatched = tempBoxes.some((b: any) => b.asset_id === asset.id);
|
||||
if (!alreadyMatched) {
|
||||
tempBoxes.push({
|
||||
asset_id: asset.id,
|
||||
x: asset.loc_x,
|
||||
y: asset.loc_y,
|
||||
w: '4',
|
||||
h: '4',
|
||||
name: asset.asset_purpose || asset.asset_code || '미지정 자산'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 최종적으로 asset_id가 null이 아닌(자산이 정상 매핑되거나 갱신된) 박스들만 남겨서 렌더링
|
||||
const boxes = tempBoxes.filter((b: any) => b.asset_id != null);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="location-view-wrapper">
|
||||
<!-- 상단 통합 바 (Unified Search Bar) -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||
import { QRPrinter } from '../core/qr_print';
|
||||
|
||||
export class MapEditor {
|
||||
private container: HTMLElement;
|
||||
@@ -42,7 +43,7 @@ export class MapEditor {
|
||||
|
||||
private async loadAssets() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
|
||||
const res = await fetch('/api/assets/master');
|
||||
const masterData = await res.json();
|
||||
const allHw = [
|
||||
...(masterData.pc || []),
|
||||
@@ -95,7 +96,7 @@ export class MapEditor {
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
const res = await fetch('/api/maps');
|
||||
this.allMapConfig = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err);
|
||||
@@ -185,6 +186,30 @@ export class MapEditor {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-print-map-qrs')?.addEventListener('click', () => {
|
||||
if (this.boxes.length === 0) {
|
||||
alert('인쇄할 구역이 없습니다.');
|
||||
return;
|
||||
}
|
||||
const cleanKey = getCleanMapKey(this.currentPath);
|
||||
const locName = getLocationName(this.currentPath);
|
||||
|
||||
const items = this.boxes.map((box, index) => {
|
||||
const padIdx = String(index + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(this.currentPath, index);
|
||||
return {
|
||||
type: 'location' as const,
|
||||
code: locCode,
|
||||
title: '[ HM LOCATION ]',
|
||||
subtitle: locName,
|
||||
dept: locDetail
|
||||
};
|
||||
});
|
||||
|
||||
QRPrinter.print(items);
|
||||
});
|
||||
|
||||
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||||
}
|
||||
|
||||
@@ -195,7 +220,7 @@ export class MapEditor {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.textContent = '저장 중...';
|
||||
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
|
||||
const res = await fetch('/api/maps/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
|
||||
@@ -248,7 +273,10 @@ export class MapEditor {
|
||||
item.innerHTML = `
|
||||
<div class="box-header">
|
||||
<span class="box-index">#${i+1}</span>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<button class="btn btn-outline btn-sm" onclick="printBoxQR(${i})" style="padding: 2px 6px; font-size: 11px; margin: 0; cursor: pointer;">QR</button>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-inputs margin-bottom">
|
||||
<select data-index="${i}" data-prop="asset_id">
|
||||
@@ -294,5 +322,46 @@ export class MapEditor {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(window as any).printBoxQR = (index: number) => {
|
||||
const box = this.boxes[index];
|
||||
if (!box) return;
|
||||
const cleanKey = getCleanMapKey(this.currentPath);
|
||||
const padIdx = String(index + 1).padStart(3, '0');
|
||||
const locCode = `LOC-${cleanKey}-${padIdx}`;
|
||||
const locDetail = getLocationDetail(this.currentPath, index);
|
||||
const locName = getLocationName(this.currentPath);
|
||||
|
||||
QRPrinter.print([{
|
||||
type: 'location',
|
||||
code: locCode,
|
||||
title: '[ HM LOCATION ]',
|
||||
subtitle: locName,
|
||||
dept: locDetail
|
||||
}]);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getCleanMapKey(path: string) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
clean = clean.replace('서관', 'W').replace('동관', 'E');
|
||||
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
|
||||
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
|
||||
clean = clean.replace(/\//g, '-');
|
||||
return clean;
|
||||
}
|
||||
|
||||
function getLocationName(path: string) {
|
||||
if (path.includes('IDC')) return 'IDC';
|
||||
if (path.includes('한맥빌딩')) return '한맥빌딩';
|
||||
if (path.includes('기술개발센터')) return '기술개발센터';
|
||||
return '기타';
|
||||
}
|
||||
|
||||
function getLocationDetail(path: string, idx: number) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
let parts = clean.split('/');
|
||||
let lastPart = parts[parts.length - 1];
|
||||
return `${lastPart} 구역 자리 #${idx + 1}`;
|
||||
}
|
||||
|
||||
BIN
system_User (20260615).xlsx
Normal file
BIN
system_User (20260615).xlsx
Normal file
Binary file not shown.
BIN
uploads/1781851412729________________1__.pdf
Normal file
BIN
uploads/1781851412729________________1__.pdf
Normal file
Binary file not shown.
BIN
uploads/1781851541227________________1__.pdf
Normal file
BIN
uploads/1781851541227________________1__.pdf
Normal file
Binary file not shown.
@@ -1,17 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const env = loadEnv('', process.cwd(), '');
|
||||
const backendPort = env.PORT || '3001';
|
||||
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || `http://localhost:${backendPort}`;
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 8080,
|
||||
host: true, // Listen on all local IPs
|
||||
allowedHosts: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3000',
|
||||
target: proxyTarget,
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +26,7 @@ export default defineConfig({
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
map_editor: resolve(__dirname, 'map_editor.html'),
|
||||
mobile: resolve(__dirname, 'mobile.html'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user