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/app.ts b/backend-node/src/app.ts index 652677ca..e928f96c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 +import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -255,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 +app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index c8e8ce82..231a7cdc 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3394,13 +3394,23 @@ export async function copyMenu( } : undefined; + // 추가 복사 옵션 (카테고리, 코드, 채번규칙 등) + const additionalCopyOptions = req.body.additionalCopyOptions + ? { + copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true, + copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true, + copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, userId, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 27f03c71..c40c6aa5 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -662,6 +662,10 @@ export const getParentOptions = async ( /** * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API + * + * 다중 부모값 지원: + * - parentValue: 단일 값 (예: "공정검사") + * - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열) */ export const getCascadingOptions = async ( req: AuthenticatedRequest, @@ -669,10 +673,26 @@ export const getCascadingOptions = async ( ) => { try { const { code } = req.params; - const { parentValue } = req.query; + const { parentValue, parentValues } = req.query; const companyCode = req.user?.companyCode || "*"; - if (!parentValue) { + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + // parentValues가 있으면 우선 사용 (다중 선택) + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + // 콤마로 구분된 문자열 + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + // 기존 단일 값 호환 + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { return res.json({ success: true, data: [], @@ -714,13 +734,17 @@ export const getCascadingOptions = async ( const relation = relationResult.rows[0]; - // 자식 옵션 조회 + // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용 + // SQL Injection 방지를 위해 파라미터화된 쿼리 사용 + const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', '); + let optionsQuery = ` - SELECT + SELECT DISTINCT ${relation.child_value_column} as value, - ${relation.child_label_column} as label + ${relation.child_label_column} as label, + ${relation.child_filter_column} as parent_value FROM ${relation.child_table} - WHERE ${relation.child_filter_column} = $1 + WHERE ${relation.child_filter_column} IN (${placeholders}) `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) @@ -730,7 +754,8 @@ export const getCascadingOptions = async ( [relation.child_table] ); - const optionsParams: any[] = [parentValue]; + const optionsParams: any[] = [...parentValueArray]; + let paramIndex = parentValueArray.length + 1; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( @@ -738,8 +763,9 @@ export const getCascadingOptions = async ( tableInfoResult.rowCount > 0 && companyCode !== "*" ) { - optionsQuery += ` AND company_code = $2`; + optionsQuery += ` AND company_code = $${paramIndex}`; optionsParams.push(companyCode); + paramIndex++; } // 정렬 @@ -751,9 +777,9 @@ export const getCascadingOptions = async ( const optionsResult = await pool.query(optionsQuery, optionsParams); - logger.info("연쇄 옵션 조회", { + logger.info("연쇄 옵션 조회 (다중 부모값 지원)", { relationCode: code, - parentValue, + parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts new file mode 100644 index 00000000..41ac330e --- /dev/null +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -0,0 +1,927 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +/** + * 카테고리 값 연쇄관계 그룹 목록 조회 + */ +export const getCategoryValueCascadingGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM category_value_cascading_group + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + params.push(companyCode); + paramIndex++; + } + + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("카테고리 값 연쇄관계 그룹 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 상세 조회 + */ +export const getCategoryValueCascadingGroupById = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupQuery = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE group_id = $1 + `; + + const groupParams: any[] = [groupId]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingQuery = ` + SELECT + mapping_id, + parent_value_code, + parent_value_label, + child_value_code, + child_value_label, + display_order, + is_active + FROM category_value_cascading_mapping + WHERE group_id = $1 AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [groupId]); + + // 부모 값별로 자식 값 그룹화 + const mappingsByParent: Record = {}; + for (const row of mappingResult.rows) { + const parentKey = row.parent_value_code; + if (!mappingsByParent[parentKey]) { + mappingsByParent[parentKey] = []; + } + mappingsByParent[parentKey].push({ + childValueCode: row.child_value_code, + childValueLabel: row.child_value_label, + displayOrder: row.display_order, + }); + } + + return res.json({ + success: true, + data: { + ...groupResult.rows[0], + mappings: mappingResult.rows, + mappingsByParent, + }, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 관계 코드로 조회 + */ +export const getCategoryValueCascadingByCode = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const params: any[] = [code]; + + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + } + + query += ` LIMIT 1`; + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 코드 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 생성 + */ +export const createCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange = true, + showGroupLabel = true, + emptyParentMessage, + noOptionsMessage, + } = req.body; + + // 필수 필드 검증 + if (!relationCode || !relationName || !parentTableName || !parentColumnName || !childTableName || !childColumnName) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group + WHERE relation_code = $1 AND (company_code = $2 OR company_code = '*')`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO category_value_cascading_group ( + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'Y', $15, NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTableName, + parentColumnName, + parentMenuObjid || null, + childTableName, + childColumnName, + childMenuObjid || null, + clearOnParentChange ? "Y" : "N", + showGroupLabel ? "Y" : "N", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + companyCode, + userId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 생성", { + groupId: result.rows[0].group_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 수정 + */ +export const updateCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange, + showGroupLabel, + emptyParentMessage, + noOptionsMessage, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE category_value_cascading_group SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table_name = COALESCE($3, parent_table_name), + parent_column_name = COALESCE($4, parent_column_name), + parent_menu_objid = COALESCE($5, parent_menu_objid), + child_table_name = COALESCE($6, child_table_name), + child_column_name = COALESCE($7, child_column_name), + child_menu_objid = COALESCE($8, child_menu_objid), + clear_on_parent_change = COALESCE($9, clear_on_parent_change), + show_group_label = COALESCE($10, show_group_label), + empty_parent_message = COALESCE($11, empty_parent_message), + no_options_message = COALESCE($12, no_options_message), + is_active = COALESCE($13, is_active), + updated_by = $14, + updated_date = NOW() + WHERE group_id = $15 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null, + showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, + emptyParentMessage, + noOptionsMessage, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + groupId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 수정", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 삭제 + */ +export const deleteCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 + await pool.query( + `UPDATE category_value_cascading_group + SET is_active = 'N', updated_by = $1, updated_date = NOW() + WHERE group_id = $2`, + [userId, groupId] + ); + + logger.info("카테고리 값 연쇄관계 그룹 삭제", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 카테고리 값 연쇄관계 매핑 CRUD +// ============================================ + +/** + * 매핑 일괄 저장 (기존 매핑 교체) + */ +export const saveCategoryValueCascadingMappings = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { mappings } = req.body; // [{ parentValueCode, parentValueLabel, childValueCode, childValueLabel, displayOrder }] + + if (!Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "mappings는 배열이어야 합니다.", + }); + } + + // 그룹 존재 확인 + const groupCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group WHERE group_id = $1 AND is_active = 'Y'`, + [groupId] + ); + + if (groupCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 트랜잭션으로 처리 + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 매핑 삭제 (하드 삭제) + await client.query( + `DELETE FROM category_value_cascading_mapping WHERE group_id = $1`, + [groupId] + ); + + // 새 매핑 삽입 + if (mappings.length > 0) { + const insertQuery = ` + INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', NOW()) + `; + + for (const mapping of mappings) { + await client.query(insertQuery, [ + groupId, + mapping.parentValueCode, + mapping.parentValueLabel || null, + mapping.childValueCode, + mapping.childValueLabel || null, + mapping.displayOrder || 0, + companyCode, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 연쇄관계 매핑 저장", { + groupId, + mappingCount: mappings.length, + companyCode, + }); + + return res.json({ + success: true, + message: `${mappings.length}개의 매핑이 저장되었습니다.`, + }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +/** + * 카테고리 값 연쇄 옵션 조회 + * 부모 값(들)에 해당하는 자식 카테고리 값 목록 반환 + * 다중 부모값 지원 + */ +export const getCategoryValueCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const { parentValue, parentValues } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let groupQuery = ` + SELECT group_id, show_group_label + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) + const placeholders = parentValueArray.map((_, idx) => `$${idx + 2}`).join(', '); + + const optionsQuery = ` + SELECT DISTINCT + child_value_code as value, + child_value_label as label, + parent_value_code as parent_value, + parent_value_label as parent_label, + display_order + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND parent_value_code IN (${placeholders}) + AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const optionsResult = await pool.query(optionsQuery, [group.group_id, ...parentValueArray]); + + logger.info("카테고리 값 연쇄 옵션 조회", { + relationCode: code, + parentValues: parentValueArray, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + showGroupLabel: group.show_group_label === 'Y', + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 부모 카테고리 값 목록 조회 + */ +export const getCategoryValueCascadingParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + parent_table_name, + parent_column_name, + parent_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 부모 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [group.parent_table_name, group.parent_column_name]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.parent_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.parent_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 카테고리 값 조회", { + relationCode: code, + tableName: group.parent_table_name, + columnName: group.parent_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) + */ +export const getCategoryValueCascadingChildOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + child_table_name, + child_column_name, + child_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 자식 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [group.child_table_name, group.child_column_name]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.child_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.child_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("자식 카테고리 값 조회", { + relationCode: code, + tableName: group.child_table_name, + columnName: group.child_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("자식 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "자식 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + 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/categoryValueCascadingRoutes.ts b/backend-node/src/routes/categoryValueCascadingRoutes.ts new file mode 100644 index 00000000..d8919627 --- /dev/null +++ b/backend-node/src/routes/categoryValueCascadingRoutes.ts @@ -0,0 +1,64 @@ +import { Router } from "express"; +import { + getCategoryValueCascadingGroups, + getCategoryValueCascadingGroupById, + getCategoryValueCascadingByCode, + createCategoryValueCascadingGroup, + updateCategoryValueCascadingGroup, + deleteCategoryValueCascadingGroup, + saveCategoryValueCascadingMappings, + getCategoryValueCascadingOptions, + getCategoryValueCascadingParentOptions, + getCategoryValueCascadingChildOptions, +} from "../controllers/categoryValueCascadingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +// 그룹 목록 조회 +router.get("/groups", getCategoryValueCascadingGroups); + +// 그룹 상세 조회 (ID) +router.get("/groups/:groupId", getCategoryValueCascadingGroupById); + +// 관계 코드로 조회 +router.get("/code/:code", getCategoryValueCascadingByCode); + +// 그룹 생성 +router.post("/groups", createCategoryValueCascadingGroup); + +// 그룹 수정 +router.put("/groups/:groupId", updateCategoryValueCascadingGroup); + +// 그룹 삭제 +router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup); + +// ============================================ +// 카테고리 값 연쇄관계 매핑 +// ============================================ + +// 매핑 일괄 저장 +router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings); + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +// 부모 카테고리 값 목록 조회 +router.get("/parent-options/:code", getCategoryValueCascadingParentOptions); + +// 자식 카테고리 값 목록 조회 (매핑 설정 UI용) +router.get("/child-options/:code", getCategoryValueCascadingChildOptions); + +// 연쇄 옵션 조회 (부모 값 기반 자식 옵션) +router.get("/options/:code", getCategoryValueCascadingOptions); + +export default router; + 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/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b12d7a4a..b5266377 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,12 +10,27 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories: number; + copiedCodes: number; + copiedNumberingRules: number; + copiedCategoryMappings: number; + copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } +/** + * 추가 복사 옵션 + */ +export interface AdditionalCopyOptions { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 +} + /** * 메뉴 정보 */ @@ -431,12 +446,13 @@ export class MenuCopyService { * properties 내부 참조 업데이트 */ /** - * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, - flowIdMap: Map + flowIdMap: Map, + numberingRuleIdMap?: Map ): any { if (!properties) return properties; @@ -444,7 +460,7 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); return updated; } @@ -456,7 +472,8 @@ export class MenuCopyService { obj: any, screenIdMap: Map, flowIdMap: Map, - path: string = "" + path: string = "", + numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -467,7 +484,8 @@ export class MenuCopyService { item, screenIdMap, flowIdMap, - `${path}[${index}]` + `${path}[${index}]`, + numberingRuleIdMap ); }); return; @@ -518,13 +536,25 @@ export class MenuCopyService { } } + // numberingRuleId 매핑 (문자열) + if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + const newRuleId = numberingRuleIdMap.get(value); + if (newRuleId) { + obj[key] = newRuleId; + logger.info( + ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` + ); + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( value, screenIdMap, flowIdMap, - currentPath + currentPath, + numberingRuleIdMap ); } } @@ -534,6 +564,8 @@ export class MenuCopyService { * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 + * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ private async deleteExistingCopy( sourceMenuObjid: number, @@ -542,9 +574,9 @@ export class MenuCopyService { ): Promise { logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); - // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + // 1. 원본 메뉴 정보 확인 const sourceMenuResult = await client.query( - `SELECT menu_name_kor, menu_name_eng + `SELECT menu_name_kor, menu_name_eng, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] @@ -556,14 +588,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; + const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) - const existingMenuResult = await client.query<{ objid: number }>( - `SELECT objid + // 최상위/하위 구분 없이 모든 복사본 검색 + const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 - AND company_code = $2 - AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); @@ -573,11 +606,14 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; + const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; + logger.info( - `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` ); - // 3. 기존 메뉴 트리 수집 + // 3. 기존 메뉴 트리 수집 (해당 메뉴 + 하위 메뉴 모두) const existingMenus = await this.collectMenuTree(existingMenuObjid, client); const existingMenuIds = existingMenus.map((m) => m.objid); @@ -595,16 +631,7 @@ export class MenuCopyService { // 5. 삭제 순서 (외래키 제약 고려) - // 5-1. 화면 레이아웃 삭제 - if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, - [screenIds] - ); - logger.info(` ✅ 화면 레이아웃 삭제 완료`); - } - - // 5-2. 화면-메뉴 할당 삭제 + // 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, @@ -612,23 +639,47 @@ export class MenuCopyService { ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); - // 5-3. 화면 정의 삭제 + // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_definitions + // 다른 메뉴에서도 사용 중인 화면 ID 조회 + const sharedScreensResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - logger.info(` ✅ 화면 정의 삭제 완료`); + const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + + // 공유되지 않은 화면만 삭제 + const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + + if (screensToDelete.length > 0) { + // 레이아웃 삭제 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screensToDelete] + ); + + // 화면 정의 삭제 + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screensToDelete, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); + } + + if (sharedScreenIds.size > 0) { + logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + } } - // 5-4. 메뉴 권한 삭제 + // 5-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ @@ -650,7 +701,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + additionalCopyOptions?: AdditionalCopyOptions ): Promise { logger.info(` 🚀 ============================================ @@ -702,6 +754,36 @@ export class MenuCopyService { client ); + // === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) === + let copiedCodeCategories = 0; + let copiedCodes = 0; + let copiedNumberingRules = 0; + let copiedCategoryMappings = 0; + let copiedTableTypeColumns = 0; + let numberingRuleIdMap = new Map(); + + const menuObjids = menus.map((m) => m.objid); + + // 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요) + const tempMenuIdMap = new Map(); + let tempObjId = await this.getNextMenuObjid(client); + for (const menu of menus) { + tempMenuIdMap.set(menu.objid, tempObjId++); + } + + if (additionalCopyOptions?.copyNumberingRules) { + logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)"); + const ruleResult = await this.copyNumberingRulesWithMap( + menuObjids, + tempMenuIdMap, + targetCompanyCode, + userId, + client + ); + copiedNumberingRules = ruleResult.copiedCount; + numberingRuleIdMap = ruleResult.ruleIdMap; + } + // === 3단계: 화면 복사 === logger.info("\n📄 [3단계] 화면 복사"); const screenIdMap = await this.copyScreens( @@ -710,7 +792,8 @@ export class MenuCopyService { flowIdMap, userId, client, - screenNameConfig + screenNameConfig, + numberingRuleIdMap ); // === 4단계: 메뉴 복사 === @@ -718,6 +801,7 @@ export class MenuCopyService { const menuIdMap = await this.copyMenus( menus, sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 + sourceCompanyCode, targetCompanyCode, screenIdMap, userId, @@ -734,6 +818,46 @@ export class MenuCopyService { client ); + // === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) === + if (additionalCopyOptions) { + // 6-1. 코드 카테고리 + 코드 복사 + if (additionalCopyOptions.copyCodeCategory) { + logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // 6-2. 카테고리 매핑 + 값 복사 + if (additionalCopyOptions.copyCategoryMapping) { + logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // 6-3. 테이블 타입관리 입력타입 설정 복사 + if (additionalCopyOptions.copyTableTypeColumns) { + logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), // 원본 화면 IDs + sourceCompanyCode, + targetCompanyCode, + client + ); + } + } + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -743,6 +867,11 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, + copiedCodeCategories, + copiedCodes, + copiedNumberingRules, + copiedCategoryMappings, + copiedTableTypeColumns, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -755,8 +884,11 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. + - 코드 카테고리: ${copiedCodeCategories}개 + - 코드: ${copiedCodes}개 + - 채번규칙: ${copiedNumberingRules}개 + - 카테고리 매핑: ${copiedCategoryMappings}개 + - 테이블 타입 설정: ${copiedTableTypeColumns}개 ============================================ `); @@ -949,7 +1081,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + numberingRuleIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -984,7 +1117,7 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; // 2) 기존 복사본 찾기: source_screen_id로 검색 - const existingCopyResult = await client.query<{ + let existingCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; @@ -996,6 +1129,36 @@ export class MenuCopyService { [originalScreenId, targetCompanyCode] ); + // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { + existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE screen_name = $1 + AND table_name = $2 + AND company_code = $3 + AND source_screen_id IS NULL + AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_name, screenDef.table_name, targetCompanyCode] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) + await client.query( + `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, + [originalScreenId, existingCopyResult.rows[0].screen_id] + ); + logger.info( + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id} ← ${originalScreenId}` + ); + } + } + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { @@ -1185,7 +1348,8 @@ export class MenuCopyService { const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, - flowIdMap + flowIdMap, + numberingRuleIdMap ); await client.query( @@ -1332,12 +1496,76 @@ export class MenuCopyService { return screenCode; } + /** + * 대상 회사에서 부모 메뉴 찾기 + * - 원본 메뉴의 parent_obj_id를 source_menu_objid로 가진 메뉴를 대상 회사에서 검색 + * - 2레벨 이하 메뉴 복사 시 기존에 복사된 부모 메뉴와 연결하기 위함 + */ + private async findParentMenuInTargetCompany( + originalParentObjId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + // 1. 대상 회사에서 source_menu_objid가 원본 부모 ID인 메뉴 찾기 + const result = await client.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [originalParentObjId, targetCompanyCode] + ); + + if (result.rows.length > 0) { + return result.rows[0].objid; + } + + // 2. source_menu_objid로 못 찾으면, 동일 원본 회사에서 복사된 메뉴 중 같은 이름으로 찾기 (fallback) + // 원본 부모 메뉴 정보 조회 + const parentMenuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [originalParentObjId] + ); + + if (parentMenuResult.rows.length === 0) { + return null; + } + + const parentMenu = parentMenuResult.rows[0]; + + // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 + const sameNameResult = await client.query<{ objid: number }>( + `SELECT m.objid FROM menu_info m + WHERE m.menu_name_kor = $1 + AND m.company_code = $2 + AND m.source_menu_objid IS NOT NULL + AND EXISTS ( + SELECT 1 FROM menu_info orig + WHERE orig.objid = m.source_menu_objid + AND orig.company_code = $3 + ) + LIMIT 1`, + [parentMenu.menu_name_kor, targetCompanyCode, sourceCompanyCode] + ); + + if (sameNameResult.rows.length > 0) { + logger.info( + ` 📎 이름으로 부모 메뉴 찾음: "${parentMenu.menu_name_kor}" → objid: ${sameNameResult.rows[0].objid}` + ); + return sameNameResult.rows[0].objid; + } + + return null; + } + /** * 메뉴 복사 */ private async copyMenus( menus: Menu[], rootMenuObjid: number, + sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Map, userId: string, @@ -1357,27 +1585,106 @@ export class MenuCopyService { for (const menu of sortedMenus) { try { - // 새 objid 생성 - const newObjId = await this.getNextMenuObjid(client); + // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) + // 1차: source_menu_objid로 검색 + let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [menu.objid, targetCompanyCode] + ); - // parent_obj_id 재매핑 - // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { + existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE menu_name_kor = $1 + AND company_code = $2 + AND menu_type = $3 + AND source_menu_objid IS NULL + LIMIT 1`, + [menu.menu_name_kor, targetCompanyCode, menu.menu_type] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_menu_objid 업데이트 (마이그레이션) + await client.query( + `UPDATE menu_info SET source_menu_objid = $1 WHERE objid = $2`, + [menu.objid, existingCopyResult.rows[0].objid] + ); + logger.info( + ` 📝 기존 메뉴에 source_menu_objid 추가: ${existingCopyResult.rows[0].objid} ← ${menu.objid}` + ); + } + } + + // parent_obj_id 계산 (신규/재연결 모두 필요) let newParentObjId: number | null; if (!menu.parent_obj_id || menu.parent_obj_id === 0) { newParentObjId = 0; // 최상위 메뉴는 항상 0 } else { - newParentObjId = - menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + // 1. 현재 복사 세션에서 부모가 이미 복사되었는지 확인 + newParentObjId = menuIdMap.get(menu.parent_obj_id) || null; + + // 2. 현재 세션에서 못 찾으면, 대상 회사에서 기존에 복사된 부모 찾기 + if (!newParentObjId) { + const existingParent = await this.findParentMenuInTargetCompany( + menu.parent_obj_id, + sourceCompanyCode, + targetCompanyCode, + client + ); + + if (existingParent) { + newParentObjId = existingParent; + logger.info( + ` 🔗 기존 부모 메뉴 연결: 원본 ${menu.parent_obj_id} → 대상 ${existingParent}` + ); + } else { + // 3. 부모를 못 찾으면 최상위로 설정 (경고 로그) + newParentObjId = 0; + logger.warn( + ` ⚠️ 부모 메뉴를 찾을 수 없음: ${menu.parent_obj_id} - 최상위로 생성됨` + ); + } + } } - // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) - // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 - const isRootMenu = String(menu.objid) === String(rootMenuObjid); - const sourceMenuObjid = isRootMenu ? menu.objid : null; + if (existingCopyResult.rows.length > 0) { + // === 이미 복사된 메뉴가 있는 경우: 재연결만 === + const existingMenu = existingCopyResult.rows[0]; + const existingObjId = existingMenu.objid; + const existingParentId = existingMenu.parent_obj_id; - if (sourceMenuObjid) { + // 부모가 다르면 업데이트 (고아 메뉴 재연결) + if (existingParentId !== newParentObjId) { + await client.query( + `UPDATE menu_info SET parent_obj_id = $1, writer = $2 WHERE objid = $3`, + [newParentObjId, userId, existingObjId] + ); + logger.info( + ` ♻️ 메뉴 재연결: ${menu.objid} → ${existingObjId} (${menu.menu_name_kor}), parent: ${existingParentId} → ${newParentObjId}` + ); + } else { + logger.info( + ` ⏭️ 메뉴 이미 존재 (스킵): ${menu.objid} → ${existingObjId} (${menu.menu_name_kor})` + ); + } + + menuIdMap.set(menu.objid, existingObjId); + continue; + } + + // === 신규 메뉴 복사 === + const newObjId = await this.getNextMenuObjid(client); + + // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) + const sourceMenuObjid = menu.objid; + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + + if (isRootMenu) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (복사 시작 메뉴)` ); } @@ -1486,4 +1793,430 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } + /** + * 코드 카테고리 + 코드 복사 + */ + private async copyCodeCategoriesAndCodes( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCategories: number; copiedCodes: number }> { + let copiedCategories = 0; + let copiedCodes = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 코드 카테고리 조회 + const categoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const category of categoriesResult.rows) { + // 대상 회사에 같은 category_code가 이미 있는지 확인 + const existingCategory = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = $1 AND company_code = $2`, + [category.category_code, targetCompanyCode] + ); + + if (existingCategory.rows.length > 0) { + logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCategories++; + logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`); + + // 2. 해당 카테고리의 코드 조회 및 복사 + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2`, + [category.category_code, menuObjid] + ); + + for (const code of codesResult.rows) { + // 대상 회사에 같은 code_value가 이미 있는지 확인 + const existingCode = await client.query( + `SELECT code_value FROM code_info + WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, + [category.category_code, code.code_value, targetCompanyCode] + ); + + if (existingCode.rows.length > 0) { + logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); + continue; + } + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`, + [ + category.category_code, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCodes++; + } + logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`); + } + } + + logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + return { copiedCategories, copiedCodes }; + } + + /** + * 채번 규칙 복사 (ID 매핑 반환 버전) + * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 + */ + private async copyNumberingRulesWithMap( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; ruleIdMap: Map }> { + let copiedCount = 0; + const ruleIdMap = new Map(); + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 채번 규칙 조회 + const rulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const rule of rulesResult.rows) { + // 대상 회사에 같은 rule_id가 이미 있는지 확인 + const existingRule = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2`, + [rule.rule_id, targetCompanyCode] + ); + + if (existingRule.rows.length > 0) { + logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) + ruleIdMap.set(rule.rule_id, rule.rule_id); + continue; + } + + // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) + const originalSuffix = rule.rule_id.includes('_') + ? rule.rule_id.replace(/^[^_]*_/, '') + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + + // 매핑 저장 (원본 rule_id → 새 rule_id) + ruleIdMap.set(rule.rule_id, newRuleId); + + // 채번 규칙 복사 + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 0, // 시퀀스는 0부터 시작 + rule.table_name, + rule.column_name, + targetCompanyCode, + userId, + newMenuObjid, + rule.scope_type, + null, // 마지막 생성일은 null로 초기화 + ] + ); + copiedCount++; + logger.info(` ✅ 채번규칙 복사: ${rule.rule_id} → ${newRuleId}`); + + // 채번 규칙 파트 복사 + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + [rule.rule_id] + ); + + for (const part of partsResult.rows) { + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config, + part.manual_config, + targetCompanyCode, + ] + ); + } + logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`); + } + } + + logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + return { copiedCount, ruleIdMap }; + } + + /** + * 카테고리 매핑 + 값 복사 + */ + private async copyCategoryMappingsAndValues( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + let copiedCount = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 카테고리 컬럼 매핑 조회 + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const mapping of mappingsResult.rows) { + // 대상 회사에 같은 매핑이 이미 있는지 확인 + const existingMapping = await client.query( + `SELECT mapping_id FROM category_column_mapping + WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`, + [mapping.table_name, mapping.logical_column_name, targetCompanyCode] + ); + + let newMappingId: number; + + if (existingMapping.rows.length > 0) { + logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`); + newMappingId = existingMapping.rows[0].mapping_id; + } else { + // 매핑 복사 + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) + RETURNING mapping_id`, + [ + mapping.table_name, + mapping.logical_column_name, + mapping.physical_column_name, + newMenuObjid, + targetCompanyCode, + mapping.description, + userId, + ] + ); + newMappingId = insertResult.rows[0].mapping_id; + copiedCount++; + logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`); + } + + // 2. 카테고리 값 조회 및 복사 (menu_objid 기준) + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3 + ORDER BY parent_value_id NULLS FIRST, value_order`, + [mapping.table_name, mapping.logical_column_name, menuObjid] + ); + + // 값 ID 매핑 (부모-자식 관계 유지를 위해) + const valueIdMap = new Map(); + + for (const value of valuesResult.rows) { + // 대상 회사에 같은 값이 이미 있는지 확인 + const existingValue = await client.query( + `SELECT value_id FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, + [value.table_name, value.column_name, value.value_code, targetCompanyCode] + ); + + if (existingValue.rows.length > 0) { + valueIdMap.set(value.value_id, existingValue.rows[0].value_id); + continue; + } + + // 부모 ID 재매핑 + const newParentId = value.parent_value_id + ? valueIdMap.get(value.parent_value_id) || null + : null; + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, company_code, created_at, created_by, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15) + RETURNING value_id`, + [ + value.table_name, + value.column_name, + value.value_code, + value.value_label, + value.value_order, + newParentId, + value.depth, + value.description, + value.color, + value.icon, + value.is_active, + value.is_default, + targetCompanyCode, + userId, + newMenuObjid, + ] + ); + + valueIdMap.set(value.value_id, insertResult.rows[0].value_id); + } + + if (valuesResult.rows.length > 0) { + logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`); + } + } + } + + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + /** + * 테이블 타입관리 입력타입 설정 복사 + * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 + */ + private async copyTableTypeColumns( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.length === 0) { + return 0; + } + + logger.info(`📋 테이블 타입 설정 복사 시작`); + logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + + // 1. 복사된 화면에서 사용하는 테이블 목록 조회 + const tablesResult = await client.query<{ table_name: string }>( + `SELECT DISTINCT table_name FROM screen_definitions + WHERE screen_id = ANY($1) AND table_name IS NOT NULL AND table_name != ''`, + [screenIds] + ); + + if (tablesResult.rows.length === 0) { + logger.info(" ⚠️ 복사된 화면에 테이블이 없음"); + return 0; + } + + const tableNames = tablesResult.rows.map((r) => r.table_name); + logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + + let copiedCount = 0; + + for (const tableName of tableNames) { + // 2. 원본 회사의 테이블 타입 설정 조회 + const sourceSettings = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = $1 AND company_code = $2`, + [tableName, sourceCompanyCode] + ); + + if (sourceSettings.rows.length === 0) { + logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`); + continue; + } + + for (const setting of sourceSettings.rows) { + // 3. 대상 회사에 같은 설정이 이미 있는지 확인 + const existing = await client.query( + `SELECT id FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, + [setting.table_name, setting.column_name, targetCompanyCode] + ); + + if (existing.rows.length > 0) { + // 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지) + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)` + ); + continue; + } + + // 새로 삽입 + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, + [ + setting.table_name, + setting.column_name, + setting.input_type, + setting.detail_settings, + setting.is_nullable, + setting.display_order, + targetCompanyCode, + ] + ); + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 신규 추가` + ); + copiedCount++; + } + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); + return copiedCount; + } + } 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/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 70382dd9..5b5f6b37 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab"; import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; +import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -20,7 +21,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); @@ -46,7 +47,7 @@ export default function CascadingManagementPage() { {/* 탭 네비게이션 */} - + 2단계 연쇄관계 @@ -72,6 +73,11 @@ export default function CascadingManagementPage() { 상호 배제 배제 + + + 카테고리값 + 카테고리 + {/* 탭 컨텐츠 */} @@ -95,6 +101,10 @@ export default function CascadingManagementPage() { + + + + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx new file mode 100644 index 00000000..ccb439e1 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx @@ -0,0 +1,1009 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + categoryValueCascadingApi, + CategoryValueCascadingGroup, + CategoryValueCascadingGroupInput, + CategoryValueCascadingMappingInput, +} from "@/lib/api/categoryValueCascading"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableInfo { + tableName: string; + displayName: string; + description?: string; + columnCount?: number; +} + +interface ColumnInfo { + columnName: string; + displayName: string; + dataType?: string; + inputType?: string; + input_type?: string; +} + +interface CategoryValue { + value: string; + label: string; +} + +export default function CategoryValueCascadingTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 테이블/컬럼 목록 + const [tables, setTables] = useState([]); + const [parentColumns, setParentColumns] = useState([]); + const [childColumns, setChildColumns] = useState([]); + + // Combobox 상태 + const [parentTableOpen, setParentTableOpen] = useState(false); + const [childTableOpen, setChildTableOpen] = useState(false); + + // 모달 상태 + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isMappingModalOpen, setIsMappingModalOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState({ + relationCode: "", + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + + // 매핑 상태 + const [parentValues, setParentValues] = useState([]); + const [childValues, setChildValues] = useState([]); + const [mappings, setMappings] = useState>>({}); + const [savingMappings, setSavingMappings] = useState(false); + + // 직접 입력 매핑 상태 (각 부모값에 대한 하위 옵션 목록) + const [childOptionsMap, setChildOptionsMap] = useState>({}); + const [newOptionInputs, setNewOptionInputs] = useState>({}); + + // 검색 + const [searchText, setSearchText] = useState(""); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await categoryValueCascadingApi.getGroups("Y"); + if (response.success && response.data) { + setGroups(response.data); + } else { + setError(response.error || "그룹 목록 로드 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + console.log("📦 테이블 목록 로드 시작"); + const response = await tableManagementApi.getTableList(); + console.log("📦 테이블 목록 응답:", response); + if (response.success && response.data) { + console.log("✅ 테이블 목록 로드 성공:", response.data.length, "개"); + setTables(response.data); + } else { + console.error("❌ 테이블 목록 로드 실패:", response); + } + } catch (err: any) { + console.error("테이블 목록 로드 실패:", err); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, target: "parent" | "child") => { + if (!tableName) { + if (target === "parent") setParentColumns([]); + else setChildColumns([]); + return; + } + + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data) { + // API 응답 형식: { columns: [...], total, page, ... } + const columns = response.data.columns || response.data; + const columnsArray = Array.isArray(columns) ? columns : []; + + // category 타입 컬럼만 필터링 + const categoryColumns = columnsArray.filter( + (col: any) => col.input_type === "category" || col.inputType === "category" + ); + + // 인터페이스에 맞게 변환 + const mappedColumns: ColumnInfo[] = categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + displayName: col.displayName || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + inputType: col.inputType || col.input_type, + })); + + if (target === "parent") setParentColumns(mappedColumns); + else setChildColumns(mappedColumns); + } + } catch (err: any) { + console.error("컬럼 목록 로드 실패:", err); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + }, [loadGroups, loadTables]); + + // 필터링된 그룹 + const filteredGroups = groups.filter((group) => { + if (!searchText) return true; + const lowerSearch = searchText.toLowerCase(); + return ( + group.relation_code.toLowerCase().includes(lowerSearch) || + group.relation_name.toLowerCase().includes(lowerSearch) || + group.parent_table_name.toLowerCase().includes(lowerSearch) || + group.child_table_name.toLowerCase().includes(lowerSearch) + ); + }); + + // 폼 초기화 + const resetForm = () => { + // 자동 관계코드 생성 + const autoCode = `CVC_${Date.now()}`; + setFormData({ + relationCode: autoCode, + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + setParentColumns([]); + setChildColumns([]); + }; + + // 생성 모달 열기 + const openCreateModal = async () => { + resetForm(); + // 테이블 목록이 없으면 다시 로드 + if (tables.length === 0) { + console.log("📦 테이블 목록이 비어있어서 다시 로드"); + await loadTables(); + } + setIsCreateModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setFormData({ + relationCode: group.relation_code, + relationName: group.relation_name, + description: group.description || "", + parentTableName: group.parent_table_name, + parentColumnName: group.parent_column_name, + childTableName: group.child_table_name, + childColumnName: group.child_column_name, + clearOnParentChange: group.clear_on_parent_change === "Y", + showGroupLabel: group.show_group_label === "Y", + emptyParentMessage: group.empty_parent_message || "", + noOptionsMessage: group.no_options_message || "", + }); + loadColumns(group.parent_table_name, "parent"); + loadColumns(group.child_table_name, "child"); + setIsEditModalOpen(true); + }; + + // 매핑 모달 열기 + const openMappingModal = async (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setMappings({}); + setParentValues([]); + setChildValues([]); + setChildOptionsMap({}); + setNewOptionInputs({}); + + try { + // 부모 카테고리 값과 기존 매핑 로드 + const [parentResponse, groupDetailResponse] = await Promise.all([ + categoryValueCascadingApi.getParentOptions(group.relation_code), + categoryValueCascadingApi.getGroupById(group.group_id), + ]); + + if (parentResponse.success && parentResponse.data) { + setParentValues(parentResponse.data); + + // 부모 값별 입력창 초기화 + const inputs: Record = {}; + for (const pv of parentResponse.data) { + inputs[pv.value] = ""; + } + setNewOptionInputs(inputs); + } + + // 기존 매핑을 직접 입력 형태로 변환 + if (groupDetailResponse.success && groupDetailResponse.data?.mappings) { + const optionsMap: Record = {}; + + for (const mapping of groupDetailResponse.data.mappings) { + const parentCode = mapping.parent_value_code; + if (!optionsMap[parentCode]) { + optionsMap[parentCode] = []; + } + // 중복 체크 + if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) { + optionsMap[parentCode].push({ + code: mapping.child_value_code, + label: mapping.child_value_label || mapping.child_value_code, + }); + } + } + setChildOptionsMap(optionsMap); + } + + setIsMappingModalOpen(true); + } catch (err: any) { + console.error("매핑 데이터 로드 실패:", err); + setError("매핑 데이터 로드 실패"); + } + }; + + // 고유 코드 생성 함수 + const generateUniqueCode = () => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `TARGET_${timestamp}${random}`; + }; + + // 하위 옵션 추가 + const addChildOption = (parentValue: string) => { + const inputValue = newOptionInputs[parentValue]?.trim(); + if (!inputValue) return; + + // 자동 고유 코드 생성 + const code = generateUniqueCode(); + + setChildOptionsMap((prev) => { + const currentOptions = prev[parentValue] || []; + // 중복 체크 (라벨만 체크 - 코드는 항상 고유) + if (currentOptions.some((opt) => opt.label === inputValue)) { + return prev; + } + return { + ...prev, + [parentValue]: [...currentOptions, { code, label: inputValue }], + }; + }); + + // 입력창 초기화 + setNewOptionInputs((prev) => ({ ...prev, [parentValue]: "" })); + }; + + // 하위 옵션 삭제 + const removeChildOption = (parentValue: string, optionCode: string) => { + setChildOptionsMap((prev) => ({ + ...prev, + [parentValue]: (prev[parentValue] || []).filter((opt) => opt.code !== optionCode), + })); + }; + + // 그룹 생성 + const handleCreate = async () => { + try { + const response = await categoryValueCascadingApi.createGroup(formData); + if (response.success) { + setIsCreateModalOpen(false); + loadGroups(); + } else { + setError(response.error || "생성 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 수정 + const handleUpdate = async () => { + if (!selectedGroup) return; + + try { + const response = await categoryValueCascadingApi.updateGroup(selectedGroup.group_id, formData); + if (response.success) { + setIsEditModalOpen(false); + loadGroups(); + } else { + setError(response.error || "수정 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 삭제 + const handleDelete = async (group: CategoryValueCascadingGroup) => { + if (!confirm(`"${group.relation_name}" 연쇄관계를 삭제하시겠습니까?`)) return; + + try { + const response = await categoryValueCascadingApi.deleteGroup(group.group_id); + if (response.success) { + loadGroups(); + } else { + setError(response.error || "삭제 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 매핑 토글 + const toggleMapping = (parentCode: string, childCode: string) => { + setMappings((prev) => { + const newMappings = { ...prev }; + if (!newMappings[parentCode]) { + newMappings[parentCode] = new Set(); + } + + const newSet = new Set(newMappings[parentCode]); + if (newSet.has(childCode)) { + newSet.delete(childCode); + } else { + newSet.add(childCode); + } + newMappings[parentCode] = newSet; + + return newMappings; + }); + }; + + // 매핑 저장 + const handleSaveMappings = async () => { + if (!selectedGroup) return; + + setSavingMappings(true); + try { + // 직접 입력된 옵션으로 매핑 데이터 생성 + const mappingInputs: CategoryValueCascadingMappingInput[] = []; + let displayOrder = 0; + + for (const parentCode of Object.keys(childOptionsMap)) { + const parentValue = parentValues.find((p) => p.value === parentCode); + const childOptions = childOptionsMap[parentCode] || []; + + for (const childOption of childOptions) { + mappingInputs.push({ + parentValueCode: parentCode, + parentValueLabel: parentValue?.label, + childValueCode: childOption.code, + childValueLabel: childOption.label, + displayOrder: displayOrder++, + }); + } + } + + const response = await categoryValueCascadingApi.saveMappings( + selectedGroup.group_id, + mappingInputs + ); + + if (response.success) { + setIsMappingModalOpen(false); + } else { + setError(response.error || "매핑 저장 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setSavingMappings(false); + } + }; + + // 활성/비활성 토글 + const toggleActive = async (group: CategoryValueCascadingGroup) => { + try { + const newActive = group.is_active !== "Y"; + const response = await categoryValueCascadingApi.updateGroup(group.group_id, { + isActive: newActive, + }); + if (response.success) { + loadGroups(); + } + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+ {/* 설명 */} +
+

카테고리 값 연쇄관계

+

+ 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시 +

+
+ + {/* 에러 메시지 */} + {error && ( +
+
+

오류

+ +
+

{error}

+
+ )} + + {/* 검색 및 액션 */} +
+
+
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ +
+
+ 총 {filteredGroups.length} 건 +
+ + +
+
+ + {/* 테이블 */} +
+ + + + 관계코드 + 관계명 + 부모 (테이블.컬럼) + 자식 (테이블.컬럼) + 사용 + 관리 + + + + {loading ? ( + Array.from({ length: 5 }).map((_, idx) => ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + )) + ) : filteredGroups.length === 0 ? ( + + + 등록된 카테고리 값 연쇄관계가 없습니다. + + + ) : ( + filteredGroups.map((group) => ( + + {group.relation_code} + {group.relation_name} + + {group.parent_table_name}. + {group.parent_column_name} + + + {group.child_table_name}. + {group.child_column_name} + + + toggleActive(group)} + aria-label="활성화 토글" + /> + + +
+ + + +
+
+
+ )) + )} + +
+
+ + {/* 생성 모달 */} + + + + 카테고리 값 연쇄관계 등록 + + 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. + + + +
+ {/* 기본 정보 */} +
+ + setFormData({ ...formData, relationName: e.target.value })} + placeholder="예: 검사유형-적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연쇄관계 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모 설정 */} +
+

부모 카테고리 설정

+
+
+ + + + + + + + + + + {tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."} + + + {tables.map((table) => ( + { + setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName }); + loadColumns(table.tableName, "parent"); + setParentTableOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.displayName && table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + +
+
+
+ + {/* 자식 옵션 라벨 설정 */} +
+

하위 옵션 설정

+

+ 부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다. +
+ 실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다. +

+
+ + setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })} + placeholder="예: 적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + 카테고리 값 연쇄관계 수정 + + 연쇄관계 설정을 수정합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + +
+
+ + setFormData({ ...formData, relationName: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모/자식 설정 - 수정 불가 표시 */} +
+

부모/자식 설정 (수정 불가)

+
+
+ 부모: + {formData.parentTableName}.{formData.parentColumnName} +
+
+ 자식: + {formData.childTableName}.{formData.childColumnName} +
+
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 값 매핑 모달 */} + + + + + 하위 옵션 설정 - {selectedGroup?.relation_name} + + + 각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다. + + + +
+ {parentValues.length === 0 ? ( +
+ 부모 카테고리 값이 등록되지 않았습니다. +
+ 먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요. +
+ ) : ( +
+ {parentValues.map((parent) => ( +
+
+

{parent.label}

+ + {(childOptionsMap[parent.value] || []).length}개 옵션 + +
+ + {/* 하위 옵션 입력 */} +
+ + setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value })) + } + onKeyDown={(e) => { + // 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지) + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + addChildOption(parent.value); + } + }} + placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼" + className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" + /> + +
+ + {/* 등록된 하위 옵션 목록 */} +
+ {(childOptionsMap[parent.value] || []).map((option) => ( +
+ {option.label} + +
+ ))} + {(childOptionsMap[parent.value] || []).length === 0 && ( + 등록된 하위 옵션이 없습니다 + )} +
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index 58b2c896..88d29de6 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -56,6 +56,12 @@ export function MenuCopyDialog({ const [removeText, setRemoveText] = useState(""); const [addPrefix, setAddPrefix] = useState(""); + // 카테고리/코드 복사 옵션 + const [copyCodeCategory, setCopyCodeCategory] = useState(false); + const [copyNumberingRules, setCopyNumberingRules] = useState(false); + const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); + const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); + // 회사 목록 로드 useEffect(() => { if (open) { @@ -66,6 +72,10 @@ export function MenuCopyDialog({ setUseBulkRename(false); setRemoveText(""); setAddPrefix(""); + setCopyCodeCategory(false); + setCopyNumberingRules(false); + setCopyCategoryMapping(false); + setCopyTableTypeColumns(false); } }, [open]); @@ -112,10 +122,19 @@ export function MenuCopyDialog({ } : undefined; + // 추가 복사 옵션 + const additionalCopyOptions = { + copyCodeCategory, + copyNumberingRules, + copyCategoryMapping, + copyTableTypeColumns, + }; + const response = await menuApi.copyMenu( menuObjid, targetCompanyCode, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); if (response.success && response.data) { @@ -264,19 +283,82 @@ export function MenuCopyDialog({ )} + {/* 추가 복사 옵션 */} + {!result && ( +
+

추가 복사 옵션 (선택사항):

+
+
+ setCopyCodeCategory(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyNumberingRules(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyCategoryMapping(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyTableTypeColumns(checked as boolean)} + disabled={copying} + /> + +
+
+
+ )} + {/* 복사 항목 안내 */} {!result && (
-

복사되는 항목:

+

기본 복사 항목:

  • 메뉴 구조 (하위 메뉴 포함)
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • 플로우 제어 (스텝, 연결)
  • -
  • 코드 카테고리 + 코드
  • -
  • 카테고리 설정 + 채번 규칙
-

- ⚠️ 실제 데이터는 복사되지 않습니다. +

+ * 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.

)} @@ -294,10 +376,40 @@ export function MenuCopyDialog({ 화면:{" "} {result.copiedScreens}개 -
+
플로우:{" "} {result.copiedFlows}개
+ {(result.copiedCodeCategories ?? 0) > 0 && ( +
+ 코드 카테고리:{" "} + {result.copiedCodeCategories}개 +
+ )} + {(result.copiedCodes ?? 0) > 0 && ( +
+ 코드:{" "} + {result.copiedCodes}개 +
+ )} + {(result.copiedNumberingRules ?? 0) > 0 && ( +
+ 채번규칙:{" "} + {result.copiedNumberingRules}개 +
+ )} + {(result.copiedCategoryMappings ?? 0) > 0 && ( +
+ 카테고리 매핑:{" "} + {result.copiedCategoryMappings}개 +
+ )} + {(result.copiedTableTypeColumns ?? 0) > 0 && ( +
+ 테이블 타입 설정:{" "} + {result.copiedTableTypeColumns}개 +
+ )}
)} 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") && (
- - 텍스트 내용 +