From 2e122b0703eb278d456a76bff062f7804e82124e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 17 Dec 2025 16:11:52 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=20Word=20=EB=B3=80=ED=99=98=20WYSIWYG=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20=EC=9C=84=EC=B9=98/=ED=81=AC=EA=B8=B0/?= =?UTF-8?q?=EC=A4=84=EB=B0=94=EA=BF=88/=EA=B0=80=EB=A1=9C=EB=B0=B0?= =?UTF-8?q?=EC=B9=98=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/package-lock.json | 598 ++++++++++++++ backend-node/package.json | 2 + .../src/controllers/reportController.ts | 749 ++++++++++++++++++ backend-node/src/routes/reportRoutes.ts | 5 + .../report/designer/ReportPreviewModal.tsx | 343 ++------ 5 files changed, 1430 insertions(+), 267 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index b9528ee0..5b5eb7d7 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -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", diff --git a/backend-node/package.json b/backend-node/package.json index bacd9fb3..e078043c 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -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", diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index f9162016..7cb33cc6 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -12,6 +12,22 @@ import { } from "../types/report"; import path from "path"; import fs from "fs"; +import { + Document, + Packer, + Paragraph, + TextRun, + ImageRun, + Table, + TableRow, + TableCell, + WidthType, + AlignmentType, + VerticalAlign, + BorderStyle, + PageOrientation, + convertMillimetersToTwip, +} from "docx"; export class ReportController { /** @@ -534,6 +550,739 @@ export class ReportController { return next(error); } } + + /** + * 컴포넌트 데이터를 WORD(DOCX)로 변환 + * POST /api/admin/reports/export-word + */ + async exportToWord(req: Request, res: Response, next: NextFunction) { + try { + const { layoutConfig, queryResults, fileName = "리포트" } = req.body; + + if (!layoutConfig || !layoutConfig.pages) { + return res.status(400).json({ + success: false, + message: "레이아웃 데이터가 필요합니다.", + }); + } + + // mm를 twip으로 변환 + const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); + // px를 twip으로 변환 (1px = 15twip at 96DPI) + const pxToTwip = (px: number) => Math.round(px * 15); + + // 쿼리 결과 맵 + const queryResultsMap: Record[] }> = + queryResults || {}; + + // 컴포넌트 값 가져오기 + const getComponentValue = (component: any): string => { + if (component.queryId && component.fieldName) { + const queryResult = queryResultsMap[component.queryId]; + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + const value = queryResult.rows[0][component.fieldName]; + if (value !== null && value !== undefined) { + return String(value); + } + } + return `{${component.fieldName}}`; + } + return component.defaultValue || ""; + }; + + // px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) + // px * 0.75 * 2 = px * 1.5 + const pxToHalfPt = (px: number) => Math.round(px * 1.5); + + // 셀 내용 생성 헬퍼 함수 (가로 배치용) + const createCellContent = ( + component: any, + displayValue: string, + pxToHalfPtFn: (px: number) => number, + pxToTwipFn: (px: number) => number, + queryResultsMapRef: Record[] }>, + AlignmentTypeRef: typeof AlignmentType, + VerticalAlignRef: typeof VerticalAlign, + BorderStyleRef: typeof BorderStyle, + ParagraphRef: typeof Paragraph, + TextRunRef: typeof TextRun, + ImageRunRef: typeof ImageRun, + TableRef: typeof Table, + TableRowRef: typeof TableRow, + TableCellRef: typeof TableCell + ): (Paragraph | Table)[] => { + const result: (Paragraph | Table)[] = []; + + // Text/Label + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPtFn(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentTypeRef.CENTER + : component.textAlign === "right" + ? AlignmentTypeRef.RIGHT + : AlignmentTypeRef.LEFT; + + result.push( + new ParagraphRef({ + alignment, + children: [ + new TextRunRef({ + text: displayValue, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: component.fontWeight === "bold" || component.fontWeight === "600", + font: "맑은 고딕", + }), + ], + }) + ); + } + + // Image + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + new ImageRunRef({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }) + ); + } catch (e) { + result.push(new ParagraphRef({ children: [] })); + } + } + + // Signature + else if (component.type === "signature") { + const sigFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.showLabel !== false) { + textRuns.push(new TextRunRef({ text: (component.labelText || "서명:") + " ", size: sigFontSize, font: "맑은 고딕" })); + } + if (component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { width: Math.round(component.width * 0.75), height: Math.round(component.height * 0.75) }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push(new TextRunRef({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // Stamp + else if (component.type === "stamp") { + const stampFontSize = pxToHalfPtFn(component.fontSize || 12); + const textRuns: TextRun[] = []; + if (component.personName) { + textRuns.push(new TextRunRef({ text: component.personName + " ", size: stampFontSize, font: "맑은 고딕" })); + } + if (component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + result.push( + new ParagraphRef({ + children: [ + ...textRuns, + new ImageRunRef({ + data: imageBuffer, + transformation: { width: Math.round(Math.min(component.width, component.height) * 0.75), height: Math.round(Math.min(component.width, component.height) * 0.75) }, + type: "png", + }), + ], + }) + ); + } catch (e) { + textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + result.push(new ParagraphRef({ children: textRuns })); + } + } else { + textRuns.push(new TextRunRef({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + result.push(new ParagraphRef({ children: textRuns })); + } + } + + // Divider - 테이블 셀로 감싸서 정확한 너비 적용 + else if (component.type === "divider" && component.orientation === "horizontal") { + result.push( + new ParagraphRef({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace("#", ""), + space: 1, + style: BorderStyleRef.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }) + ); + } + + // 기타 (빈 paragraph) + else { + result.push(new ParagraphRef({ children: [] })); + } + + return result; + }; + + // 섹션 생성 (페이지별) + const sections = layoutConfig.pages + .sort((a: any, b: any) => a.page_order - b.page_order) + .map((page: any) => { + const pageWidthTwip = mmToTwip(page.width); + const pageHeightTwip = mmToTwip(page.height); + const marginTopMm = page.margins?.top || 10; + const marginBottomMm = page.margins?.bottom || 10; + const marginLeftMm = page.margins?.left || 10; + const marginRightMm = page.margins?.right || 10; + + const marginTop = mmToTwip(marginTopMm); + const marginBottom = mmToTwip(marginBottomMm); + const marginLeft = mmToTwip(marginLeftMm); + const marginRight = mmToTwip(marginRightMm); + + // 마진을 px로 변환 (1mm ≈ 3.78px at 96 DPI) + const marginLeftPx = marginLeftMm * 3.78; + const marginTopPx = marginTopMm * 3.78; + + // 컴포넌트를 Y좌표순으로 정렬 + const sortedComponents = [...(page.components || [])].sort( + (a: any, b: any) => a.y - b.y + ); + + // 같은 Y좌표 범위(±30px)의 컴포넌트들을 그룹화 + const Y_GROUP_THRESHOLD = 30; // px + const componentGroups: any[][] = []; + let currentGroup: any[] = []; + let groupBaseY = -Infinity; + + for (const comp of sortedComponents) { + const compY = comp.y - marginTopPx; + if (currentGroup.length === 0) { + currentGroup.push(comp); + groupBaseY = compY; + } else if (Math.abs(compY - groupBaseY) <= Y_GROUP_THRESHOLD) { + currentGroup.push(comp); + } else { + componentGroups.push(currentGroup); + currentGroup = [comp]; + groupBaseY = compY; + } + } + if (currentGroup.length > 0) { + componentGroups.push(currentGroup); + } + + // 컴포넌트를 Paragraph/Table로 변환 + const children: (Paragraph | Table)[] = []; + + // Y좌표를 spacing으로 변환하기 위한 추적 변수 + let lastBottomY = 0; + + // 각 그룹 처리 + for (const group of componentGroups) { + // 그룹 내 컴포넌트들을 X좌표 순으로 정렬 + const sortedGroup = [...group].sort((a: any, b: any) => a.x - b.x); + + // 그룹의 Y 좌표 (첫 번째 컴포넌트 기준) + const groupY = Math.max(0, sortedGroup[0].y - marginTopPx); + const groupHeight = Math.max(...sortedGroup.map((c: any) => c.height)); + + // spacing 계산 + const gapFromPrevious = Math.max(0, groupY - lastBottomY); + const spacingBefore = pxToTwip(gapFromPrevious); + + // 그룹에 컴포넌트가 여러 개면 하나의 테이블 행으로 배치 + if (sortedGroup.length > 1) { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); + } + + // 각 컴포넌트를 셀로 변환 + const cells: TableCell[] = []; + let prevEndX = 0; + + for (const component of sortedGroup) { + const adjustedX = Math.max(0, component.x - marginLeftPx); + const displayValue = getComponentValue(component); + + // 이전 셀과의 간격을 위한 빈 셀 추가 + if (adjustedX > prevEndX + 5) { + const gapWidth = adjustedX - prevEndX; + cells.push( + new TableCell({ + children: [new Paragraph({ children: [] })], + width: { size: pxToTwip(gapWidth), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }) + ); + } + + // 컴포넌트 셀 생성 + const cellContent = createCellContent(component, displayValue, pxToHalfPt, pxToTwip, queryResultsMap, AlignmentType, VerticalAlign, BorderStyle, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell); + cells.push( + new TableCell({ + children: cellContent, + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }) + ); + prevEndX = adjustedX + component.width; + } + + // 테이블 행 생성 + const rowTable = new Table({ + rows: [new TableRow({ children: cells })], + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + children.push(rowTable); + lastBottomY = groupY + groupHeight; + continue; + } + + // 단일 컴포넌트 처리 (기존 로직) + const component = sortedGroup[0]; + const displayValue = getComponentValue(component); + const adjustedX = Math.max(0, component.x - marginLeftPx); + const adjustedY = groupY; + + // X좌표를 indent로 변환 (마진 제외한 순수 들여쓰기) + const indentLeft = pxToTwip(adjustedX); + + // Text/Label 컴포넌트 - 테이블 셀로 감싸서 width 내 줄바꿈 적용 + if (component.type === "text" || component.type === "label") { + const fontSizeHalfPt = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 + const textCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: [ + new TextRun({ + text: displayValue, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: component.fontWeight === "bold" || component.fontWeight === "600", + font: "맑은 고딕", + }), + ], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + verticalAlign: VerticalAlign.TOP, + }); + + const textTable = new Table({ + rows: [new TableRow({ children: [textCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); + } + children.push(textTable); + lastBottomY = adjustedY + component.height; + } + + // Image 컴포넌트 + else if (component.type === "image" && component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + lastBottomY = adjustedY + component.height; + } catch (imgError) { + console.error("이미지 처리 오류:", imgError); + } + } + + // Divider 컴포넌트 - 테이블 셀로 감싸서 정확한 위치와 너비 적용 + else if (component.type === "divider") { + if (component.orientation === "horizontal") { + // spacing을 위한 빈 paragraph + if (spacingBefore > 0) { + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, children: [] })); + } + + // 테이블 셀로 감싸서 너비 제한 + const dividerCell = new TableCell({ + children: [ + new Paragraph({ + border: { + bottom: { + color: (component.lineColor || "#000000").replace("#", ""), + space: 1, + style: BorderStyle.SINGLE, + size: (component.lineWidth || 1) * 8, + }, + }, + children: [], + }), + ], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + + const dividerTable = new Table({ + rows: [new TableRow({ children: [dividerCell] })], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + borders: { + top: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + bottom: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + left: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + insideVertical: { style: BorderStyle.NONE, size: 0, color: "FFFFFF" }, + }, + }); + children.push(dividerTable); + lastBottomY = adjustedY + component.height; + } + } + + // Signature 컴포넌트 + else if (component.type === "signature") { + const labelText = component.labelText || "서명:"; + const showLabel = component.showLabel !== false; + const sigFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (showLabel) { + textRuns.push( + new TextRun({ + text: labelText + " ", + size: sigFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(component.width * 0.75), + height: Math.round(component.height * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("서명 이미지 오류:", imgError); + textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); + } + } else { + textRuns.push(new TextRun({ text: "_".repeat(20), size: sigFontSize, font: "맑은 고딕" })); + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); + } + lastBottomY = adjustedY + component.height; + } + + // Stamp 컴포넌트 + else if (component.type === "stamp") { + const personName = component.personName || ""; + const stampFontSize = pxToHalfPt(component.fontSize || 12); + const textRuns: TextRun[] = []; + + if (personName) { + textRuns.push( + new TextRun({ + text: personName + " ", + size: stampFontSize, + font: "맑은 고딕", + }) + ); + } + + if (component.imageBase64) { + try { + const base64Data = component.imageBase64.split(",")[1] || component.imageBase64; + const imageBuffer = Buffer.from(base64Data, "base64"); + + const paragraph = new Paragraph({ + spacing: { before: spacingBefore, after: 0 }, + indent: { left: indentLeft }, + children: [ + ...textRuns, + new ImageRun({ + data: imageBuffer, + transformation: { + width: Math.round(Math.min(component.width, component.height) * 0.75), + height: Math.round(Math.min(component.width, component.height) * 0.75), + }, + type: "png", + }), + ], + }); + children.push(paragraph); + } catch (imgError) { + console.error("도장 이미지 오류:", imgError); + textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); + } + } else { + textRuns.push(new TextRun({ text: "(인)", color: "DC2626", size: stampFontSize, font: "맑은 고딕" })); + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: textRuns })); + } + lastBottomY = adjustedY + component.height; + } + + // Table 컴포넌트 + else if (component.type === "table" && component.queryId) { + const queryResult = queryResultsMap[component.queryId]; + if (queryResult && queryResult.rows && queryResult.rows.length > 0) { + // 테이블 앞에 spacing과 indent를 위한 빈 paragraph 추가 + if (spacingBefore > 0 || indentLeft > 0) { + children.push(new Paragraph({ spacing: { before: spacingBefore, after: 0 }, indent: { left: indentLeft }, children: [] })); + } + + const columns = + component.tableColumns && component.tableColumns.length > 0 + ? component.tableColumns + : queryResult.fields.map((field: string) => ({ + field, + header: field, + align: "left", + width: undefined, + })); + + // 테이블 폰트 사이즈 (기본 12px) + const tableFontSize = pxToHalfPt(component.fontSize || 12); + + // 헤더 행 + const headerCells = columns.map( + (col: { header: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: col.header, + bold: true, + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + shading: { + fill: (component.headerBackgroundColor || "#f3f4f6").replace("#", ""), + }, + verticalAlign: VerticalAlign.CENTER, + }) + ); + const headerRow = new TableRow({ children: headerCells }); + + // 데이터 행 + const dataRows = queryResult.rows.map( + (row: Record) => + new TableRow({ + children: columns.map( + (col: { field: string; align?: string }) => + new TableCell({ + children: [ + new Paragraph({ + alignment: + col.align === "center" + ? AlignmentType.CENTER + : col.align === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + children: [ + new TextRun({ + text: String(row[col.field] ?? ""), + size: tableFontSize, + font: "맑은 고딕", + }), + ], + }), + ], + verticalAlign: VerticalAlign.CENTER, + }) + ), + }) + ); + + const table = new Table({ + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + indent: { size: indentLeft, type: WidthType.DXA }, + rows: [headerRow, ...dataRows], + }); + children.push(table); + lastBottomY = adjustedY + component.height; + } + } + } + + // 빈 페이지 방지 + if (children.length === 0) { + children.push(new Paragraph({ children: [] })); + } + + return { + properties: { + page: { + size: { + width: pageWidthTwip, + height: pageHeightTwip, + orientation: + page.width > page.height ? PageOrientation.LANDSCAPE : PageOrientation.PORTRAIT, + }, + margin: { + top: marginTop, + bottom: marginBottom, + left: marginLeft, + right: marginRight, + }, + }, + }, + children, + }; + }); + + // Document 생성 + const doc = new Document({ + sections, + }); + + // Buffer로 변환 + const docxBuffer = await Packer.toBuffer(doc); + + // 파일명 인코딩 (한글 지원) + const timestamp = new Date().toISOString().slice(0, 10); + const safeFileName = encodeURIComponent(`${fileName}_${timestamp}.docx`); + + // DOCX 파일로 응답 + res.setHeader( + "Content-Type", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + res.setHeader( + "Content-Disposition", + `attachment; filename*=UTF-8''${safeFileName}` + ); + res.setHeader("Content-Length", docxBuffer.length); + + return res.send(docxBuffer); + } catch (error: any) { + console.error("WORD 변환 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "WORD 변환에 실패했습니다.", + }); + } + } } export default new ReportController(); diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index 76e1a955..bb644fef 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -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) diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 97b3ac48..46f1d31c 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -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 { @@ -282,270 +267,93 @@ 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 => { + 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> = []; - 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( + 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[] }> = {}; + for (const page of layoutConfig.pages) { + for (const component of page.components) { + 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 +366,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: "오류", From 7acb4981b5b3fffdebb44325ac6d9ba269b2d9c7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 17 Dec 2025 16:31:58 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/PageListPanel.tsx | 48 +++++++++---------- .../report/designer/ReportDesignerCanvas.tsx | 7 +-- .../report/designer/ReportPreviewModal.tsx | 5 -- 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/frontend/components/report/designer/PageListPanel.tsx b/frontend/components/report/designer/PageListPanel.tsx index e350ce51..4f191d5a 100644 --- a/frontend/components/report/designer/PageListPanel.tsx +++ b/frontend/components/report/designer/PageListPanel.tsx @@ -76,25 +76,25 @@ export function PageListPanel() { }; return ( -
+
{/* 헤더 */} -
-

페이지 목록

-
{/* 페이지 목록 */}
- -
+ +
{layoutConfig.pages .sort((a, b) => a.page_order - b.page_order) .map((page, index) => (
handleDragOver(e, index)} onDrop={(e) => handleDrop(e, index)} > -
+
{/* 드래그 핸들 */}
e.stopPropagation()} > - +
{/* 페이지 정보 */}
{editingPageId === page.page_id ? ( -
e.stopPropagation()}> +
e.stopPropagation()}> 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 /> - -
) : ( -
{page.page_name}
+
{page.page_name}
)} -
- {page.width}x{page.height}mm • {page.components.length}개 +
+ {page.width}x{page.height}mm
@@ -153,10 +153,10 @@ export function PageListPanel() { @@ -199,9 +199,9 @@ export function PageListPanel() {
{/* 푸터 */} -
-
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index ace87249..c9c86a69 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -297,13 +297,8 @@ export function ReportDesignerCanvas() { return (
- {/* 작업 영역 제목 */} -
- {currentPage.page_name} ({currentPage.width} x {currentPage.height}mm) -
- {/* 캔버스 스크롤 영역 */} -
+
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
{/* 좌상단 코너 + 가로 눈금자 */} diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 46f1d31c..098e4570 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -395,11 +395,6 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) .sort((a, b) => a.page_order - b.page_order) .map((page) => (
- {/* 페이지 번호 라벨 */} -
- 페이지 {page.page_order + 1} - {page.page_name} -
- {/* 페이지 컨텐츠 */}
Date: Wed, 17 Dec 2025 16:43:29 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=20AccordionTrigger=20=EB=82=B4=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=A4=91=EC=B2=A9=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/QueryManager.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index 622713b7..0fd25f43 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -264,24 +264,24 @@ export function QueryManager() { return ( - -
+
+
{query.name} {query.type}
- -
- + + +
{/* 쿼리 이름 */}
From f47a0c770b9e9d76e599644249eeb138f6f634da Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 17 Dec 2025 16:51:19 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=8C=94?= =?UTF-8?q?=EB=A0=88=ED=8A=B8=EC=97=90=EC=84=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=84=B9=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/designer/TemplatePalette.tsx | 28 ++----------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/frontend/components/report/designer/TemplatePalette.tsx b/frontend/components/report/designer/TemplatePalette.tsx index 276ccff4..268b2dcc 100644 --- a/frontend/components/report/designer/TemplatePalette.tsx +++ b/frontend/components/report/designer/TemplatePalette.tsx @@ -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([]); const [customTemplates, setCustomTemplates] = useState([]); const [isLoading, setIsLoading] = useState(false); const [deletingId, setDeletingId] = useState(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 ( -
- {/* 시스템 템플릿 (DB에서 조회) */} - {systemTemplates.length > 0 && ( -
-
-

시스템 템플릿

-
- {systemTemplates.map((template) => ( - - ))} -
- )} - +
{/* 사용자 정의 템플릿 */}
-
-

사용자 정의 템플릿

+
From b7b881ee86c2f735a5e80c02e94a5adbd79e0cb7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 17 Dec 2025 17:02:26 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=B8=94=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/report/designer/ComponentPalette.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index d6591d0e..c1d348b5 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -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 } from "lucide-react"; interface ComponentItem { type: string; @@ -12,7 +12,6 @@ interface ComponentItem { const COMPONENTS: ComponentItem[] = [ { type: "text", label: "텍스트", icon: }, { type: "table", label: "테이블", icon: }, - { type: "label", label: "레이블", icon: }, { type: "image", label: "이미지", icon: }, { type: "divider", label: "구분선", icon: }, { type: "signature", label: "서명란", icon: }, From fb4b5b7e26162b5be1b56e27356df7a7c88f2c9d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 17 Dec 2025 17:10:26 +0900 Subject: [PATCH 06/15] =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20textarea=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/reportController.ts | 57 +++++++++++++------ .../report/designer/CanvasComponent.tsx | 2 + .../designer/ReportDesignerRightPanel.tsx | 9 +-- .../report/designer/ReportPreviewModal.tsx | 4 +- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index 7cb33cc6..62c1fd06 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -623,18 +623,29 @@ export class ReportController { ? AlignmentTypeRef.RIGHT : AlignmentTypeRef.LEFT; + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + // 줄바꿈 추가 (break: 1은 줄바꿈 1개) + textChildren.push(new TextRunRef({ break: 1 })); + } + textChildren.push( + new TextRunRef({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: component.fontWeight === "bold" || component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + result.push( new ParagraphRef({ alignment, - children: [ - new TextRunRef({ - text: displayValue, - size: fontSizeHalfPt, - color: (component.fontColor || "#000000").replace("#", ""), - bold: component.fontWeight === "bold" || component.fontWeight === "600", - font: "맑은 고딕", - }), - ], + children: textChildren, }) ); } @@ -908,20 +919,30 @@ export class ReportController { ? AlignmentType.RIGHT : AlignmentType.LEFT; + // 줄바꿈 처리: \n으로 split하여 각 줄을 TextRun으로 생성 + const lines = displayValue.split("\n"); + const textChildren: TextRun[] = []; + lines.forEach((line: string, index: number) => { + if (index > 0) { + textChildren.push(new TextRun({ break: 1 })); + } + textChildren.push( + new TextRun({ + text: line, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + bold: component.fontWeight === "bold" || component.fontWeight === "600", + font: "맑은 고딕", + }) + ); + }); + // 테이블 셀로 감싸서 width 제한 → 자동 줄바꿈 const textCell = new TableCell({ children: [ new Paragraph({ alignment, - children: [ - new TextRun({ - text: displayValue, - size: fontSizeHalfPt, - color: (component.fontColor || "#000000").replace("#", ""), - bold: component.fontWeight === "bold" || component.fontWeight === "600", - font: "맑은 고딕", - }), - ], + children: textChildren, }), ], width: { size: pxToTwip(component.width), type: WidthType.DXA }, diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 7e8e54d2..cb7b7347 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -270,6 +270,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 +292,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) { color: component.fontColor, fontWeight: component.fontWeight, textAlign: component.textAlign as "left" | "center" | "right", + whiteSpace: "pre-wrap", }} > {displayValue} diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index bc2eea74..f2f0949a 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -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"; @@ -1120,16 +1121,16 @@ export function ReportDesignerRightPanel() { {/* 기본값 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
- - 텍스트 내용 +