feat: implement web-based QR label print and audit endpoints with TDD tests
This commit is contained in:
2
.env
2
.env
@@ -3,4 +3,4 @@ DB_PORT=3306
|
|||||||
DB_USER=itam_admin
|
DB_USER=itam_admin
|
||||||
DB_PASS=itam1234
|
DB_PASS=itam1234
|
||||||
DB_NAME=itam
|
DB_NAME=itam
|
||||||
PORT=3000
|
PORT=3001
|
||||||
@@ -557,7 +557,7 @@
|
|||||||
"y": "3.30",
|
"y": "3.30",
|
||||||
"w": "8.58",
|
"w": "8.58",
|
||||||
"h": "11.45",
|
"h": "11.45",
|
||||||
"asset_id": "server_1779761946023_41"
|
"asset_id": "test_asset_uuid_123456"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": "79.05",
|
"x": "79.05",
|
||||||
|
|||||||
@@ -30,8 +30,9 @@
|
|||||||
|
|
||||||
<div class="box-list" id="box-list"></div>
|
<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-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>
|
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
||||||
<div id="save-status"></div>
|
<div id="save-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
291
package-lock.json
generated
291
package-lock.json
generated
@@ -14,9 +14,11 @@
|
|||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide": "^0.364.0",
|
"lucide": "^0.364.0",
|
||||||
"mysql2": "^3.22.1",
|
"mysql2": "^3.22.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
@@ -774,11 +776,19 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -801,6 +811,28 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/aws-ssl-profiles": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
"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"
|
"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": {
|
"node_modules/cfb": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
@@ -885,6 +925,16 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/codepage": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
@@ -894,6 +944,22 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"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": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
@@ -998,6 +1072,11 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.4.2",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
@@ -1030,6 +1109,11 @@
|
|||||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -1187,6 +1271,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/forwarded": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||||
@@ -1247,6 +1343,14 @@
|
|||||||
"is-property": "^1.0.2"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -1371,6 +1475,14 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -1383,6 +1495,17 @@
|
|||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
@@ -1575,6 +1698,39 @@
|
|||||||
"wrappy": "1"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1584,6 +1740,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "8.4.2",
|
"version": "8.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||||
@@ -1601,6 +1765,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.9",
|
"version": "8.5.9",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
|
||||||
@@ -1643,6 +1815,22 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.1",
|
"version": "6.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||||
@@ -1682,6 +1870,19 @@
|
|||||||
"node": ">= 0.10"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -1794,6 +1995,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -1918,6 +2124,30 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -1959,8 +2189,7 @@
|
|||||||
"version": "7.19.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"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": {
|
"node_modules/wmf": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
@@ -2058,6 +2292,19 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
@@ -2084,6 +2331,44 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8"
|
"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"
|
"db-init": "node db_init.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
},
|
},
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide": "^0.364.0",
|
"lucide": "^0.364.0",
|
||||||
"mysql2": "^3.22.1",
|
"mysql2": "^3.22.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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_unicode_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_unicode_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_unicode_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_unicode_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);
|
||||||
|
});
|
||||||
157
scratch/test_audit.cjs
Normal file
157
scratch/test_audit.cjs
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
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!');
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
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));
|
||||||
212
server.js
212
server.js
@@ -712,6 +712,214 @@ app.post('/api/maps/save', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 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)
|
// 7. File Upload API (Base64)
|
||||||
app.post('/api/upload', (req, res) => {
|
app.post('/api/upload', (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -736,6 +944,6 @@ app.post('/api/upload', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(3000, '0.0.0.0', () => {
|
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
|
||||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './ModalUtils';
|
} from './ModalUtils';
|
||||||
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
|
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 { BaseModal } from './BaseModal';
|
||||||
|
import { QRPrinter } from '../../core/qr_print';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 하드웨어 자산 상세 모달 (Styled Main Edition)
|
* 하드웨어 자산 상세 모달 (Styled Main Edition)
|
||||||
@@ -239,6 +240,7 @@ class HwAssetModal extends BaseModal {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
|
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||||
|
<button id="btn-print-hw-qr" class="btn btn-outline btn-primary hidden">QR 인쇄</button>
|
||||||
<div class="footer-actions">
|
<div class="footer-actions">
|
||||||
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
|
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||||
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
|
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
|
||||||
@@ -264,6 +266,21 @@ class HwAssetModal extends BaseModal {
|
|||||||
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||||
|
|
||||||
this.fetchMapConfig();
|
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.fetchMasterComponents().then(() => {
|
||||||
this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU');
|
this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU');
|
||||||
this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM');
|
this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM');
|
||||||
@@ -642,6 +659,13 @@ class HwAssetModal extends BaseModal {
|
|||||||
protected onAfterOpen(asset: any, mode: string): void {
|
protected onAfterOpen(asset: any, mode: string): void {
|
||||||
const genBtn = document.getElementById('btn-gen-hw-code');
|
const genBtn = document.getElementById('btn-gen-hw-code');
|
||||||
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
|
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.toggleFileUploadUI(mode !== 'view');
|
||||||
this.toggleEditOnlyBtns(mode !== 'view');
|
this.toggleEditOnlyBtns(mode !== 'view');
|
||||||
this.updateMapButtonVisibility();
|
this.updateMapButtonVisibility();
|
||||||
|
|||||||
246
src/core/qr_print.ts
Normal file
246
src/core/qr_print.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
await QRCode.toCanvas(canvas, qrUrl, {
|
||||||
|
margin: 0,
|
||||||
|
width: 100,
|
||||||
|
errorCorrectionLevel: 'H'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 약간의 딜레이를 주어 QR 코드가 완전히 렌더링되도록 함
|
||||||
|
setTimeout(() => {
|
||||||
|
window.print();
|
||||||
|
// 인쇄 완료 후 컨테이너 정리
|
||||||
|
window.onafterprint = () => {
|
||||||
|
container.remove();
|
||||||
|
};
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||||
|
import { QRPrinter } from '../core/qr_print';
|
||||||
|
|
||||||
export class MapEditor {
|
export class MapEditor {
|
||||||
private container: HTMLElement;
|
private container: HTMLElement;
|
||||||
@@ -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());
|
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +273,10 @@ export class MapEditor {
|
|||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="box-header">
|
<div class="box-header">
|
||||||
<span class="box-index">#${i+1}</span>
|
<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>
|
||||||
<div class="box-inputs margin-bottom">
|
<div class="box-inputs margin-bottom">
|
||||||
<select data-index="${i}" data-prop="asset_id">
|
<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}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user