mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:46:39 +02:00
feat: add PDF and Word export for expert profile
Professional CV-style document generation using pdfkit (PDF) and docx (Word). Two-column layout with avatar, contact info, languages on the left and work experience timeline on the right. Skills rendered as chips. Accent color configurable (default teal #009688) for later admin customization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a275cf83e1
commit
2e5a697224
8 changed files with 1214 additions and 7 deletions
329
packages/core-service/package-lock.json
generated
329
packages/core-service/package-lock.json
generated
|
|
@ -23,11 +23,13 @@
|
|||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"docx": "^9.6.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
|
@ -43,6 +45,7 @@
|
|||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
|
@ -252,6 +255,7 @@
|
|||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -2376,6 +2380,15 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
|
||||
"integrity": "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/inflate": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||
|
|
@ -2689,6 +2702,16 @@
|
|||
"@types/passport": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
|
|
@ -3579,7 +3602,6 @@
|
|||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3713,6 +3735,15 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
|
|
@ -4403,7 +4434,6 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
|
|
@ -4500,6 +4530,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -4666,6 +4702,12 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
|
|
@ -4692,6 +4734,38 @@
|
|||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.6.0",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.0.tgz",
|
||||
"integrity": "sha512-y6EaJJMDvt4P7wgGQB9KsZf4wsRkQMJfkc9LlNufRshggI5BT35hGNkXBCAeEoI3MLMwApKguxzjdqqVcBCqNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"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": "25.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
|
||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.4.5",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
|
||||
|
|
@ -5405,7 +5479,6 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
|
|
@ -5610,6 +5683,32 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fontkit/node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/foreground-child": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||
|
|
@ -6110,6 +6209,16 @@
|
|||
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -6223,6 +6332,12 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
|
@ -6471,6 +6586,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
|
|
@ -7335,6 +7456,13 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -7450,6 +7578,48 @@
|
|||
"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/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||
|
|
@ -7521,6 +7691,34 @@
|
|||
"integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==",
|
||||
"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/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -7848,6 +8046,12 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"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": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
|
|
@ -7957,6 +8161,24 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"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/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
|
@ -8296,6 +8518,12 @@
|
|||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"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/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -8461,6 +8689,19 @@
|
|||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
|
|
@ -8589,6 +8830,11 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
|
@ -8693,6 +8939,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
|
|
@ -9113,6 +9365,12 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
|
||||
|
|
@ -9224,6 +9482,15 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
|
||||
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
|
|
@ -9368,6 +9635,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",
|
||||
|
|
@ -10021,6 +10294,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
|
@ -10443,6 +10722,32 @@
|
|||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
|
|
@ -10843,6 +11148,24 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"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/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -40,11 +40,13 @@
|
|||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"docx": "^9.6.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"otplib": "^12.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
|
|
@ -60,6 +62,7 @@
|
|||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.5",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
|
@ -78,13 +81,19 @@
|
|||
"typescript": "^5.6.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ import {
|
|||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Res,
|
||||
ParseUUIDPipe,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { ExpertProfileService } from './expert-profile.service';
|
||||
import { ProfileExportService } from './profile-export.service';
|
||||
import { UpdateSkillsDto } from './dto/update-skills.dto';
|
||||
import { CreateExperienceDto } from './dto/create-experience.dto';
|
||||
import { CreateLanguageDto } from './dto/create-language.dto';
|
||||
|
|
@ -26,7 +29,10 @@ import { UploadAttachmentDto } from './dto/upload-attachment.dto';
|
|||
@ApiBearerAuth('access-token')
|
||||
@Controller('expert-profile')
|
||||
export class ExpertProfileController {
|
||||
constructor(private readonly expertProfileService: ExpertProfileService) {}
|
||||
constructor(
|
||||
private readonly expertProfileService: ExpertProfileService,
|
||||
private readonly profileExportService: ProfileExportService,
|
||||
) {}
|
||||
|
||||
// ============================================================
|
||||
// Profil
|
||||
|
|
@ -157,6 +163,39 @@ export class ExpertProfileController {
|
|||
await this.expertProfileService.deleteCertification(userId, id);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Export (PDF / DOCX)
|
||||
// ============================================================
|
||||
@Get('me/export/pdf')
|
||||
@ApiOperation({ summary: 'Profil als PDF exportieren' })
|
||||
async exportPdf(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const buffer = await this.profileExportService.generatePdf(userId);
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': 'attachment; filename="Profil.pdf"',
|
||||
'Content-Length': String(buffer.length),
|
||||
});
|
||||
res.end(buffer);
|
||||
}
|
||||
|
||||
@Get('me/export/docx')
|
||||
@ApiOperation({ summary: 'Profil als Word-Dokument exportieren' })
|
||||
async exportDocx(
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const buffer = await this.profileExportService.generateDocx(userId);
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'Content-Disposition': 'attachment; filename="Profil.docx"',
|
||||
'Content-Length': String(buffer.length),
|
||||
});
|
||||
res.end(buffer);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Profilanlagen
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ExpertProfileController } from './expert-profile.controller';
|
||||
import { ExpertProfileService } from './expert-profile.service';
|
||||
import { ProfileExportService } from './profile-export.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExpertProfileController],
|
||||
providers: [ExpertProfileService],
|
||||
providers: [ExpertProfileService, ProfileExportService],
|
||||
exports: [ExpertProfileService],
|
||||
})
|
||||
export class ExpertProfileModule {}
|
||||
|
|
|
|||
|
|
@ -294,6 +294,36 @@ export class ExpertProfileService {
|
|||
this.logger.log(`Anhang gelöscht: ${attachment.filename}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Export-Daten (User + Profil-Daten komplett)
|
||||
// ============================================================
|
||||
async getExportData(userId: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
mobile: true,
|
||||
street: true,
|
||||
postalCode: true,
|
||||
city: true,
|
||||
avatar: true,
|
||||
expertProfile: {
|
||||
include: {
|
||||
experiences: { orderBy: { createdAt: 'desc' } },
|
||||
languages: { orderBy: { language: 'asc' } },
|
||||
projects: { orderBy: [{ fromYear: 'desc' }, { fromMonth: 'desc' }] },
|
||||
certifications: { orderBy: { issueYear: 'desc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hilfsfunktion: Ownership-Check
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,756 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ExpertProfileService } from './expert-profile.service';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import {
|
||||
Document,
|
||||
Packer,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
BorderStyle,
|
||||
AlignmentType,
|
||||
ImageRun,
|
||||
HeadingLevel,
|
||||
ShadingType,
|
||||
} from 'docx';
|
||||
|
||||
// ============================================================
|
||||
// Typen für Export-Daten
|
||||
// ============================================================
|
||||
interface ExportProject {
|
||||
fromMonth: number;
|
||||
fromYear: number;
|
||||
toMonth: number | null;
|
||||
toYear: number | null;
|
||||
isCurrent: boolean;
|
||||
role: string;
|
||||
tasks: string | null;
|
||||
company: string | null;
|
||||
companySize: string | null;
|
||||
industry: string | null;
|
||||
}
|
||||
|
||||
interface ExportCertification {
|
||||
title: string;
|
||||
issuingBody: string;
|
||||
website: string | null;
|
||||
issueYear: number;
|
||||
}
|
||||
|
||||
interface ExportExperience {
|
||||
area: string;
|
||||
years: number;
|
||||
level: string | null;
|
||||
}
|
||||
|
||||
interface ExportLanguage {
|
||||
language: string;
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface ExportData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
street: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
avatar: string | null;
|
||||
expertProfile: {
|
||||
skills: string[];
|
||||
experiences: ExportExperience[];
|
||||
languages: ExportLanguage[];
|
||||
projects: ExportProject[];
|
||||
certifications: ExportCertification[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Farb-Hilfsfunktionen
|
||||
// ============================================================
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) }
|
||||
: { r: 0, g: 150, b: 136 };
|
||||
}
|
||||
|
||||
function lightenColor(hex: string, factor: number): string {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const lr = Math.round(r + (255 - r) * factor);
|
||||
const lg = Math.round(g + (255 - g) * factor);
|
||||
const lb = Math.round(b + (255 - b) * factor);
|
||||
return `#${lr.toString(16).padStart(2, '0')}${lg.toString(16).padStart(2, '0')}${lb.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ProfileExportService
|
||||
// ============================================================
|
||||
@Injectable()
|
||||
export class ProfileExportService {
|
||||
private readonly logger = new Logger(ProfileExportService.name);
|
||||
|
||||
constructor(private readonly expertProfileService: ExpertProfileService) {}
|
||||
|
||||
// ============================================================
|
||||
// PDF Export
|
||||
// ============================================================
|
||||
async generatePdf(userId: string, accentColor = '#009688'): Promise<Buffer> {
|
||||
const data = await this.expertProfileService.getExportData(userId) as ExportData;
|
||||
const profile = data.expertProfile;
|
||||
const fullName = `${data.firstName} ${data.lastName}`;
|
||||
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margins: { top: 40, bottom: 40, left: 40, right: 40 },
|
||||
bufferPages: true,
|
||||
});
|
||||
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
|
||||
// --- Konstanten ---
|
||||
const pageWidth = 595.28;
|
||||
const leftColWidth = 180;
|
||||
const leftColX = 40;
|
||||
const rightColX = leftColX + leftColWidth + 20;
|
||||
const rightColWidth = pageWidth - rightColX - 40;
|
||||
const pageBottom = 800;
|
||||
|
||||
let yLeft = 40;
|
||||
let yRight = 40;
|
||||
|
||||
// --- SEITE 1: Linke Spalte ---
|
||||
|
||||
// Avatar (rundes Bild)
|
||||
if (data.avatar) {
|
||||
try {
|
||||
const avatarBuffer = this.base64ToBuffer(data.avatar);
|
||||
const avatarSize = 110;
|
||||
const centerX = leftColX + leftColWidth / 2;
|
||||
const centerY = yLeft + avatarSize / 2;
|
||||
|
||||
doc.save();
|
||||
doc.circle(centerX, centerY, avatarSize / 2).clip();
|
||||
doc.image(avatarBuffer, centerX - avatarSize / 2, yLeft, {
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
});
|
||||
doc.restore();
|
||||
yLeft += avatarSize + 15;
|
||||
} catch (err) {
|
||||
this.logger.warn('Avatar konnte nicht geladen werden', err);
|
||||
yLeft += 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Name
|
||||
doc.font('Helvetica-Bold').fontSize(16).fillColor('#333333');
|
||||
doc.text(fullName, leftColX, yLeft, { width: leftColWidth, align: 'center' });
|
||||
yLeft += doc.heightOfString(fullName, { width: leftColWidth }) + 5;
|
||||
|
||||
// Rolle (erste Erfahrung als Titel)
|
||||
if (profile && profile.experiences.length > 0) {
|
||||
const mainRole = profile.experiences[0].area;
|
||||
doc.font('Helvetica').fontSize(10).fillColor(accentColor);
|
||||
doc.text(mainRole, leftColX, yLeft, { width: leftColWidth, align: 'center' });
|
||||
yLeft += doc.heightOfString(mainRole, { width: leftColWidth }) + 8;
|
||||
}
|
||||
|
||||
// Akzentlinie
|
||||
doc.moveTo(leftColX + 20, yLeft).lineTo(leftColX + leftColWidth - 20, yLeft)
|
||||
.strokeColor(accentColor).lineWidth(2).stroke();
|
||||
yLeft += 15;
|
||||
|
||||
// --- KONTAKT ---
|
||||
yLeft = this.pdfSectionTitle(doc, 'KONTAKT', leftColX, yLeft, leftColWidth, accentColor);
|
||||
|
||||
if (data.phone) {
|
||||
yLeft = this.pdfContactLine(doc, '\u260E', data.phone, leftColX, yLeft, leftColWidth);
|
||||
}
|
||||
if (data.mobile) {
|
||||
yLeft = this.pdfContactLine(doc, '\u260E', data.mobile, leftColX, yLeft, leftColWidth);
|
||||
}
|
||||
if (data.email) {
|
||||
yLeft = this.pdfContactLine(doc, '\u2709', data.email, leftColX, yLeft, leftColWidth);
|
||||
}
|
||||
if (data.street || data.city) {
|
||||
const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
yLeft = this.pdfContactLine(doc, '\u2302', address, leftColX, yLeft, leftColWidth);
|
||||
}
|
||||
yLeft += 10;
|
||||
|
||||
// --- SPRACHEN ---
|
||||
if (profile && profile.languages.length > 0) {
|
||||
yLeft = this.pdfSectionTitle(doc, 'SPRACHEN', leftColX, yLeft, leftColWidth, accentColor);
|
||||
for (const lang of profile.languages) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#333333');
|
||||
doc.text(lang.language, leftColX, yLeft, { width: leftColWidth * 0.55, continued: false });
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#777777');
|
||||
doc.text(lang.level, leftColX + leftColWidth * 0.6, yLeft, { width: leftColWidth * 0.4 });
|
||||
yLeft += 14;
|
||||
}
|
||||
yLeft += 8;
|
||||
}
|
||||
|
||||
// --- ERFAHRUNG (Expertise-Bereiche) ---
|
||||
if (profile && profile.experiences.length > 0) {
|
||||
yLeft = this.pdfSectionTitle(doc, 'ERFAHRUNG', leftColX, yLeft, leftColWidth, accentColor);
|
||||
for (const exp of profile.experiences) {
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#333333');
|
||||
doc.text(exp.area, leftColX, yLeft, { width: leftColWidth * 0.65 });
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#777777');
|
||||
const expDetail = `${exp.years}J.${exp.level ? ' · ' + exp.level : ''}`;
|
||||
doc.text(expDetail, leftColX + leftColWidth * 0.65, yLeft, { width: leftColWidth * 0.35 });
|
||||
yLeft += 14;
|
||||
}
|
||||
}
|
||||
|
||||
// --- SEITE 1: Rechte Spalte — BERUFSERFAHRUNG ---
|
||||
if (profile && profile.projects.length > 0) {
|
||||
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG', rightColX, yRight, rightColWidth, accentColor);
|
||||
|
||||
const timelineX = rightColX + 6;
|
||||
const contentX = rightColX + 18;
|
||||
const contentWidth = rightColWidth - 18;
|
||||
|
||||
for (let i = 0; i < profile.projects.length; i++) {
|
||||
const proj = profile.projects[i];
|
||||
|
||||
// Seitenumbruch prüfen
|
||||
if (yRight > pageBottom) {
|
||||
doc.addPage();
|
||||
yRight = 40;
|
||||
yRight = this.pdfSectionTitle(doc, 'BERUFSERFAHRUNG (Forts.)', rightColX, yRight, rightColWidth, accentColor);
|
||||
}
|
||||
|
||||
// Timeline-Punkt
|
||||
doc.circle(timelineX, yRight + 4, 3.5).fill(accentColor);
|
||||
|
||||
// Timeline-Linie (bis zum nächsten Eintrag)
|
||||
if (i < profile.projects.length - 1) {
|
||||
doc.moveTo(timelineX, yRight + 8).lineTo(timelineX, yRight + 70)
|
||||
.strokeColor(accentColor).lineWidth(1).stroke();
|
||||
}
|
||||
|
||||
// Zeitraum
|
||||
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#888888');
|
||||
doc.text(dateRange, contentX, yRight, { width: contentWidth });
|
||||
yRight += 12;
|
||||
|
||||
// Rolle
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
|
||||
doc.text(proj.role, contentX, yRight, { width: contentWidth });
|
||||
yRight += doc.heightOfString(proj.role, { width: contentWidth }) + 2;
|
||||
|
||||
// Firma + Branche
|
||||
if (proj.company) {
|
||||
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#555555');
|
||||
doc.text(companyLine, contentX, yRight, { width: contentWidth });
|
||||
yRight += doc.heightOfString(companyLine, { width: contentWidth }) + 2;
|
||||
}
|
||||
|
||||
// Aufgaben
|
||||
if (proj.tasks) {
|
||||
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#444444');
|
||||
for (const task of taskLines) {
|
||||
if (yRight > pageBottom) {
|
||||
doc.addPage();
|
||||
yRight = 40;
|
||||
}
|
||||
const bulletText = `\u2022 ${task.trim()}`;
|
||||
doc.text(bulletText, contentX + 4, yRight, { width: contentWidth - 8 });
|
||||
yRight += doc.heightOfString(bulletText, { width: contentWidth - 8 }) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
yRight += 12;
|
||||
|
||||
// Aktualisiere Timeline-Linie (Länge basiert auf tatsächlicher Position)
|
||||
}
|
||||
}
|
||||
|
||||
// --- FOLGESEITEN: ZERTIFIZIERUNGEN ---
|
||||
if (profile && profile.certifications.length > 0) {
|
||||
const certY = Math.max(yLeft, yRight);
|
||||
let y = certY > pageBottom - 80 ? 40 : certY + 20;
|
||||
if (certY > pageBottom - 80) {
|
||||
doc.addPage();
|
||||
}
|
||||
|
||||
y = this.pdfSectionTitle(doc, 'ZERTIFIZIERUNGEN', 40, y, pageWidth - 80, accentColor);
|
||||
|
||||
const timelineX = 46;
|
||||
const contentX = 58;
|
||||
const contentWidth = pageWidth - 58 - 40;
|
||||
|
||||
for (let i = 0; i < profile.certifications.length; i++) {
|
||||
const cert = profile.certifications[i];
|
||||
|
||||
if (y > pageBottom) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
}
|
||||
|
||||
// Timeline-Punkt
|
||||
doc.circle(timelineX, y + 4, 3.5).fill(accentColor);
|
||||
if (i < profile.certifications.length - 1) {
|
||||
doc.moveTo(timelineX, y + 8).lineTo(timelineX, y + 40)
|
||||
.strokeColor(accentColor).lineWidth(1).stroke();
|
||||
}
|
||||
|
||||
// Jahr
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#888888');
|
||||
doc.text(String(cert.issueYear), contentX, y, { width: contentWidth });
|
||||
y += 12;
|
||||
|
||||
// Titel
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor(accentColor);
|
||||
doc.text(cert.title, contentX, y, { width: contentWidth });
|
||||
y += doc.heightOfString(cert.title, { width: contentWidth }) + 2;
|
||||
|
||||
// Zertifizierungsstelle
|
||||
doc.font('Helvetica').fontSize(9).fillColor('#555555');
|
||||
doc.text(cert.issuingBody, contentX, y, { width: contentWidth });
|
||||
y += 14;
|
||||
|
||||
y += 8;
|
||||
}
|
||||
|
||||
yRight = y;
|
||||
yLeft = y;
|
||||
}
|
||||
|
||||
// --- FÄHIGKEITEN (Skills als Chips) ---
|
||||
if (profile && profile.skills.length > 0) {
|
||||
let y = Math.max(yLeft, yRight);
|
||||
if (y > pageBottom - 60) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
} else {
|
||||
y += 10;
|
||||
}
|
||||
|
||||
y = this.pdfSectionTitle(doc, 'FÄHIGKEITEN', 40, y, pageWidth - 80, accentColor);
|
||||
|
||||
const chipStartX = 40;
|
||||
const maxX = pageWidth - 40;
|
||||
let chipX = chipStartX;
|
||||
const chipHeight = 20;
|
||||
const chipPadding = 10;
|
||||
const chipGap = 6;
|
||||
const lightBg = lightenColor(accentColor, 0.85);
|
||||
|
||||
for (const skill of profile.skills) {
|
||||
doc.font('Helvetica').fontSize(8);
|
||||
const textWidth = doc.widthOfString(skill);
|
||||
const chipWidth = textWidth + chipPadding * 2;
|
||||
|
||||
if (chipX + chipWidth > maxX) {
|
||||
chipX = chipStartX;
|
||||
y += chipHeight + chipGap;
|
||||
if (y > pageBottom) {
|
||||
doc.addPage();
|
||||
y = 40;
|
||||
}
|
||||
}
|
||||
|
||||
// Chip-Hintergrund (abgerundetes Rechteck)
|
||||
doc.roundedRect(chipX, y, chipWidth, chipHeight, 10).fill(lightBg);
|
||||
|
||||
// Chip-Text
|
||||
doc.font('Helvetica').fontSize(8).fillColor(accentColor);
|
||||
doc.text(skill, chipX + chipPadding, y + 5.5, { width: textWidth, lineBreak: false });
|
||||
|
||||
chipX += chipWidth + chipGap;
|
||||
}
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOCX Export
|
||||
// ============================================================
|
||||
async generateDocx(userId: string, accentColor = '#009688'): Promise<Buffer> {
|
||||
const data = await this.expertProfileService.getExportData(userId) as ExportData;
|
||||
const profile = data.expertProfile;
|
||||
const fullName = `${data.firstName} ${data.lastName}`;
|
||||
const accentHex = accentColor.replace('#', '');
|
||||
const lightAccent = lightenColor(accentColor, 0.85).replace('#', '');
|
||||
|
||||
const sections: Paragraph[] = [];
|
||||
|
||||
// --- Kontakt-Infos für linke Spalte ---
|
||||
const leftParagraphs: Paragraph[] = [];
|
||||
|
||||
// Avatar
|
||||
let avatarImageRun: ImageRun | null = null;
|
||||
if (data.avatar) {
|
||||
try {
|
||||
const avatarBuffer = this.base64ToBuffer(data.avatar);
|
||||
avatarImageRun = new ImageRun({
|
||||
data: avatarBuffer,
|
||||
transformation: { width: 120, height: 120 },
|
||||
type: 'png',
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn('Avatar für DOCX konnte nicht geladen werden');
|
||||
}
|
||||
}
|
||||
|
||||
if (avatarImageRun) {
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [avatarImageRun],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Name
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: fullName,
|
||||
bold: true,
|
||||
size: 28,
|
||||
color: '333333',
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 100 },
|
||||
}),
|
||||
);
|
||||
|
||||
// Rolle
|
||||
if (profile && profile.experiences.length > 0) {
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: profile.experiences[0].area,
|
||||
size: 20,
|
||||
color: accentHex,
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Kontakt-Sektion
|
||||
leftParagraphs.push(this.docxSectionHeading('KONTAKT', accentHex));
|
||||
|
||||
const contactItems: Array<{ icon: string; text: string }> = [];
|
||||
if (data.phone) contactItems.push({ icon: '\u260E', text: data.phone });
|
||||
if (data.mobile) contactItems.push({ icon: '\u260E', text: data.mobile });
|
||||
if (data.email) contactItems.push({ icon: '\u2709', text: data.email });
|
||||
if (data.street || data.city) {
|
||||
const address = [data.street, [data.postalCode, data.city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
|
||||
contactItems.push({ icon: '\u2302', text: address });
|
||||
}
|
||||
|
||||
for (const item of contactItems) {
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `${item.icon} `, size: 18, color: accentHex }),
|
||||
new TextRun({ text: item.text, size: 16, color: '555555' }),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Sprachen
|
||||
if (profile && profile.languages.length > 0) {
|
||||
leftParagraphs.push(this.docxSectionHeading('SPRACHEN', accentHex));
|
||||
for (const lang of profile.languages) {
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: lang.language, size: 18, color: '333333' }),
|
||||
new TextRun({ text: ` ${lang.level}`, size: 16, color: '777777' }),
|
||||
],
|
||||
spacing: { after: 40 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Erfahrung (Expertise-Bereiche)
|
||||
if (profile && profile.experiences.length > 0) {
|
||||
leftParagraphs.push(this.docxSectionHeading('ERFAHRUNG', accentHex));
|
||||
for (const exp of profile.experiences) {
|
||||
const detail = `${exp.years} J.${exp.level ? ' · ' + exp.level : ''}`;
|
||||
leftParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: exp.area, size: 18, color: '333333' }),
|
||||
new TextRun({ text: ` ${detail}`, size: 16, color: '777777' }),
|
||||
],
|
||||
spacing: { after: 40 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Rechte Spalte: Berufserfahrung ---
|
||||
const rightParagraphs: Paragraph[] = [];
|
||||
|
||||
if (profile && profile.projects.length > 0) {
|
||||
rightParagraphs.push(this.docxSectionHeading('BERUFSERFAHRUNG', accentHex));
|
||||
|
||||
for (const proj of profile.projects) {
|
||||
const dateRange = this.formatDateRange(proj.fromMonth, proj.fromYear, proj.toMonth, proj.toYear, proj.isCurrent);
|
||||
|
||||
rightParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: dateRange, size: 16, color: '888888', italics: true }),
|
||||
],
|
||||
spacing: { before: 160, after: 40 },
|
||||
}),
|
||||
);
|
||||
|
||||
rightParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: proj.role, bold: true, size: 20, color: accentHex }),
|
||||
],
|
||||
spacing: { after: 30 },
|
||||
}),
|
||||
);
|
||||
|
||||
if (proj.company) {
|
||||
const companyLine = [proj.company, proj.industry].filter(Boolean).join(' · ');
|
||||
rightParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: companyLine, size: 18, color: '555555' }),
|
||||
],
|
||||
spacing: { after: 40 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (proj.tasks) {
|
||||
const taskLines = proj.tasks.split('\n').filter((l: string) => l.trim());
|
||||
for (const task of taskLines) {
|
||||
rightParagraphs.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `\u2022 ${task.trim()}`, size: 16, color: '444444' }),
|
||||
],
|
||||
spacing: { after: 20 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tabelle (Zwei-Spalten-Layout) ---
|
||||
const noBorders = {
|
||||
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 layoutTable = new Table({
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: leftParagraphs,
|
||||
width: { size: 30, type: WidthType.PERCENTAGE },
|
||||
borders: noBorders,
|
||||
}),
|
||||
new TableCell({
|
||||
children: rightParagraphs.length > 0 ? rightParagraphs : [new Paragraph('')],
|
||||
width: { size: 70, type: WidthType.PERCENTAGE },
|
||||
borders: noBorders,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
});
|
||||
|
||||
// --- Volle Breite: Zertifizierungen ---
|
||||
if (profile && profile.certifications.length > 0) {
|
||||
sections.push(this.docxSectionHeading('ZERTIFIZIERUNGEN', accentHex));
|
||||
|
||||
for (const cert of profile.certifications) {
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: String(cert.issueYear), size: 16, color: '888888', italics: true }),
|
||||
],
|
||||
spacing: { before: 120, after: 30 },
|
||||
}),
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: cert.title, bold: true, size: 20, color: accentHex }),
|
||||
],
|
||||
spacing: { after: 20 },
|
||||
}),
|
||||
);
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: cert.issuingBody, size: 18, color: '555555' }),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Volle Breite: Fähigkeiten (Skills als Chips) ---
|
||||
if (profile && profile.skills.length > 0) {
|
||||
sections.push(this.docxSectionHeading('FÄHIGKEITEN', accentHex));
|
||||
|
||||
// Skills als inline TextRuns mit Shading
|
||||
const skillRuns: TextRun[] = [];
|
||||
for (let i = 0; i < profile.skills.length; i++) {
|
||||
skillRuns.push(
|
||||
new TextRun({
|
||||
text: ` ${profile.skills[i]} `,
|
||||
size: 16,
|
||||
color: accentHex,
|
||||
shading: { type: ShadingType.CLEAR, color: 'auto', fill: lightAccent },
|
||||
}),
|
||||
);
|
||||
if (i < profile.skills.length - 1) {
|
||||
skillRuns.push(new TextRun({ text: ' ', size: 16 }));
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(
|
||||
new Paragraph({
|
||||
children: skillRuns,
|
||||
spacing: { before: 100, after: 100 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Dokument zusammenstellen ---
|
||||
const document = new Document({
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
margin: { top: 720, bottom: 720, left: 720, right: 720 },
|
||||
},
|
||||
},
|
||||
children: [layoutTable, ...sections],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const buffer = await Packer.toBuffer(document);
|
||||
this.logger.log(`DOCX generiert für User ${userId}: ${buffer.length} Bytes`);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PDF-Hilfsfunktionen
|
||||
// ============================================================
|
||||
|
||||
private pdfSectionTitle(
|
||||
doc: PDFKit.PDFDocument,
|
||||
title: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
accentColor: string,
|
||||
): number {
|
||||
doc.font('Helvetica-Bold').fontSize(10).fillColor('#333333');
|
||||
doc.text(title, x, y, { width });
|
||||
y += 14;
|
||||
doc.moveTo(x, y).lineTo(x + Math.min(width, 60), y)
|
||||
.strokeColor(accentColor).lineWidth(1.5).stroke();
|
||||
y += 8;
|
||||
return y;
|
||||
}
|
||||
|
||||
private pdfContactLine(
|
||||
doc: PDFKit.PDFDocument,
|
||||
icon: string,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
): number {
|
||||
doc.font('Helvetica').fontSize(8).fillColor('#777777');
|
||||
doc.text(`${icon} ${text}`, x, y, { width });
|
||||
return y + doc.heightOfString(`${icon} ${text}`, { width }) + 3;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOCX-Hilfsfunktionen
|
||||
// ============================================================
|
||||
|
||||
private docxSectionHeading(title: string, accentHex: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title,
|
||||
bold: true,
|
||||
size: 22,
|
||||
color: '333333',
|
||||
}),
|
||||
],
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: { before: 300, after: 80 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 3, color: accentHex, space: 4 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Utility
|
||||
// ============================================================
|
||||
|
||||
private formatDateRange(
|
||||
fromMonth: number,
|
||||
fromYear: number,
|
||||
toMonth: number | null,
|
||||
toYear: number | null,
|
||||
isCurrent: boolean,
|
||||
): string {
|
||||
const from = `${String(fromMonth).padStart(2, '0')}/${fromYear}`;
|
||||
if (isCurrent) return `${from} - heute`;
|
||||
if (toMonth && toYear) return `${from} - ${String(toMonth).padStart(2, '0')}/${toYear}`;
|
||||
return from;
|
||||
}
|
||||
|
||||
private base64ToBuffer(dataUrl: string): Buffer {
|
||||
// Entferne Data-URL-Prefix (z.B. "data:image/png;base64,")
|
||||
const base64Data = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
|
||||
return Buffer.from(base64Data, 'base64');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,12 @@
|
|||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.exportBar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.twoColumnRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export function ExpertProfileTab() {
|
|||
const [profile, setProfile] = useState<ExpertProfile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -84,6 +85,28 @@ export function ExpertProfileTab() {
|
|||
loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
const handleExport = async (format: 'pdf' | 'docx') => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const response = await api.get(`/expert-profile/me/export/${format}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const blob = response.data as Blob;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = format === 'pdf' ? 'Profil.pdf' : 'Profil.docx';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError(`Export als ${format.toUpperCase()} fehlgeschlagen`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
|
|
@ -100,6 +123,26 @@ export function ExpertProfileTab() {
|
|||
|
||||
return (
|
||||
<div className={styles.expertContainer}>
|
||||
{/* Export-Buttons */}
|
||||
<div className={styles.exportBar}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnSecondary}
|
||||
onClick={() => handleExport('pdf')}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? 'Exportiert...' : 'PDF Export'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnSecondary}
|
||||
onClick={() => handleExport('docx')}
|
||||
disabled={exporting}
|
||||
>
|
||||
{exporting ? 'Exportiert...' : 'Word Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skills + Sprachen nebeneinander */}
|
||||
<div className={styles.twoColumnRow}>
|
||||
<SkillsSection skills={profile.skills} onUpdate={loadProfile} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue