diff --git a/.gitignore b/.gitignore index 0d79c56..c98db9f 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ consent-service/server # Coverage coverage/ *.coverage + +# Allow Finanzplan exports (generated by pitch-deck/scripts/export-finanzplan.sh) +!pitch-deck/exports/*.xlsx diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx new file mode 100644 index 0000000..068ffd9 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Base.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx new file mode 100644 index 0000000..ae1b335 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx new file mode 100644 index 0000000..9accdae Binary files /dev/null and b/pitch-deck/exports/Finanzplan-1Mio-Euro-Bull.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx new file mode 100644 index 0000000..8663500 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-200k.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx new file mode 100644 index 0000000..fbfa5e2 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bear.xlsx differ diff --git a/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx new file mode 100644 index 0000000..1c58837 Binary files /dev/null and b/pitch-deck/exports/Finanzplan-Wandeldarlehen-Bull.xlsx differ diff --git a/pitch-deck/package-lock.json b/pitch-deck/package-lock.json index c40ae67..121790e 100644 --- a/pitch-deck/package-lock.json +++ b/pitch-deck/package-lock.json @@ -29,6 +29,7 @@ "@types/react-dom": "^18.3.5", "@vitest/expect": "^4.1.2", "autoprefixer": "^10.4.20", + "exceljs": "^4.4.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "tsx": "^4.21.0", @@ -535,6 +536,51 @@ "node": ">=18" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -1819,6 +1865,80 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/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==", + "dev": true, + "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/archiver-utils/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/archiver-utils/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1836,6 +1956,13 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.27", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", @@ -1873,6 +2000,34 @@ "postcss": "^8.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "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", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.14", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", @@ -1895,6 +2050,30 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1908,6 +2087,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1955,6 +2164,60 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "dev": true, + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1995,6 +2258,19 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "dev": true, + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2058,6 +2334,29 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2065,6 +2364,40 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "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/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2205,6 +2538,13 @@ "node": ">=12" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", @@ -2245,6 +2585,49 @@ "csstype": "^3.0.2" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/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==", + "dev": true, + "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/duplexer2/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -2252,6 +2635,16 @@ "dev": true, "license": "ISC" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -2327,6 +2720,27 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2337,6 +2751,20 @@ "node": ">=12.0.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-equals": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -2440,6 +2868,20 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2455,6 +2897,23 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2478,6 +2937,28 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2491,6 +2972,13 @@ "node": ">=10.13.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2504,6 +2992,53 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2575,6 +3110,13 @@ "node": ">=0.12.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2600,6 +3142,108 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/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==", + "dev": true, + "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/lazystream/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2881,12 +3525,111 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2942,6 +3685,42 @@ "node": ">=8.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -3123,6 +3902,33 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3467,6 +4273,13 @@ "node": ">=0.10.0" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -3577,6 +4390,54 @@ "pify": "^2.3.0" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3664,6 +4525,20 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rolldown": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", @@ -3722,6 +4597,40 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -3750,6 +4659,13 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/sharp": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", @@ -3834,6 +4750,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -3931,6 +4857,23 @@ "node": ">=14.0.0" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -4035,6 +4978,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4048,6 +5001,16 @@ "node": ">=8.0" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "dev": true, + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4102,6 +5065,58 @@ "dev": true, "license": "MIT" }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/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==", + "dev": true, + "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/unzipper/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/unzipper/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4140,6 +5155,17 @@ "dev": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -4365,6 +5391,20 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -4373,6 +5413,43 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } } } } diff --git a/pitch-deck/package.json b/pitch-deck/package.json index b30facc..b77bece 100644 --- a/pitch-deck/package.json +++ b/pitch-deck/package.json @@ -32,6 +32,7 @@ "@types/react-dom": "^18.3.5", "@vitest/expect": "^4.1.2", "autoprefixer": "^10.4.20", + "exceljs": "^4.4.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "tsx": "^4.21.0", diff --git a/pitch-deck/scripts/add-charts.py b/pitch-deck/scripts/add-charts.py new file mode 100644 index 0000000..fd2cd9e --- /dev/null +++ b/pitch-deck/scripts/add-charts.py @@ -0,0 +1,145 @@ +""" +Post-process Finanzplan Excel exports: attach charts to the Dashboard sheet +and move Dashboard to be the first tab. + +Run after export-finanzplan-excel.ts: + python3 scripts/add-charts.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.chart import BarChart, LineChart, Reference +from openpyxl.chart.label import DataLabelList +from openpyxl.utils import get_column_letter + +EXPORTS_DIR = Path(__file__).resolve().parent.parent / "exports" + + +def column_count_until_empty(ws, start_col: int, header_row: int) -> int: + """Count consecutive non-empty cells starting at (header_row, start_col).""" + c = start_col + while ws.cell(row=header_row, column=c).value not in (None, ""): + c += 1 + return c - start_col + + +def add_charts_to_workbook(path: Path) -> None: + wb = load_workbook(path) + if "Dashboard" not in wb.sheetnames: + print(f" skip {path.name}: no Dashboard tab") + return + ws = wb["Dashboard"] + + # --- Chart 1: YoY Revenue / Material / Personnel / EBIT --- + # Source: rows 3..9 (3=year headers, rows 4=Umsatz, 5=Material, 6=Personal, 9=EBIT) + # categories: B3:F3 (years), data: rows 4,5,6,9 + cat_ref = Reference(ws, min_col=2, max_col=6, min_row=3, max_row=3) + chart1 = BarChart() + chart1.type = "col" + chart1.style = 11 + chart1.title = "Umsatz / Material / Personal / EBIT — YoY" + chart1.y_axis.title = "EUR" + chart1.x_axis.title = "Jahr" + for r in (4, 5, 6, 9): + data_ref = Reference(ws, min_col=1, max_col=6, min_row=r, max_row=r) + chart1.add_data(data_ref, titles_from_data=True, from_rows=True) + chart1.set_categories(cat_ref) + chart1.height = 9 + chart1.width = 18 + ws.add_chart(chart1, "H3") + + # --- Chart 2: Jahresueberschuss YoY (row 11) --- + chart2 = BarChart() + chart2.type = "col" + chart2.style = 13 + chart2.title = "Jahresüberschuss — YoY" + chart2.y_axis.title = "EUR" + chart2.x_axis.title = "Jahr" + data_ref = Reference(ws, min_col=1, max_col=6, min_row=11, max_row=11) + chart2.add_data(data_ref, titles_from_data=True, from_rows=True) + chart2.set_categories(cat_ref) + chart2.height = 9 + chart2.width = 18 + chart2.dataLabels = DataLabelList(showVal=True) + ws.add_chart(chart2, "H22") + + # --- Chart 3: Liquidity monthly (row 16, months from col B) --- + # Determine the last month column by scanning row 15 (month labels) + n_months = column_count_until_empty(ws, 2, 15) + last_col = 1 + n_months # col 2..(1+n_months) + + chart3 = LineChart() + chart3.title = "Liquidität (monatlich)" + chart3.y_axis.title = "EUR" + chart3.x_axis.title = "Monat" + chart3.style = 12 + cat_months = Reference(ws, min_col=2, max_col=last_col, min_row=15, max_row=15) + data_liq = Reference(ws, min_col=1, max_col=last_col, min_row=16, max_row=16) + chart3.add_data(data_liq, titles_from_data=True, from_rows=True) + chart3.set_categories(cat_months) + chart3.height = 9 + chart3.width = 24 + ws.add_chart(chart3, "H41") + + # --- Chart 4: Headcount (row 20) --- + chart4 = LineChart() + chart4.title = "Headcount (monatlich)" + chart4.y_axis.title = "Personen" + chart4.x_axis.title = "Monat" + chart4.style = 10 + data_hc = Reference(ws, min_col=1, max_col=last_col, min_row=20, max_row=20) + chart4.add_data(data_hc, titles_from_data=True, from_rows=True) + chart4.set_categories(cat_months) + chart4.height = 9 + chart4.width = 24 + ws.add_chart(chart4, "H60") + + # --- Chart 5: Personalkosten total monthly (row 24) --- + chart5 = LineChart() + chart5.title = "Personalkosten total (monatlich)" + chart5.y_axis.title = "EUR" + chart5.x_axis.title = "Monat" + chart5.style = 13 + data_pers = Reference(ws, min_col=1, max_col=last_col, min_row=24, max_row=24) + chart5.add_data(data_pers, titles_from_data=True, from_rows=True) + chart5.set_categories(cat_months) + chart5.height = 9 + chart5.width = 24 + ws.add_chart(chart5, "H79") + + # --- Move Dashboard to be the first sheet --- + idx = wb.sheetnames.index("Dashboard") + if idx != 0: + # openpyxl uses _sheets internal list; reorder by index. + wb._sheets.insert(0, wb._sheets.pop(idx)) + # Also put the Formelübersicht (docs) tab at the end if present + for docs_name in ("Formelübersicht", "Formulas"): + if docs_name in wb.sheetnames: + fidx = wb.sheetnames.index(docs_name) + wb._sheets.append(wb._sheets.pop(fidx)) + break + # Make Dashboard the active sheet on open + wb.active = 0 + + wb.save(path) + print(f" charts added to {path.name}") + + +def main() -> None: + if not EXPORTS_DIR.exists(): + print(f"no exports dir at {EXPORTS_DIR}") + sys.exit(1) + files = sorted(EXPORTS_DIR.glob("Finanzplan-*.xlsx")) + if not files: + print("no Finanzplan-*.xlsx files found in exports/") + sys.exit(1) + for f in files: + print(f"Processing {f.name}") + add_charts_to_workbook(f) + + +if __name__ == "__main__": + main() diff --git a/pitch-deck/scripts/export-finanzplan-excel.ts b/pitch-deck/scripts/export-finanzplan-excel.ts new file mode 100644 index 0000000..012231c --- /dev/null +++ b/pitch-deck/scripts/export-finanzplan-excel.ts @@ -0,0 +1,1254 @@ +/** + * Export Finanzplan to Excel with back-formulas. + * + * Generates one .xlsx per scenario. Editable inputs become raw values; + * computed cells become live Excel formulas that reference the inputs + * across sheets, so editing an input recalculates downstream values. + * + * Run: npx tsx scripts/export-finanzplan-excel.ts + */ + +import { Pool } from 'pg' +import ExcelJS from 'exceljs' +import * as path from 'path' + +const CONN = process.env.PG_CONN +if (!CONN) { + console.error('PG_CONN environment variable is required (e.g. postgresql://user:pass@host:port/breakpilot_db)') + process.exit(1) +} + +const SCENARIOS: { id: string; slug: string }[] = [ + { id: 'c0000000-0000-0000-0000-000000000200', slug: 'Wandeldarlehen-200k' }, + { id: 'd0000000-0000-0000-0000-000000000201', slug: 'Wandeldarlehen-Bear' }, + { id: 'd0000000-0000-0000-0000-000000000202', slug: 'Wandeldarlehen-Bull' }, + { id: 'd0000000-0000-0000-0000-000000000300', slug: '1Mio-Euro-Base' }, + { id: 'd0000000-0000-0000-0000-000000000301', slug: '1Mio-Euro-Bear' }, + { id: 'd0000000-0000-0000-0000-000000000302', slug: '1Mio-Euro-Bull' }, +] + +const MONTHS = 60 +const START_YEAR = 2026 +const FOUNDING_M = 8 // Aug 2026 +const FIRST_M = FOUNDING_M // drop Jan..Jul 2026 from the workbook +const VISIBLE_MONTHS = MONTHS - FIRST_M + 1 // 53 + +// Number format that displays zero as blank +const NUMFMT = '#,##0;-#,##0;""' + +// Sheet name registry — full German names with umlauts. +const SHEET = { + Dashboard: 'Dashboard', + Kunden: 'Kunden', + Umsatz: 'Umsatzerlöse', + Personal: 'Personalkosten', + Invest: 'Investitionen', + Material: 'Materialaufwand', + Betrieb: 'Betriebliche Aufwendungen', + Liquid: 'Liquidität', + GuV: 'GuV', + Formulas: 'Formelübersicht', +} as const + +// In Excel formulas, sheet names with spaces, periods or umlauts must be wrapped in single quotes. +function S(name: string): string { + return /[\s.\-äöüÄÖÜß]/.test(name) ? `'${name.replace(/'/g, "''")}'` : name +} + +// --- column helpers --- +function col(n: number): string { + // 1 -> A, 26 -> Z, 27 -> AA + let s = '' + while (n > 0) { + const r = (n - 1) % 26 + s = String.fromCharCode(65 + r) + s + n = Math.floor((n - 1) / 26) + } + return s +} + +// monthIdx FIRST_M..MONTHS -> Excel column letter (B..BC). A is label column. +const monthCol = (m: number) => col(m - FIRST_M + 2) + +// Iterate visible months (FIRST_M..MONTHS) +function* visibleMonths(): Generator { + for (let m = FIRST_M; m <= MONTHS; m++) yield m +} + +function monthToDate(m: number): Date { + const y = START_YEAR + Math.floor((m - 1) / 12) + const mo = ((m - 1) % 12) + 1 + return new Date(Date.UTC(y, mo - 1, 1)) +} + +function dateToMonth(d: Date): number { + return (d.getUTCFullYear() - START_YEAR) * 12 + (d.getUTCMonth() + 1) +} + +// --- types --- +type Row = Record & { id: number; sort_order: number } + +interface ScenarioData { + scenario: Row + kunden: Row[] + umsatz: Row[] + material: Row[] + personal: Row[] + betrieb: Row[] + invest: Row[] + sonst: Row[] + liquid: Row[] + guv: Row[] +} + +async function loadScenario(pool: Pool, id: string): Promise { + const [scen, kunden, umsatz, material, personal, betrieb, invest, sonst, liquid, guv] = await Promise.all([ + pool.query('SELECT * FROM fp_scenarios WHERE id=$1', [id]), + pool.query('SELECT * FROM fp_kunden WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_umsatzerloese WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_materialaufwand WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_personalkosten WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_betriebliche_aufwendungen WHERE scenario_id=$1 ORDER BY sort_order, id', [id]), + pool.query('SELECT * FROM fp_investitionen WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_sonst_ertraege WHERE scenario_id=$1 ORDER BY sort_order', [id]), + pool.query('SELECT * FROM fp_liquiditaet WHERE scenario_id=$1 ORDER BY sort_order, id', [id]), + pool.query('SELECT * FROM fp_guv WHERE scenario_id=$1 ORDER BY sort_order, id', [id]), + ]) + return { + scenario: scen.rows[0], + kunden: kunden.rows, + umsatz: umsatz.rows, + material: material.rows, + personal: personal.rows, + betrieb: betrieb.rows, + invest: invest.rows, + sonst: sonst.rows, + liquid: liquid.rows, + guv: guv.rows, + } +} + +// Returns m1..m60 array of values from a JSONB values field +function vals(row: any, field = 'values'): number[] { + const v = (row[field] || {}) as Record + const out = new Array(MONTHS) + for (let m = 1; m <= MONTHS; m++) out[m - 1] = Number(v[`m${m}`] || 0) + return out +} + +// Write the three header rows (Year / Month-num / Month-name) on a worksheet starting col B. +// We avoid storing Date objects (timezone hazards) and use numeric Year+Month for formulas. +function writeMonthHeader(ws: ExcelJS.Worksheet): void { + ws.getCell('A1').value = 'Year' + ws.getCell('A2').value = 'Month' + ws.getCell('A3').value = 'Label' + for (const m of visibleMonths()) { + const c = monthCol(m) + const d = monthToDate(m) + ws.getCell(`${c}1`).value = d.getUTCFullYear() + ws.getCell(`${c}2`).value = d.getUTCMonth() + 1 + ws.getCell(`${c}3`).value = + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getUTCMonth()] + + ' ' + d.getUTCFullYear() + } + ws.getRow(1).font = { bold: true } + ws.getRow(2).font = { bold: true } + ws.getRow(3).font = { bold: true } + ws.getColumn(1).width = 38 + for (let i = 0; i < VISIBLE_MONTHS; i++) ws.getColumn(i + 2).width = 10 + + // Freeze panes: keep label column + headers visible + ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 3 }] +} + +// Apply numFmt to all month columns on a row (the data row) +function fmtMonthRow(ws: ExcelJS.Worksheet, r: number, fmt = NUMFMT): void { + for (const m of visibleMonths()) ws.getCell(`${monthCol(m)}${r}`).numFmt = fmt +} + +// Apply NUMFMT to all numeric data cells on a worksheet (skipping header rows 1-3) +function applyNumFmtToSheet(ws: ExcelJS.Worksheet, headerRows = 3): void { + ws.eachRow({ includeEmpty: false }, (row, rNum) => { + if (rNum <= headerRows) return + row.eachCell({ includeEmpty: false }, (cell, cNum) => { + if (cNum < 2) return + const v = cell.value + if (typeof v === 'number' || (typeof v === 'object' && v !== null && 'formula' in (v as any))) { + cell.numFmt = NUMFMT + } + }) + }) +} + +// Parse a YYYY-MM-DD string (or Date) into { year, month, day } with no timezone shift. +// node-postgres parses date columns as JS Dates constructed in local time, so use local accessors. +function parseYMD(d: unknown): { y: number; m: number; day: number } | null { + if (!d) return null + if (d instanceof Date) { + return { y: d.getFullYear(), m: d.getMonth() + 1, day: d.getDate() } + } + const s = String(d) + const match = s.match(/^(\d{4})-(\d{2})-(\d{2})/) + if (!match) return null + return { y: Number(match[1]), m: Number(match[2]), day: Number(match[3]) } +} + +// Write the 5-year header for GuV sheet +function writeYearHeader(ws: ExcelJS.Worksheet): void { + ws.getCell('A1').value = 'Position' + for (let y = 0; y < 5; y++) { + const c = col(y + 2) + ws.getCell(`${c}1`).value = `${START_YEAR + y}` + } + ws.getRow(1).font = { bold: true } + ws.getColumn(1).width = 38 + for (let y = 0; y < 5; y++) ws.getColumn(y + 2).width = 14 +} + +// ===================================================================== +// SHEETS +// ===================================================================== + +interface SheetRefs { + kunden: Map // row_label_with_segment -> excel row + umsatzByLabel: Map // section+row_label -> excel row + materialByLabel: Map + personalInputRow: Map // person id -> excel row (input block) + personalBruttoRow: Map // person id -> excel row (Brutto monthly) + personalSozialRow: Map + personalTotalRow: Map + personalSummary: { brutto: number; sozial: number; total: number; headcount: number; founderHc: number } + investInputRow: Map + investInvestRow: Map + investAfaRow: Map + investTotals: { invest: number; afa: number } + betriebByLabel: Map + sonstByIdx: Map + sonstSumGesamt: number + liquidByLabel: Map + guvByLabel: Map +} + +function buildKunden(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Kunden) + writeMonthHeader(ws) + + // Group kunden by segment_name and row_index. "Gesamt" rows compute formulas; others raw. + const segRows = data.kunden.filter(r => (r as any).segment_name !== 'Gesamt') + const gesamtRows = data.kunden.filter(r => (r as any).segment_name === 'Gesamt') + + let r = 4 + for (const row of segRows) { + const label = `${(row as any).segment_name} — ${(row as any).row_label}` + ws.getCell(`A${r}`).value = label + refs.kunden.set((row as any).row_label, r) + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) { + ws.getCell(`${monthCol(m)}${r}`).value = v[m - 1] + } + r++ + } + + r += 1 + for (const row of gesamtRows) { + const label = (row as any).row_label as string + ws.getCell(`A${r}`).value = label + ws.getCell(`A${r}`).font = { bold: true } + // Match: "Neukunden gesamt" sums all "Neukunden (...)" rows + const baseLabel = label.replace(' gesamt', '') + const sumRows: number[] = [] + for (const seg of segRows) { + const sl = (seg as any).row_label as string + if (sl.startsWith(baseLabel + ' ')) { + const er = refs.kunden.get(sl) + if (er) sumRows.push(er) + } + } + refs.kunden.set(label, r) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const parts = sumRows.map(er => `${c}${er}`).join('+') + ws.getCell(`${c}${r}`).value = { formula: parts || '0' } + } + r++ + } +} + +function buildUmsatz(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Umsatz) + writeMonthHeader(ws) + + // Determine row positions by sort_order + let r = 4 + const rowsByLabel = new Map() + for (const row of data.umsatz) { + const label = (row as any).row_label as string + rowsByLabel.set(label, r) + refs.umsatzByLabel.set(label, r) + ws.getCell(`A${r}`).value = label + if ((row as any).section === 'revenue' && !(row as any).is_editable) ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + // tier extraction + const tierOf = (l: string) => { + const m = l.match(/\(([^)]+)\)/) + return m ? m[1] : l + } + + for (const row of data.umsatz) { + const label = (row as any).row_label as string + const section = (row as any).section as string + const editable = (row as any).is_editable as boolean + const rr = rowsByLabel.get(label)! + if (section === 'price' || (section === 'quantity' /* not editable but no source we can recompute */) || (section === 'revenue' && editable)) { + // raw values + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + } else if (section === 'revenue' && !editable && label !== 'GESAMTUMSATZ') { + // Umsatz (X) = price_row * quantity_row for same tier + const tier = tierOf(label) + const priceRow = data.umsatz.find(x => (x as any).section === 'price' && tierOf((x as any).row_label) === tier) + const qtyRow = data.umsatz.find(x => (x as any).section === 'quantity' && tierOf((x as any).row_label) === tier) + const prR = priceRow ? rowsByLabel.get((priceRow as any).row_label) : undefined + const qR = qtyRow ? rowsByLabel.get((qtyRow as any).row_label) : undefined + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (prR && qR) ws.getCell(`${c}${rr}`).value = { formula: `${c}${prR}*${c}${qR}` } + else ws.getCell(`${c}${rr}`).value = vals(row)[m - 1] + } + } else if (label === 'GESAMTUMSATZ') { + // SUM all revenue rows except itself + const revRows = data.umsatz + .filter(x => (x as any).section === 'revenue' && (x as any).row_label !== 'GESAMTUMSATZ') + .map(x => rowsByLabel.get((x as any).row_label)!) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: revRows.map(er => `${c}${er}`).join('+') } + } + } + } +} + +function buildMaterial(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Material) + writeMonthHeader(ws) + + const costRows = data.material.filter(r => (r as any).section === 'cost') + let r = 4 + const labelToRow = new Map() + for (const row of costRows) { + const label = (row as any).row_label as string + labelToRow.set(label, r) + refs.materialByLabel.set(label, r) + ws.getCell(`A${r}`).value = label + if (label === 'SUMME') ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + const sumRow = labelToRow.get('SUMME') + + for (const row of costRows) { + const label = (row as any).row_label as string + const rr = labelToRow.get(label)! + if (label === 'SUMME') { + // SUM all other cost rows + const ids = [...labelToRow.entries()].filter(([k]) => k !== 'SUMME').map(([, er]) => er) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: ids.map(er => `${c}${er}`).join('+') } + } + } else if (label.includes('Cloud-Hosting')) { + // = MAX(0, ${S(SHEET.Kunden)}!Bestandskunden_gesamt[m] - 10) * 100 + 1500, only from m=FOUNDING_M onwards + const bestRow = refs.kunden.get('Bestandskunden gesamt') + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (m < FOUNDING_M || !bestRow) { + ws.getCell(`${c}${rr}`).value = 0 + } else { + ws.getCell(`${c}${rr}`).value = { formula: `MAX(0,${S(SHEET.Kunden)}!${c}${bestRow}-10)*100+1500` } + } + } + } else { + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + } + } +} + +function buildPersonal(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Personal) + writeMonthHeader(ws) + + // Input block at top (cols A..H) + // A: Person Nr, B: Name, C: Position, D: Brutto, E: Raise%, F: Sozial%, G: Start, H: End + // But col A is used for the month-header row labels too. So we use a different layout: + // Row 5: Input headers + // Row 6..6+N-1: input rows (A: Nr, B: Name, ..., G: Start, H: End) + // Then leave a gap and write Brutto/Sozial/Total monthly blocks where col A=label and col B..BI=m1..m60. + + ws.getCell('A5').value = 'Inputs' + ws.getCell('A5').font = { bold: true } + // Inputs: A=Nr, B=Name, C=Position, D=Brutto, E=Raise%, F=Sozial%, G=StartYear, H=StartMonth, I=EndYear, J=EndMonth + const inHdr = ['Nr', 'Name', 'Position', 'Brutto/Monat', 'Raise %/Yr', 'AG-Sozial %', 'Start-Jahr', 'Start-Monat', 'End-Jahr', 'End-Monat'] + inHdr.forEach((h, i) => { + ws.getCell(`${col(i + 1)}6`).value = h + ws.getCell(`${col(i + 1)}6`).font = { bold: true } + }) + + const personStart = 7 + data.personal.forEach((row, i) => { + const rr = personStart + i + refs.personalInputRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = (row as any).person_nr || '' + ws.getCell(`B${rr}`).value = (row as any).person_name + ws.getCell(`C${rr}`).value = (row as any).position || '' + ws.getCell(`D${rr}`).value = Number((row as any).brutto_monthly || 0) + ws.getCell(`E${rr}`).value = Number((row as any).annual_raise_pct || 0) + ws.getCell(`F${rr}`).value = Number((row as any).ag_sozial_pct || 0) + const sd = parseYMD((row as any).start_date) + const ed = parseYMD((row as any).end_date) + ws.getCell(`G${rr}`).value = sd ? sd.y : '' + ws.getCell(`H${rr}`).value = sd ? sd.m : '' + ws.getCell(`I${rr}`).value = ed ? ed.y : '' + ws.getCell(`J${rr}`).value = ed ? ed.m : '' + }) + + // Block: Brutto monthly per person + const bruttoStart = personStart + data.personal.length + 2 + ws.getCell(`A${bruttoStart - 1}`).value = 'Brutto monatlich' + ws.getCell(`A${bruttoStart - 1}`).font = { bold: true } + data.personal.forEach((row, i) => { + const rr = bruttoStart + i + const inR = refs.personalInputRow.get((row as any).id)! + refs.personalBruttoRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Brutto` + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + // Compare monthIndex = year*12 + month. Active if startKey<=cur and (endKey is blank or cur<=endKey). + // Brutto = ROUND(brutto * (1+raise/100)^(curYear - startYear), 0) + const cur = `${c}$1*12+${c}$2` + const startKey = `$G$${inR}*12+$H$${inR}` + const endKey = `$I$${inR}*12+$J$${inR}` + const active = `AND(${cur}>=${startKey},OR($I$${inR}="",${cur}<=${endKey}))` + const expo = `${c}$1-$G$${inR}` + const f = `IF(${active},ROUND($D$${inR}*(1+$E$${inR}/100)^(${expo}),0),0)` + ws.getCell(`${c}${rr}`).value = { formula: f } + } + }) + + // Sum Brutto + const sumBruttoRow = bruttoStart + data.personal.length + 1 + ws.getCell(`A${sumBruttoRow}`).value = 'TOTAL Brutto' + ws.getCell(`A${sumBruttoRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const refs1 = data.personal.map(p => `${c}${refs.personalBruttoRow.get((p as any).id)!}`).join('+') + ws.getCell(`${c}${sumBruttoRow}`).value = { formula: refs1 || '0' } + } + refs.personalSummary.brutto = sumBruttoRow + + // Block: Sozial monthly + const sozialStart = sumBruttoRow + 2 + ws.getCell(`A${sozialStart - 1}`).value = 'AG-Sozialversicherung monatlich' + ws.getCell(`A${sozialStart - 1}`).font = { bold: true } + data.personal.forEach((row, i) => { + const rr = sozialStart + i + const inR = refs.personalInputRow.get((row as any).id)! + const bR = refs.personalBruttoRow.get((row as any).id)! + refs.personalSozialRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Sozial` + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${c}${bR}*$F$${inR}/100,0)` } + } + }) + const sumSozialRow = sozialStart + data.personal.length + 1 + ws.getCell(`A${sumSozialRow}`).value = 'TOTAL Sozial' + ws.getCell(`A${sumSozialRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const refs1 = data.personal.map(p => `${c}${refs.personalSozialRow.get((p as any).id)!}`).join('+') + ws.getCell(`${c}${sumSozialRow}`).value = { formula: refs1 || '0' } + } + refs.personalSummary.sozial = sumSozialRow + + // Block: Total = Brutto + Sozial + const totalStart = sumSozialRow + 2 + ws.getCell(`A${totalStart - 1}`).value = 'Total pro Person monatlich' + ws.getCell(`A${totalStart - 1}`).font = { bold: true } + data.personal.forEach((row, i) => { + const rr = totalStart + i + const bR = refs.personalBruttoRow.get((row as any).id)! + const sR = refs.personalSozialRow.get((row as any).id)! + refs.personalTotalRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = `${(row as any).person_name} — Total` + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: `${c}${bR}+${c}${sR}` } + } + }) + const sumTotalRow = totalStart + data.personal.length + 1 + ws.getCell(`A${sumTotalRow}`).value = 'TOTAL Personalkosten' + ws.getCell(`A${sumTotalRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const refs1 = data.personal.map(p => `${c}${refs.personalTotalRow.get((p as any).id)!}`).join('+') + ws.getCell(`${c}${sumTotalRow}`).value = { formula: refs1 || '0' } + } + refs.personalSummary.total = sumTotalRow + + // Headcount (= count of persons with brutto>0 in that month) + const hcRow = sumTotalRow + 2 + ws.getCell(`A${hcRow}`).value = 'Headcount' + ws.getCell(`A${hcRow}`).font = { bold: true } + const firstBr = refs.personalBruttoRow.get((data.personal[0] as any).id)! + const lastBr = refs.personalBruttoRow.get((data.personal[data.personal.length - 1] as any).id)! + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${hcRow}`).value = { formula: `COUNTIF(${c}${firstBr}:${c}${lastBr},">0")` } + } + refs.personalSummary.headcount = hcRow + + // Headcount minus founders (2). Used by some opex formulas. + const hcMinusRow = hcRow + 1 + ws.getCell(`A${hcMinusRow}`).value = 'Headcount (ohne 2 Gruender)' + ws.getCell(`A${hcMinusRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${hcMinusRow}`).value = { formula: `MAX(0,${c}${hcRow}-2)` } + } + refs.personalSummary.founderHc = hcMinusRow +} + +function buildInvest(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Invest) + writeMonthHeader(ws) + + // Input block: A=Position, B=Kategorie, C=Betrag, D=Anschaffungs-Jahr, E=Anschaffungs-Monat, F=AfA Jahre + ws.getCell('A5').value = 'Inputs' + ws.getCell('A5').font = { bold: true } + const inHdr = ['Position', 'Kategorie', 'Betrag', 'Anschaff-Jahr', 'Anschaff-Monat', 'AfA Jahre'] + inHdr.forEach((h, i) => { + ws.getCell(`${col(i + 1)}6`).value = h + ws.getCell(`${col(i + 1)}6`).font = { bold: true } + }) + + const itemStart = 7 + data.invest.forEach((row, i) => { + const rr = itemStart + i + refs.investInputRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = (row as any).item_name + ws.getCell(`B${rr}`).value = (row as any).category || '' + ws.getCell(`C${rr}`).value = Number((row as any).purchase_amount || 0) + const pd = parseYMD((row as any).purchase_date) + ws.getCell(`D${rr}`).value = pd ? pd.y : '' + ws.getCell(`E${rr}`).value = pd ? pd.m : '' + ws.getCell(`F${rr}`).value = (row as any).afa_years ?? '' + }) + + // Block: Investitionsausgaben per item + const invStart = itemStart + data.invest.length + 2 + ws.getCell(`A${invStart - 1}`).value = 'Investitionsausgaben' + ws.getCell(`A${invStart - 1}`).font = { bold: true } + data.invest.forEach((row, i) => { + const rr = invStart + i + const inR = refs.investInputRow.get((row as any).id)! + refs.investInvestRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = `${(row as any).item_name} — Ausgabe` + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + // Outlay only in purchase month: when cur_year==purchase_year AND cur_month==purchase_month + const f = `IF(AND(${c}$1=$D$${inR},${c}$2=$E$${inR}),$C$${inR},0)` + ws.getCell(`${c}${rr}`).value = { formula: f } + } + }) + const sumInvestRow = invStart + data.invest.length + 1 + ws.getCell(`A${sumInvestRow}`).value = 'TOTAL Investitionsausgaben' + ws.getCell(`A${sumInvestRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const parts = data.invest.map(it => `${c}${refs.investInvestRow.get((it as any).id)!}`).join('+') + ws.getCell(`${c}${sumInvestRow}`).value = { formula: parts || '0' } + } + refs.investTotals.invest = sumInvestRow + + // Block: AfA per item + const afaStart = sumInvestRow + 2 + ws.getCell(`A${afaStart - 1}`).value = 'Abschreibungen (AfA)' + ws.getCell(`A${afaStart - 1}`).font = { bold: true } + data.invest.forEach((row, i) => { + const rr = afaStart + i + const inR = refs.investInputRow.get((row as any).id)! + refs.investAfaRow.set((row as any).id, rr) + ws.getCell(`A${rr}`).value = `${(row as any).item_name} — AfA` + const hasYears = (row as any).afa_years != null && Number((row as any).afa_years) > 0 + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + // Inputs: D=purchaseYear, E=purchaseMonth, F=afa_years + // monthIndex = year*12 + month + const cur = `${c}$1*12+${c}$2` + const startKey = `$D$${inR}*12+$E$${inR}` + // end_exclusive = startKey + F*12 + const endKey = `$D$${inR}*12+$E$${inR}+$F$${inR}*12` + let f: string + if (hasYears) { + f = `IF(AND(${cur}>=${startKey},${cur}<${endKey}),ROUND($C$${inR}/($F$${inR}*12),0),0)` + } else { + // GWG: full in purchase month + f = `IF(AND(${c}$1=$D$${inR},${c}$2=$E$${inR}),$C$${inR},0)` + } + ws.getCell(`${c}${rr}`).value = { formula: f } + } + }) + const sumAfaRow = afaStart + data.invest.length + 1 + ws.getCell(`A${sumAfaRow}`).value = 'TOTAL AfA' + ws.getCell(`A${sumAfaRow}`).font = { bold: true } + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + const parts = data.invest.map(it => `${c}${refs.investAfaRow.get((it as any).id)!}`).join('+') + ws.getCell(`${c}${sumAfaRow}`).value = { formula: parts || '0' } + } + refs.investTotals.afa = sumAfaRow +} + +function buildSonst(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet('SonstErtraege') + writeMonthHeader(ws) + + // Group by category. is_sum_row=true means subtotal across that category's detail rows. + let r = 4 + const rowsByLabel = new Map() + // Pre-pass: assign rows + for (const row of data.sonst) { + rowsByLabel.set(`${(row as any).category}|${(row as any).row_label}|${(row as any).row_index}`, r) + refs.sonstByIdx.set((row as any).row_index, r) + ws.getCell(`A${r}`).value = `${(row as any).category} — ${(row as any).row_label}` + if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + for (const row of data.sonst) { + const key = `${(row as any).category}|${(row as any).row_label}|${(row as any).row_index}` + const rr = rowsByLabel.get(key)! + if ((row as any).row_label === 'GESAMTUMSATZ') { + // Sum of all category-sum rows + const sumIds = data.sonst + .filter(x => (x as any).is_sum_row && (x as any).row_label !== 'GESAMTUMSATZ') + .map(x => rowsByLabel.get(`${(x as any).category}|${(x as any).row_label}|${(x as any).row_index}`)!) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: sumIds.length ? sumIds.map(er => `${c}${er}`).join('+') : '0' } + } + refs.sonstSumGesamt = rr + } else if ((row as any).is_sum_row) { + // Sum of detail rows in same category + const detailIds = data.sonst + .filter(x => (x as any).category === (row as any).category && !(x as any).is_sum_row) + .map(x => rowsByLabel.get(`${(x as any).category}|${(x as any).row_label}|${(x as any).row_index}`)!) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' } + } + } else { + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + } + } +} + +function buildBetrieb(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Betrieb) + writeMonthHeader(ws) + + // Excel column for the date row (row 3): column letter c + // Engine-defined formula rows (label -> per-cell formula factory) + // Per-unit formulas (apply only m >= FOUNDING_M, else 0) + const perUnitMap: Record = { + 'Fort-/Weiterbildungskosten (F)': { perUnit: 300, source: 'hcMinusFounders' }, + 'Reisekosten (F)': { perUnit: 75, source: 'hc' }, + 'Bewirtungskosten (F)': { perUnit: 50, source: 'bestand' }, + 'Internet/Mobilfunk (F)': { perUnit: 50, source: 'hc' }, + } + + let r = 4 + for (const row of data.betrieb) { + refs.betriebByLabel.set((row as any).row_label as string, r) + ws.getCell(`A${r}`).value = `${(row as any).category} — ${(row as any).row_label}` + if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + // First pass: simple rows + for (const row of data.betrieb) { + const label = (row as any).row_label as string + const rr = refs.betriebByLabel.get(label)! + const isSum = (row as any).is_sum_row as boolean + const category = (row as any).category as string + + if (label === 'Personalkosten' && category === 'personal') { + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}` } + } + continue + } + if (label === 'Abschreibungen' && category === 'abschreibungen') { + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: `${S(SHEET.Invest)}!${c}${refs.investTotals.afa}` } + } + continue + } + if (perUnitMap[label]) { + const { perUnit, source } = perUnitMap[label] + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue } + let src = '' + if (source === 'hc') src = `${S(SHEET.Personal)}!${c}${refs.personalSummary.headcount}` + else if (source === 'hcMinusFounders') src = `${S(SHEET.Personal)}!${c}${refs.personalSummary.founderHc}` + else src = `${S(SHEET.Kunden)}!${c}${refs.kunden.get('Bestandskunden gesamt')!}` + ws.getCell(`${c}${rr}`).value = { formula: `${src}*${perUnit}` } + } + continue + } + if (label.includes('Berufsgenossenschaft')) { + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue } + ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${S(SHEET.Personal)}!${c}${refs.personalSummary.brutto}*0.005,0)` } + } + continue + } + if (label.includes('Allgemeine Marketingkosten')) { + const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')! + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue } + const rate = m <= 36 ? 0.08 : 0.10 + ws.getCell(`${c}${rr}`).value = { formula: `ROUND(${S(SHEET.Umsatz)}!${c}${umsRow}*${rate},0)` } + } + continue + } + if (isSum) continue // handled below + // Default: raw values + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + } + + // Second pass: category sums + sum sonstige + gesamtkosten + gewerbesteuer + // Determine for each row: which non-tax-opex rows belong to "rest of opex" for Gewerbesteuer + const nonTaxOpexRows = data.betrieb.filter(r => { + const cat = (r as any).category as string + if (cat === 'steuern' || cat === 'personal' || cat === 'abschreibungen') return false + if ((r as any).is_sum_row) return false + const lbl = (r as any).row_label as string + if (lbl.includes('Summe') || lbl.includes('SUMME')) return false + return true + }) + + for (const row of data.betrieb) { + const label = (row as any).row_label as string + const rr = refs.betriebByLabel.get(label)! + const isSum = (row as any).is_sum_row as boolean + const category = (row as any).category as string + + if (label.includes('Gewerbesteuer')) { + const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')! + const matSum = refs.materialByLabel.get('SUMME')! + const persTotal = refs.personalSummary.total + const afaTotal = refs.investTotals.afa + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (m < FOUNDING_M) { ws.getCell(`${c}${rr}`).value = 0; continue } + const opexParts = nonTaxOpexRows + .map(or => `${c}${refs.betriebByLabel.get((or as any).row_label as string)!}`) + .join('+') + const profit = `${S(SHEET.Umsatz)}!${c}${umsRow}-${S(SHEET.Material)}!${c}${matSum}-${S(SHEET.Personal)}!${c}${persTotal}-${S(SHEET.Invest)}!${c}${afaTotal}-(${opexParts})` + ws.getCell(`${c}${rr}`).value = { formula: `IF((${profit})>0,ROUND((${profit})*0.1225,0),0)` } + } + continue + } + if (!isSum) continue + + if (label.includes('Summe sonstige')) { + // Sum: all betrieb rows except personal/abschreibungen, sum rows, gesamtkosten + const detailIds = data.betrieb.filter(x => { + const xl = (x as any).row_label as string + if (xl === 'Personalkosten' || xl === 'Abschreibungen') return false + if (xl.includes('Summe sonstige') || xl.includes('Gesamtkosten') || xl.includes('SUMME Betriebliche')) return false + if ((x as any).is_sum_row) return false + if ((x as any).category === 'personal' || (x as any).category === 'abschreibungen') return false + return true + }).map(x => refs.betriebByLabel.get((x as any).row_label as string)!) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' } + } + continue + } + if (label.includes('Gesamtkosten') || label.includes('SUMME Betriebliche')) { + const persR = refs.betriebByLabel.get('Personalkosten')! + const abrR = refs.betriebByLabel.get('Abschreibungen')! + const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1] + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: `${c}${persR}+${c}${abrR}+${c}${sonstSum}` } + } + continue + } + // Category sum (steuern, versicherungen, besondere, marketing, sonstige, fahrzeug) + const detailIds = data.betrieb.filter(x => (x as any).category === category && !(x as any).is_sum_row) + .map(x => refs.betriebByLabel.get((x as any).row_label as string)!) + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: detailIds.length ? detailIds.map(er => `${c}${er}`).join('+') : '0' } + } + } +} + +function buildLiquid(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.Liquid) + writeMonthHeader(ws) + + let r = 4 + for (const row of data.liquid) { + refs.liquidByLabel.set((row as any).row_label as string, r) + ws.getCell(`A${r}`).value = `${(row as any).row_type} — ${(row as any).row_label}` + if (!(row as any).is_editable && (row as any).row_type !== 'einzahlung') ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + // Pass 1: linked rows + editable raw rows + for (const row of data.liquid) { + const label = (row as any).row_label as string + const rType = (row as any).row_type as string + const editable = (row as any).is_editable as boolean + const rr = refs.liquidByLabel.get(label)! + + const setFormulaAll = (fn: (c: string) => string) => { + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + ws.getCell(`${c}${rr}`).value = { formula: fn(c) } + } + } + + if (label === 'Umsatzerlöse') { + const umsRow = refs.umsatzByLabel.get('GESAMTUMSATZ')! + setFormulaAll(c => `${S(SHEET.Umsatz)}!${c}${umsRow}`) + continue + } + if (label === 'Sonst. betriebl. Erträge') { + // SonstErtraege sheet was dropped (empty in DB). Write 0 inline. + for (const m of visibleMonths()) ws.getCell(`${monthCol(m)}${rr}`).value = 0 + continue + } + if (label === 'Materialaufwand') { + const matR = refs.materialByLabel.get('SUMME')! + setFormulaAll(c => `${S(SHEET.Material)}!${c}${matR}`) + continue + } + if (label === 'Personalkosten') { + setFormulaAll(c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}`) + continue + } + if (label === 'Sonstige Kosten') { + const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1] + if (sonstSum) { + setFormulaAll(c => `${S(SHEET.Betrieb)}!${c}${sonstSum}`) + continue + } + } + if (label === 'Investitionen' && rType === 'ueberschuss') { + setFormulaAll(c => `${S(SHEET.Invest)}!${c}${refs.investTotals.invest}`) + continue + } + // Editable rows: write raw values (stored in DB) + if (editable) { + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + continue + } + // For Gewerbesteuer / Körperschaftsteuer in Liquiditaet — they reference GuV (1/12 of annual) + // Write raw values for now (they have circular dependency we don't want in Excel) + const v = vals(row) + for (let m = FIRST_M; m <= MONTHS; m++) ws.getCell(`${monthCol(m)}${rr}`).value = v[m - 1] + } + + // Pass 2: aggregation rows (Summe ERTRÄGE, AUSZAHLUNGEN, UEBERSCHUSS chain, Kontostand, LIQUIDITAT) + const sumEin = refs.liquidByLabel.get('Summe ERTRÄGE') || refs.liquidByLabel.get('Summe EINZAHLUNGEN') + const sumAus = refs.liquidByLabel.get('Summe AUSZAHLUNGEN') + const uvi = refs.liquidByLabel.get('ÜBERSCHUSS VOR INVESTITIONEN') || refs.liquidByLabel.get('UEBERSCHUSS VOR INVESTITIONEN') + const uve = refs.liquidByLabel.get('ÜBERSCHUSS VOR ENTNAHMEN') || refs.liquidByLabel.get('UEBERSCHUSS VOR ENTNAHMEN') + const ueb = refs.liquidByLabel.get('ÜBERSCHUSS') || refs.liquidByLabel.get('UEBERSCHUSS') + const liqInvest = refs.liquidByLabel.get('Investitionen') + const entnahmen = refs.liquidByLabel.get('Kapitalentnahmen/Ausschüttungen') || refs.liquidByLabel.get('Kapitalentnahmen/Ausschuettungen') + + // Find kontostand (no LIQUIDIT in label) and liquiditaet (with LIQUIDIT) + const kontostandRow = data.liquid.find(r => (r as any).row_type === 'kontostand' && !((r as any).row_label as string).includes('LIQUIDIT')) + const liqRow = data.liquid.find(r => (r as any).row_type === 'kontostand' && ((r as any).row_label as string).includes('LIQUIDIT')) + const konto = kontostandRow ? refs.liquidByLabel.get((kontostandRow as any).row_label) : undefined + const liquidit = liqRow ? refs.liquidByLabel.get((liqRow as any).row_label) : undefined + + const einzIds = data.liquid.filter(x => (x as any).row_type === 'einzahlung' && (x as any).row_label !== 'Summe ERTRÄGE' && (x as any).row_label !== 'Summe EINZAHLUNGEN') + .map(x => refs.liquidByLabel.get((x as any).row_label as string)!) + const auszIds = data.liquid.filter(x => (x as any).row_type === 'auszahlung' && (x as any).row_label !== 'Summe AUSZAHLUNGEN') + .map(x => refs.liquidByLabel.get((x as any).row_label as string)!) + + for (let m = FIRST_M; m <= MONTHS; m++) { + const c = monthCol(m) + if (sumEin) ws.getCell(`${c}${sumEin}`).value = { formula: einzIds.map(er => `${c}${er}`).join('+') || '0' } + if (sumAus) ws.getCell(`${c}${sumAus}`).value = { formula: auszIds.map(er => `${c}${er}`).join('+') || '0' } + if (uvi && sumEin && sumAus) ws.getCell(`${c}${uvi}`).value = { formula: `${c}${sumEin}-${c}${sumAus}` } + if (uve && uvi && liqInvest) ws.getCell(`${c}${uve}`).value = { formula: `${c}${uvi}-${c}${liqInvest}` } + if (ueb && uve && entnahmen) ws.getCell(`${c}${ueb}`).value = { formula: `${c}${uve}-${c}${entnahmen}` } + else if (ueb && uve) ws.getCell(`${c}${ueb}`).value = { formula: `${c}${uve}` } + if (konto && liquidit && ueb) { + if (m === FIRST_M) ws.getCell(`${c}${konto}`).value = 0 + else { + const prev = monthCol(m - 1) + ws.getCell(`${c}${konto}`).value = { formula: `${prev}${liquidit}` } + } + ws.getCell(`${c}${liquidit}`).value = { formula: `${c}${konto}+${c}${ueb}` } + } + } +} + +function buildGuV(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs): void { + const ws = wb.addWorksheet(SHEET.GuV) + writeYearHeader(ws) + + let r = 2 + for (const row of data.guv) { + refs.guvByLabel.set((row as any).row_label as string, r) + ws.getCell(`A${r}`).value = (row as any).row_label + if ((row as any).is_sum_row) ws.getCell(`A${r}`).font = { bold: true } + r++ + } + + // For each year, the columns B..F (idx 2..6) correspond to 2026..2030 (months m=1..12, 13..24, ..., 49..60). + const yearCol = (y: number) => col(y - START_YEAR + 2) + const monthRangeFor = (y: number, sheet: string, row: number) => { + const startM = Math.max(FIRST_M, (y - START_YEAR) * 12 + 1) + const endM = startM > (y - START_YEAR) * 12 + 12 ? startM : (y - START_YEAR) * 12 + 12 + return `SUM(${S(sheet)}!${monthCol(startM)}${row}:${monthCol(endM)}${row})` + } + + // For each GuV row, build formula by year + for (const row of data.guv) { + const label = (row as any).row_label as string + const rr = refs.guvByLabel.get(label)! + + const setEachYear = (fn: (y: number) => string | { formula: string } | number) => { + for (let y = START_YEAR; y <= START_YEAR + 4; y++) { + const c = yearCol(y) + const v = fn(y) + if (typeof v === 'string') ws.getCell(`${c}${rr}`).value = { formula: v } + else if (typeof v === 'number') ws.getCell(`${c}${rr}`).value = v + else ws.getCell(`${c}${rr}`).value = v + } + } + + if (label === 'Umsatzerlöse' || label === 'Gesamtleistung') { + setEachYear(y => monthRangeFor(y, SHEET.Umsatz, refs.umsatzByLabel.get('GESAMTUMSATZ')!)) + continue + } + if (label === 'Summe Materialaufwand') { + setEachYear(y => monthRangeFor(y, SHEET.Material, refs.materialByLabel.get('SUMME')!)) + continue + } + if (label === 'Rohergebnis') { + const umsR = refs.guvByLabel.get('Umsatzerlöse')! + const matR = refs.guvByLabel.get('Summe Materialaufwand')! + setEachYear(y => `${yearCol(y)}${umsR}-${yearCol(y)}${matR}`) + continue + } + if (label === 'Löhne und Gehälter') { + setEachYear(y => monthRangeFor(y, SHEET.Personal, refs.personalSummary.brutto)) + continue + } + if (label === 'Soziale Abgaben') { + setEachYear(y => monthRangeFor(y, SHEET.Personal, refs.personalSummary.sozial)) + continue + } + if (label === 'Summe Personalaufwand') { + const lR = refs.guvByLabel.get('Löhne und Gehälter')! + const sR = refs.guvByLabel.get('Soziale Abgaben')! + setEachYear(y => `${yearCol(y)}${lR}+${yearCol(y)}${sR}`) + continue + } + if (label === 'Abschreibungen') { + setEachYear(y => monthRangeFor(y, SHEET.Invest, refs.investTotals.afa)) + continue + } + if (label === 'Sonst. betriebl. Aufwendungen') { + const sonstSum = [...refs.betriebByLabel.entries()].find(([k]) => k.includes('Summe sonstige'))?.[1] + if (sonstSum) setEachYear(y => monthRangeFor(y, SHEET.Betrieb, sonstSum)) + continue + } + if (label === 'Sonst. betriebl. Erträge') { + // SonstErtraege sheet is dropped (empty in DB) — leave as 0 + continue + } + if (label === 'Summe sonst. Erträge') { + const sR = refs.guvByLabel.get('Sonst. betriebl. Erträge')! + setEachYear(y => `${yearCol(y)}${sR}`) + continue + } + if (label === 'EBIT') { + const um = refs.guvByLabel.get('Umsatzerlöse')! + const mat = refs.guvByLabel.get('Summe Materialaufwand')! + const per = refs.guvByLabel.get('Summe Personalaufwand')! + const abr = refs.guvByLabel.get('Abschreibungen')! + const sonst = refs.guvByLabel.get('Sonst. betriebl. Aufwendungen')! + setEachYear(y => { + const c = yearCol(y) + return `${c}${um}-${c}${mat}-${c}${per}-${c}${abr}-${c}${sonst}` + }) + continue + } + if (label === 'Ergebnis nach Steuern' || label === 'Jahresüberschuss') { + const ebit = refs.guvByLabel.get('EBIT')! + const steu = refs.guvByLabel.get('Steuern gesamt')! + const zinsE = refs.guvByLabel.get('Zinserträge')! + const zinsA = refs.guvByLabel.get('Zinsaufwendungen')! + setEachYear(y => `${yearCol(y)}${ebit}+${yearCol(y)}${zinsE}-${yearCol(y)}${zinsA}-${yearCol(y)}${steu}`) + continue + } + if (label === 'Steuern gesamt') { + const gst = refs.guvByLabel.get('Gewerbesteuer')! + const kst = refs.guvByLabel.get('Körperschaftssteuer')! + setEachYear(y => `${yearCol(y)}${gst}+${yearCol(y)}${kst}`) + continue + } + // Körperschaftssteuer / Gewerbesteuer: keep stored values (tax with Verlustvortrag is too complex to inline) + // Other rows (Materialaufwand Waren/Leistungen, Bestandsveränderungen, Zinserträge, Zinsaufwendungen, Sonstige Steuern): raw values from DB + const v = (row as any).values as Record + for (let y = START_YEAR; y <= START_YEAR + 4; y++) { + ws.getCell(`${yearCol(y)}${rr}`).value = Number(v?.[`y${y}`] || 0) + } + } +} + +/** + * Dashboard sheet with KPI tables that chart drivers will reference. + * Layout: + * Annual KPIs (B2..F11): Year | Umsatz | Material | Personnel | AfA | Sonst Opex | EBIT | Steuern | Jahresueberschuss + * Monthly Liquidity (B14:?): Month label header + value row + * Monthly Headcount (B17): label header + value row + * Monthly Personalkosten (B20): label header + value row + * + * Charts are added by the openpyxl post-processor referencing these ranges. + */ +function buildDashboard(wb: ExcelJS.Workbook, data: ScenarioData, refs: SheetRefs, scenarioName: string): void { + const ws = wb.addWorksheet(SHEET.Dashboard) + ws.getCell('A1').value = scenarioName + ws.getCell('A1').font = { bold: true, size: 16 } + ws.getColumn(1).width = 28 + + // Annual KPI table (rows 3..11 to leave room): columns A=Label, B..F=2026..2030 + const yearCol = (y: number) => col(y - START_YEAR + 2) + ws.getCell('A3').value = 'Jahres-KPI' + ws.getCell('A3').font = { bold: true } + for (let y = START_YEAR; y < START_YEAR + 5; y++) { + const c = yearCol(y) + ws.getCell(`${c}3`).value = y + ws.getCell(`${c}3`).font = { bold: true } + ws.getColumn(c.charCodeAt(0) - 64).width = 14 + } + + // Metrics map to GuV rows (formula-based pull). Some need rebuilt formulas. + const metrics: { label: string; build: (c: string) => string }[] = [ + { label: 'Umsatzerlöse', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Umsatzerlöse')!}` }, + { label: 'Materialaufwand', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Summe Materialaufwand')!}` }, + { label: 'Personalkosten', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Summe Personalaufwand')!}` }, + { label: 'Abschreibungen', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Abschreibungen')!}` }, + { label: 'Sonst. betr. Aufwand', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Sonst. betriebl. Aufwendungen')!}` }, + { label: 'EBIT', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('EBIT')!}` }, + { label: 'Steuern', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Steuern gesamt')!}` }, + { label: 'Jahresüberschuss', build: c => `${S(SHEET.GuV)}!${c}${refs.guvByLabel.get('Jahresüberschuss')!}` }, + ] + metrics.forEach((m, i) => { + const rr = 4 + i + ws.getCell(`A${rr}`).value = m.label + if (m.label === 'EBIT' || m.label === 'Jahresüberschuss') ws.getCell(`A${rr}`).font = { bold: true } + for (let y = START_YEAR; y < START_YEAR + 5; y++) { + const c = yearCol(y) + ws.getCell(`${c}${rr}`).value = { formula: m.build(c) } + ws.getCell(`${c}${rr}`).numFmt = NUMFMT + } + }) + + // Monthly drivers — each driver gets a 2-row block: header (month label) + values + const blockBase = 4 + metrics.length + 2 // ~14 + const writeMonthlyBlock = (label: string, baseRow: number, formulaBuilder: (c: string) => string): void => { + ws.getCell(`A${baseRow}`).value = label + ws.getCell(`A${baseRow}`).font = { bold: true } + ws.getCell(`A${baseRow + 1}`).value = 'Monat' + ws.getCell(`A${baseRow + 2}`).value = label + for (const m of visibleMonths()) { + const c = monthCol(m) + const d = monthToDate(m) + const monthName = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getUTCMonth()] + ws.getCell(`${c}${baseRow + 1}`).value = `${monthName} ${d.getUTCFullYear()}` + ws.getCell(`${c}${baseRow + 2}`).value = { formula: formulaBuilder(c) } + ws.getCell(`${c}${baseRow + 2}`).numFmt = NUMFMT + } + } + + // Liquidity + const liqLabel = data.liquid.find(r => (r as any).row_type === 'kontostand' && ((r as any).row_label as string).includes('LIQUIDIT')) + const liqRow = liqLabel ? refs.liquidByLabel.get((liqLabel as any).row_label as string)! : 0 + writeMonthlyBlock('Liquidität (monatlich)', blockBase, c => `${S(SHEET.Liquid)}!${c}${liqRow}`) + + // Headcount + writeMonthlyBlock('Headcount (monatlich)', blockBase + 4, c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.headcount}`) + + // Personalkosten total + writeMonthlyBlock('Personalkosten total (monatlich)', blockBase + 8, c => `${S(SHEET.Personal)}!${c}${refs.personalSummary.total}`) + + ws.views = [{ state: 'frozen', xSplit: 1, ySplit: 3 }] +} + +function buildFormulas(wb: ExcelJS.Workbook, data: ScenarioData): void { + const ws = wb.addWorksheet(SHEET.Formulas) + ws.getCell('A1').value = 'Sheet' + ws.getCell('B1').value = 'Row' + ws.getCell('C1').value = 'Formel-Beschreibung' + ws.getRow(1).font = { bold: true } + ws.getColumn(1).width = 18 + ws.getColumn(2).width = 50 + ws.getColumn(3).width = 80 + + let r = 2 + const push = (sheet: string, label: string, desc: string) => { + ws.getCell(`A${r}`).value = sheet + ws.getCell(`B${r}`).value = label + ws.getCell(`C${r}`).value = desc + r++ + } + + push('Kunden', '... gesamt', 'SUM ueber 3 Segmente') + push('Umsatzerloese', 'Umsatz (X)', 'Preis * Anzahl Kunden im Tier X') + push('Umsatzerloese', 'GESAMTUMSATZ', 'SUM aller Umsatz-Zeilen + Beratung & Service') + push('Materialaufwand', 'Cloud-Hosting', 'MAX(0, Bestandskunden_gesamt - 10) * 100 + 1500 (ab Aug 2026)') + push('Materialaufwand', 'SUMME', 'SUM aller Cost-Zeilen') + push('Personalkosten', 'Brutto je Person', 'IF(Monat in [start..end], Brutto * (1+raise)^Jahre_seit_start, 0)') + push('Personalkosten', 'Sozial je Person', 'Brutto * AG-Sozial% / 100') + push('Personalkosten', 'Total je Person', 'Brutto + Sozial') + push('Personalkosten', 'TOTAL Personalkosten', 'SUM ueber alle Positionen') + push('Personalkosten', 'Headcount', 'COUNTIF Brutto>0 in der Spalte') + push('Investitionen', 'Ausgabe je Position', 'IF(Monat == Anschaffungsmonat, Betrag, 0)') + push('Investitionen', 'AfA je Position', 'IF AfA-Jahre vorhanden: linear ueber afa_jahre*12 Monate, sonst voll im Anschaffungsmonat (GWG)') + push('Betriebliche', 'Personalkosten (Zeile)', '=Personalkosten!TOTAL Personalkosten') + push('Betriebliche', 'Abschreibungen (Zeile)', '=Investitionen!TOTAL AfA') + push('Betriebliche', 'Fort-/Weiterbildungskosten', 'Headcount(ohne Gruender) * 300, ab Aug 2026') + push('Betriebliche', 'Reisekosten', 'Headcount * 75, ab Aug 2026') + push('Betriebliche', 'Bewirtungskosten', 'Bestandskunden_gesamt * 50, ab Aug 2026') + push('Betriebliche', 'Internet/Mobilfunk', 'Headcount * 50, ab Aug 2026') + push('Betriebliche', 'Berufsgenossenschaft', '0,5% von Brutto-Personalkosten') + push('Betriebliche', 'Allgemeine Marketingkosten', '8% von Gesamtumsatz bis Dez 2028, 10% ab Jan 2029') + push('Betriebliche', 'Gewerbesteuer (F)', '12,25% vom monatlichen Profit (falls positiv); Profit = Umsatz - Material - Personal - AfA - Rest-Opex') + push('Betriebliche', 'Kategorie-Summen', 'SUM aller Detailzeilen der Kategorie') + push('Betriebliche', 'Summe sonstige', 'SUM aller Betrieb-Zeilen ohne Personal/Abschr./Sum-Zeilen') + push('Betriebliche', 'Gesamtkosten', 'Personalkosten + Abschreibungen + Summe sonstige') + push('Liquiditaet', 'Umsatzerloese', '=Umsatzerlöse!GESAMTUMSATZ') + push('Liquiditaet', 'Materialaufwand', '=Materialaufwand!SUMME') + push('Liquiditaet', 'Personalkosten', '=Personalkosten!TOTAL') + push('Liquiditaet', 'Sonstige Kosten', '=Betriebliche Aufwendungen!Summe sonstige') + push('Liquiditaet', 'Investitionen', '=Investitionen!TOTAL Investitionsausgaben') + push('Liquiditaet', 'Summe ERTRAEGE', 'SUM aller Einzahlungen') + push('Liquiditaet', 'Summe AUSZAHLUNGEN', 'SUM aller Auszahlungen') + push('Liquiditaet', 'UEBERSCHUSS VOR INVEST.', 'Summe ERTRAEGE - Summe AUSZAHLUNGEN') + push('Liquiditaet', 'UEBERSCHUSS VOR ENTN.', 'UEBERSCHUSS VOR INVEST - Investitionen') + push('Liquiditaet', 'UEBERSCHUSS', 'UEBERSCHUSS VOR ENTN - Kapitalentnahmen') + push('Liquiditaet', 'Kontostand (Monatsbeginn)', '0 in m1, sonst LIQUIDITAET des Vormonats') + push('Liquiditaet', 'LIQUIDITAET', 'Kontostand + UEBERSCHUSS') + push('Liquiditaet', 'Gewerbe-/Koerperschaftsteuer', 'Aus DB uebernommen (Verlustvortrag-Logik nicht in Excel inline)') + push('GuV', 'Umsatzerloese / Gesamtleistung', 'SUM Umsatzerloese!GESAMTUMSATZ ueber das Jahr') + push('GuV', 'Summe Materialaufwand', 'SUM Materialaufwand!SUMME ueber das Jahr') + push('GuV', 'Rohergebnis', 'Umsatzerloese - Summe Materialaufwand') + push('GuV', 'Loehne und Gehaelter', 'SUM Personalkosten!TOTAL Brutto') + push('GuV', 'Soziale Abgaben', 'SUM Personalkosten!TOTAL Sozial') + push('GuV', 'Summe Personalaufwand', 'Loehne + Soziale Abgaben') + push('GuV', 'Abschreibungen', 'SUM Investitionen!TOTAL AfA') + push('GuV', 'Sonst. betr. Aufwendungen', 'SUM Betriebliche Aufwendungen!Summe sonstige') + push('GuV', 'EBIT', 'Umsatz - Material - Personal - AfA - Sonst. betr. Aufwand') + push('GuV', 'Koerperschaft-/Gewerbesteuer', 'Aus DB uebernommen (Verlustvortrag-Logik)') + push('GuV', 'Ergebnis nach Steuern / Jahresueberschuss', 'EBIT + Zinsertraege - Zinsaufwendungen - Steuern gesamt') +} + +// ===================================================================== +// MAIN +// ===================================================================== +async function main() { + const pool = new Pool({ connectionString: CONN, ssl: false }) + const outDir = path.join(__dirname, '..', 'exports') + require('fs').mkdirSync(outDir, { recursive: true }) + + for (const sc of SCENARIOS) { + console.log(`\n=== Exporting ${sc.slug} (${sc.id}) ===`) + const data = await loadScenario(pool, sc.id) + if (!data.scenario) { + console.log(' Scenario not found, skip') + continue + } + const refs: SheetRefs = { + kunden: new Map(), + umsatzByLabel: new Map(), + materialByLabel: new Map(), + personalInputRow: new Map(), + personalBruttoRow: new Map(), + personalSozialRow: new Map(), + personalTotalRow: new Map(), + personalSummary: { brutto: 0, sozial: 0, total: 0, headcount: 0, founderHc: 0 }, + investInputRow: new Map(), + investInvestRow: new Map(), + investAfaRow: new Map(), + investTotals: { invest: 0, afa: 0 }, + betriebByLabel: new Map(), + sonstByIdx: new Map(), + sonstSumGesamt: 0, + liquidByLabel: new Map(), + guvByLabel: new Map(), + } + + const wb = new ExcelJS.Workbook() + wb.creator = 'BreakPilot Finanzplan Export' + wb.created = new Date() + + // Order matters: dependent sheets reference earlier ones. + // SonstErtraege is skipped — DB has 0 values for all rows across all scenarios. + buildKunden(wb, data, refs) + buildUmsatz(wb, data, refs) + buildPersonal(wb, data, refs) + buildInvest(wb, data, refs) + buildMaterial(wb, data, refs) + buildBetrieb(wb, data, refs) + buildLiquid(wb, data, refs) + buildGuV(wb, data, refs) + // Dashboard must be built after GuV (uses guvByLabel) and Liquid/Personal/Material refs. + buildDashboard(wb, data, refs, `${(data.scenario as any).name} — Finanzplan`) + buildFormulas(wb, data) + + // Sheet order is handled by the Python post-processor. + + // Apply zero-suppressing number format to every data sheet (skip the docs tab, which is text) + wb.eachSheet(s => { + if (s.name === SHEET.Formulas) return + applyNumFmtToSheet(s) + }) + + const file = path.join(outDir, `Finanzplan-${sc.slug}.xlsx`) + await wb.xlsx.writeFile(file) + console.log(` Wrote ${file}`) + } + + await pool.end() +} + +main().catch(e => { + console.error(e) + process.exit(1) +}) diff --git a/pitch-deck/scripts/export-finanzplan.sh b/pitch-deck/scripts/export-finanzplan.sh new file mode 100755 index 0000000..f0feb4c --- /dev/null +++ b/pitch-deck/scripts/export-finanzplan.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Generate Finanzplan Excel exports with formulas + charts. +# Step 1: TS script writes data + formulas via exceljs +# Step 2: Python script adds charts via openpyxl +# +# Requires PG_CONN env var pointing at the breakpilot_db postgres instance. +set -e +cd "$(dirname "$0")/.." +if [[ -z "${PG_CONN:-}" ]]; then + echo "PG_CONN env var is required (postgresql://user:pass@host:port/breakpilot_db)" >&2 + exit 1 +fi +npx tsx scripts/export-finanzplan-excel.ts +python3 scripts/add-charts.py +echo +echo "Done. Files in $(pwd)/exports/"