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..a2e8e8a9 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 { /** @@ -207,11 +223,31 @@ export class ReportController { }); } - // components JSON 파싱 - const layoutData = { - ...layout, - components: layout.components ? JSON.parse(layout.components) : [], - }; + // components 컬럼에서 JSON 파싱 + const parsedComponents = layout.components + ? JSON.parse(layout.components) + : null; + + let layoutData; + // 새 구조 (layoutConfig.pages)인지 확인 + if ( + parsedComponents && + parsedComponents.pages && + Array.isArray(parsedComponents.pages) + ) { + // pages 배열을 직접 포함하여 반환 + layoutData = { + ...layout, + pages: parsedComponents.pages, + components: [], // 호환성을 위해 빈 배열 + }; + } else { + // 기존 구조: components 배열 + layoutData = { + ...layout, + components: parsedComponents || [], + }; + } return res.json({ success: true, @@ -232,16 +268,15 @@ export class ReportController { const data: SaveLayoutRequest = req.body; const userId = (req as any).user?.userId || "SYSTEM"; - // 필수 필드 검증 + // 필수 필드 검증 (페이지 기반 구조) if ( - !data.canvasWidth || - !data.canvasHeight || - !data.pageOrientation || - !data.components + !data.layoutConfig || + !data.layoutConfig.pages || + data.layoutConfig.pages.length === 0 ) { return res.status(400).json({ success: false, - message: "필수 레이아웃 정보가 누락되었습니다.", + message: "레이아웃 설정이 필요합니다.", }); } @@ -534,6 +569,2226 @@ 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< + string, + { fields: string[]; rows: 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< + string, + { fields: string[]; rows: 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, + pageIndex: number = 0, + totalPages: number = 1 + ): (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; + + // 줄바꿈 처리: \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: textChildren, + }) + ); + } + + // 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 })); + } + } + + // PageNumber + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPages}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + 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: pageNumberText, + size: fontSizeHalfPt, + color: (component.fontColor || "#000000").replace("#", ""), + font: "맑은 고딕", + }), + ], + }) + ); + } + + // 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 = pxToHalfPtFn(component.titleFontSize || 14); + const labelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const valueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const titleColor = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColor = (component.borderColor || "#e5e7eb").replace( + "#", + "" + ); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueFn = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (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; + }; + + // 제목 + if (showCardTitle) { + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: cardTitle, + size: titleFontSize, + color: titleColor, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + result.push( + new ParagraphRef({ + border: { + bottom: { + color: borderColor, + space: 1, + style: BorderStyleRef.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들 + for (const item of cardItems) { + const itemValue = getCardValueFn( + item as { label: string; value: string; fieldName?: string } + ); + result.push( + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: labelFontSize, + color: labelColor, + bold: true, + font: "맑은 고딕", + }), + new TextRunRef({ + text: " ", + size: labelFontSize, + font: "맑은 고딕", + }), + new TextRunRef({ + text: itemValue, + size: valueFontSize, + color: valueColor, + font: "맑은 고딕", + }), + ], + }) + ); + } + } + + // 계산 컴포넌트 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPtFn(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPtFn(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPtFn( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = (component.resultColor || "#2563eb").replace( + "#", + "" + ); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (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 getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMapRef[component.queryId] + ) { + const qResult = queryResultsMapRef[component.queryId]; + if (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 = getCalcItemValueFn( + 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 = getCalcItemValueFn( + 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 calcTableRows = []; + + // 각 항목 + for (const item of calcItems) { + const itemValue = getCalcItemValueFn( + item as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: item.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + columnSpan: 2, + children: [new ParagraphRef({ children: [] })], + borders: { + top: { + style: BorderStyleRef.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRowRef({ + children: [ + new TableCellRef({ + children: [ + new ParagraphRef({ + children: [ + new TextRunRef({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwipFn(calcLabelWidth), + type: WidthType.DXA, + }, + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCellRef({ + children: [ + new ParagraphRef({ + alignment: AlignmentTypeRef.RIGHT, + children: [ + new TextRunRef({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + borders: { + top: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + result.push( + new TableRef({ + rows: calcTableRows, + width: { size: pxToTwipFn(component.width), type: WidthType.DXA }, + borders: { + top: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + bottom: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + left: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + right: { style: BorderStyleRef.NONE, size: 0, color: "FFFFFF" }, + insideHorizontal: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyleRef.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }) + ); + } + + // 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 sortedPages = layoutConfig.pages.sort( + (a: any, b: any) => a.page_order - b.page_order + ); + const totalPagesCount = sortedPages.length; + + const sections = sortedPages.map((page: any, pageIndex: number) => { + 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, + pageIndex, + totalPagesCount + ); + 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; + + // 줄바꿈 처리: \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: textChildren, + }), + ], + 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; + } + + // PageNumber 컴포넌트 - 테이블 셀로 감싸서 정확한 위치 적용 + else if (component.type === "pageNumber") { + const format = component.pageNumberFormat || "number"; + const currentPageNum = pageIndex + 1; + let pageNumberText = ""; + switch (format) { + case "number": + pageNumberText = `${currentPageNum}`; + break; + case "numberTotal": + pageNumberText = `${currentPageNum} / ${totalPagesCount}`; + break; + case "koreanNumber": + pageNumberText = `${currentPageNum} 페이지`; + break; + default: + pageNumberText = `${currentPageNum}`; + } + const pageNumFontSize = pxToHalfPt(component.fontSize || 13); + const alignment = + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT; + + // 테이블 셀로 감싸서 width와 indent 정확히 적용 + const pageNumCell = new TableCell({ + children: [ + new Paragraph({ + alignment, + children: [ + new TextRun({ + text: pageNumberText, + size: pageNumFontSize, + color: (component.fontColor || "#000000").replace( + "#", + "" + ), + 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 pageNumTable = new Table({ + rows: [new TableRow({ children: [pageNumCell] })], + 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(pageNumTable); + lastBottomY = adjustedY + component.height; + } + + // Card 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "card") { + const cardTitle = component.cardTitle || "정보 카드"; + const cardItems = component.cardItems || []; + const labelWidthPx = component.labelWidth || 80; + const showCardTitle = component.showCardTitle !== false; + const titleFontSize = pxToHalfPt(component.titleFontSize || 14); + const labelFontSizeCard = pxToHalfPt(component.labelFontSize || 13); + const valueFontSizeCard = pxToHalfPt(component.valueFontSize || 13); + const titleColorCard = (component.titleColor || "#1e40af").replace( + "#", + "" + ); + const labelColorCard = (component.labelColor || "#374151").replace( + "#", + "" + ); + const valueColorCard = (component.valueColor || "#000000").replace( + "#", + "" + ); + const borderColorCard = ( + component.borderColor || "#e5e7eb" + ).replace("#", ""); + + // 쿼리 바인딩된 값 가져오기 + const getCardValueLocal = (item: { + label: string; + value: string; + fieldName?: string; + }) => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (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; + }; + + const cardParagraphs: Paragraph[] = []; + + // 제목 + if (showCardTitle) { + cardParagraphs.push( + new Paragraph({ + children: [ + new TextRun({ + text: cardTitle, + size: titleFontSize, + color: titleColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }) + ); + // 구분선 + cardParagraphs.push( + new Paragraph({ + border: { + bottom: { + color: borderColorCard, + space: 1, + style: BorderStyle.SINGLE, + size: 8, + }, + }, + children: [], + }) + ); + } + + // 항목들을 테이블로 구성 (라벨 + 값) + const itemRows = cardItems.map( + (item: { label: string; value: string; fieldName?: string }) => { + const itemValue = getCardValueLocal(item); + return new TableRow({ + children: [ + new TableCell({ + width: { + size: pxToTwip(labelWidthPx), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: item.label, + size: labelFontSizeCard, + color: labelColorCard, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + 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", + }, + }, + }), + new TableCell({ + width: { + size: pxToTwip(component.width - labelWidthPx - 16), + type: WidthType.DXA, + }, + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: itemValue, + size: valueFontSizeCard, + color: valueColorCard, + font: "맑은 고딕", + }), + ], + }), + ], + 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 itemsTable = new Table({ + rows: itemRows, + 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" }, + insideHorizontal: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + insideVertical: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }); + + // 전체를 하나의 테이블 셀로 감싸기 + const cardCell = new TableCell({ + children: [...cardParagraphs, itemsTable], + width: { size: pxToTwip(component.width), type: WidthType.DXA }, + borders: + component.showCardBorder !== false + ? { + top: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + bottom: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + left: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + right: { + style: BorderStyle.SINGLE, + size: 4, + color: borderColorCard, + }, + } + : { + 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 cardTable = new Table({ + rows: [new TableRow({ children: [cardCell] })], + 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(cardTable); + lastBottomY = adjustedY + component.height; + } + + // 계산 컴포넌트 - 테이블로 감싸서 정확한 위치 적용 + else if (component.type === "calculation") { + const calcItems = component.calcItems || []; + const resultLabel = component.resultLabel || "합계"; + const calcLabelWidth = component.labelWidth || 120; + const calcLabelFontSize = pxToHalfPt(component.labelFontSize || 13); + const calcValueFontSize = pxToHalfPt(component.valueFontSize || 13); + const calcResultFontSize = pxToHalfPt( + component.resultFontSize || 16 + ); + const calcLabelColor = (component.labelColor || "#374151").replace( + "#", + "" + ); + const calcValueColor = (component.valueColor || "#000000").replace( + "#", + "" + ); + const calcResultColor = ( + component.resultColor || "#2563eb" + ).replace("#", ""); + const numberFormat = component.numberFormat || "currency"; + const currencySuffix = component.currencySuffix || "원"; + const borderColor = (component.borderColor || "#374151").replace( + "#", + "" + ); + + // 숫자 포맷팅 함수 + const formatNumberFn = (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 getCalcItemValueFn = (item: { + label: string; + value: number | string; + operator: string; + fieldName?: string; + }): number => { + if ( + item.fieldName && + component.queryId && + queryResultsMap[component.queryId] + ) { + const qResult = queryResultsMap[component.queryId]; + if (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 = getCalcItemValueFn( + calcItems[0] as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + + // 두 번째 항목부터 연산자 적용 + for (let i = 1; i < calcItems.length; i++) { + const calcItem = calcItems[i]; + const val = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + switch ((calcItem 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 calcTableRows: TableRow[] = []; + + // 각 항목 행 + for (const calcItem of calcItems) { + const itemValue = getCalcItemValueFn( + calcItem as { + label: string; + value: number | string; + operator: string; + fieldName?: string; + } + ); + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: calcItem.label, + size: calcLabelFontSize, + color: calcLabelColor, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(itemValue), + size: calcValueFontSize, + color: calcValueColor, + font: "맑은 고딕", + }), + ], + }), + ], + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + } + + // 구분선 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + columnSpan: 2, + children: [new Paragraph({ children: [] })], + borders: { + top: { + style: BorderStyle.SINGLE, + size: 8, + color: borderColor, + }, + bottom: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + left: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + right: { + style: BorderStyle.NONE, + size: 0, + color: "FFFFFF", + }, + }, + }), + ], + }) + ); + + // 결과 행 + calcTableRows.push( + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: resultLabel, + size: calcResultFontSize, + color: calcLabelColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + width: { + size: pxToTwip(calcLabelWidth), + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + new TableCell({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun({ + text: formatNumberFn(calcResult), + size: calcResultFontSize, + color: calcResultColor, + bold: true, + font: "맑은 고딕", + }), + ], + }), + ], + 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", + }, + }, + margins: { top: 50, bottom: 50, left: 100, right: 100 }, + }), + ], + }) + ); + + const calcTable = new Table({ + rows: calcTableRows, + 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(calcTable); + 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/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index 77087f25..f4991863 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -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 { 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, ]); } diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index 77cc35d7..2fe1cfd3 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -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; }>; } diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index f511a7b1..03abee6f 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -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 자재가 없습니다 ) : ( - - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((col) => ( + + {col.label} + + ))} + + + + {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 ( - - -
- 층 {layerValue} - {keyValue} -
-
- - {displayColumns.length === 0 ? ( -

- 표시할 컬럼이 설정되지 않았습니다 -

- ) : ( -
- {displayColumns.map((item) => ( -
- {item.label}: - - {material[item.column] || "-"} - -
- ))} -
- )} -
-
- ); - })} - + return ( + + {layerNumber}단 + {displayColumns.map((col) => ( + + {material[col.column] || "-"} + + ))} + + ); + })} +
+
+
)} ) : selectedObject ? ( diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 71462ebe..a702a047 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -624,7 +624,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) )} - {/* 자재 목록 (Location인 경우) - 아코디언 */} + {/* 자재 목록 (Location인 경우) - 테이블 형태 */} {(selectedObject.type === "location-bed" || selectedObject.type === "location-stp" || selectedObject.type === "location-temp" || @@ -640,47 +640,48 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) ) : (
- - {materials.map((material, index) => { - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( -
- -
-
- - 층 {material[hierarchyConfig?.material?.layerColumn || "LOLAYER"]} - - {displayColumns[0] && ( - - {material[displayColumns[0].column]} - - )} -
-
- - - -
-
- {displayColumns.map((colConfig: any) => ( -
- {colConfig.label}: - {material[colConfig.column] || "-"} -
+ + {/* 테이블 형태로 전체 조회 */} +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( + ))} - - - ); - })} + + + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( + + + {displayColumns.map((colConfig: any) => ( + + ))} + + ); + })} + +
+ {colConfig.label} +
+ {material[layerColumn]}단 + + {material[colConfig.column] || "-"} +
+
)}
diff --git a/frontend/components/report/ReportListTable.tsx b/frontend/components/report/ReportListTable.tsx index 7c09537f..f8ad96ad 100644 --- a/frontend/components/report/ReportListTable.tsx +++ b/frontend/components/report/ReportListTable.tsx @@ -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 ( - + handleEdit(report.report_id)} + className="cursor-pointer hover:bg-muted/50" + > {rowNumber}
@@ -162,34 +166,25 @@ export function ReportListTable({ {report.created_by || "-"} {formatDate(report.updated_at || report.created_at)} -
+
e.stopPropagation()}> -
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index 7e8e54d2..01f0390b 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -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) {
); + 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 ( +
+ {pageNumberText} +
+ ); + + 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 ( +
+ {/* 제목 */} + {showCardTitle && ( + <> +
+ {cardTitle} +
+ {/* 구분선 */} +
+ + )} + {/* 항목 목록 */} +
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => ( +
+ + {item.label} + + + {getCardItemValue(item)} + +
+ ))} +
+
+ ); + + 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 ( +
+ {/* 항목 목록 */} +
+ {calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => { + const itemValue = getCalcItemValue(item); + return ( +
+ + {item.label} + + + {formatNumber(itemValue)} + +
+ ); + })} +
+ {/* 구분선 */} +
+ {/* 결과 */} +
+ + {resultLabel} + + + {formatNumber(calcResult)} + +
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index d6591d0e..9dd0543f 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, Hash, CreditCard, Calculator } from "lucide-react"; interface ComponentItem { type: string; @@ -12,11 +12,13 @@ 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: }, { type: "stamp", label: "도장란", icon: }, + { type: "pageNumber", label: "페이지번호", icon: }, + { type: "card", label: "정보카드", icon: }, + { type: "calculation", label: "계산", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { 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/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index 622713b7..e9ccb813 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -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 ( - -
+
+
{query.name} {query.type}
- -
- + + +
{/* 쿼리 이름 */}
diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index ace87249..bcf9d88f 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -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 (
- {/* 작업 영역 제목 */} -
- {currentPage.page_name} ({currentPage.width} x {currentPage.height}mm) -
- {/* 캔버스 스크롤 영역 */} -
+
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
{/* 좌상단 코너 + 가로 눈금자 */} diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index bc2eea74..ff832e21 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"; @@ -27,6 +28,7 @@ export function ReportDesignerRightPanel() { currentPage, currentPageId, updatePageSettings, + getQueryResult, } = context; const [activeTab, setActiveTab] = useState("properties"); const [uploadingImage, setUploadingImage] = useState(false); @@ -918,6 +920,717 @@ export function ReportDesignerRightPanel() { )} + {/* 페이지 번호 설정 */} + {selectedComponent.type === "pageNumber" && ( + + + 페이지 번호 설정 + + +
+ + +
+
+
+ )} + + {/* 카드 컴포넌트 설정 */} + {selectedComponent.type === "card" && ( + + + 카드 설정 + + + {/* 제목 표시 여부 */} +
+ + updateComponent(selectedComponent.id, { + showCardTitle: e.target.checked, + }) + } + className="h-4 w-4" + /> + +
+ + {/* 제목 텍스트 */} + {selectedComponent.showCardTitle !== false && ( +
+ + + updateComponent(selectedComponent.id, { + cardTitle: e.target.value, + }) + } + placeholder="정보 카드" + className="h-8" + /> +
+ )} + + {/* 라벨 너비 */} +
+ + + updateComponent(selectedComponent.id, { + labelWidth: Number(e.target.value), + }) + } + min={40} + max={200} + className="h-8" + /> +
+ + {/* 테두리 표시 */} +
+ + updateComponent(selectedComponent.id, { + showCardBorder: e.target.checked, + borderWidth: e.target.checked ? 1 : 0, + }) + } + className="h-4 w-4" + /> + +
+ + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleFontSize: Number(e.target.value), + }) + } + min={10} + max={24} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + titleColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(selectedComponent.cardItems || []).map( + (item: { label: string; value: string; fieldName?: string }, index: number) => ( +
+
+ 항목 {index + 1} + +
+
+
+ + { + const currentItems = [...(selectedComponent.cardItems || [])]; + currentItems[index] = { ...item, label: e.target.value }; + updateComponent(selectedComponent.id, { + cardItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="항목명" + /> +
+ {selectedComponent.queryId ? ( +
+ + +
+ ) : ( +
+ + { + const currentItems = [...(selectedComponent.cardItems || [])]; + currentItems[index] = { ...item, value: e.target.value }; + updateComponent(selectedComponent.id, { + cardItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="내용" + /> +
+ )} +
+
+ ), + )} +
+
+
+
+ )} + + {/* 계산 컴포넌트 설정 */} + {selectedComponent.type === "calculation" && ( + + + 계산 설정 + + + {/* 결과 라벨 */} +
+ + + updateComponent(selectedComponent.id, { + resultLabel: e.target.value, + }) + } + placeholder="합계 금액" + className="h-8" + /> +
+ + {/* 라벨 너비 */} +
+ + + updateComponent(selectedComponent.id, { + labelWidth: Number(e.target.value), + }) + } + min={60} + max={200} + className="h-8" + /> +
+ + {/* 숫자 포맷 */} +
+ + +
+ + {/* 통화 접미사 */} + {selectedComponent.numberFormat === "currency" && ( +
+ + + updateComponent(selectedComponent.id, { + currencySuffix: e.target.value, + }) + } + placeholder="원" + className="h-8" + /> +
+ )} + + {/* 폰트 크기 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueFontSize: Number(e.target.value), + }) + } + min={10} + max={20} + className="h-8" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultFontSize: Number(e.target.value), + }) + } + min={12} + max={24} + className="h-8" + /> +
+
+ + {/* 색상 설정 */} +
+
+ + + updateComponent(selectedComponent.id, { + labelColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + valueColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + + updateComponent(selectedComponent.id, { + resultColor: e.target.value, + }) + } + className="h-8 w-full cursor-pointer p-1" + /> +
+
+ + {/* 계산 항목 목록 관리 */} +
+
+ + +
+ + {/* 쿼리 선택 (데이터 바인딩용) */} +
+ + +
+ + {/* 항목 리스트 */} +
+ {(selectedComponent.calcItems || []).map((item, index: number) => ( +
+
+ 항목 {index + 1} + +
+
+
+ + { + const currentItems = [...(selectedComponent.calcItems || [])]; + currentItems[index] = { ...currentItems[index], label: e.target.value }; + updateComponent(selectedComponent.id, { + calcItems: currentItems, + }); + }} + className="h-6 text-xs" + placeholder="항목명" + /> +
+ {/* 두 번째 항목부터 연산자 표시 */} + {index > 0 && ( +
+ + +
+ )} +
+
+ {selectedComponent.queryId ? ( +
+ + +
+ ) : ( +
+ + { + 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" + /> +
+ )} +
+
+ ))} +
+
+
+
+ )} + {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || @@ -1120,16 +1833,16 @@ export function ReportDesignerRightPanel() { {/* 기본값 (텍스트/라벨만) */} {(selectedComponent.type === "text" || selectedComponent.type === "label") && (
- - 텍스트 내용 +