reportMng #298

Merged
hyeonsu merged 16 commits from reportMng into main 2025-12-18 13:28:17 +09:00
19 changed files with 3937 additions and 493 deletions

View File

@ -14,10 +14,12 @@
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
@ -2256,6 +2258,93 @@
"node": ">= 8"
}
},
"node_modules/@oozcitak/dom": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.6.tgz",
"integrity": "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.3.4"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.5.tgz",
"integrity": "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.0.tgz",
"integrity": "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.3",
"@oozcitak/util": "1.0.2"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.3.tgz",
"integrity": "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag==",
"license": "MIT",
"dependencies": {
"@oozcitak/util": "1.0.1"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/infra/node_modules/@oozcitak/util": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.1.tgz",
"integrity": "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url/node_modules/@oozcitak/util": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-1.0.2.tgz",
"integrity": "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.4.tgz",
"integrity": "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@paralleldrive/cuid2": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
@ -4326,6 +4415,12 @@
"node": ">=8"
}
},
"node_modules/browser-split": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
@ -4521,6 +4616,15 @@
"node": ">=6"
}
},
"node_modules/camelize": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001745",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
@ -5202,6 +5306,56 @@
"node": ">=6.0.0"
}
},
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/@types/node": {
"version": "24.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.4.tgz",
"integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@ -5216,6 +5370,11 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-walk": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@ -5349,6 +5508,27 @@
"node": ">=8.10.0"
}
},
"node_modules/ent": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
"integrity": "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.3",
"es-errors": "^1.3.0",
"punycode": "^1.4.1",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ent/node_modules/punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@ -5361,6 +5541,16 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
"dependencies": {
"camelize": "^1.0.0",
"string-template": "~0.2.0",
"xtend": "~4.0.0"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
@ -5643,6 +5833,14 @@
"node": ">= 0.6"
}
},
"node_modules/ev-store": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
"dependencies": {
"individual": "^3.0.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -6279,6 +6477,16 @@
"node": "*"
}
},
"node_modules/global": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
"license": "MIT",
"dependencies": {
"min-document": "^2.19.0",
"process": "^0.11.10"
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@ -6413,6 +6621,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6443,6 +6661,22 @@
"node": ">=16.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@ -6450,6 +6684,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-to-docx": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/html-to-docx/-/html-to-docx-1.8.0.tgz",
"integrity": "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.6",
"@oozcitak/util": "8.3.4",
"color-name": "^1.1.4",
"html-entities": "^2.3.3",
"html-to-vdom": "^0.7.0",
"image-size": "^1.0.0",
"image-to-base64": "^2.2.0",
"jszip": "^3.7.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "^3.1.25",
"virtual-dom": "^2.1.1",
"xmlbuilder2": "2.1.2"
}
},
"node_modules/html-to-text": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
@ -6466,6 +6721,106 @@
"node": ">=14"
}
},
"node_modules/html-to-vdom": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/html-to-vdom/-/html-to-vdom-0.7.0.tgz",
"integrity": "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ==",
"license": "ISC",
"dependencies": {
"ent": "^2.0.0",
"htmlparser2": "^3.8.2"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
"integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.0.1",
"entities": "^2.0.0"
}
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/dom-serializer/node_modules/entities": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
"license": "BSD-2-Clause",
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-vdom/node_modules/domelementtype": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/domhandler": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/domutils": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "0",
"domelementtype": "1"
}
},
"node_modules/html-to-vdom/node_modules/entities": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
"license": "BSD-2-Clause"
},
"node_modules/html-to-vdom/node_modules/htmlparser2": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
"license": "MIT",
"dependencies": {
"domelementtype": "^1.3.1",
"domhandler": "^2.3.0",
"domutils": "^1.5.1",
"entities": "^1.1.1",
"inherits": "^2.0.1",
"readable-stream": "^3.1.1"
}
},
"node_modules/html-to-vdom/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
@ -6590,6 +6945,30 @@
"dev": true,
"license": "ISC"
},
"node_modules/image-size": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
"license": "MIT",
"dependencies": {
"queue": "6.0.2"
},
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=16.x"
}
},
"node_modules/image-to-base64": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/image-to-base64/-/image-to-base64-2.2.0.tgz",
"integrity": "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.0"
}
},
"node_modules/imap": {
"version": "0.8.19",
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
@ -6626,6 +7005,12 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -6673,6 +7058,11 @@
"node": ">=0.8.19"
}
},
"node_modules/individual": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -6854,6 +7244,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-object": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-path-inside": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
@ -7696,6 +8095,18 @@
"npm": ">=6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
@ -7812,6 +8223,15 @@
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -8177,6 +8597,21 @@
"node": ">=6"
}
},
"node_modules/min-document": {
"version": "2.19.2",
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
"license": "MIT",
"dependencies": {
"dom-walk": "^0.1.0"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
@ -8300,6 +8735,24 @@
"node": ">=12"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/native-duplexpair": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/native-duplexpair/-/native-duplexpair-1.0.0.tgz",
@ -8329,6 +8782,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/next-tick": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
"license": "MIT"
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@ -8670,6 +9129,12 @@
"node": ">=6"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
@ -9179,6 +9644,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
"license": "MIT",
"dependencies": {
"inherits": "~2.0.3"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -9595,6 +10069,23 @@
],
"license": "MIT"
},
"node_modules/safe-regex-test": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"is-regex": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
@ -9610,6 +10101,12 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@ -9744,6 +10241,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@ -10020,6 +10523,11 @@
"node": ">=10"
}
},
"node_modules/string-template": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@ -10685,6 +11193,22 @@
"node": ">= 0.8"
}
},
"node_modules/virtual-dom": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
"license": "MIT",
"dependencies": {
"browser-split": "0.0.1",
"error": "^4.3.0",
"ev-store": "^7.0.0",
"global": "^4.3.0",
"is-object": "^1.0.1",
"next-tick": "^0.2.2",
"x-is-array": "0.1.0",
"x-is-string": "0.1.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -10862,6 +11386,80 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/x-is-array": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
},
"node_modules/x-is-string": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder2": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-2.1.2.tgz",
"integrity": "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg==",
"license": "MIT",
"dependencies": {
"@oozcitak/dom": "1.15.5",
"@oozcitak/infra": "1.0.5",
"@oozcitak/util": "8.3.3"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.5.tgz",
"integrity": "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A==",
"license": "MIT",
"dependencies": {
"@oozcitak/infra": "1.0.5",
"@oozcitak/url": "1.0.0",
"@oozcitak/util": "8.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/dom/node_modules/@oozcitak/util": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.0.0.tgz",
"integrity": "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xmlbuilder2/node_modules/@oozcitak/util": {
"version": "8.3.3",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.3.tgz",
"integrity": "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w==",
"license": "MIT",
"engines": {
"node": ">=6.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@ -28,10 +28,12 @@
"bcryptjs": "^2.4.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
"docx": "^9.5.1",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"html-to-docx": "^1.8.0",
"iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,11 @@ router.post("/upload-image", upload.single("image"), (req, res, next) =>
reportController.uploadImage(req, res, next)
);
// WORD(DOCX) 내보내기
router.post("/export-word", (req, res, next) =>
reportController.exportToWord(req, res, next)
);
// 리포트 목록
router.get("/", (req, res, next) =>
reportController.getReports(req, res, next)

View File

@ -477,6 +477,12 @@ export class ReportService {
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`;
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
const componentsData =
typeof originalLayout.components === "string"
? originalLayout.components
: JSON.stringify(originalLayout.components);
await client.query(copyLayoutQuery, [
newLayoutId,
newReportId,
@ -487,7 +493,7 @@ export class ReportService {
originalLayout.margin_bottom,
originalLayout.margin_left,
originalLayout.margin_right,
JSON.stringify(originalLayout.components),
componentsData,
userId,
]);
}
@ -561,7 +567,7 @@ export class ReportService {
}
/**
* ( )
* ( ) -
*/
async saveLayout(
reportId: string,
@ -569,6 +575,19 @@ export class ReportService {
userId: string
): Promise<boolean> {
return transaction(async (client) => {
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
const firstPage = data.layoutConfig.pages[0];
const canvasWidth = firstPage?.width || 210;
const canvasHeight = firstPage?.height || 297;
const pageOrientation =
canvasWidth > canvasHeight ? "landscape" : "portrait";
const margins = firstPage?.margins || {
top: 20,
bottom: 20,
left: 20,
right: 20,
};
// 1. 레이아웃 저장
const existingQuery = `
SELECT layout_id FROM report_layout WHERE report_id = $1
@ -576,7 +595,7 @@ export class ReportService {
const existing = await client.query(existingQuery, [reportId]);
if (existing.rows.length > 0) {
// 업데이트
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
const updateQuery = `
UPDATE report_layout
SET
@ -594,14 +613,14 @@ export class ReportService {
`;
await client.query(updateQuery, [
data.canvasWidth,
data.canvasHeight,
data.pageOrientation,
data.marginTop,
data.marginBottom,
data.marginLeft,
data.marginRight,
JSON.stringify(data.components),
canvasWidth,
canvasHeight,
pageOrientation,
margins.top,
margins.bottom,
margins.left,
margins.right,
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId,
reportId,
]);
@ -627,14 +646,14 @@ export class ReportService {
await client.query(insertQuery, [
layoutId,
reportId,
data.canvasWidth,
data.canvasHeight,
data.pageOrientation,
data.marginTop,
data.marginBottom,
data.marginLeft,
data.marginRight,
JSON.stringify(data.components),
canvasWidth,
canvasHeight,
pageOrientation,
margins.top,
margins.bottom,
margins.left,
margins.right,
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장
userId,
]);
}

View File

@ -116,22 +116,38 @@ export interface UpdateReportRequest {
useYn?: string;
}
// 페이지 설정
export interface PageConfig {
page_id: string;
page_name: string;
page_order: number;
width: number;
height: number;
background_color: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: any[];
}
// 레이아웃 설정
export interface ReportLayoutConfig {
pages: PageConfig[];
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: any[];
layoutConfig: ReportLayoutConfig;
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number;
}>;
}

View File

@ -19,6 +19,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import dynamic from "next/dynamic";
import { useToast } from "@/hooks/use-toast";
import type { PlacedObject, ToolType, Warehouse, Area, Location, ObjectType } from "@/types/digitalTwin";
@ -2141,45 +2142,39 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div>
) : (
<Accordion type="single" collapsible className="w-full">
{materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
<div className="max-h-[400px] overflow-y-auto rounded-md border">
<Table>
<TableHeader className="bg-muted sticky top-0">
<TableRow>
<TableHead className="w-[60px] text-xs"></TableHead>
{(hierarchyConfig?.material?.displayColumns || []).map((col) => (
<TableHead key={col.column} className="text-xs">
{col.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY";
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
const layerNumber = material[layerColumn] || index + 1;
const layerValue = material[layerColumn] || index + 1;
const keyValue = material[keyColumn] || `자재 ${index + 1}`;
return (
<AccordionItem key={`${keyValue}-${index}`} value={`item-${index}`} className="border-b">
<AccordionTrigger className="px-2 py-3 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<span className="text-sm font-medium"> {layerValue}</span>
<span className="text-muted-foreground max-w-[150px] truncate text-xs">{keyValue}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-2 pb-3">
{displayColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
) : (
<div className="space-y-1.5">
{displayColumns.map((item) => (
<div key={item.column} className="flex justify-between gap-2 text-xs">
<span className="text-muted-foreground shrink-0">{item.label}:</span>
<span className="text-right font-medium break-all">
{material[item.column] || "-"}
</span>
</div>
))}
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
return (
<TableRow key={material[keyColumn] || `material-${index}`}>
<TableCell className="text-xs font-medium">{layerNumber}</TableCell>
{displayColumns.map((col) => (
<TableCell key={col.column} className="text-xs">
{material[col.column] || "-"}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
) : selectedObject ? (

View File

@ -624,7 +624,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)}
</div>
{/* 자재 목록 (Location인 경우) - 아코디언 */}
{/* 자재 목록 (Location인 경우) - 테이블 형태 */}
{(selectedObject.type === "location-bed" ||
selectedObject.type === "location-stp" ||
selectedObject.type === "location-temp" ||
@ -640,47 +640,48 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
</div>
) : (
<div className="space-y-2">
<Label className="mb-2 block text-sm font-semibold"> ({materials.length})</Label>
{materials.map((material, index) => {
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return (
<details
key={`${material.STKKEY}-${index}`}
className="bg-muted group hover:bg-accent rounded-lg border transition-colors"
>
<summary className="flex cursor-pointer items-center justify-between p-3 text-sm font-medium">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">
{material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]}
</span>
{displayColumns[0] && (
<span className="text-muted-foreground text-xs">
{material[displayColumns[0].column]}
</span>
)}
</div>
</div>
<svg
className="text-muted-foreground h-4 w-4 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="space-y-2 border-t p-3 pt-3">
{displayColumns.map((colConfig: any) => (
<div key={colConfig.column} className="flex justify-between text-xs">
<span className="text-muted-foreground">{colConfig.label}:</span>
<span className="font-medium">{material[colConfig.column] || "-"}</span>
</div>
<Label className="mb-2 block text-sm font-semibold">
({materials.length})
</Label>
{/* 테이블 형태로 전체 조회 */}
<div className="max-h-[400px] overflow-auto rounded-lg border">
<table className="w-full text-xs">
<thead className="bg-muted sticky top-0">
<tr>
<th className="border-b px-2 py-2 text-left font-semibold"></th>
{(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => (
<th
key={colConfig.column}
className="border-b px-2 py-2 text-left font-semibold"
>
{colConfig.label}
</th>
))}
</div>
</details>
);
})}
</tr>
</thead>
<tbody>
{materials.map((material, index) => {
const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER";
const displayColumns = hierarchyConfig?.material?.displayColumns || [];
return (
<tr
key={`${material.STKKEY}-${index}`}
className="hover:bg-accent border-b transition-colors last:border-0"
>
<td className="px-2 py-2 font-medium">
{material[layerColumn]}
</td>
{displayColumns.map((colConfig: any) => (
<td key={colConfig.column} className="px-2 py-2">
{material[colConfig.column] || "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>

View File

@ -14,7 +14,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
import { Copy, Trash2, Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
@ -149,7 +149,11 @@ export function ReportListTable({
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<TableRow key={report.report_id}>
<TableRow
key={report.report_id}
onClick={() => handleEdit(report.report_id)}
className="cursor-pointer hover:bg-muted/50"
>
<TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell>
<div>
@ -162,34 +166,25 @@ export function ReportListTable({
<TableCell>{report.created_by || "-"}</TableCell>
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
<TableCell>
<div className="flex gap-2">
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(report.report_id)}
className="gap-1"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
size="icon"
variant="outline"
onClick={() => handleCopy(report.report_id)}
disabled={isCopying}
className="gap-1"
className="h-8 w-8"
title="복사"
>
<Copy className="h-3 w-3" />
<Copy className="h-4 w-4" />
</Button>
<Button
size="sm"
size="icon"
variant="destructive"
onClick={() => handleDeleteClick(report.report_id)}
className="gap-1"
className="h-8 w-8"
title="삭제"
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>

View File

@ -23,6 +23,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasWidth,
canvasHeight,
margins,
layoutConfig,
currentPageId,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@ -270,6 +272,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
className="w-full"
>
@ -291,6 +294,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
@ -561,6 +565,245 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
</div>
);
case "pageNumber":
// 페이지 번호 포맷
const format = component.pageNumberFormat || "number";
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
const totalPages = sortedPages.length;
const currentPageNum = currentPageIndex + 1;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${currentPageNum}`;
break;
case "numberTotal":
pageNumberText = `${currentPageNum} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${currentPageNum} 페이지`;
break;
default:
pageNumberText = `${currentPageNum}`;
}
return (
<div
className="flex h-full w-full items-center justify-center"
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{pageNumberText}
</div>
);
case "card":
// 카드 컴포넌트: 제목 + 항목 목록
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
// 쿼리 바인딩된 값 가져오기
const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
}
return item.value;
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 제목 */}
{showCardTitle && (
<>
<div
className="flex-shrink-0 px-2 py-1 font-semibold"
style={{
fontSize: `${titleFontSize}px`,
color: titleColor,
}}
>
{cardTitle}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-b"
style={{ borderColor: component.borderColor || "#e5e7eb" }}
/>
</>
)}
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="flex py-0.5">
<span
className="flex-shrink-0 font-medium"
style={{
width: `${labelWidth}px`,
fontSize: `${labelFontSize}px`,
color: labelColor,
}}
>
{item.label}
</span>
<span
className="flex-1"
style={{
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardItemValue(item)}
</span>
</div>
))}
</div>
</div>
);
case "calculation":
// 계산 컴포넌트
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
if (item.fieldName && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
const calculateResult = (): number => {
if (calcItems.length === 0) return 0;
// 첫 번째 항목은 기준값
let result = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
switch (item.operator) {
case "+":
result += val;
break;
case "-":
result -= val;
break;
case "x":
result *= val;
break;
case "÷":
result = val !== 0 ? result / val : result;
break;
}
}
return result;
};
const calcResult = calculateResult();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
{/* 항목 목록 */}
<div className="flex-1 overflow-auto px-2 py-1">
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={index} className="flex items-center justify-between py-1">
<span
className="flex-shrink-0"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
className="text-right"
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
{/* 구분선 */}
<div
className="mx-1 flex-shrink-0 border-t"
style={{ borderColor: component.borderColor || "#374151" }}
/>
{/* 결과 */}
<div className="flex items-center justify-between px-2 py-2">
<span
className="font-semibold"
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
className="text-right font-bold"
style={{
fontSize: `${calcResultFontSize}px`,
color: calcResultColor,
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
default:
return <div> </div>;
}

View File

@ -1,7 +1,7 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react";
interface ComponentItem {
type: string;
@ -12,11 +12,13 @@ interface ComponentItem {
const COMPONENTS: ComponentItem[] = [
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@ -76,25 +76,25 @@ export function PageListPanel() {
};
return (
<div className="bg-background flex h-full w-64 flex-col border-r">
<div className="bg-background flex h-full w-32 flex-col border-r">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" variant="ghost" onClick={() => addPage()}>
<Plus className="h-4 w-4" />
<div className="flex items-center justify-between border-b px-2 py-1.5">
<h3 className="text-[10px] font-semibold"></h3>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* 페이지 목록 */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-2">
<div className="space-y-2">
<ScrollArea className="h-full p-1">
<div className="space-y-1">
{layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page, index) => (
<div
key={page.page_id}
className={`group relative cursor-pointer rounded-md border p-2 transition-all ${
className={`group relative cursor-pointer rounded border p-1.5 transition-all ${
page.page_id === currentPageId
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-accent/50"
@ -103,7 +103,7 @@ export function PageListPanel() {
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{/* 드래그 핸들 */}
<div
draggable
@ -115,13 +115,13 @@ export function PageListPanel() {
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3 w-3" />
<GripVertical className="h-2.5 w-2.5" />
</div>
{/* 페이지 정보 */}
<div className="min-w-0 flex-1">
{editingPageId === page.page_id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
@ -129,21 +129,21 @@ export function PageListPanel() {
if (e.key === "Enter") handleSaveEdit();
if (e.key === "Escape") handleCancelEdit();
}}
className="h-6 text-xs"
className="h-5 text-[10px]"
autoFocus
/>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}>
<Check className="h-3 w-3" />
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
<Check className="h-2.5 w-2.5" />
</Button>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}>
<X className="h-3 w-3" />
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
<X className="h-2.5 w-2.5" />
</Button>
</div>
) : (
<div className="truncate text-xs font-medium">{page.page_name}</div>
<div className="truncate text-[10px] font-medium">{page.page_name}</div>
)}
<div className="text-muted-foreground text-[10px]">
{page.width}x{page.height}mm {page.components.length}
<div className="text-muted-foreground text-[8px]">
{page.width}x{page.height}mm
</div>
</div>
@ -153,10 +153,10 @@ export function PageListPanel() {
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
className="h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<span className="sr-only"></span>
<span className="text-sm leading-none"></span>
<span className="text-[10px] leading-none"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -199,9 +199,9 @@ export function PageListPanel() {
</div>
{/* 푸터 */}
<div className="border-t p-2">
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
<Plus className="mr-2 h-4 w-4" />
<div className="border-t p-1">
<Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>

View File

@ -201,7 +201,8 @@ export function QueryManager() {
setIsTestRunning({ ...isTestRunning, [query.id]: true });
try {
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
// 항상 sqlQuery를 전달 (새 쿼리가 아직 DB에 저장되지 않았을 수 있음)
const sqlQuery = query.sqlQuery;
const externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {};
@ -264,24 +265,24 @@ export function QueryManager() {
return (
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<div className="flex items-center gap-1">
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{query.name}</span>
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
{query.type}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</AccordionTrigger>
</AccordionTrigger>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 shrink-0 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
{/* 쿼리 이름 */}
<div className="space-y-2">

View File

@ -65,6 +65,9 @@ export function ReportDesignerCanvas() {
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
} else if (item.componentType === "pageNumber") {
width = 100;
height = 30;
}
// 여백을 px로 변환 (1mm ≈ 3.7795px)
@ -143,6 +146,55 @@ export function ReportDesignerCanvas() {
borderWidth: 0,
borderColor: "#cccccc",
}),
// 페이지 번호 전용
...(item.componentType === "pageNumber" && {
pageNumberFormat: "number" as const, // number, numberTotal, koreanNumber
textAlign: "center" as const,
}),
// 카드 컴포넌트 전용
...(item.componentType === "card" && {
width: 300,
height: 180,
cardTitle: "정보 카드",
showCardTitle: true,
cardItems: [
{ label: "항목1", value: "내용1", fieldName: "" },
{ label: "항목2", value: "내용2", fieldName: "" },
{ label: "항목3", value: "내용3", fieldName: "" },
],
labelWidth: 80,
showCardBorder: true,
titleFontSize: 14,
labelFontSize: 13,
valueFontSize: 13,
titleColor: "#1e40af",
labelColor: "#374151",
valueColor: "#000000",
borderWidth: 1,
borderColor: "#e5e7eb",
}),
// 계산 컴포넌트 전용
...(item.componentType === "calculation" && {
width: 350,
height: 120,
calcItems: [
{ label: "공급가액", value: 0, operator: "+" as const, fieldName: "" },
{ label: "부가세 (10%)", value: 0, operator: "+" as const, fieldName: "" },
],
resultLabel: "합계 금액",
labelWidth: 120,
labelFontSize: 13,
valueFontSize: 13,
resultFontSize: 16,
labelColor: "#374151",
valueColor: "#000000",
resultColor: "#2563eb",
showCalcBorder: false,
numberFormat: "currency" as const,
currencySuffix: "원",
borderWidth: 0,
borderColor: "#e5e7eb",
}),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
@ -297,13 +349,8 @@ export function ReportDesignerCanvas() {
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
</div>
{/* 캔버스 스크롤 영역 */}
<div className="flex flex-1 items-center justify-center overflow-auto p-8">
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */}

View File

@ -5,6 +5,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() {
currentPage,
currentPageId,
updatePageSettings,
getQueryResult,
} = context;
const [activeTab, setActiveTab] = useState<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false);
@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() {
</Card>
)}
{/* 페이지 번호 설정 */}
{selectedComponent.type === "pageNumber" && (
<Card className="mt-4 border-purple-200 bg-purple-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-purple-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.pageNumberFormat || "number"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"> (1, 2, 3...)</SelectItem>
<SelectItem value="numberTotal">/ (1 / 3)</SelectItem>
<SelectItem value="koreanNumber"> (1 )</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)}
{/* 카드 컴포넌트 설정 */}
{selectedComponent.type === "card" && (
<Card className="mt-4 border-teal-200 bg-teal-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-teal-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 제목 표시 여부 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardTitle"
checked={selectedComponent.showCardTitle !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardTitle: e.target.checked,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardTitle" className="text-xs">
</Label>
</div>
{/* 제목 텍스트 */}
{selectedComponent.showCardTitle !== false && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.cardTitle || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
cardTitle: e.target.value,
})
}
placeholder="정보 카드"
className="h-8"
/>
</div>
)}
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 80}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={40}
max={200}
className="h-8"
/>
</div>
{/* 테두리 표시 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="showCardBorder"
checked={selectedComponent.showCardBorder !== false}
onChange={(e) =>
updateComponent(selectedComponent.id, {
showCardBorder: e.target.checked,
borderWidth: e.target.checked ? 1 : 0,
})
}
className="h-4 w-4"
/>
<Label htmlFor="showCardBorder" className="text-xs">
</Label>
</div>
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.titleFontSize || 14}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleFontSize: Number(e.target.value),
})
}
min={10}
max={24}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.titleColor || "#1e40af"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
titleColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.cardItems || [];
updateComponent(selectedComponent.id, {
cardItems: [
...currentItems,
{ label: `항목${currentItems.length + 1}`, value: "", fieldName: "" },
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.cardItems || []).map(
(item: { label: string; value: string; fieldName?: string }, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className="grid grid-cols-2 gap-1">
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, label: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = {
...item,
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.value}
onChange={(e) => {
const currentItems = [...(selectedComponent.cardItems || [])];
currentItems[index] = { ...item, value: e.target.value };
updateComponent(selectedComponent.id, {
cardItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="내용"
/>
</div>
)}
</div>
</div>
),
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* 계산 컴포넌트 설정 */}
{selectedComponent.type === "calculation" && (
<Card className="mt-4 border-orange-200 bg-orange-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-orange-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 결과 라벨 */}
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.resultLabel || "합계"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultLabel: e.target.value,
})
}
placeholder="합계 금액"
className="h-8"
/>
</div>
{/* 라벨 너비 */}
<div>
<Label className="text-xs"> (px)</Label>
<Input
type="number"
value={selectedComponent.labelWidth || 120}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelWidth: Number(e.target.value),
})
}
min={60}
max={200}
className="h-8"
/>
</div>
{/* 숫자 포맷 */}
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.numberFormat || "currency"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
numberFormat: value as "none" | "comma" | "currency",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="comma"> </SelectItem>
<SelectItem value="currency"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* 통화 접미사 */}
{selectedComponent.numberFormat === "currency" && (
<div>
<Label className="text-xs"> </Label>
<Input
type="text"
value={selectedComponent.currencySuffix || "원"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
currencySuffix: e.target.value,
})
}
placeholder="원"
className="h-8"
/>
</div>
)}
{/* 폰트 크기 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.labelFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.valueFontSize || 13}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueFontSize: Number(e.target.value),
})
}
min={10}
max={20}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
value={selectedComponent.resultFontSize || 16}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultFontSize: Number(e.target.value),
})
}
min={12}
max={24}
className="h-8"
/>
</div>
</div>
{/* 색상 설정 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.labelColor || "#374151"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
labelColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.valueColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
valueColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="color"
value={selectedComponent.resultColor || "#2563eb"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
resultColor: e.target.value,
})
}
className="h-8 w-full cursor-pointer p-1"
/>
</div>
</div>
{/* 계산 항목 목록 관리 */}
<div className="mt-4 border-t pt-3">
<div className="mb-2 flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
onClick={() => {
const currentItems = selectedComponent.calcItems || [];
updateComponent(selectedComponent.id, {
calcItems: [
...currentItems,
{
label: `항목${currentItems.length + 1}`,
value: 0,
operator: "+" as const,
fieldName: "",
},
],
});
}}
>
+
</Button>
</div>
{/* 쿼리 선택 (데이터 바인딩용) */}
<div className="mb-2">
<Label className="text-xs"> ()</Label>
<Select
value={selectedComponent.queryId || "none"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
queryId: value === "none" ? undefined : value,
})
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="쿼리 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.name} ({q.type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 항목 리스트 */}
<div className="max-h-48 space-y-2 overflow-y-auto">
{(selectedComponent.calcItems || []).map((item, index: number) => (
<div key={index} className="rounded border bg-white p-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium"> {index + 1}</span>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
onClick={() => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems.splice(index, 1);
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
x
</Button>
</div>
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
<div className={index === 0 ? "" : "col-span-2"}>
<Label className="text-[10px]"></Label>
<Input
type="text"
value={item.label}
onChange={(e) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = { ...currentItems[index], label: e.target.value };
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="항목명"
/>
</div>
{/* 두 번째 항목부터 연산자 표시 */}
{index > 0 && (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.operator}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
operator: value as "+" | "-" | "x" | "÷",
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="+">+</SelectItem>
<SelectItem value="-">-</SelectItem>
<SelectItem value="x">x</SelectItem>
<SelectItem value="÷">÷</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<div className="mt-1">
{selectedComponent.queryId ? (
<div>
<Label className="text-[10px]"></Label>
<Select
value={item.fieldName || "none"}
onValueChange={(value) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
fieldName: value === "none" ? "" : value,
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
>
<SelectTrigger className="h-6 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{(() => {
const query = queries.find((q) => q.id === selectedComponent.queryId);
const result = query ? getQueryResult(query.id) : null;
if (result && result.fields) {
return result.fields.map((field: string) => (
<SelectItem key={field} value={field}>
{field}
</SelectItem>
));
}
return null;
})()}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label className="text-[10px]"></Label>
<Input
type="number"
value={item.value}
onChange={(e) => {
const currentItems = [...(selectedComponent.calcItems || [])];
currentItems[index] = {
...currentItems[index],
value: Number(e.target.value),
};
updateComponent(selectedComponent.id, {
calcItems: currentItems,
});
}}
className="h-6 text-xs"
placeholder="0"
/>
</div>
)}
</div>
</div>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||
@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() {
{/* 기본값 (텍스트/라벨만) */}
{(selectedComponent.type === "text" || selectedComponent.type === "label") && (
<div>
<Label className="text-xs"></Label>
<Input
<Label className="text-xs"> </Label>
<Textarea
value={selectedComponent.defaultValue || ""}
onChange={(e) =>
updateComponent(selectedComponent.id, {
defaultValue: e.target.value,
})
}
placeholder="데이터가 없을 때 표시할 값"
className="h-8"
placeholder="텍스트 내용 (엔터로 줄바꿈 가능)"
className="min-h-[80px] resize-y"
/>
</div>
)}

View File

@ -13,21 +13,6 @@ import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
// @ts-ignore - docx 라이브러리 타입 이슈
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableCell,
TableRow,
WidthType,
ImageRun,
AlignmentType,
VerticalAlign,
convertInchesToTwip,
} from "docx";
import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps {
@ -73,6 +58,8 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
pageWidth: number,
pageHeight: number,
backgroundColor: string,
pageIndex: number = 0,
totalPages: number = 1,
): string => {
const componentsHTML = pageComponents
.map((component) => {
@ -82,7 +69,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// Text/Label 컴포넌트
if (component.type === "text" || component.type === "label") {
const displayValue = getComponentValue(component);
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`;
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"}; white-space: pre-wrap;">${displayValue}</div>`;
}
// Image 컴포넌트
@ -154,6 +141,163 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>`;
}
// PageNumber 컴포넌트
else if (component.type === "pageNumber") {
const format = component.pageNumberFormat || "number";
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; font-size: ${component.fontSize}px; color: ${component.fontColor}; font-weight: ${component.fontWeight}; text-align: ${component.textAlign};">${pageNumberText}</div>`;
}
// Card 컴포넌트
else if (component.type === "card") {
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
const borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
return item.value;
};
const itemsHtml = cardItems
.map(
(item: { label: string; value: string; fieldName?: string }) => `
<div style="display: flex; padding: 2px 0;">
<span style="width: ${labelWidth}px; flex-shrink: 0; font-size: ${labelFontSize}px; color: ${labelColor}; font-weight: 500;">${item.label}</span>
<span style="flex: 1; font-size: ${valueFontSize}px; color: ${valueColor};">${getCardValue(item)}</span>
</div>
`
)
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%; overflow: hidden;">
${
showCardTitle
? `
<div style="flex-shrink: 0; padding: 4px 8px; font-size: ${titleFontSize}px; font-weight: 600; color: ${titleColor};">
${cardTitle}
</div>
<div style="flex-shrink: 0; margin: 0 4px; border-bottom: 1px solid ${borderColor};"></div>
`
: ""
}
<div style="flex: 1; padding: 4px 8px; overflow: auto;">
${itemsHtml}
</div>
</div>`;
}
// 계산 컴포넌트
else if (component.type === "calculation") {
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
const borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
if (item.fieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
const row = queryResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
let calcResult = 0;
if (calcItems.length > 0) {
// 첫 번째 항목은 기준값
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
switch ((item as { operator: string }).operator) {
case "+":
calcResult += val;
break;
case "-":
calcResult -= val;
break;
case "x":
calcResult *= val;
break;
case "÷":
calcResult = val !== 0 ? calcResult / val : calcResult;
break;
}
}
}
const itemsHtml = calcItems
.map((item: { label: string; value: number | string; operator: string; fieldName?: string }) => {
const itemValue = getCalcItemValue(item);
return `
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcLabelFontSize}px; color: ${calcLabelColor};">${item.label}</span>
<span style="font-size: ${calcValueFontSize}px; color: ${calcValueColor}; text-align: right;">${formatNumber(itemValue)}</span>
</div>
`;
})
.join("");
content = `
<div style="display: flex; flex-direction: column; height: 100%;">
<div style="flex: 1;">
${itemsHtml}
</div>
<div style="border-top: 1px solid ${borderColor}; margin: 4px 8px;"></div>
<div style="display: flex; justify-content: space-between; padding: 4px 8px;">
<span style="width: ${calcLabelWidth}px; font-size: ${calcResultFontSize}px; font-weight: 600; color: ${calcLabelColor};">${resultLabel}</span>
<span style="font-size: ${calcResultFontSize}px; font-weight: 700; color: ${calcResultColor}; text-align: right;">${formatNumber(calcResult)}</span>
</div>
</div>`;
}
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
@ -204,9 +348,20 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
// 모든 페이지 HTML 생성 (인쇄/PDF용)
const generatePrintHTML = (): string => {
const pagesHTML = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
const totalPages = sortedPages.length;
const pagesHTML = sortedPages
.map((page, pageIndex) =>
generatePageHTML(
Array.isArray(page.components) ? page.components : [],
page.width,
page.height,
page.background_color,
pageIndex,
totalPages,
),
)
.join('<div style="page-break-after: always;"></div>');
return `
@ -282,270 +437,94 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
});
};
// Base64를 Uint8Array로 변환
const base64ToUint8Array = (base64: string): Uint8Array => {
const base64Data = base64.split(",")[1] || base64;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// 이미지 URL을 Base64로 변환
const imageUrlToBase64 = async (url: string): Promise<string> => {
try {
// 이미 Base64인 경우 그대로 반환
if (url.startsWith("data:")) {
return url;
}
// 컴포넌트를 TableCell로 변환
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
const cellWidth = widthPercent || 100;
// 서버 이미지 URL을 fetch하여 Base64로 변환
const fullUrl = getFullImageUrl(url);
const response = await fetch(fullUrl);
const blob = await response.blob();
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2,
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
alignment:
component.textAlign === "center"
? AlignmentType.CENTER
: component.textAlign === "right"
? AlignmentType.RIGHT
: AlignmentType.LEFT,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} else if (component.type === "signature" || component.type === "stamp") {
if (component.imageUrl) {
try {
const imageData = base64ToUint8Array(component.imageUrl);
return new TableCell({
children: [
new Paragraph({
children: [
new ImageRun({
data: imageData,
transformation: {
width: component.width || 150,
height: component.height || 50,
},
}),
],
alignment: AlignmentType.CENTER,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} catch {
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
size: 24,
}),
],
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
return new TableCell({
children: [table],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
} catch (error) {
console.error("이미지 변환 실패:", error);
return "";
}
return null;
};
// WORD 다운로드
// WORD 다운로드 (백엔드 API 사용 - 컴포넌트 데이터 전송)
const handleDownloadWord = async () => {
setIsExporting(true);
try {
// 페이지별로 섹션 생성
const sections = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => {
// 페이지 크기 설정 (A4 기준)
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
const marginLeft = convertInchesToTwip(page.margins.left / 96);
const marginRight = convertInchesToTwip(page.margins.right / 96);
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
const sortedComponents = [...page.components].sort((a, b) => {
// Y좌표 우선, 같으면 X좌표
if (Math.abs(a.y - b.y) < 5) {
return a.x - b.x;
}
return a.y - b.y;
});
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
const rowTolerance = 20; // Y 좌표 허용 오차
for (const component of sortedComponents) {
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
if (existingRow) {
existingRow.push(component);
} else {
rows.push([component]);
}
}
// 각 행 내에서 X좌표로 정렬
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
const tableRows: TableRow[] = [];
for (const row of rows) {
if (row.length === 1) {
// 단일 컴포넌트 - 전체 너비 사용
const component = row[0];
const cell = createTableCell(component, pageWidth);
if (cell) {
tableRows.push(
new TableRow({
children: [cell],
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
}),
);
}
} else {
// 여러 컴포넌트 - 가로 배치
const cells: TableCell[] = [];
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
for (const component of row) {
const widthPercent = (component.width / totalWidth) * 100;
const cell = createTableCell(component, pageWidth, widthPercent);
if (cell) {
cells.push(cell);
}
}
if (cells.length > 0) {
const maxHeight = Math.max(...row.map((c) => c.height));
tableRows.push(
new TableRow({
children: cells,
height: { value: maxHeight * 15, rule: 1 },
}),
);
}
}
}
return {
properties: {
page: {
width: pageWidth,
height: pageHeight,
margin: {
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
},
},
},
children:
tableRows.length > 0
? [
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
},
}),
]
: [new Paragraph({ text: "" })],
};
});
// 문서 생성
const doc = new Document({
sections,
toast({
title: "처리 중",
description: "WORD 파일을 생성하고 있습니다...",
});
// Blob 생성 및 다운로드
const blob = await Packer.toBlob(doc);
const fileName = reportDetail?.report?.report_name_kor || "리포트";
const timestamp = new Date().toISOString().slice(0, 10);
// 이미지를 Base64로 변환하여 컴포넌트 데이터에 포함
const pagesWithBase64 = await Promise.all(
layoutConfig.pages.map(async (page) => {
const componentsWithBase64 = await Promise.all(
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
// 이미지가 있는 컴포넌트는 Base64로 변환
if (component.imageUrl) {
try {
const base64 = await imageUrlToBase64(component.imageUrl);
return { ...component, imageBase64: base64 };
} catch {
return component;
}
}
return component;
}),
);
return { ...page, components: componentsWithBase64 };
}),
);
// 쿼리 결과 수집
const queryResults: Record<string, { fields: string[]; rows: Record<string, unknown>[] }> = {};
for (const page of layoutConfig.pages) {
const pageComponents = Array.isArray(page.components) ? page.components : [];
for (const component of pageComponents) {
if (component.queryId) {
const result = getQueryResult(component.queryId);
if (result) {
queryResults[component.queryId] = result;
}
}
}
}
const fileName = reportDetail?.report?.report_name_kor || "리포트";
// 백엔드 API 호출 (컴포넌트 데이터 전송)
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post(
"/admin/reports/export-word",
{
layoutConfig: { ...layoutConfig, pages: pagesWithBase64 },
queryResults,
fileName,
},
{ responseType: "blob" },
);
// Blob 다운로드
const blob = new Blob([response.data], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const timestamp = new Date().toISOString().slice(0, 10);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@ -558,6 +537,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
description: "WORD 파일이 다운로드되었습니다.",
});
} catch (error) {
console.error("WORD 변환 오류:", error);
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
toast({
title: "오류",
@ -586,11 +566,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
.sort((a, b) => a.page_order - b.page_order)
.map((page) => (
<div key={page.page_id} className="relative">
{/* 페이지 번호 라벨 */}
<div className="mb-2 text-center text-xs text-gray-500">
{page.page_order + 1} - {page.page_name}
</div>
{/* 페이지 컨텐츠 */}
<div
className="relative mx-auto shadow-lg"
@ -600,7 +575,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
backgroundColor: page.background_color,
}}
>
{page.components.map((component) => {
{(Array.isArray(page.components) ? page.components : []).map((component) => {
const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
@ -627,6 +602,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
@ -640,6 +616,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
whiteSpace: "pre-wrap",
}}
>
{displayValue}
@ -886,6 +863,256 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
</div>
</div>
)}
{component.type === "pageNumber" && (() => {
const format = component.pageNumberFormat || "number";
const pageIndex = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.findIndex((p) => p.page_id === page.page_id);
const totalPages = layoutConfig.pages.length;
let pageNumberText = "";
switch (format) {
case "number":
pageNumberText = `${pageIndex + 1}`;
break;
case "numberTotal":
pageNumberText = `${pageIndex + 1} / ${totalPages}`;
break;
case "koreanNumber":
pageNumberText = `${pageIndex + 1} 페이지`;
break;
default:
pageNumberText = `${pageIndex + 1}`;
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100%",
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
}}
>
{pageNumberText}
</div>
);
})()}
{/* Card 컴포넌트 */}
{component.type === "card" && (() => {
const cardTitle = component.cardTitle || "정보 카드";
const cardItems = component.cardItems || [];
const labelWidth = component.labelWidth || 80;
const showCardTitle = component.showCardTitle !== false;
const titleFontSize = component.titleFontSize || 14;
const labelFontSize = component.labelFontSize || 13;
const valueFontSize = component.valueFontSize || 13;
const titleColor = component.titleColor || "#1e40af";
const labelColor = component.labelColor || "#374151";
const valueColor = component.valueColor || "#000000";
const borderColor = component.borderColor || "#e5e7eb";
// 쿼리 바인딩된 값 가져오기
const getCardValue = (item: { label: string; value: string; fieldName?: string }) => {
if (item.fieldName && component.queryId) {
const qResult = getQueryResult(component.queryId);
if (qResult && qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
}
}
return item.value;
};
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
}}
>
{showCardTitle && (
<>
<div
style={{
flexShrink: 0,
padding: "4px 8px",
fontSize: `${titleFontSize}px`,
fontWeight: 600,
color: titleColor,
}}
>
{cardTitle}
</div>
<div
style={{
flexShrink: 0,
margin: "0 4px",
borderBottom: `1px solid ${borderColor}`,
}}
/>
</>
)}
<div style={{ flex: 1, padding: "4px 8px", overflow: "auto" }}>
{cardItems.map((item: { label: string; value: string; fieldName?: string }, idx: number) => (
<div key={idx} style={{ display: "flex", padding: "2px 0" }}>
<span
style={{
width: `${labelWidth}px`,
flexShrink: 0,
fontSize: `${labelFontSize}px`,
color: labelColor,
fontWeight: 500,
}}
>
{item.label}
</span>
<span
style={{
flex: 1,
fontSize: `${valueFontSize}px`,
color: valueColor,
}}
>
{getCardValue(item)}
</span>
</div>
))}
</div>
</div>
);
})()}
{/* 계산 컴포넌트 */}
{component.type === "calculation" && (() => {
const calcItems = component.calcItems || [];
const resultLabel = component.resultLabel || "합계";
const calcLabelWidth = component.labelWidth || 120;
const calcLabelFontSize = component.labelFontSize || 13;
const calcValueFontSize = component.valueFontSize || 13;
const calcResultFontSize = component.resultFontSize || 16;
const calcLabelColor = component.labelColor || "#374151";
const calcValueColor = component.valueColor || "#000000";
const calcResultColor = component.resultColor || "#2563eb";
const numberFormat = component.numberFormat || "currency";
const currencySuffix = component.currencySuffix || "원";
const borderColor = component.borderColor || "#374151";
// 숫자 포맷팅 함수
const formatNumber = (num: number): string => {
if (numberFormat === "none") return String(num);
if (numberFormat === "comma") return num.toLocaleString();
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
return String(num);
};
// 쿼리 바인딩된 값 가져오기
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
if (item.fieldName && component.queryId) {
const qResult = getQueryResult(component.queryId);
if (qResult && qResult.rows && qResult.rows.length > 0) {
const row = qResult.rows[0];
const val = row[item.fieldName];
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
}
}
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
};
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
let calcResult = 0;
if (calcItems.length > 0) {
// 첫 번째 항목은 기준값
calcResult = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
// 두 번째 항목부터 연산자 적용
for (let i = 1; i < calcItems.length; i++) {
const item = calcItems[i];
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
switch ((item as { operator: string }).operator) {
case "+":
calcResult += val;
break;
case "-":
calcResult -= val;
break;
case "x":
calcResult *= val;
break;
case "÷":
calcResult = val !== 0 ? calcResult / val : calcResult;
break;
}
}
}
return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<div style={{ flex: 1 }}>
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, idx: number) => {
const itemValue = getCalcItemValue(item);
return (
<div key={idx} style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcLabelFontSize}px`,
color: calcLabelColor,
}}
>
{item.label}
</span>
<span
style={{
fontSize: `${calcValueFontSize}px`,
color: calcValueColor,
textAlign: "right",
}}
>
{formatNumber(itemValue)}
</span>
</div>
);
})}
</div>
<div style={{ borderTop: `1px solid ${borderColor}`, margin: "4px 8px" }} />
<div style={{ display: "flex", justifyContent: "space-between", padding: "4px 8px" }}>
<span
style={{
width: `${calcLabelWidth}px`,
fontSize: `${calcResultFontSize}px`,
fontWeight: 600,
color: calcLabelColor,
}}
>
{resultLabel}
</span>
<span
style={{
fontSize: `${calcResultFontSize}px`,
fontWeight: 700,
color: calcResultColor,
textAlign: "right",
}}
>
{formatNumber(calcResult)}
</span>
</div>
</div>
);
})()}
</div>
);
})}

View File

@ -6,7 +6,6 @@ import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface Template {
template_id: string;
@ -17,7 +16,6 @@ interface Template {
export function TemplatePalette() {
const { applyTemplate } = useReportDesigner();
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
@ -28,7 +26,6 @@ export function TemplatePalette() {
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
}
} catch (error) {
@ -79,31 +76,10 @@ export function TemplatePalette() {
};
return (
<div className="space-y-4">
{/* 시스템 템플릿 (DB에서 조회) */}
{systemTemplates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
</div>
{systemTemplates.map((template) => (
<Button
key={template.template_id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>{template.template_name_kor}</span>
</Button>
))}
</div>
)}
<div className="space-y-2">
{/* 사용자 정의 템플릿 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
<div className="flex items-center justify-end">
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button>

View File

@ -162,8 +162,8 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
// 현재 페이지 계산
const currentPage = layoutConfig.pages.find((p) => p.page_id === currentPageId);
// 현재 페이지의 컴포넌트 (읽기 전용)
const components = currentPage?.components || [];
// 현재 페이지의 컴포넌트 (읽기 전용) - 배열인지 확인
const components = Array.isArray(currentPage?.components) ? currentPage.components : [];
// currentPageId를 ref로 저장하여 클로저 문제 해결
const currentPageIdRef = useRef<string | null>(currentPageId);

View File

@ -158,6 +158,37 @@ export interface ComponentConfig {
headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px)
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; // 페이지 번호 포맷
// 카드 컴포넌트 전용
cardTitle?: string; // 카드 제목
cardItems?: Array<{
label: string; // 항목 라벨 (예: "회사명")
value: string; // 항목 값 (예: "당사 주식회사") 또는 기본값
fieldName?: string; // 쿼리 필드명 (바인딩용)
}>;
labelWidth?: number; // 라벨 컬럼 너비 (px)
showCardBorder?: boolean; // 카드 테두리 표시 여부
showCardTitle?: boolean; // 카드 제목 표시 여부
titleFontSize?: number; // 제목 폰트 크기
labelFontSize?: number; // 라벨 폰트 크기
valueFontSize?: number; // 값 폰트 크기
titleColor?: string; // 제목 색상
labelColor?: string; // 라벨 색상
valueColor?: string; // 값 색상
// 계산 컴포넌트 전용
calcItems?: Array<{
label: string; // 항목 라벨 (예: "공급가액")
value: number | string; // 항목 값 또는 기본값
operator: "+" | "-" | "x" | "÷"; // 연산자
fieldName?: string; // 쿼리 필드명 (바인딩용)
}>;
resultLabel?: string; // 결과 라벨 (예: "합계 금액")
resultColor?: string; // 결과 색상
resultFontSize?: number; // 결과 폰트 크기
showCalcBorder?: boolean; // 테두리 표시 여부
numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화)
currencySuffix?: string; // 통화 접미사 (예: "원")
}
// 리포트 상세