3 Commits

66 changed files with 2372 additions and 76 deletions

2
.env
View File

@@ -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
View File

@@ -0,0 +1,22 @@
목적
- 정기적인 실물자산 점검을 실시하여 시스템 내 자산정보의 정확성을 확보하고, 실제 자산의 위치 및 상태를 체계적으로 파악·관리할 수 있는 관리체계를 구축
- QR 스캔 시스템을 통해 자산별 관리 이력 및 관리 책임자 정보를 즉시 확인할 수 있으며, 자산의 이동·변경 이력 추적과 안정적인 운영 관리를 추구
구조 구성안
A. 실제 위치 정보를 가진 마스터 테이블 구축
- 현재 DB의 위치 정보는 건물 및 호수 정보(예: 기술개발센터 / 서버실)와 이미지 파일 내 픽셀 좌표 정보로 관리되고 있으며, 실제 서버가 설치된 랙(Rack) 및 물리적 위치 정보를 관리하는 항목은 존재하지 않음
- 이미지 좌표 데이터와 실제 자산 위치 데이터를 연결하는 별도 마스터 테이블을 생성하여, 좌표 정보와 물리적 위치 정보 간 관계 정의 필요
B. 기존 테이블 개편
- 픽셀 좌표 정보는 마스터 테이블에서 통합관리하고, 기존 테이블은 마스터 코드를상속받는 구조로 변경하여 유지 보수성을 확보
QR코드 정보
- 자산 QR : 시스템에 등록된 자산 고유의 자산번호
- 위치 QR : 물리적 위치 테이블에 저장된 마스터 코드
현장실사 시나리오
① 담당자가 서버 렉 전면에 부착된 위치 QR을 스캔
② 위치 QR에 저장된 주소로 접속하여 세션에 현재 위치를 저장
③ 자산에 부착된 자산 QR을 스캔하여 주소에 접속하게 되면 정보를 매칭하여 API로 전송
④ 결합된 정보를 받아 기존 위치를 확인 혹은 업데이트
⑤ 시스템에서 관리자가 확인하여 승인하게 되면 시스템에도 업데이트 완료

View File

@@ -1,30 +0,0 @@
# 📝 작업 보고서 (2026-06-15)
## 1. 서버 및 개발 환경 설정
- **백엔드 서버 구동**: 3000번 포트(DB 서버) 정상 구동 완료.
- **프론트엔드 서버 구동**: 8080번 포트 정상 구동 완료.
- **브랜치 전환**: \`db_setting\` 브랜치로 전환 및 최신 코드 Pull 완료.
## 2. 데이터베이스 정제 및 보강 (Surgical Update)
- **사용자 정보(system_users) 업데이트**:
- 엑셀(\`system_User (20260615).xlsx\`) 기반 987건 신규 입력.
- 기존 백업 데이터(212건)와 병합하여 총 1,199건의 사용자 DB 구축.
- **PC 자산(asset_pc) 데이터 입력**:
- 엑셀(\`asset_pc (2026.06.15).xlsx\`) 기반 1,030건 입력 완료.
- **용량 정제**: 괄호 제거 및 4자리 GB 단위를 TB로 자동 변환 (예: 1863GB -> 1.86TB).
- **구매일 보강**: 연도 데이터에 월/일 추가 (\`YYYY-12-01\` 형식으로 통일).
- **자산번호 재매핑**: \`PC-YYYY12-NNNN\` 형식으로 전수 재부여 및 기존 번호와의 연속성 유지.
## 3. 부서 및 자산 유형 정상화
- **부서명 통합**: '총괄기획실', '기술개발센터', '한맥', '장헌', 'PTC', '현타' 등을 제외한 1,045건의 부서명을 **'삼안'**으로 일괄 통합.
- **자산 유형 교정 (핵심)**:
- 엑셀의 오기입과 상관없이 **사번(emp_no) 존재 여부**를 기준으로 자산 유형을 재분류.
- 사번이 있는 991건 -> **개인PC**로 정상화.
- 사번이 없는 39건 -> **공용PC**로 지정 및 사용자명 '공용'으로 정리.
## 4. 운영 규칙 업데이트
- **README.md 수정**: 'DB 삭제 및 초기화 절대 엄금 (Rule 5)' 항목 추가.
---
**보고자**: Gemini CLI
**상태**: 소스 코드 수정 없음, 데이터베이스 정제 완료.

Binary file not shown.

View File

@@ -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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
label/LabelPrinter.exe Normal file

Binary file not shown.

BIN
label/Newtonsoft.Json.dll Normal file

Binary file not shown.

BIN
label/WebQuery.dll Normal file

Binary file not shown.

4
label/config.ini Normal file
View File

@@ -0,0 +1,4 @@
[PRINT]
FONT=8
LEFT=143
TOP=40

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
label/tmp/file_1.txt Normal file
View File

@@ -0,0 +1,7 @@
자산번호 : 210312
자산명 : 가을-PC(i5-12400F)
공급사 : (주)가을디에스
자산위치 : 지반부
관리부서 : 전산
사용자 : 박노석
취득일자 : 2024-08-05

Binary file not shown.

View File

@@ -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
}
]
}

View File

@@ -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
View 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
View File

@@ -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"
}
}
}
}

View File

@@ -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

File diff suppressed because one or more lines are too long

189
scratch/db_migrate.cjs Normal file
View 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);
});

231
scratch/test_audit.cjs Normal file
View 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));

348
server.js
View File

@@ -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)`);
});

View File

@@ -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="닫기">&times;</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('파일 업로드 중 오류가 발생했습니다.'); }
@@ -385,7 +402,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);
@@ -621,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 = '';
}
@@ -642,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();

View File

@@ -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
View 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);
}
}

View File

@@ -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
View 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);
}
}
});

View 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();
}

View File

@@ -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); }
};

View File

@@ -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}`;
}

View File

@@ -1,12 +1,15 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import { resolve } from 'path';
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
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: proxyTarget,
@@ -23,6 +26,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
map_editor: resolve(__dirname, 'map_editor.html'),
mobile: resolve(__dirname, 'mobile.html'),
}
}
}

Binary file not shown.

Binary file not shown.