Compare commits
58 Commits
87e6a5f564
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a3c86fc559 | |||
| 5842a84b20 | |||
| ff862ed030 | |||
| f41c3614b5 | |||
| 113616cc1f | |||
| f9d7ca07fd | |||
| ce987b9978 | |||
| 5ff9bc0709 | |||
| f22b0a3f27 | |||
| 6ff144d7e1 | |||
| 2f6b788bd4 | |||
| 42d5585969 | |||
| c440063a93 | |||
| 79fb2b9ca6 | |||
| 2f762175d2 | |||
| cc61a120ee | |||
| 0f656f4070 | |||
| 1865db5de7 | |||
| 9907fda6f4 | |||
| 7979fed7bd | |||
| 0953880a80 | |||
| 0761f6b3e2 | |||
| b42f9b385f | |||
| 96fdd00d5c | |||
| efcc04c645 | |||
| 52c48a9956 | |||
| 2bb243b896 | |||
| b47d3d0a3e | |||
| d6855ef1a0 | |||
| c429624635 | |||
| 8b50f8d4ae | |||
| e22f2cfa2b | |||
| d9ef980edf | |||
| 489390a4a3 | |||
| 74d1577bd8 | |||
| fb70781e5b | |||
| ce5f83823f | |||
| 44c6c81f5d | |||
| e117e5a4e5 | |||
| 4343d50aa4 | |||
| d023534a03 | |||
| d04e07fdfb | |||
| 50e98122cf | |||
| 683d240518 | |||
| 1d046e9f7e | |||
| 9252f7188d | |||
| c2fb82b0de | |||
| e1f74fca95 | |||
| 4791dccf93 | |||
| 1836cad7f3 | |||
| 3fe7a3978d | |||
| d203e504c0 | |||
| a5d4868c58 | |||
| 71137f7cea | |||
| 5bf35bd6da | |||
| a97de5b1ed | |||
| e901d23970 | |||
| 9f5bd754eb |
+3944
-260
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// pdfkit lädt seine Font-Metriken (.afm) zur Laufzeit über Dateipfade. Gebündelt stimmt
|
||||
// `__dirname` nicht mehr und die Dateien werden nicht gefunden -- der PDF-Bericht bräche
|
||||
// erst in der Produktion. Als externes Paket wird es zur Laufzeit aus node_modules
|
||||
// geladen (die der Docker-Build vollständig mitkopiert).
|
||||
serverExternalPackages: ["pdfkit"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+198
@@ -15,6 +15,7 @@
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -26,8 +27,10 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electric-sql/pglite": "^0.4.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -1319,6 +1322,30 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -2552,6 +2579,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz",
|
||||
"integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
@@ -3684,6 +3721,26 @@
|
||||
"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==",
|
||||
"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.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
@@ -3735,6 +3792,24 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/browserify-zlib": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz",
|
||||
"integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "~1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||
@@ -3987,6 +4062,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4389,6 +4473,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||
@@ -5343,6 +5433,23 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
@@ -6465,6 +6572,12 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-md5": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz",
|
||||
"integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6875,6 +6988,25 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -8283,6 +8415,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -8353,6 +8491,20 @@
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz",
|
||||
"integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^1.0.0",
|
||||
"@noble/hashes": "^1.6.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"js-md5": "^0.8.3",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz",
|
||||
@@ -8487,6 +8639,14 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
|
||||
"integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==",
|
||||
"dependencies": {
|
||||
"browserify-zlib": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -9047,6 +9207,12 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
@@ -9740,6 +9906,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
@@ -10053,6 +10225,32 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie/node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
@@ -28,8 +29,10 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electric-sql/pglite": "^0.4.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Einmalige Sonderein-/ausgaben (Roadmap Nr. 1): Entscheid fuer das Cash-Konto beim Uebergang
|
||||
-- NACH dieser Phase. Cash ist kein FinancialElement und kann deshalb keinen
|
||||
-- ElementTransitionValue tragen -- der Entscheid haengt darum direkt an der Von-Phase.
|
||||
ALTER TABLE "Phase" ADD COLUMN "cashTransition" JSONB;
|
||||
@@ -0,0 +1,86 @@
|
||||
-- V6: Szenario-Hierarchie. Der bisherige "Plan" wird zum SZENARIO (IDs bleiben erhalten,
|
||||
-- damit alle Kind-Datensaetze gueltig bleiben); darueber entsteht ein neuer, schlanker PLAN
|
||||
-- als Behaelter. Bestehende Szenarien (bisher: Plan mit parentPlanId) werden als Baum unter
|
||||
-- denselben Plan gehaengt wie ihr Ursprung.
|
||||
|
||||
-- 1) Bisherige Plan-Tabelle wird zum Szenario. Kind-FKs folgen dem Rename automatisch.
|
||||
ALTER TABLE "Plan" RENAME TO "Scenario";
|
||||
ALTER TABLE "Scenario" RENAME COLUMN "parentPlanId" TO "parentScenarioId";
|
||||
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_pkey" TO "Scenario_pkey";
|
||||
ALTER TABLE "Scenario" RENAME CONSTRAINT "Plan_parentPlanId_fkey" TO "Scenario_parentScenarioId_fkey";
|
||||
|
||||
ALTER TABLE "Scenario" ADD COLUMN "isBase" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Scenario" ADD COLUMN "planId" TEXT;
|
||||
|
||||
-- 2) Neuer Behaelter "Plan".
|
||||
CREATE TABLE "Plan" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Plan_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- 3) Je Wurzel-Szenario (bisher: eigenstaendiger Plan) einen Behaelter anlegen. Die Plan-ID
|
||||
-- wird deterministisch aus der Szenario-ID abgeleitet, damit die Zuordnung ohne
|
||||
-- Hilfstabelle moeglich ist.
|
||||
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
|
||||
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
|
||||
FROM "Scenario" s
|
||||
WHERE s."parentScenarioId" IS NULL;
|
||||
|
||||
-- 4) Wurzel-Szenarien werden zum Basisszenario ihres Behaelters. Der bisherige Plan-Name
|
||||
-- wandert auf den Behaelter (Schritt 3); das Szenario heisst neu "Basisszenario".
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = 'plan_' || s."id", "isBase" = true, "name" = 'Basisszenario'
|
||||
WHERE s."parentScenarioId" IS NULL;
|
||||
|
||||
-- 5) Kind-Szenarien erben den Behaelter ihrer Wurzel (beliebig tief verschachtelt).
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT "id", "planId" FROM "Scenario" WHERE "parentScenarioId" IS NULL
|
||||
UNION ALL
|
||||
SELECT c."id", t."planId" FROM "Scenario" c JOIN tree t ON c."parentScenarioId" = t."id"
|
||||
)
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = t."planId"
|
||||
FROM tree t
|
||||
WHERE s."id" = t."id" AND s."planId" IS NULL;
|
||||
|
||||
-- 6) Sicherheitsnetz: Szenarien, deren Elternteil fehlt (verwaist), werden eigenstaendig.
|
||||
INSERT INTO "Plan" ("id", "userId", "name", "createdAt", "updatedAt")
|
||||
SELECT 'plan_' || s."id", s."userId", s."name", s."createdAt", CURRENT_TIMESTAMP
|
||||
FROM "Scenario" s
|
||||
WHERE s."planId" IS NULL;
|
||||
|
||||
UPDATE "Scenario" s
|
||||
SET "planId" = 'plan_' || s."id", "isBase" = true, "parentScenarioId" = NULL
|
||||
WHERE s."planId" IS NULL;
|
||||
|
||||
-- 7) Beziehungen des Behaelters festziehen.
|
||||
ALTER TABLE "Scenario" ALTER COLUMN "planId" SET NOT NULL;
|
||||
ALTER TABLE "Plan" ADD CONSTRAINT "Plan_userId_fkey"
|
||||
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "Scenario" ADD CONSTRAINT "Scenario_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- 8) Eigentuemer haengt neu am Behaelter; die alten Felder am Szenario entfallen.
|
||||
ALTER TABLE "Scenario" DROP CONSTRAINT "Plan_userId_fkey";
|
||||
ALTER TABLE "Scenario" DROP COLUMN "userId";
|
||||
ALTER TABLE "Scenario" DROP COLUMN "branchFromPhaseId";
|
||||
|
||||
-- 9) Kind-Tabellen: planId -> scenarioId (inkl. Constraint- und Index-Namen).
|
||||
ALTER TABLE "Person" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "Person" RENAME CONSTRAINT "Person_planId_fkey" TO "Person_scenarioId_fkey";
|
||||
ALTER INDEX "Person_planId_role_key" RENAME TO "Person_scenarioId_role_key";
|
||||
|
||||
ALTER TABLE "Phase" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "Phase" RENAME CONSTRAINT "Phase_planId_fkey" TO "Phase_scenarioId_fkey";
|
||||
ALTER INDEX "Phase_planId_sequenceNumber_key" RENAME TO "Phase_scenarioId_sequenceNumber_key";
|
||||
|
||||
ALTER TABLE "FinancialElement" RENAME COLUMN "planId" TO "scenarioId";
|
||||
ALTER TABLE "FinancialElement" RENAME CONSTRAINT "FinancialElement_planId_fkey" TO "FinancialElement_scenarioId_fkey";
|
||||
|
||||
-- 10) Herkunfts-Verweise fuer die Abweichungs-Markierung (Diff gegen das Eltern-Szenario).
|
||||
ALTER TABLE "Phase" ADD COLUMN "sourcePhaseId" TEXT;
|
||||
ALTER TABLE "FinancialElement" ADD COLUMN "sourceElementId" TEXT;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Kalenderjahr des Planbeginns (Jahr 1). Nur fuer die Darstellung (Zeitachse, Grafiken);
|
||||
-- die Berechnung bleibt in relativen Jahren.
|
||||
ALTER TABLE "Scenario" ADD COLUMN "startYear" INTEGER;
|
||||
|
||||
-- Bestehende Szenarien auf das laufende Jahr setzen, damit die Zeitachse sofort Jahre zeigt.
|
||||
UPDATE "Scenario" SET "startYear" = EXTRACT(YEAR FROM CURRENT_DATE)::int WHERE "startYear" IS NULL;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Versionierung je Szenario (SPEZIFIKATION 3.8, 4.16).
|
||||
--
|
||||
-- Eine Version haelt den VOLLSTAENDIGEN Zustand des Szenarios als JSON (Form: PlanInput),
|
||||
-- nicht eine Differenz. Grund: Alle Analysewerkzeuge nehmen ohnehin PlanInput entgegen --
|
||||
-- damit lassen sich alte Staende ohne Umbau rechnen, anzeigen und wiederherstellen.
|
||||
|
||||
-- Laufende Hauptversion des Szenarios. Nebenversionen zaehlen in ScenarioVersion.
|
||||
ALTER TABLE "Scenario" ADD COLUMN "currentMajor" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
CREATE TABLE "ScenarioVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scenarioId" TEXT NOT NULL,
|
||||
-- Version A.B: major wird manuell hochgezogen, minor je Bearbeitungssitzung.
|
||||
"major" INTEGER NOT NULL,
|
||||
"minor" INTEGER NOT NULL,
|
||||
-- Pflicht bei Hauptversionen, sonst leer. Wiederherstellungen tragen hier ihre Herkunft.
|
||||
"comment" TEXT,
|
||||
"isMajor" BOOLEAN NOT NULL DEFAULT false,
|
||||
-- Wer die Version erzeugt hat. Heute immer der Eigentuemer; vorbereitet auf spaetere
|
||||
-- Freigabe an einen Finanzberater.
|
||||
"createdById" TEXT NOT NULL,
|
||||
-- Vollstaendiger Zustand (PlanInput als JSON).
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
-- Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ScenarioVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ScenarioVersion_scenarioId_major_minor_key"
|
||||
ON "ScenarioVersion"("scenarioId", "major", "minor");
|
||||
|
||||
-- Die Historie wird immer neuestzuerst gelesen.
|
||||
CREATE INDEX "ScenarioVersion_scenarioId_createdAt_idx"
|
||||
ON "ScenarioVersion"("scenarioId", "createdAt");
|
||||
|
||||
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_scenarioId_fkey"
|
||||
FOREIGN KEY ("scenarioId") REFERENCES "Scenario"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Beim Loeschen eines Benutzers verschwinden dessen Plaene ohnehin per Cascade; der
|
||||
-- Verweis bleibt bewusst RESTRICT-frei, damit die Historie nie ein Loeschen blockiert.
|
||||
ALTER TABLE "ScenarioVersion" ADD CONSTRAINT "ScenarioVersion_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Effektive (Ist-)Werte, Roadmap Nr. 5 (SPEZIFIKATION 3.9).
|
||||
--
|
||||
-- Ein Ist-Satz gehoert zum PLAN, nicht zum Szenario: Die Realitaet ist dieselbe, egal gegen
|
||||
-- welches Szenario man sie haelt. Die Zuordnung auf die szenario-eigenen Element-IDs laeuft
|
||||
-- ueber die Herkunfts-Kette (sourceElementId) in der Applikationsschicht.
|
||||
|
||||
CREATE TABLE "ActualsSet" (
|
||||
"id" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
-- Exaktes Erfassungsdatum: erscheint in der Liste und auf der Zeitachse.
|
||||
"recordedOn" DATE NOT NULL,
|
||||
-- Kalenderjahr. NUR dieses geht in die Berechnung ein (der Rechenkern arbeitet in
|
||||
-- ganzen Jahren ab Planbeginn).
|
||||
"year" INTEGER NOT NULL,
|
||||
"comment" TEXT,
|
||||
-- Effektiver Cash-Bestand. Eigenes Feld, weil Cash kein FinancialElement ist.
|
||||
"cash" DOUBLE PRECISION,
|
||||
-- Werte je WURZEL-Element: { "<rootElementId>": { "value": 120000, "mortgage": 400000 } }
|
||||
"values" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ActualsSet_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Die Saetze werden immer nach Jahr sortiert gelesen (aeltester zuerst fuer die Rechnung).
|
||||
CREATE INDEX "ActualsSet_planId_year_idx" ON "ActualsSet"("planId", "year");
|
||||
|
||||
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "ActualsSet" ADD CONSTRAINT "ActualsSet_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,71 @@
|
||||
-- V7: Haushaltsform, Personen (Name/Alter) und Planstartjahr wandern auf PLAN-Ebene.
|
||||
--
|
||||
-- Begruendung (SPEZIFIKATION 2.1, 9.30): Diese Angaben beschreiben den HAUSHALT, nicht eine
|
||||
-- Planungsvariante. Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
|
||||
-- Solange sie am Szenario hingen, konnten zwei Szenarien desselben Plans verschiedene
|
||||
-- Startjahre tragen; derselbe Ist-Satz waere dann je Szenario auf einem anderen Planjahr
|
||||
-- gelandet.
|
||||
--
|
||||
-- Das PENSIONSALTER bleibt bewusst am Szenario -- es ist der Kern jedes
|
||||
-- Frueh-/Spaetpensionierungs-Szenarios.
|
||||
|
||||
ALTER TABLE "Plan" ADD COLUMN "householdType" "HouseholdType";
|
||||
ALTER TABLE "Plan" ADD COLUMN "startYear" INTEGER;
|
||||
|
||||
CREATE TABLE "PlanPerson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"role" "PersonRole" NOT NULL,
|
||||
"name" TEXT,
|
||||
"age" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "PlanPerson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "PlanPerson_planId_role_key" ON "PlanPerson"("planId", "role");
|
||||
|
||||
ALTER TABLE "PlanPerson" ADD CONSTRAINT "PlanPerson_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- --- Datenuebernahme aus dem BASISSZENARIO ------------------------------------------------
|
||||
-- Das Basisszenario ist der kanonische Stand. Weichen Nebenszenarien ab, gewinnt die Basis.
|
||||
|
||||
UPDATE "Plan" p
|
||||
SET "householdType" = s."householdType",
|
||||
"startYear" = s."startYear"
|
||||
FROM "Scenario" s
|
||||
WHERE s."planId" = p."id" AND s."isBase" = true;
|
||||
|
||||
-- Sicherheitsnetz: Plaene ohne Basisszenario (sollte es nicht geben) nehmen irgendein Szenario.
|
||||
UPDATE "Plan" p
|
||||
SET "householdType" = s."householdType",
|
||||
"startYear" = COALESCE(p."startYear", s."startYear")
|
||||
FROM "Scenario" s
|
||||
WHERE s."planId" = p."id" AND p."householdType" IS NULL;
|
||||
|
||||
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
|
||||
SELECT 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
|
||||
FROM "Person" pe
|
||||
JOIN "Scenario" s ON s."id" = pe."scenarioId"
|
||||
WHERE s."isBase" = true
|
||||
ON CONFLICT ("planId", "role") DO NOTHING;
|
||||
|
||||
-- Netz fuer Plaene, deren Basisszenario keine Personen traegt.
|
||||
INSERT INTO "PlanPerson" ("id", "planId", "role", "name", "age")
|
||||
SELECT DISTINCT ON (s."planId", pe."role") 'pp_' || pe."id", s."planId", pe."role", pe."name", pe."age"
|
||||
FROM "Person" pe
|
||||
JOIN "Scenario" s ON s."id" = pe."scenarioId"
|
||||
ORDER BY s."planId", pe."role", s."isBase" DESC
|
||||
ON CONFLICT ("planId", "role") DO NOTHING;
|
||||
|
||||
-- Ein Plan ohne Haushaltsform waere nicht rechenbar.
|
||||
UPDATE "Plan" SET "householdType" = 'SINGLE' WHERE "householdType" IS NULL;
|
||||
ALTER TABLE "Plan" ALTER COLUMN "householdType" SET NOT NULL;
|
||||
|
||||
-- --- Szenario-Ebene verschlanken ----------------------------------------------------------
|
||||
-- Person traegt nur noch Rolle + Pensionsalter; Name und Alter kommen vom Plan.
|
||||
ALTER TABLE "Person" DROP COLUMN "name";
|
||||
ALTER TABLE "Person" DROP COLUMN "age";
|
||||
|
||||
ALTER TABLE "Scenario" DROP COLUMN "householdType";
|
||||
ALTER TABLE "Scenario" DROP COLUMN "startYear";
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Gespeicherte Analysen (SPEZIFIKATION 3.10).
|
||||
--
|
||||
-- Eingefroren werden EINGABEN und ERGEBNIS als Zahlen -- nicht als Bild. Beim Oeffnen wird
|
||||
-- nichts neu gerechnet, die gespeicherten Werte werden nur gezeichnet. Zahlen statt Pixel,
|
||||
-- weil der spaetere PDF-Bericht (Roadmap Nr. 11) daraus vektoriell und in Druckaufloesung
|
||||
-- zeichnen kann; ein Bildschirm-Bitmap waere im Druck unscharf und im Format fix.
|
||||
--
|
||||
-- Bewusst NICHT gespeichert wird `finalWealthSorted` aus der Monte-Carlo-Simulation: ein
|
||||
-- Eintrag je Lauf, bei 10'000 Laeufen also megabyteweise. Fuer die Anzeige genuegen die
|
||||
-- abgelesenen Wahrscheinlichkeiten und die Baender.
|
||||
|
||||
CREATE TABLE "SavedAnalysis" (
|
||||
"id" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
-- CHART | MONTE_CARLO | SENSITIVITY. Als Text statt Enum, damit spaetere Typen (etwa
|
||||
-- ein Berichts-Zusammenzug) keine Schema-Migration brauchen.
|
||||
"type" TEXT NOT NULL,
|
||||
|
||||
-- Kerndaten fuer die Liste, denormalisiert: So laesst sich die Uebersicht anzeigen, ohne
|
||||
-- die (groesseren) JSON-Felder zu laden.
|
||||
"scenarioName" TEXT,
|
||||
"versionLabel" TEXT,
|
||||
"metric" TEXT NOT NULL DEFAULT 'nominal',
|
||||
"source" TEXT NOT NULL DEFAULT 'PLAN',
|
||||
"summary" TEXT,
|
||||
|
||||
"inputs" JSONB NOT NULL DEFAULT '{}',
|
||||
"result" JSONB NOT NULL DEFAULT '{}',
|
||||
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SavedAnalysis_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "SavedAnalysis_planId_createdAt_idx" ON "SavedAnalysis"("planId", "createdAt");
|
||||
|
||||
ALTER TABLE "SavedAnalysis" ADD CONSTRAINT "SavedAnalysis_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "SavedAnalysis" ADD CONSTRAINT "SavedAnalysis_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- PDF-Berichte (Roadmap Nr. 11, SPEZIFIKATION 3.11).
|
||||
--
|
||||
-- Die fertige PDF-Datei wird ALS BYTES abgelegt, nicht nur ihre Definition. Grund ist der
|
||||
-- Audit-Trail: Ein Bericht, den man heute erzeugt und verschickt, muss in drei Jahren
|
||||
-- byte-identisch wieder herunterladbar sein -- auch wenn sich Plan, Rechenkern oder Layout
|
||||
-- zwischenzeitlich geaendert haben. Eine Neuerzeugung koennte das nicht garantieren.
|
||||
--
|
||||
-- Der App-Container wird bei jedem Deploy neu gebaut; nur das DB-Volume ueberlebt. Deshalb
|
||||
-- Postgres und nicht das Dateisystem.
|
||||
|
||||
CREATE TABLE "Report" (
|
||||
"id" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
|
||||
-- Gewaehlte Parameter (Metrik, Quelle, Szenarien, Analysen) -- fuer die Liste und
|
||||
-- damit nachvollziehbar bleibt, wie der Bericht zustande kam.
|
||||
"config" JSONB NOT NULL DEFAULT '{}',
|
||||
-- Das eingefrorene Berichtsmodell (alle Zahlen). Erlaubt eine Zusammenfassung in der
|
||||
-- Liste, ohne das PDF zu oeffnen.
|
||||
"model" JSONB NOT NULL DEFAULT '{}',
|
||||
-- Die Datei selbst.
|
||||
"pdf" BYTEA NOT NULL,
|
||||
"pdfBytes" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
"createdById" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "Report_planId_createdAt_idx" ON "Report"("planId", "createdAt");
|
||||
|
||||
ALTER TABLE "Report" ADD CONSTRAINT "Report_planId_fkey"
|
||||
FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Report" ADD CONSTRAINT "Report_createdById_fkey"
|
||||
FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Versionierung startet neu bei 0.1 statt 1.0 (SPEZIFIKATION 3.8).
|
||||
--
|
||||
-- Ein Szenario laeuft in der 0er-Reihe (0.1, 0.2, ... 0.137), bis der Benutzer bewusst eine
|
||||
-- HAUPTVERSION setzt -- erst dann wird daraus 1.0. Vorher begann jedes Szenario bereits bei
|
||||
-- 1.0, wodurch die Hauptversion ihre Bedeutung verlor.
|
||||
--
|
||||
-- Nur der DEFAULT wird umgestellt; bestehende Zeilen bleiben unveraendert, damit eine bereits
|
||||
-- gelaufene Historie nicht rueckwirkend umnummeriert wird.
|
||||
ALTER TABLE "Scenario" ALTER COLUMN "currentMajor" SET DEFAULT 0;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Pensionierung wird eine Eigenschaft der PERSON statt der Zeitachse (SPEZIFIKATION 3.13).
|
||||
--
|
||||
-- (1) `FinancialElement.retirementDecision` haelt den Bezugs-Entscheid von AHV, Pensionskasse
|
||||
-- und Saeule 3a. Bisher lag er in `ElementTransitionValue` -- also am Schluessel
|
||||
-- Element x Phasen-ID. Verschob sich das Pensionsalter, wanderte die Phasengrenze, und der
|
||||
-- Entscheid musste verlustbehaftet von Grenze zu Grenze gerettet werden. Ohne Phasen-ID im
|
||||
-- Schluessel ueberlebt er jede Zeitachsen-Aenderung.
|
||||
--
|
||||
-- (2) `Person.planningHorizonAge` macht das Planende explizit. Bisher ergab es sich
|
||||
-- stillschweigend als Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt
|
||||
-- verschieden weit rechnen und waren nicht vergleichbar. NULL bedeutet weiterhin
|
||||
-- "aus den Phasendauern ableiten", der Wert ist also rueckwaertskompatibel.
|
||||
--
|
||||
-- Bewusst OHNE Datenmigration: Alte Entscheide in `ElementTransitionValue` bleiben liegen und
|
||||
-- werden vom Rechenkern ignoriert. Die betroffenen Elemente erscheinen dadurch als
|
||||
-- "Vorgabe ungeprueft" -- genau der Zustand, den das neue Drei-Zustands-Modell dafuer kennt.
|
||||
ALTER TABLE "FinancialElement" ADD COLUMN "retirementDecision" JSONB;
|
||||
ALTER TABLE "Person" ADD COLUMN "planningHorizonAge" INTEGER;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Umbau 0.36: Bestandsaufnahme vor der Zeitachse, Assistent als Herzstueck.
|
||||
--
|
||||
-- (1) `FinancialElement.baseData` haelt den Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
|
||||
-- Bisher lagen diese Werte in `ElementPhaseValue` der ersten Phase. Das war schon immer
|
||||
-- schief -- ein Startwert ist nicht "phase-1-spezifisch", sondern der Stand am Anfang --
|
||||
-- und es machte den ersten Schritt des Assistenten unmoeglich: Eine Bestandsaufnahme
|
||||
-- braucht noch keine Lebensphasen. Zugleich ist es die Wurzel der Feld-Vererbung.
|
||||
--
|
||||
-- (2) `Scenario.planningHorizonYears` fuehrt den Horizont in JAHREN statt als Endalter je
|
||||
-- Person. Eine Zahl statt zweier, die bei einem Paar auseinanderlaufen koennten; die
|
||||
-- Endalter werden abgeleitet. Ersetzt `Person.planningHorizonAge` aus 0.35.
|
||||
--
|
||||
-- (3) `Scenario.assistantProgress` haelt die sieben Haken des FPT-Assistenten.
|
||||
--
|
||||
-- Bewusst OHNE Datenmigration (Plaene wurden vorgaengig geloescht).
|
||||
ALTER TABLE "FinancialElement" ADD COLUMN "baseData" JSONB;
|
||||
ALTER TABLE "Scenario" ADD COLUMN "planningHorizonYears" INTEGER;
|
||||
ALTER TABLE "Scenario" ADD COLUMN "assistantProgress" JSONB;
|
||||
ALTER TABLE "Person" DROP COLUMN IF EXISTS "planningHorizonAge";
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Der Planungshorizont wird ABGELEITET statt erfasst (SPEZIFIKATION 3.14.5).
|
||||
--
|
||||
-- Dritter und letzter Anlauf: 0.35 fuehrte ihn als Endalter je Person, 0.36 als Jahreszahl am
|
||||
-- Szenario -- beide Male eine zweite Wahrheit ueber dieselbe Sache. Wer die Lebensphasen
|
||||
-- einzeln plant, hat den Horizont damit bereits bestimmt; ihn zusaetzlich zu speichern hiess
|
||||
-- nur, zwei Zahlen synchron halten zu muessen. Er ist jetzt schlicht die Summe der
|
||||
-- Phasendauern.
|
||||
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "planningHorizonYears";
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Der FPT-Assistent wird durch eine Uebersicht der OFFENEN PUNKTE ersetzt (SPEZIFIKATION 3.14).
|
||||
--
|
||||
-- Der Unterschied ist grundsaetzlich: Der Assistent war ein ABLAUF ("tu dies, dann das") und
|
||||
-- funktionierte nur beim ersten Aufsetzen. Die Uebersicht ist ein ZUSTAND ("das ist noch
|
||||
-- offen") und traegt bei jedem Plan, in jeder Reihenfolge, auch beim zwanzigsten Szenario.
|
||||
--
|
||||
-- (1) `Phase.ratesConfirmed` haelt fest, ob die Spar-/Verzehrquote dieser Phase einmal bewusst
|
||||
-- verteilt wurde. Was man nicht verteilt, sammelt sich still als Cash an -- rechnerisch
|
||||
-- richtig, aber selten die Absicht. Der Haken unterscheidet "bewusst so gelassen" von
|
||||
-- "noch nie angeschaut".
|
||||
--
|
||||
-- (2) `Scenario.assistantProgress` entfaellt mit dem Assistenten. Der Fortschritt wird nicht
|
||||
-- mehr erklaert, sondern ABGELEITET -- aus dem, was tatsaechlich noch offen ist.
|
||||
--
|
||||
-- Die Bestaetigung der Phasenwerte je Element liegt als `confirmed` im vorhandenen JSON von
|
||||
-- `ElementPhaseValue` und braucht deshalb keine Spalte.
|
||||
ALTER TABLE "Phase" ADD COLUMN "ratesConfirmed" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "Scenario" DROP COLUMN IF EXISTS "assistantProgress";
|
||||
+232
-32
@@ -1,13 +1,21 @@
|
||||
// FPT (Financial Planning Tool) — Datenmodell.
|
||||
// Kernkonzept (Rework 07/2026): finanzielle Elemente leben auf PLAN-Ebene und sind
|
||||
// ueber alle Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase
|
||||
// ein Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||
//
|
||||
// Kernkonzept: finanzielle Elemente leben auf SZENARIO-Ebene und sind ueber alle
|
||||
// Lebensphasen hinweg dieselbe Entitaet. Pro Element existiert je Lebensphase ein
|
||||
// Werte-Datensatz (ElementPhaseValue) und je Uebergang ein Entscheid-Datensatz
|
||||
// (ElementTransitionValue). Die kategorie-/kontextspezifischen Felder liegen als JSON,
|
||||
// validiert und typisiert in der Applikationsschicht (lib/elements.ts).
|
||||
//
|
||||
// Rework 07/2026 (V3): Das Grundprofil (Haushaltsform, Personen, Inflation) wurde vom
|
||||
// frueheren Household auf die PLAN-Ebene verschoben. Jeder Plan ist selbsttragend und
|
||||
// definiert seine eigenen Personen (Alter, Pensionsalter) und Inflationsannahme.
|
||||
// Rework 07/2026 (V6, Szenario-Hierarchie): Der PLAN ist neu ein schlanker Behaelter und
|
||||
// traegt selbst keine Finanzdaten. Die berechenbare Einheit ist das SZENARIO -- es traegt
|
||||
// das Grundprofil (Haushaltsform, Personen, Inflation, Cash-Anfangswert), die Phasenkette
|
||||
// und die Elemente. Jeder Plan hat genau ein Basisszenario (isBase) und beliebig viele
|
||||
// weitere Szenarien, die als Baum (parentScenarioId) darunter haengen. Kopierte Phasen und
|
||||
// Elemente tragen einen Herkunfts-Verweis (sourcePhaseId / sourceElementId) auf ihr
|
||||
// Gegenstueck im Eltern-Szenario -- darauf beruht die Abweichungs-Markierung im UI.
|
||||
//
|
||||
// Namens-Hinweis: In der Berechnungsschicht heisst die berechenbare Einheit weiterhin
|
||||
// `PlanInput` / `computePlan` (sie beschreibt eine Finanzplanung -- fachlich ein Szenario).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -24,7 +32,11 @@ model User {
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
plans Plan[]
|
||||
plans Plan[]
|
||||
versions ScenarioVersion[]
|
||||
actuals ActualsSet[]
|
||||
analyses SavedAnalysis[]
|
||||
reports Report[]
|
||||
}
|
||||
|
||||
enum HouseholdType {
|
||||
@@ -54,74 +66,262 @@ enum ElementCategory {
|
||||
OTHER_DEBT
|
||||
}
|
||||
|
||||
// Einzelperson eines Plans. retirementAge ist das (plan-eigene) Pensionsalter.
|
||||
// Einzelperson eines Szenarios. retirementAge ist das (szenario-eigene) Pensionsalter --
|
||||
// dadurch sind Frueh-/Spaetpensionierungs-Szenarien moeglich.
|
||||
// Szenario-EIGENE Angabe zu einer Person. Seit V7 nur noch das Pensionsalter -- es ist der
|
||||
// Kern jedes Frueh-/Spaetpensionierungs-Szenarios. Name und Alter beschreiben den Haushalt
|
||||
// und liegen deshalb am Plan (PlanPerson).
|
||||
model Person {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
role PersonRole
|
||||
name String?
|
||||
age Int
|
||||
// Alter, in dem die Person die Erwerbstaetigkeit aufgibt. Wird bei der Plan-Anlage nicht
|
||||
// mehr abgefragt (Default 65) -- die Pensionsplanung im Assistenten legt es fest.
|
||||
retirementAge Int
|
||||
|
||||
@@unique([scenarioId, role])
|
||||
}
|
||||
|
||||
// Eine Person des HAUSHALTS. Gilt fuer alle Szenarien des Plans.
|
||||
model PlanPerson {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
role PersonRole
|
||||
name String?
|
||||
age Int
|
||||
|
||||
@@unique([planId, role])
|
||||
}
|
||||
|
||||
// Eine vollstaendige Phasenkette; selbsttragend inkl. Grundprofil (Haushaltsform,
|
||||
// Personen, Inflationsannahme). Kann Szenario eines anderen Plans sein (Deep-Copy).
|
||||
// Behaelter einer Planung. Traegt selbst KEINE Finanzdaten, nur den Namen und die Szenarien.
|
||||
model Plan {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
householdType HouseholdType
|
||||
inflationRateDefault Float
|
||||
initialCash Float @default(0)
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
|
||||
parentPlanId String?
|
||||
parentPlan Plan? @relation("PlanScenarios", fields: [parentPlanId], references: [id], onDelete: SetNull)
|
||||
scenarios Plan[] @relation("PlanScenarios")
|
||||
branchFromPhaseId String?
|
||||
// Seit V7 am Plan: Diese Angaben beschreiben den HAUSHALT, nicht eine Planungsvariante.
|
||||
// Unterscheiden sie sich, ist es ein anderer Plan -- kein anderes Szenario.
|
||||
householdType HouseholdType
|
||||
// Kalenderjahr, in dem die Planung beginnt (Jahr 1). Rein fuer die Darstellung --
|
||||
// die Berechnung rechnet weiterhin in relativen Jahren ab Planbeginn.
|
||||
startYear Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
scenarios Scenario[]
|
||||
actuals ActualsSet[]
|
||||
persons PlanPerson[]
|
||||
analyses SavedAnalysis[]
|
||||
reports Report[]
|
||||
}
|
||||
|
||||
// Ein erzeugter PDF-Bericht (Roadmap Nr. 11). Die FERTIGE DATEI wird abgelegt, nicht nur
|
||||
// ihre Definition: Ein Bericht, den man heute verschickt, muss in drei Jahren unveraendert
|
||||
// wieder herunterladbar sein -- eine Neuerzeugung koennte das nach Aenderungen an Plan,
|
||||
// Rechenkern oder Layout nicht garantieren.
|
||||
model Report {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
|
||||
title String
|
||||
|
||||
// Gewaehlte Parameter und das eingefrorene Berichtsmodell (alle Zahlen).
|
||||
config Json @default("{}")
|
||||
model Json @default("{}")
|
||||
|
||||
pdf Bytes
|
||||
pdfBytes Int @default(0)
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([planId, createdAt])
|
||||
}
|
||||
|
||||
// Eine festgehaltene Analyse (Roadmap Nr. 5 / Analysen-Ansicht). Haelt EINGABEN und ERGEBNIS
|
||||
// als Zahlen fest -- read-only, es wird nichts neu gerechnet. Zahlen statt Bild, damit der
|
||||
// spaetere PDF-Bericht daraus vektoriell zeichnen kann.
|
||||
model SavedAnalysis {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
|
||||
name String
|
||||
type String // CHART | MONTE_CARLO | SENSITIVITY
|
||||
|
||||
// Denormalisierte Kerndaten fuer die Liste.
|
||||
scenarioName String?
|
||||
versionLabel String?
|
||||
metric String @default("nominal")
|
||||
source String @default("PLAN")
|
||||
summary String?
|
||||
|
||||
inputs Json @default("{}")
|
||||
result Json @default("{}")
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([planId, createdAt])
|
||||
}
|
||||
|
||||
// Ein erfasster Stand der WIRKLICHKEIT zu einem Stichtag (Roadmap Nr. 5).
|
||||
//
|
||||
// Haengt am PLAN, nicht am Szenario: Das tatsaechliche PK-Guthaben am 18.8.2026 ist eine
|
||||
// Zahl, unabhaengig davon, gegen welches Szenario man sie haelt. Die Zuordnung auf die
|
||||
// szenario-eigenen Element-IDs erfolgt ueber die Herkunfts-Kette (lib/actuals.ts).
|
||||
model ActualsSet {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Exaktes Datum fuer Liste und Zeitachse; fuer die Rechnung zaehlt nur `year`.
|
||||
recordedOn DateTime @db.Date
|
||||
year Int
|
||||
comment String?
|
||||
|
||||
// Effektiver Cash-Bestand (Cash ist kein FinancialElement).
|
||||
cash Float?
|
||||
// Werte je Wurzel-Element: Record<rootElementId, { value?, mortgage? }>
|
||||
values Json @default("{}")
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([planId, year])
|
||||
}
|
||||
|
||||
// Die berechenbare Einheit: Grundprofil + Phasenkette + Elemente. Genau ein Szenario je
|
||||
// Plan ist das Basisszenario (isBase); weitere haengen als Baum darunter (parentScenarioId).
|
||||
model Scenario {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
isBase Boolean @default(false)
|
||||
|
||||
// Szenario, aus dem dieses kopiert wurde. Zugleich die Vergleichsbasis fuer den Diff
|
||||
// (Basisszenario: null). Beim Loeschen des Elternteils bleibt der Baum flach erhalten.
|
||||
parentScenarioId String?
|
||||
parentScenario Scenario? @relation("ScenarioTree", fields: [parentScenarioId], references: [id], onDelete: SetNull)
|
||||
children Scenario[] @relation("ScenarioTree")
|
||||
|
||||
// Szenario-eigene Annahmen. Haushaltsform, Personen und Startjahr liegen seit V7 am Plan.
|
||||
inflationRateDefault Float
|
||||
initialCash Float @default(0)
|
||||
|
||||
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Laufende Hauptversion. Die Nebenversionen zaehlen in ScenarioVersion.
|
||||
currentMajor Int @default(0)
|
||||
|
||||
persons Person[]
|
||||
phases Phase[]
|
||||
elements FinancialElement[]
|
||||
versions ScenarioVersion[]
|
||||
}
|
||||
|
||||
// Ein festgehaltener Stand eines Szenarios (Version A.B). Haelt den VOLLSTAENDIGEN Zustand
|
||||
// als JSON in der Form PlanInput -- nicht eine Differenz. Dadurch laesst sich jeder alte
|
||||
// Stand ohne Umbau rechnen (computePlan), anzeigen und wiederherstellen.
|
||||
model ScenarioVersion {
|
||||
id String @id @default(cuid())
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
|
||||
major Int
|
||||
minor Int
|
||||
// Pflicht bei Hauptversionen; Wiederherstellungen tragen hier ihre Herkunft.
|
||||
comment String?
|
||||
isMajor Boolean @default(false)
|
||||
|
||||
createdById String
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
|
||||
snapshot Json
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
// Wird bei Zusammenfassung innerhalb einer Bearbeitungssitzung mitgezogen.
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scenarioId, major, minor])
|
||||
@@index([scenarioId, createdAt])
|
||||
}
|
||||
|
||||
// Ein Lebensabschnitt innerhalb eines Plans. Der Phasentyp (Erwerb/Pension/Mischung)
|
||||
// wird NICHT gespeichert, sondern aus Alter + Pensionsalter abgeleitet (lib/calculations).
|
||||
// Die Inflation liegt seit V5 plan-weit am Plan (inflationRateDefault), nicht mehr hier.
|
||||
model Phase {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
sequenceNumber Int
|
||||
name String
|
||||
durationYears Int
|
||||
|
||||
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
|
||||
sourcePhaseId String?
|
||||
|
||||
// Entscheid fuer das Cash-Konto beim UEBERGANG NACH dieser Phase (JSON, validiert in
|
||||
// lib/elements.ts): 1:1 uebernehmen oder einmaliger Zufluss/einmalige Kosten. Liegt hier
|
||||
// und nicht in ElementTransitionValue, weil Cash kein FinancialElement ist.
|
||||
cashTransition Json?
|
||||
|
||||
// Wurde die Spar-/Verzehrquote dieser Phase einmal bewusst verteilt? Was man nicht verteilt,
|
||||
// sammelt sich still als Cash an -- rechnerisch richtig, aber selten die Absicht. Der Haken
|
||||
// unterscheidet "bewusst so gelassen" von "noch nie angeschaut".
|
||||
ratesConfirmed Boolean @default(false)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
phaseValues ElementPhaseValue[]
|
||||
transitionValues ElementTransitionValue[] @relation("TransitionFromPhase")
|
||||
|
||||
@@unique([planId, sequenceNumber])
|
||||
@@unique([scenarioId, sequenceNumber])
|
||||
}
|
||||
|
||||
// Ein finanzielles Element (plan-weit): Kategorie + optionale Personenzuordnung.
|
||||
// Ein finanzielles Element (szenario-weit): Kategorie + optionale Personenzuordnung.
|
||||
model FinancialElement {
|
||||
id String @id @default(cuid())
|
||||
planId String
|
||||
plan Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
scenarioId String
|
||||
scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade)
|
||||
category ElementCategory
|
||||
name String
|
||||
ownerRole OwnerRole?
|
||||
orderIndex Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Gegenstueck im Eltern-Szenario (lose Referenz, kein FK) -- Grundlage des Diffs.
|
||||
sourceElementId String?
|
||||
|
||||
// Pensionierungs-Entscheid (AHV/PK/3a). Bewusst OHNE Phasen-ID im Schluessel: So ueberlebt
|
||||
// er jede Verschiebung der Zeitachse. Siehe src/lib/retirement-decision.ts.
|
||||
retirementDecision Json?
|
||||
|
||||
// Stammdaten des Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen (Rendite,
|
||||
// Wertsteigerung, Zins). Zwei Gruende dafuer, dass das nicht in Phase 1 liegt:
|
||||
// 1. Ein Startwert ist nicht "phase-1-spezifisch", sondern schlicht der Stand am Anfang.
|
||||
// 2. Elemente lassen sich damit erfassen, BEVOR es Lebensphasen gibt -- der erste Schritt
|
||||
// des Assistenten ist eine Bestandsaufnahme und braucht noch keine Zeitachse.
|
||||
// Zugleich die Wurzel der Feld-Vererbung: Phase 1 erbt von hier.
|
||||
baseData Json?
|
||||
|
||||
phaseValues ElementPhaseValue[]
|
||||
transitionValues ElementTransitionValue[]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { changeUserPassword } from "@/lib/users";
|
||||
import { rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
@@ -14,10 +15,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
|
||||
// Bremse gegen das Durchprobieren des aktuellen Passworts: 10 Versuche je Benutzer und
|
||||
// 15 Minuten (nach dem Auth-Check, damit der Schlüssel die echte Benutzer-ID ist).
|
||||
const limited = rateLimit(`change-pw:${userId}`, 10, 15 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = changePasswordSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
||||
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@ export async function POST(request: NextRequest) {
|
||||
await changeUserPassword(userId, parsed.data.currentPassword, parsed.data.newPassword);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Passwort aendern fehlgeschlagen." },
|
||||
{ error: e instanceof Error ? e.message : "Passwort ändern fehlgeschlagen." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||
import { verifyUserCredentials } from "@/lib/users";
|
||||
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
@@ -15,6 +16,15 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Bitte Benutzername und Passwort angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Bremse gegen Brute-Force: 10 Versuche je IP UND Benutzername in 15 Minuten. Bewusst nicht
|
||||
// nur je IP -- sonst würden Fehlversuche gegen ein Konto alle anderen Konten derselben IP
|
||||
// mitsperren (z. B. mehrere Testkonten hinter demselben Anschluss). Der Schlüssel wird
|
||||
// normalisiert (trim/lowercase), damit sich die Bremse nicht per Gross-/Kleinschreibung
|
||||
// umgehen lässt; die eigentliche Anmeldung prüft den Namen weiterhin exakt.
|
||||
const rateKey = `login:${clientIp(request)}:${parsed.data.username.trim().toLowerCase()}`;
|
||||
const limited = rateLimit(rateKey, 10, 15 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const user = await verifyUserCredentials(parsed.data.username, parsed.data.password);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Benutzername oder Passwort falsch." }, { status: 401 });
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createSessionToken, SESSION_COOKIE_NAME } from "@/lib/auth";
|
||||
import { registerUser, validateUsername } from "@/lib/users";
|
||||
import { registerUser, UsernameTakenError, validateUsername } from "@/lib/users";
|
||||
import { clientIp, rateLimit, tooManyRequests } from "@/lib/rate-limit";
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
@@ -9,10 +10,14 @@ const registerSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Bremse gegen Massen-Registrierung: 5 Konten je IP und Stunde.
|
||||
const limited = rateLimit(`register:${clientIp(request)}`, 5, 60 * 60 * 1000);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfterSeconds);
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = registerSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const message = parsed.error.issues[0]?.message ?? "Ungueltige Eingabe.";
|
||||
const message = parsed.error.issues[0]?.message ?? "Ungültige Eingabe.";
|
||||
return NextResponse.json({ error: message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -25,10 +30,13 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
user = await registerUser(parsed.data.username, parsed.data.password);
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Registrierung fehlgeschlagen." },
|
||||
{ status: 409 }
|
||||
);
|
||||
// Nur der "Name vergeben"-Fall ist ein 409 mit freundlicher Meldung; alles andere
|
||||
// (z. B. DB kurzzeitig weg) ist ein echter Serverfehler und darf nicht als Konflikt
|
||||
// mit roher Prisma-Meldung durchgereicht werden.
|
||||
if (e instanceof UsernameTakenError) {
|
||||
return NextResponse.json({ error: e.message }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: "Registrierung fehlgeschlagen." }, { status: 500 });
|
||||
}
|
||||
|
||||
const token = await createSessionToken(user.id);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { phaseDataSchema } from "@/lib/elements";
|
||||
|
||||
// Stammdaten eines Elements: der Bestand bei PLANBEGINN und die Ausgangs-Annahmen.
|
||||
//
|
||||
// Bewusst ohne Phasenbezug. Zwei Gründe: Ein Startwert ist nicht «phase-1-spezifisch»,
|
||||
// sondern schlicht der Stand am Anfang -- und Elemente lassen sich damit erfassen, BEVOR es
|
||||
// Lebensphasen gibt. Genau das braucht der erste Schritt des Assistenten: eine
|
||||
// Bestandsaufnahme, die noch keine Zeitachse voraussetzt.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ elementId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { elementId } = await params;
|
||||
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = phaseDataSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({
|
||||
where: { id: elementId },
|
||||
data: { baseData: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { phaseDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert die Werte eines Elements innerhalb einer Lebensphase (Upsert).
|
||||
@@ -16,12 +17,12 @@ export async function PUT(
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
const phase = await prisma.phase.findFirst({ where: { id: phaseId, planId: element.planId } });
|
||||
const phase = await prisma.phase.findFirst({ where: { id: phaseId, scenarioId: element.scenarioId } });
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = phaseDataSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.elementPhaseValue.upsert({
|
||||
where: { elementId_phaseId: { elementId, phaseId } },
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { RETIREMENT_CATEGORIES, retirementDecisionSchema } from "@/lib/retirement-decision";
|
||||
|
||||
// Speichert den Pensionierungs-Entscheid eines Elements (AHV, PK, Säule 3a).
|
||||
//
|
||||
// Bewusst OHNE Phasenbezug in der Route: Der Entscheid gilt für die Pensionierung des
|
||||
// Besitzers, wo immer die auf der Zeitachse gerade liegt. Genau das unterscheidet ihn vom
|
||||
// Übergangs-Entscheid (`/transition/<fromPhaseId>`), der an einer konkreten Grenze hängt.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ elementId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { elementId } = await params;
|
||||
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
if (!RETIREMENT_CATEGORIES.includes(element.category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Nur AHV, Pensionskasse und Säule 3a kennen einen Pensionierungs-Entscheid." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = retirementDecisionSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({
|
||||
where: { id: elementId },
|
||||
data: { retirementDecision: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -3,8 +3,15 @@ import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
// Name UND Zuordnung sind änderbar. Die Zuordnung läuft über `ownerRole` (nicht über eine
|
||||
// Person-ID), deshalb überlebt sie auch ein Neuanlegen der Personen im Profil-Dialog.
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
@@ -19,9 +26,23 @@ export async function PATCH(
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.financialElement.update({ where: { id: element.id }, data: { name: parsed.data.name } });
|
||||
// AHV, PK und 3a hängen am individuellen Pensionierungszeitpunkt und müssen deshalb einer
|
||||
// Person gehören -- «Gemeinsam» wäre dort fachlich sinnlos.
|
||||
const nextOwner = parsed.data.ownerRole;
|
||||
if (nextOwner === "HOUSEHOLD" && PERSON_ONLY_CATEGORIES.includes(element.category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Diese Kategorie muss einer Person zugeordnet bleiben." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.financialElement.update({
|
||||
where: { id: element.id },
|
||||
data: { name: parsed.data.name ?? undefined, ownerRole: nextOwner ?? undefined },
|
||||
});
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -37,5 +58,6 @@ export async function DELETE(
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.financialElement.delete({ where: { id: element.id } });
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedElement } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { transitionDataSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Uebergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||
// Speichert den Übergangs-Entscheid eines Elements nach der Phase fromPhase (Upsert).
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ elementId: string; fromPhaseId: string }> }
|
||||
@@ -16,12 +17,12 @@ export async function PUT(
|
||||
const element = await getOwnedElement(elementId, userId);
|
||||
if (!element) return NextResponse.json({ error: "Element nicht gefunden." }, { status: 404 });
|
||||
|
||||
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, planId: element.planId } });
|
||||
const phase = await prisma.phase.findFirst({ where: { id: fromPhaseId, scenarioId: element.scenarioId } });
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = transitionDataSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.elementTransitionValue.upsert({
|
||||
where: { elementId_fromPhaseId: { elementId, fromPhaseId } },
|
||||
@@ -29,5 +30,6 @@ export async function PUT(
|
||||
update: { data: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(element.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { cashTransitionSchema } from "@/lib/elements";
|
||||
|
||||
// Speichert den Cash-Entscheid beim UEBERGANG nach dieser Phase: 1:1 übernehmen oder
|
||||
// einmaliger Zufluss / einmalige Kosten (Roadmap Nr. 1). Cash ist kein FinancialElement,
|
||||
// deshalb liegt der Entscheid direkt an der Von-Phase.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ phaseId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { phaseId } = await params;
|
||||
|
||||
const phase = await getOwnedPhase(phaseId, userId);
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = cashTransitionSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
await prisma.phase.update({
|
||||
where: { id: phase.id },
|
||||
data: { cashTransition: parsed.data },
|
||||
});
|
||||
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPhase, getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||
import { getOwnedPhase, getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { maxPhaseDuration } from "@/lib/calculations";
|
||||
import { planDurationChange } from "@/lib/phaseplan";
|
||||
|
||||
const updatePhaseSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
durationYears: z.number().int().min(1).max(80).optional(),
|
||||
// Die Spar-/Verzehrquote dieser Phase wurde bewusst verteilt (SPEZIFIKATION 3.14).
|
||||
ratesConfirmed: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(
|
||||
@@ -23,34 +27,52 @@ export async function PUT(
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = updatePhaseSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
let duration = parsed.data.durationYears;
|
||||
// Die Folgephase gleicht eine geänderte Dauer aus -- sonst verschieben sich alle späteren
|
||||
// Phasengrenzen und eine davon überspannt eine Pensionierung (siehe lib/phaseplan.ts).
|
||||
let neighbour: { id: string; durationYears: number } | null = null;
|
||||
|
||||
if (duration != null) {
|
||||
// Dauer ans naechste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||
const plan = await getOwnedPlan(existing.planId, userId);
|
||||
if (plan) {
|
||||
const planInput = toPlanInput(plan);
|
||||
const scenario = await getOwnedScenario(existing.scenarioId, userId);
|
||||
if (scenario) {
|
||||
const planInput = toPlanInput(scenario);
|
||||
// Dauer ans nächste Pensionsereignis kappen (Jahre vor dieser Phase).
|
||||
const yearsBefore = planInput.phases
|
||||
.filter((p) => p.sequenceNumber < existing.sequenceNumber)
|
||||
.reduce((s, p) => s + p.durationYears, 0);
|
||||
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||
if (cap != null) duration = Math.min(duration, cap);
|
||||
duration = Math.max(1, duration);
|
||||
|
||||
const change = planDurationChange(planInput.phases, phaseId, duration);
|
||||
if (!change.ok) return NextResponse.json({ error: change.error }, { status: 400 });
|
||||
if (change.neighbourId && change.neighbourNewDuration != null) {
|
||||
neighbour = { id: change.neighbourId, durationYears: change.neighbourNewDuration };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const phase = await prisma.phase.update({
|
||||
where: { id: phaseId },
|
||||
data: {
|
||||
name: parsed.data.name ?? undefined,
|
||||
durationYears: duration ?? undefined,
|
||||
},
|
||||
const phase = await prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.phase.update({
|
||||
where: { id: phaseId },
|
||||
data: {
|
||||
name: parsed.data.name ?? undefined,
|
||||
durationYears: duration ?? undefined,
|
||||
ratesConfirmed: parsed.data.ratesConfirmed ?? undefined,
|
||||
},
|
||||
});
|
||||
if (neighbour) {
|
||||
await tx.phase.update({ where: { id: neighbour.id }, data: { durationYears: neighbour.durationYears } });
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
return NextResponse.json({ phase: { id: phase.id } });
|
||||
await touchScenario(existing.scenarioId, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id }, adjustedNeighbour: neighbour });
|
||||
}
|
||||
|
||||
// Nur die letzte Phase kann geloescht werden (Verkettung bleibt intakt).
|
||||
// Nur die letzte Phase kann gelöscht werden (Verkettung bleibt intakt).
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ phaseId: string }> }
|
||||
@@ -63,12 +85,13 @@ export async function DELETE(
|
||||
if (!phase) return NextResponse.json({ error: "Phase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const later = await prisma.phase.findFirst({
|
||||
where: { planId: phase.planId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||
where: { scenarioId: phase.scenarioId, sequenceNumber: { gt: phase.sequenceNumber } },
|
||||
});
|
||||
if (later) {
|
||||
return NextResponse.json({ error: "Nur die letzte Phase kann geloescht werden." }, { status: 400 });
|
||||
return NextResponse.json({ error: "Nur die letzte Phase kann gelöscht werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.phase.delete({ where: { id: phaseId } });
|
||||
await touchScenario(phase.scenarioId, userId);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
const valueSchema = z.object({
|
||||
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
|
||||
mortgage: z.number().min(0).max(1_000_000_000).optional(),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
|
||||
comment: z.string().max(500).optional(),
|
||||
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
|
||||
values: z.record(z.string(), valueSchema),
|
||||
});
|
||||
|
||||
// Ändert einen bestehenden Ist-Satz. Wie beim Anlegen gilt: Ein Ist-Satz ist eine Beobachtung,
|
||||
// keine Planänderung -- er erzeugt also KEINE Szenario-Version. Der ursprüngliche Erfasser
|
||||
// (`createdById`) bleibt stehen; korrigiert wird der Datensatz selbst.
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; setId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, setId } = await params;
|
||||
|
||||
const existing = await prisma.actualsSet.findFirst({ where: { id: setId, planId, plan: { userId } } });
|
||||
if (!existing) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0];
|
||||
return NextResponse.json({ error: first?.message ?? "Ungültige Eingabe." }, { status: 400 });
|
||||
}
|
||||
|
||||
const { recordedOn, comment, cash, values } = parsed.data;
|
||||
const updated = await prisma.actualsSet.update({
|
||||
where: { id: setId },
|
||||
data: {
|
||||
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
|
||||
// Für die Berechnung zählt nur die Jahreszahl (siehe POST).
|
||||
year: Number(recordedOn.slice(0, 4)),
|
||||
comment: comment?.trim() || null,
|
||||
cash: typeof cash === "number" ? cash : null,
|
||||
values,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ set: { id: updated.id, year: updated.year } });
|
||||
}
|
||||
|
||||
// Löscht einen Ist-Satz. Ein Ist-Satz ist eine Beobachtung, keine Planänderung -- deshalb
|
||||
// gibt es hier weder Versionierung noch Wiederherstellung.
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; setId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, setId } = await params;
|
||||
|
||||
const set = await prisma.actualsSet.findFirst({
|
||||
where: { id: setId, planId, plan: { userId } },
|
||||
});
|
||||
if (!set) return NextResponse.json({ error: "Datensatz nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.actualsSet.delete({ where: { id: setId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
// Ein Ist-Wert je Wurzel-Element. Beide Felder optional: Wer eine Zahl nicht kennt, lässt sie
|
||||
// weg -- die Lücke fällt in der Berechnung auf die Plandaten zurück.
|
||||
const valueSchema = z.object({
|
||||
value: z.number().min(-1_000_000_000).max(1_000_000_000).optional(),
|
||||
mortgage: z.number().min(0).max(1_000_000_000).optional(),
|
||||
});
|
||||
|
||||
const createSchema = z.object({
|
||||
recordedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Datum im Format JJJJ-MM-TT"),
|
||||
comment: z.string().max(500).optional(),
|
||||
cash: z.number().min(-1_000_000_000).max(1_000_000_000).nullable().optional(),
|
||||
values: z.record(z.string(), valueSchema),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Alle Ist-Sätze eines Plans, neueste zuerst.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.actualsSet.findMany({
|
||||
where: { planId },
|
||||
orderBy: [{ year: "desc" }, { recordedOn: "desc" }],
|
||||
include: { createdBy: { select: { username: true } } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
sets: rows.map((r) => ({
|
||||
id: r.id,
|
||||
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||
year: r.year,
|
||||
comment: r.comment,
|
||||
cash: r.cash,
|
||||
values: r.values,
|
||||
author: r.createdBy.username,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const { recordedOn, comment, cash, values } = parsed.data;
|
||||
// Für die Berechnung zählt nur die Jahreszahl -- der Rechenkern arbeitet in ganzen Jahren
|
||||
// ab Planbeginn. Das exakte Datum bleibt für Liste und Zeitachse erhalten.
|
||||
const year = Number(recordedOn.slice(0, 4));
|
||||
|
||||
const created = await prisma.actualsSet.create({
|
||||
data: {
|
||||
planId,
|
||||
recordedOn: new Date(`${recordedOn}T00:00:00.000Z`),
|
||||
year,
|
||||
comment: comment?.trim() || null,
|
||||
cash: typeof cash === "number" ? cash : null,
|
||||
values,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ set: { id: created.id, year: created.year } }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
// Eine einzelne gespeicherte Analyse MIT den Zahlen (Eingaben + Ergebnis) -- zum Öffnen der
|
||||
// read-only Ansicht.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, analysisId } = await params;
|
||||
|
||||
const a = await prisma.savedAnalysis.findFirst({
|
||||
where: { id: analysisId, planId, plan: { userId } },
|
||||
});
|
||||
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
|
||||
|
||||
return NextResponse.json({
|
||||
analysis: {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
type: a.type,
|
||||
scenarioName: a.scenarioName,
|
||||
versionLabel: a.versionLabel,
|
||||
metric: a.metric,
|
||||
source: a.source,
|
||||
summary: a.summary,
|
||||
inputs: a.inputs,
|
||||
result: a.result,
|
||||
createdAt: a.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; analysisId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, analysisId } = await params;
|
||||
|
||||
const a = await prisma.savedAnalysis.findFirst({
|
||||
where: { id: analysisId, planId, plan: { userId } },
|
||||
});
|
||||
if (!a) return NextResponse.json({ error: "Analyse nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.savedAnalysis.delete({ where: { id: analysisId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(160),
|
||||
type: z.enum(["CHART", "MONTE_CARLO", "SENSITIVITY"]),
|
||||
scenarioName: z.string().max(120).nullish(),
|
||||
versionLabel: z.string().max(40).nullish(),
|
||||
metric: z.string().max(20).default("nominal"),
|
||||
source: z.string().max(20).default("PLAN"),
|
||||
summary: z.string().max(400).nullish(),
|
||||
// Eingaben und Ergebnis als Zahlen -- read-only festgehalten, nichts wird neu gerechnet.
|
||||
inputs: z.record(z.string(), z.unknown()).default({}),
|
||||
result: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Liste der gespeicherten Analysen, neueste zuerst -- OHNE die grossen JSON-Felder.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.savedAnalysis.findMany({
|
||||
where: { planId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
scenarioName: true,
|
||||
versionLabel: true,
|
||||
metric: true,
|
||||
source: true,
|
||||
summary: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
analyses: rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
scenarioName: r.scenarioName,
|
||||
versionLabel: r.versionLabel,
|
||||
metric: r.metric,
|
||||
source: r.source,
|
||||
summary: r.summary,
|
||||
createdAt: r.createdAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const d = parsed.data;
|
||||
|
||||
const created = await prisma.savedAnalysis.create({
|
||||
data: {
|
||||
planId,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
scenarioName: d.scenarioName ?? null,
|
||||
versionLabel: d.versionLabel ?? null,
|
||||
metric: d.metric,
|
||||
source: d.source,
|
||||
summary: d.summary ?? null,
|
||||
inputs: d.inputs as object,
|
||||
result: d.result as object,
|
||||
createdById: userId,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ analysis: { id: created.id } }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { buildViews } from "@/lib/dataview";
|
||||
|
||||
// Kennzahlen für das Plan-Dashboard. Vieles ist SZENARIO-eigen (Endvermögen, Ruinalter,
|
||||
// Phasen) -- dafür dient das BASISSZENARIO als kanonischer Vertreter; die Oberfläche
|
||||
// beschriftet das entsprechend.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await prisma.plan.findFirst({
|
||||
where: { id: planId, userId },
|
||||
include: {
|
||||
persons: { orderBy: { role: "asc" } },
|
||||
scenarios: {
|
||||
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
||||
include: {
|
||||
...planInclude,
|
||||
_count: { select: { elements: true, versions: true } },
|
||||
// Höchste Version je Szenario -- die Liste zeigt die echte Nummer (z. B. «0.17»),
|
||||
// nicht nur die Hauptversion.
|
||||
versions: {
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
take: 1,
|
||||
select: { major: true, minor: true, comment: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||
_count: { select: { scenarios: true, actuals: true, analyses: true } },
|
||||
},
|
||||
});
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const baseScenario = plan.scenarios.find((s) => s.isBase) ?? plan.scenarios[0];
|
||||
|
||||
// Kennzahlen des Basisszenarios (inkl. Ist-Sicht, falls erfasst).
|
||||
let baseInfo: unknown = null;
|
||||
if (baseScenario) {
|
||||
const planInput = toPlanInput(baseScenario);
|
||||
const origins = plan.scenarios.flatMap((s) => s.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId })));
|
||||
const views = buildViews(
|
||||
planInput,
|
||||
plan.actuals.map((a) => ({
|
||||
id: a.id,
|
||||
recordedOn: a.recordedOn.toISOString().slice(0, 10),
|
||||
year: a.year,
|
||||
comment: a.comment,
|
||||
cash: a.cash,
|
||||
values: a.values as Record<string, { value?: number; mortgage?: number }>,
|
||||
})),
|
||||
origins
|
||||
);
|
||||
const last = views.plan.phases[views.plan.phases.length - 1];
|
||||
const lastActual = views.actual ? views.actual.phases[views.actual.phases.length - 1] : null;
|
||||
|
||||
baseInfo = {
|
||||
scenarioId: baseScenario.id,
|
||||
name: baseScenario.name,
|
||||
phaseCount: views.plan.phases.length,
|
||||
endNominal: last ? Math.round(last.endWealthNominal) : 0,
|
||||
endReal: last ? Math.round(last.endWealthReal) : 0,
|
||||
ruinAge: views.plan.ruinAge,
|
||||
// Abweichung des Endvermögens Ist gegenüber Plan -- die Zahl, für die das Monitoring da ist.
|
||||
actualEndNominal: lastActual ? Math.round(lastActual.endWealthNominal) : null,
|
||||
actualYears: views.actualYears,
|
||||
};
|
||||
}
|
||||
|
||||
// Laufen Szenarien in Angaben auseinander, die seit V7 plan-weit sein SOLLTEN? Kann nach
|
||||
// der Migration alter Daten vorkommen. Nur ein Hinweis, kein Fehler.
|
||||
const inconsistencies: string[] = [];
|
||||
|
||||
// Offene Übergangs-Entscheide je Szenario (Summe) -- die aktionierbarste Kennzahl.
|
||||
const scenarioRows = plan.scenarios.map((s) => {
|
||||
const pi = toPlanInput(s);
|
||||
const computed = computePlan(pi);
|
||||
const last = computed.phases[computed.phases.length - 1];
|
||||
const top = s.versions[0];
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
isBase: s.isBase,
|
||||
parentScenarioId: s.parentScenarioId,
|
||||
// Die tatsächliche aktuelle Version, nicht nur die Hauptversion. Ohne Historie noch keine.
|
||||
version: top ? `${top.major}.${top.minor}` : null,
|
||||
// Kommentar der aktuellen Version -- gesetzt wird er bei Hauptversionen und beim
|
||||
// Wiederherstellen; Nebenversionen haben in der Regel keinen.
|
||||
versionComment: top?.comment ?? null,
|
||||
elementCount: s._count.elements,
|
||||
phaseCount: computed.phases.length,
|
||||
versionCount: s._count.versions,
|
||||
endNominal: last ? Math.round(last.endWealthNominal) : 0,
|
||||
endReal: last ? Math.round(last.endWealthReal) : 0,
|
||||
ruinAge: computed.ruinAge,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
householdType: plan.householdType,
|
||||
startYear: plan.startYear,
|
||||
persons: plan.persons.map((p) => ({ role: p.role, name: p.name, age: p.age })),
|
||||
},
|
||||
counts: {
|
||||
scenarios: plan._count.scenarios,
|
||||
actuals: plan._count.actuals,
|
||||
analyses: plan._count.analyses,
|
||||
},
|
||||
base: baseInfo,
|
||||
scenarios: scenarioRows,
|
||||
inconsistencies,
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan, planToCsv } from "@/lib/calculations";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) {
|
||||
return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const computed = computePlan(planInput);
|
||||
const csv = planToCsv(planInput, computed);
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${plan.name.replace(/[^a-z0-9]+/gi, "_")}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// Liefert die GESPEICHERTE PDF-Datei. Sie wird nicht neu erzeugt -- das ist der Kern des
|
||||
// Audit-Trails: derselbe Bericht ergibt in drei Jahren dieselben Bytes.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({
|
||||
where: { id: reportId, planId, plan: { userId } },
|
||||
select: { title: true, pdf: true, createdAt: true },
|
||||
});
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
const date = report.createdAt.toISOString().slice(0, 10);
|
||||
const safe = report.title.replace(/[^\w\s.-]/g, "").trim().replace(/\s+/g, "_") || "Bericht";
|
||||
|
||||
return new NextResponse(new Uint8Array(report.pdf), {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename="${date}_${safe}.pdf"`,
|
||||
"Content-Length": String(report.pdf.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string; reportId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId, reportId } = await params;
|
||||
|
||||
const report = await prisma.report.findFirst({ where: { id: reportId, planId, plan: { userId } } });
|
||||
if (!report) return NextResponse.json({ error: "Bericht nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.report.delete({ where: { id: reportId } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { planInclude, toPlanInput } from "@/lib/queries";
|
||||
import { buildReport, MAX_REPORT_SCENARIOS, type ReportConfig } from "@/lib/report";
|
||||
import { renderReportPdf } from "@/lib/report-pdf";
|
||||
import type { ActualsSetInput } from "@/lib/actuals";
|
||||
|
||||
// pdfkit braucht die Node-Runtime (Streams, Buffer) -- nicht Edge.
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const createSchema = z.object({
|
||||
title: z.string().min(1).max(160),
|
||||
metric: z.enum(["nominal", "real"]),
|
||||
source: z.enum(["PLAN", "ACTUAL"]),
|
||||
scenarioIds: z.array(z.string()).min(1).max(MAX_REPORT_SCENARIOS),
|
||||
analysisIds: z.array(z.string()).max(20).default([]),
|
||||
comment: z.string().max(500).nullish(),
|
||||
});
|
||||
|
||||
async function ownedPlan(planId: string, userId: string) {
|
||||
return prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
}
|
||||
|
||||
// Liste der Berichte -- ohne die PDF-Bytes (die kommen erst beim Herunterladen).
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await ownedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.report.findMany({
|
||||
where: { planId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
config: true,
|
||||
pdfBytes: true,
|
||||
createdAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
reports: rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
config: r.config,
|
||||
pdfBytes: r.pdfBytes,
|
||||
createdAt: r.createdAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Erzeugt den Bericht: rechnet, baut das Modell, rendert das PDF und legt beides ab.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const parsed = createSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const cfg = parsed.data;
|
||||
|
||||
// Der GESAMTE weitere Ablauf liegt in einem try/catch. Ein unbehandelter Fehler ergäbe ein
|
||||
// nacktes 500 ohne JSON-Körper -- der Client könnte dann nur «Fehler 500» melden, und die
|
||||
// Ursache bliebe im Dunkeln.
|
||||
try {
|
||||
const plan = await prisma.plan.findFirst({
|
||||
where: { id: planId, userId },
|
||||
include: {
|
||||
persons: { orderBy: { role: "asc" } },
|
||||
scenarios: { orderBy: [{ isBase: "desc" }, { createdAt: "asc" }], select: { id: true, name: true, isBase: true } },
|
||||
actuals: { orderBy: [{ year: "asc" }, { recordedOn: "asc" }] },
|
||||
},
|
||||
});
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
// Szenarien einzeln nachladen statt über eine verschachtelte Abfrage: Plan -> Szenarien ->
|
||||
// Plan -> Personen wäre ein Ringbezug, und ein paar Abfragen mehr sind hier belanglos.
|
||||
const wanted = plan.scenarios
|
||||
.filter((s) => cfg.scenarioIds.includes(s.id))
|
||||
.sort((a, b) => (a.isBase === b.isBase ? 0 : a.isBase ? -1 : 1));
|
||||
if (wanted.length === 0) {
|
||||
return NextResponse.json({ error: "Kein gültiges Szenario ausgewählt." }, { status: 400 });
|
||||
}
|
||||
const chosen = (
|
||||
await Promise.all(wanted.map((s) => prisma.scenario.findUnique({ where: { id: s.id }, include: planInclude })))
|
||||
).filter((s): s is NonNullable<typeof s> => !!s);
|
||||
if (chosen.length === 0) {
|
||||
return NextResponse.json({ error: "Szenarien konnten nicht geladen werden." }, { status: 400 });
|
||||
}
|
||||
|
||||
// Element-Herkunft aller Szenarien -- Grundlage der Ist-Zuordnung.
|
||||
const allElements = await prisma.financialElement.findMany({
|
||||
where: { scenario: { planId } },
|
||||
select: { id: true, sourceElementId: true },
|
||||
});
|
||||
|
||||
const analyses =
|
||||
cfg.analysisIds.length === 0
|
||||
? []
|
||||
: await prisma.savedAnalysis.findMany({
|
||||
where: { id: { in: cfg.analysisIds }, planId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { name: true, type: true, result: true },
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { username: true } });
|
||||
|
||||
const config: ReportConfig = {
|
||||
title: cfg.title,
|
||||
metric: cfg.metric,
|
||||
source: cfg.source,
|
||||
scenarioIds: chosen.map((s) => s.id),
|
||||
analysisIds: cfg.analysisIds,
|
||||
comment: cfg.comment ?? null,
|
||||
};
|
||||
|
||||
const model = buildReport({
|
||||
planName: plan.name,
|
||||
author: user?.username ?? "unbekannt",
|
||||
createdAt: new Date(),
|
||||
config,
|
||||
scenarios: chosen.map((s) => ({ name: s.name, isBase: s.isBase, plan: toPlanInput(s) })),
|
||||
household: {
|
||||
householdType: plan.householdType,
|
||||
startYear: plan.startYear,
|
||||
persons: plan.persons.map((p) => ({ name: p.name, age: p.age, role: p.role })),
|
||||
},
|
||||
actuals: plan.actuals.map(
|
||||
(a): ActualsSetInput => ({
|
||||
id: a.id,
|
||||
recordedOn: a.recordedOn.toISOString().slice(0, 10),
|
||||
year: a.year,
|
||||
comment: a.comment,
|
||||
cash: a.cash,
|
||||
values: a.values as Record<string, { value?: number; mortgage?: number }>,
|
||||
})
|
||||
),
|
||||
origins: allElements,
|
||||
analyses: analyses.map((a) => ({ name: a.name, type: a.type, result: a.result })),
|
||||
});
|
||||
|
||||
const pdf = await renderReportPdf(model);
|
||||
|
||||
const created = await prisma.report.create({
|
||||
data: {
|
||||
planId,
|
||||
title: cfg.title,
|
||||
config: config as unknown as object,
|
||||
model: JSON.parse(JSON.stringify(model)) as object,
|
||||
pdf: new Uint8Array(pdf),
|
||||
pdfBytes: pdf.length,
|
||||
createdById: userId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return NextResponse.json({ report: { id: created.id, bytes: pdf.length } }, { status: 201 });
|
||||
} catch (err) {
|
||||
// Jede Ursache landet hier -- Datenbank, Modellaufbau, Schriftdaten, Speichern.
|
||||
console.error("[reports] Bericht fehlgeschlagen", err);
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
||||
return NextResponse.json({ error: `Bericht fehlgeschlagen -- ${message}` }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,12 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedPlan } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { planProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
// Der Plan ist nur noch der Behälter: er trägt den Namen; die Finanzdaten liegen im Szenario.
|
||||
const patchSchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const computed = computePlan(planInput);
|
||||
|
||||
return NextResponse.json({ plan: planInput, computed });
|
||||
}
|
||||
|
||||
// Name/Cash-Anfangswert aendern ODER das ganze Plan-Profil (Haushaltsform/Personen/Inflation).
|
||||
const patchSchema = z.union([
|
||||
planProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
initialCash: z.number().min(0).max(1_000_000_000).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
@@ -43,50 +14,22 @@ export async function PATCH(
|
||||
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = patchSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
const parsed = patchSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const data = parsed.data;
|
||||
const hasProfile = "householdType" in data;
|
||||
|
||||
if (hasProfile) {
|
||||
const error = validatePersonsForType(data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.person.deleteMany({ where: { planId: plan.id } });
|
||||
return tx.plan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
name: data.name ?? undefined,
|
||||
householdType: data.householdType,
|
||||
inflationRateDefault: data.inflationRateDefault,
|
||||
persons: { create: data.persons },
|
||||
},
|
||||
});
|
||||
});
|
||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
const updated = await prisma.plan.update({
|
||||
where: { id: plan.id },
|
||||
data: {
|
||||
name: "name" in data ? data.name : undefined,
|
||||
initialCash: "initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
const updated = await prisma.plan.update({ where: { id: plan.id }, data: { name: parsed.data.name } });
|
||||
return NextResponse.json({ plan: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
// Löscht den Plan inkl. aller Szenarien (Cascade).
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ planId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const plan = await prisma.plan.findFirst({ where: { id: planId, userId } });
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
|
||||
await prisma.plan.delete({ where: { id: plan.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
|
||||
const scenarioSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
branchFromPhaseId: z.string().min(1),
|
||||
});
|
||||
|
||||
// Erstellt ein Szenario als Deep-Copy eines Plans bis zur Verzweigungsphase (inkl.).
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = scenarioSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
|
||||
const source = await getOwnedPlan(planId, userId);
|
||||
if (!source) return NextResponse.json({ error: "Ursprungsplan nicht gefunden." }, { status: 404 });
|
||||
|
||||
const branchPhase = source.phases.find((p) => p.id === parsed.data.branchFromPhaseId);
|
||||
if (!branchPhase) return NextResponse.json({ error: "Verzweigungsphase nicht gefunden." }, { status: 404 });
|
||||
|
||||
const copiedPhases = source.phases
|
||||
.filter((p) => p.sequenceNumber <= branchPhase.sequenceNumber)
|
||||
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
||||
const copiedPhaseIds = new Set(copiedPhases.map((p) => p.id));
|
||||
|
||||
const newPlanId = await prisma.$transaction(async (tx) => {
|
||||
const newPlan = await tx.plan.create({
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
householdType: source.householdType,
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
parentPlanId: source.id,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({ role: p.role, name: p.name, age: p.age, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Phasen kopieren (alte -> neue Id).
|
||||
const phaseIdMap = new Map<string, string>();
|
||||
let lastNewPhaseId = "";
|
||||
for (const phase of copiedPhases) {
|
||||
const created = await tx.phase.create({
|
||||
data: {
|
||||
planId: newPlan.id,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, created.id);
|
||||
lastNewPhaseId = created.id;
|
||||
}
|
||||
|
||||
// Elemente + deren Phasen-/Uebergangswerte kopieren.
|
||||
for (const el of source.elements) {
|
||||
const newEl = await tx.financialElement.create({
|
||||
data: {
|
||||
planId: newPlan.id,
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole,
|
||||
orderIndex: el.orderIndex,
|
||||
},
|
||||
});
|
||||
for (const pv of el.phaseValues) {
|
||||
const newPhaseId = phaseIdMap.get(pv.phaseId);
|
||||
if (!newPhaseId) continue;
|
||||
await tx.elementPhaseValue.create({
|
||||
data: { elementId: newEl.id, phaseId: newPhaseId, data: pv.data as object },
|
||||
});
|
||||
}
|
||||
for (const tv of el.transitionValues) {
|
||||
if (!copiedPhaseIds.has(tv.fromPhaseId)) continue;
|
||||
const newFromId = phaseIdMap.get(tv.fromPhaseId);
|
||||
if (!newFromId) continue;
|
||||
await tx.elementTransitionValue.create({
|
||||
data: { elementId: newEl.id, fromPhaseId: newFromId, data: tv.data as object },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.plan.update({ where: { id: newPlan.id }, data: { branchFromPhaseId: lastNewPhaseId } });
|
||||
return newPlan.id;
|
||||
});
|
||||
|
||||
return NextResponse.json({ planId: newPlanId }, { status: 201 });
|
||||
}
|
||||
+50
-23
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const personSchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
@@ -10,73 +11,99 @@ const personSchema = z.object({
|
||||
retirementAge: z.number().int().min(30).max(100),
|
||||
});
|
||||
|
||||
// Ein Plan traegt sein eigenes Grundprofil (Haushaltsform, Personen, Inflation).
|
||||
export const planProfileSchema = z.object({
|
||||
// Das Grundprofil liegt am SZENARIO (nicht am Plan) -- dadurch kann ein Szenario z. B. ein
|
||||
// anderes Pensionsalter tragen als das Basisszenario (Frühpensionierungs-Szenario).
|
||||
export const scenarioProfileSchema = z.object({
|
||||
householdType: z.enum(["SINGLE", "COUPLE"]),
|
||||
inflationRateDefault: z.number().min(-20).max(50),
|
||||
startYear: z.number().int().min(1900).max(2200).nullish(),
|
||||
persons: z.array(personSchema).min(1).max(2),
|
||||
});
|
||||
|
||||
const createPlanSchema = z
|
||||
.object({ name: z.string().min(1).max(120) })
|
||||
.and(planProfileSchema);
|
||||
const createPlanSchema = z.object({ name: z.string().min(1).max(120) }).and(scenarioProfileSchema);
|
||||
|
||||
export function validatePersonsForType(data: z.infer<typeof planProfileSchema>): string | null {
|
||||
export function validatePersonsForType(data: z.infer<typeof scenarioProfileSchema>): string | null {
|
||||
if (data.householdType === "SINGLE" && data.persons.length !== 1) {
|
||||
return "Einzelperson-Plan benoetigt genau eine Person.";
|
||||
return "Einzelperson-Plan benötigt genau eine Person.";
|
||||
}
|
||||
if (data.householdType === "COUPLE" && data.persons.length !== 2) {
|
||||
return "Paar-Plan benoetigt genau zwei Personen (Person A und Person B).";
|
||||
return "Paar-Plan benötigt genau zwei Personen (Person A und Person B).";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Liste der Pläne mit ihrem Szenario-Baum (nur Kopfdaten).
|
||||
export async function GET() {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
|
||||
const plans = await prisma.plan.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
parentPlanId: true,
|
||||
branchFromPhaseId: true,
|
||||
createdAt: true,
|
||||
phases: {
|
||||
select: { id: true, name: true, sequenceNumber: true },
|
||||
orderBy: { sequenceNumber: "asc" },
|
||||
householdType: true,
|
||||
startYear: true,
|
||||
persons: { select: { role: true, name: true, age: true }, orderBy: { role: "asc" } },
|
||||
scenarios: {
|
||||
orderBy: [{ isBase: "desc" }, { createdAt: "asc" }],
|
||||
select: { id: true, planId: true, name: true, isBase: true, parentScenarioId: true },
|
||||
},
|
||||
// Zähler für die Übersicht und das Plan-Dashboard -- ohne die Datensätze selbst zu laden.
|
||||
_count: { select: { actuals: true, analyses: true } },
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ plans });
|
||||
}
|
||||
|
||||
// Legt einen Plan an -- zusammen mit seinem Basisszenario, das das Grundprofil trägt.
|
||||
export async function POST(request: NextRequest) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createPlanSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
// Lesbare Meldung statt des rohen zod-Objekts (sonst sieht der Nutzer einen JSON-Blob).
|
||||
const first = parsed.error.issues[0];
|
||||
return NextResponse.json(
|
||||
{ error: first ? `${first.path.join(".") || "Eingabe"}: ${first.message}` : "Ungültige Eingabe." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const error = validatePersonsForType(parsed.data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
|
||||
// Haushaltsform, Personen und Startjahr am PLAN; das Pensionsalter je Szenario.
|
||||
const plan = await prisma.plan.create({
|
||||
data: {
|
||||
userId,
|
||||
name: parsed.data.name,
|
||||
householdType: parsed.data.householdType,
|
||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||
persons: { create: parsed.data.persons },
|
||||
startYear: parsed.data.startYear ?? new Date().getFullYear(),
|
||||
persons: {
|
||||
create: parsed.data.persons.map((p) => ({ role: p.role, name: p.name ?? null, age: p.age })),
|
||||
},
|
||||
scenarios: {
|
||||
create: {
|
||||
name: "Basisszenario",
|
||||
isBase: true,
|
||||
inflationRateDefault: parsed.data.inflationRateDefault,
|
||||
persons: {
|
||||
create: parsed.data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { scenarios: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({ plan: { id: plan.id } }, { status: 201 });
|
||||
// Das frisch angelegte Basisszenario startet mit Version 1.0.
|
||||
await touchScenario(plan.scenarios[0].id, userId);
|
||||
return NextResponse.json(
|
||||
{ plan: { id: plan.id }, scenario: { id: plan.scenarios[0].id } },
|
||||
{ status: 201 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
|
||||
const copySchema = z.object({ name: z.string().min(1).max(120) });
|
||||
|
||||
// Erstellt ein neues Szenario als vollständige Kopie eines bestehenden. Das Original wird
|
||||
// zum Elternteil -- damit hängt der Baum in der Seitenleiste und der Diff hat seine Basis.
|
||||
// Jede kopierte Phase/jedes kopierte Element trägt einen Herkunfts-Verweis auf sein
|
||||
// Gegenstück im Original; darauf beruht die Abweichungs-Markierung im UI.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const parsed = copySchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const source = await getOwnedScenario(scenarioId, userId);
|
||||
if (!source) return NextResponse.json({ error: "Ursprungs-Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const newId = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.scenario.create({
|
||||
data: {
|
||||
planId: source.planId,
|
||||
name: parsed.data.name,
|
||||
isBase: false,
|
||||
parentScenarioId: source.id,
|
||||
// Haushaltsform, Personen und Startjahr liegen am Plan und werden mitbenutzt, nicht
|
||||
// kopiert. Szenario-eigen sind nur Inflation, Cash-Anfangswert und Pensionsalter.
|
||||
inflationRateDefault: source.inflationRateDefault,
|
||||
initialCash: source.initialCash,
|
||||
persons: {
|
||||
create: source.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Phasen kopieren (alte -> neue Id merken, für die Werte-Zuordnung).
|
||||
const phaseIdMap = new Map<string, string>();
|
||||
for (const phase of source.phases) {
|
||||
const p = await tx.phase.create({
|
||||
data: {
|
||||
scenarioId: created.id,
|
||||
sequenceNumber: phase.sequenceNumber,
|
||||
name: phase.name,
|
||||
durationYears: phase.durationYears,
|
||||
cashTransition: phase.cashTransition ?? undefined,
|
||||
sourcePhaseId: phase.id,
|
||||
},
|
||||
});
|
||||
phaseIdMap.set(phase.id, p.id);
|
||||
}
|
||||
|
||||
// Elemente inkl. Phasen- und Übergangswerten kopieren.
|
||||
for (const el of source.elements) {
|
||||
const newEl = await tx.financialElement.create({
|
||||
data: {
|
||||
scenarioId: created.id,
|
||||
category: el.category,
|
||||
name: el.name,
|
||||
ownerRole: el.ownerRole,
|
||||
orderIndex: el.orderIndex,
|
||||
// Ohne das waere die Kopie eines Szenarios genau fuer den Zweck unbrauchbar, fuer
|
||||
// den man sie am haeufigsten anlegt: ein anderes Pensionierungs-Szenario.
|
||||
retirementDecision: el.retirementDecision ?? undefined,
|
||||
baseData: el.baseData ?? undefined,
|
||||
sourceElementId: el.id,
|
||||
},
|
||||
});
|
||||
for (const pv of el.phaseValues) {
|
||||
const phaseId = phaseIdMap.get(pv.phaseId);
|
||||
if (!phaseId) continue;
|
||||
await tx.elementPhaseValue.create({
|
||||
data: { elementId: newEl.id, phaseId, data: pv.data as object },
|
||||
});
|
||||
}
|
||||
for (const tv of el.transitionValues) {
|
||||
const fromPhaseId = phaseIdMap.get(tv.fromPhaseId);
|
||||
if (!fromPhaseId) continue;
|
||||
await tx.elementTransitionValue.create({
|
||||
data: { elementId: newEl.id, fromPhaseId, data: tv.data as object },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return created.id;
|
||||
});
|
||||
|
||||
// Das kopierte Szenario startet mit einer eigenen Version 1.0.
|
||||
await touchScenario(newId, userId);
|
||||
return NextResponse.json({ scenarioId: newId }, { status: 201 });
|
||||
}
|
||||
+11
-9
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan } from "@/lib/queries";
|
||||
import { getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { PERSON_ONLY_CATEGORIES } from "@/lib/elements";
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -20,21 +21,21 @@ const createSchema = z.object({
|
||||
ownerRole: z.enum(["PERSON_A", "PERSON_B", "HOUSEHOLD"]).nullable().optional(),
|
||||
});
|
||||
|
||||
// Legt ein neues finanzielles Element (plan-weit) an. Personen-Pflicht je Kategorie.
|
||||
// Legt ein neues finanzielles Element (szenario-weit) an. Personen-Pflicht je Kategorie.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json();
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const { category, name } = parsed.data;
|
||||
let ownerRole = parsed.data.ownerRole ?? null;
|
||||
@@ -51,13 +52,13 @@ export async function POST(
|
||||
}
|
||||
|
||||
const maxOrder = await prisma.financialElement.aggregate({
|
||||
where: { planId: plan.id },
|
||||
where: { scenarioId: scenario.id },
|
||||
_max: { orderIndex: true },
|
||||
});
|
||||
|
||||
const element = await prisma.financialElement.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
scenarioId: scenario.id,
|
||||
category,
|
||||
name,
|
||||
ownerRole,
|
||||
@@ -65,5 +66,6 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ element: { id: element.id } }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toPlanInput, getOwnedScenario } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { planToCsv } from "@/lib/csv";
|
||||
|
||||
// Umlaute und ss ausschreiben, alles Uebrige auf Unterstrich -- ein Dateiname mit Umlauten
|
||||
// kommt je nach Betriebssystem verstuemmelt an.
|
||||
function safeFileName(name: string): string {
|
||||
const map: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue", "ß": "ss" };
|
||||
const t = name.replace(/[äöüÄÖÜß]/g, (c) => map[c]);
|
||||
return t.replace(/[^a-z0-9]+/gi, "_").replace(/^_+|_+$/g, "") || "szenario";
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
}
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) {
|
||||
return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const computed = computePlan(planInput);
|
||||
const csv = planToCsv(planInput, computed);
|
||||
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${safeFileName(scenario.name)}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
+31
-27
@@ -1,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedPlan, toPlanInput } from "@/lib/queries";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { computePlan, maxPhaseDuration } from "@/lib/calculations";
|
||||
import { num, type PhaseData } from "@/lib/elements";
|
||||
@@ -12,25 +13,25 @@ const createPhaseSchema = z.object({
|
||||
durationYears: z.number().int().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans naechste
|
||||
// Pensionsereignis gekappt. Fuer bestehende Elemente werden die editierbaren Felder
|
||||
// Legt eine neue Lebensphase am Ende der Kette an. Die Dauer wird ans nächste
|
||||
// Pensionsereignis gekappt. Für bestehende Elemente werden die editierbaren Felder
|
||||
// vorbelegt; die Startwerte werden in der Berechnung live aus der Vorphase fortgeschrieben.
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ planId: string }> }
|
||||
{ params }: { params: Promise<{ scenarioId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { planId } = await params;
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const plan = await getOwnedPlan(planId, userId);
|
||||
if (!plan) return NextResponse.json({ error: "Plan nicht gefunden." }, { status: 404 });
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const parsed = createPhaseSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungueltige Eingabe." }, { status: 400 });
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const planInput = toPlanInput(plan);
|
||||
const planInput = toPlanInput(scenario);
|
||||
const yearsBefore = planInput.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
const cap = maxPhaseDuration(planInput.persons, yearsBefore);
|
||||
|
||||
@@ -40,12 +41,12 @@ export async function POST(
|
||||
|
||||
const nextSequence = planInput.phases.length + 1;
|
||||
|
||||
// Phasentyp der neuen Phase fuer den Default-Namen bestimmen.
|
||||
// Phasentyp der neuen Phase für den Default-Namen bestimmen.
|
||||
const anyRetiredAtStart = planInput.persons.some((p) => p.age + yearsBefore >= p.retirementAge);
|
||||
const defaultName =
|
||||
parsed.data.name ?? (nextSequence === 1 ? "Erste Lebensphase" : anyRetiredAtStart ? "Pensionsphase" : "Erwerbsphase");
|
||||
|
||||
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortfuehren).
|
||||
// Status der Elemente in der bisher letzten Phase (verkauft/getilgt nicht fortführen).
|
||||
const prevComputed = planInput.phases.length > 0 ? computePlan(planInput) : null;
|
||||
const lastPhaseId = planInput.phases.at(-1)?.id;
|
||||
const prevPhase = prevComputed?.phases.find((p) => p.id === lastPhaseId) ?? null;
|
||||
@@ -54,7 +55,7 @@ export async function POST(
|
||||
const phase = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.phase.create({
|
||||
data: {
|
||||
planId: plan.id,
|
||||
scenarioId: scenario.id,
|
||||
sequenceNumber: nextSequence,
|
||||
name: defaultName,
|
||||
durationYears: duration,
|
||||
@@ -74,29 +75,32 @@ export async function POST(
|
||||
return created;
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ phase: { id: phase.id } }, { status: 201 });
|
||||
}
|
||||
|
||||
// Vorbelegung fuer eine neue Phase: nur die editierbaren Felder werden uebernommen. Start-
|
||||
// bzw. Restwerte (PK/3a/Vermoegen/Hypothek/Schuld) werden in der Berechnung live aus der
|
||||
// Vorphase fortgeschrieben und deshalb hier NICHT als Snapshot gespeichert.
|
||||
// Vorbelegung für eine neue Phase.
|
||||
//
|
||||
// Seit Roadmap Nr. 44 (Punkt A) werden die WIEDERKEHR-Parameter (Raten, Beiträge, Amortisation,
|
||||
// Wertsteigerung, Zinssatz) NICHT mehr als Kopie gespeichert, sondern bewusst leer gelassen:
|
||||
// Die Berechnung übernimmt sie live aus der Vorphase, und im UI steht dafür sichtbar
|
||||
// «Aus Vorphase übernehmen» -- angehakt. Der Unterschied zeigt sich, sobald man die Vorphase
|
||||
// später ändert: Eine Kopie bliebe stehen, die Vererbung zieht mit.
|
||||
//
|
||||
// Start- bzw. Restwerte (PK/3a/Vermögen/Hypothek/Schuld) werden ohnehin live fortgeschrieben.
|
||||
function buildCarryData(category: string, prev: PhaseData): PhaseData {
|
||||
switch (category) {
|
||||
case "INCOME":
|
||||
case "EXPENSE":
|
||||
// Basis wird live indexiert fortgeschrieben; nur der Teuerungsausgleich wird uebernommen.
|
||||
return prev.teuerungsausgleich != null ? { teuerungsausgleich: prev.teuerungsausgleich } : {};
|
||||
case "AHV":
|
||||
// Ausfalljahre sind KEIN Wiederkehr-Parameter: Sie gelten für genau diese Phase, und
|
||||
// ein geerbter Wert würde eine Lücke erfinden, die es nicht gibt.
|
||||
return { gapYears: 0 };
|
||||
case "PENSION_FUND":
|
||||
case "PILLAR_3A":
|
||||
case "OTHER_ASSET":
|
||||
return { annualContribution: num(prev.annualContribution), expectedReturn: num(prev.expectedReturn) };
|
||||
case "REAL_ESTATE":
|
||||
// purchasePrice + amortization bleiben; die Resthypothek wird live fortgeschrieben.
|
||||
return { purchasePrice: num(prev.purchasePrice), amortization: num(prev.amortization) };
|
||||
case "OTHER_DEBT":
|
||||
return { annualRepayment: num(prev.annualRepayment) };
|
||||
// Der Kaufpreis ist eine Tatsache, keine Annahme (Basis der Grundstückgewinnsteuer);
|
||||
// die Zins-Behandlung ist ein Schalter ohne Zahlenwert -- beide werden weiterhin kopiert.
|
||||
return {
|
||||
purchasePrice: num(prev.purchasePrice),
|
||||
interestHandling: prev.interestHandling ?? "INCLUDED",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOwnedScenario, toPlanInput } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { retirementBoundaries, shiftRetirement } from "@/lib/retirement";
|
||||
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Das ist bewusst KEIN einfaches PATCH auf `retirementAge`: Eine Pensionierung liegt immer
|
||||
// auf einer Phasengrenze, also verschiebt jede Änderung zwei Phasendauern gleichzeitig --
|
||||
// und kann eine Phase ganz entfallen lassen. Die Entscheidung darüber trifft `lib/retirement`
|
||||
// (rein und getestet), hier wird sie nur noch geschrieben.
|
||||
|
||||
const bodySchema = z.object({
|
||||
role: z.enum(["PERSON_A", "PERSON_B"]),
|
||||
delta: z.number().int().min(-80).max(80),
|
||||
// Muss der Aufrufer ausdrücklich setzen, wenn dabei eine Lebensphase wegfällt.
|
||||
confirmMerge: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await getOwnedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
const { role, delta, confirmMerge } = parsed.data;
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const boundary = retirementBoundaries(planInput).find((b) => b.role === role);
|
||||
if (!boundary) return NextResponse.json({ error: "Diese Person gibt es in diesem Szenario nicht." }, { status: 400 });
|
||||
if (boundary.blocked) return NextResponse.json({ error: boundary.blocked }, { status: 400 });
|
||||
|
||||
if (delta < (boundary.mergeDeltaDown ?? boundary.minDelta) || delta > (boundary.mergeDeltaUp ?? boundary.maxDelta)) {
|
||||
return NextResponse.json(
|
||||
{ error: "So weit lässt sich das Pensionsalter hier nicht verschieben. Passe zuerst die Lebensphasen an." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = shiftRetirement(planInput, role, delta);
|
||||
if (!result) return NextResponse.json({ error: "Die Verschiebung ist hier nicht möglich." }, { status: 400 });
|
||||
|
||||
// Eine Zusammenlegung verändert den Plan inhaltlich (zwei Übergänge werden einer) und muss
|
||||
// deshalb vom Aufrufer bestätigt sein. 409 = "verstanden, aber so nicht ohne Zustimmung".
|
||||
if (result.removedPhaseId && !confirmMerge) {
|
||||
const removed = planInput.phases.find((p) => p.id === result.removedPhaseId);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Bestätigung erforderlich.",
|
||||
needsMergeConfirmation: true,
|
||||
removedPhaseId: result.removedPhaseId,
|
||||
removedPhaseName: removed?.name ?? "",
|
||||
mergedIntoPhaseId: result.mergedIntoPhaseId,
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.person.updateMany({
|
||||
where: { scenarioId: scenario.id, role },
|
||||
data: { retirementAge: boundary.retirementAge + delta },
|
||||
});
|
||||
|
||||
if (result.removedPhaseId) {
|
||||
// Die Übergangsdaten der entfallenden Phase wurden in den Vorgänger gezogen; hier wird
|
||||
// nur noch der zusammengeführte Stand geschrieben, bevor die Phase verschwindet.
|
||||
if (result.mergedIntoPhaseId) {
|
||||
const target = result.plan.phases.find((p) => p.id === result.mergedIntoPhaseId);
|
||||
if (target) {
|
||||
await tx.phase.update({
|
||||
where: { id: target.id },
|
||||
data: { cashTransition: (target.cashTransition ?? {}) as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
for (const e of result.plan.elements) {
|
||||
const td = e.transitionValues?.[result.mergedIntoPhaseId];
|
||||
if (!td) continue;
|
||||
await tx.elementTransitionValue.upsert({
|
||||
where: { elementId_fromPhaseId: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId } },
|
||||
create: { elementId: e.id, fromPhaseId: result.mergedIntoPhaseId, data: td as Prisma.InputJsonValue },
|
||||
update: { data: td as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
}
|
||||
// Löscht die Phase samt ihrer Phasen- und Übergangswerte (Kaskade im Schema).
|
||||
await tx.phase.delete({ where: { id: result.removedPhaseId } });
|
||||
}
|
||||
|
||||
for (const p of result.plan.phases) {
|
||||
await tx.phase.update({
|
||||
where: { id: p.id },
|
||||
data: { durationYears: p.durationYears, sequenceNumber: p.sequenceNumber },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
removedPhaseId: result.removedPhaseId,
|
||||
retirementAge: boundary.retirementAge + delta,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { toPlanInput, getOwnedScenario, getOwnedScenarioWithMeta } from "@/lib/queries";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { touchScenario } from "@/lib/versioning-db";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { scenarioProfileSchema, validatePersonsForType } from "@/app/api/plans/route";
|
||||
|
||||
// Liefert das Szenario samt Berechnung -- und zusätzlich das ELTERN-Szenario als
|
||||
// Vergleichsbasis, damit das UI die Abweichungen markieren kann (Basisszenario: null).
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await getOwnedScenarioWithMeta(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const planInput = toPlanInput(scenario);
|
||||
const computed = computePlan(planInput);
|
||||
|
||||
let base: ReturnType<typeof toPlanInput> | null = null;
|
||||
if (scenario.parentScenarioId) {
|
||||
const parent = await getOwnedScenario(scenario.parentScenarioId, userId);
|
||||
if (parent) base = toPlanInput(parent);
|
||||
}
|
||||
|
||||
// Ist-Werte des Plans plus die Element-Herkunft ALLER Szenarien: Nur damit lässt sich die
|
||||
// auf Wurzel-IDs erfasste Realität auf dieses Szenario abbilden (siehe lib/actuals.ts).
|
||||
// Die Ist-Rechnung selbst passiert im Browser -- computePlan ist rein.
|
||||
const [actualsRows, siblings] = await Promise.all([
|
||||
prisma.actualsSet.findMany({
|
||||
where: { planId: scenario.planId },
|
||||
orderBy: [{ year: "asc" }, { recordedOn: "asc" }],
|
||||
}),
|
||||
prisma.scenario.findMany({
|
||||
where: { planId: scenario.planId },
|
||||
select: { elements: { select: { id: true, sourceElementId: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
plan: planInput,
|
||||
computed,
|
||||
base,
|
||||
actuals: actualsRows.map((r) => ({
|
||||
id: r.id,
|
||||
recordedOn: r.recordedOn.toISOString().slice(0, 10),
|
||||
year: r.year,
|
||||
comment: r.comment,
|
||||
cash: r.cash,
|
||||
values: r.values,
|
||||
})),
|
||||
elementOrigins: siblings.flatMap((s) => s.elements),
|
||||
meta: {
|
||||
id: scenario.id,
|
||||
planId: scenario.planId,
|
||||
planName: scenario.plan.name,
|
||||
name: scenario.name,
|
||||
isBase: scenario.isBase,
|
||||
parentScenarioId: scenario.parentScenarioId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Name/Cash-Anfangswert ODER das ganze Grundprofil (Haushaltsform/Personen/Inflation).
|
||||
const patchSchema = z.union([
|
||||
scenarioProfileSchema.extend({ name: z.string().min(1).max(120).optional() }),
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
initialCash: z.number().min(0).max(1_000_000_000).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = patchSchema.safeParse(await request.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: "Ungültige Eingabe." }, { status: 400 });
|
||||
|
||||
const data = parsed.data;
|
||||
if ("householdType" in data) {
|
||||
const error = validatePersonsForType(data);
|
||||
if (error) return NextResponse.json({ error }, { status: 400 });
|
||||
// Haushaltsform, Personen (Name/Alter) und Startjahr gehen an den PLAN -- sie gelten
|
||||
// fuer alle Szenarien. Am Szenario bleiben Inflation und die Pensionsalter.
|
||||
const updated = await prisma.$transaction(async (tx) => {
|
||||
await tx.plan.update({
|
||||
where: { id: scenario.planId },
|
||||
data: {
|
||||
householdType: data.householdType,
|
||||
startYear: data.startYear ?? undefined,
|
||||
},
|
||||
});
|
||||
await tx.planPerson.deleteMany({ where: { planId: scenario.planId } });
|
||||
await tx.planPerson.createMany({
|
||||
data: data.persons.map((p) => ({
|
||||
planId: scenario.planId,
|
||||
role: p.role,
|
||||
name: p.name ?? null,
|
||||
age: p.age,
|
||||
})),
|
||||
});
|
||||
await tx.person.deleteMany({ where: { scenarioId: scenario.id } });
|
||||
return tx.scenario.update({
|
||||
where: { id: scenario.id },
|
||||
data: {
|
||||
name: data.name ?? undefined,
|
||||
inflationRateDefault: data.inflationRateDefault,
|
||||
persons: {
|
||||
create: data.persons.map((p) => ({ role: p.role, retirementAge: p.retirementAge })),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
const updated = await prisma.scenario.update({
|
||||
where: { id: scenario.id },
|
||||
data: {
|
||||
name: "name" in data ? data.name : undefined,
|
||||
initialCash:
|
||||
"initialCash" in data && data.initialCash != null ? Math.round(data.initialCash) : undefined,
|
||||
},
|
||||
});
|
||||
await touchScenario(scenario.id, userId);
|
||||
return NextResponse.json({ scenario: { id: updated.id, name: updated.name } });
|
||||
}
|
||||
|
||||
// Löscht ein Szenario. Das Basisszenario kann nicht gelöscht werden (dafür den Plan löschen).
|
||||
export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
if (scenario.isBase) {
|
||||
return NextResponse.json(
|
||||
{ error: "Das Basisszenario kann nicht gelöscht werden. Löschen Sie stattdessen den Plan." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.scenario.delete({ where: { id: scenario.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { restoreImpact, restoreVersion } from "@/lib/versioning-db";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
async function ownedVersion(scenarioId: string, versionId: string, userId: string) {
|
||||
return prisma.scenarioVersion.findFirst({
|
||||
where: { id: versionId, scenarioId, scenario: { plan: { userId } } },
|
||||
});
|
||||
}
|
||||
|
||||
// Ein einzelner Stand: der Snapshot selbst, das daraus Gerechnete und -- als Vorwarnung für
|
||||
// den Wiederherstellen-Knopf -- welche Kind-Szenarien dabei ihre Diff-Basis verlieren würden.
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const snapshot = version.snapshot as unknown as PlanInput;
|
||||
|
||||
return NextResponse.json({
|
||||
version: {
|
||||
id: version.id,
|
||||
major: version.major,
|
||||
minor: version.minor,
|
||||
comment: version.comment,
|
||||
isMajor: version.isMajor,
|
||||
createdAt: version.createdAt,
|
||||
},
|
||||
plan: snapshot,
|
||||
computed: computePlan(snapshot),
|
||||
impact: await restoreImpact(scenarioId, snapshot),
|
||||
});
|
||||
}
|
||||
|
||||
// Setzt das Szenario auf diesen Stand zurück. Löscht KEINE Historie: Der wiederhergestellte
|
||||
// Stand wird selbst als neue Version mit Herkunftsvermerk festgehalten.
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ scenarioId: string; versionId: string }> }
|
||||
) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId, versionId } = await params;
|
||||
|
||||
const version = await ownedVersion(scenarioId, versionId, userId);
|
||||
if (!version) return NextResponse.json({ error: "Version nicht gefunden." }, { status: 404 });
|
||||
|
||||
const ref = await restoreVersion(scenarioId, versionId, userId);
|
||||
if (!ref) return NextResponse.json({ error: "Wiederherstellen fehlgeschlagen." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref });
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getCurrentUserId } from "@/lib/session";
|
||||
import { createMajorVersion } from "@/lib/versioning-db";
|
||||
import { isValidMajorComment } from "@/lib/versioning";
|
||||
|
||||
async function ownedScenario(scenarioId: string, userId: string) {
|
||||
return prisma.scenario.findFirst({ where: { id: scenarioId, plan: { userId } } });
|
||||
}
|
||||
|
||||
// Änderungshistorie eines Szenarios, neueste zuerst. Ohne die Snapshots -- die sind je
|
||||
// Version zig Kilobyte gross und werden erst beim Anzeigen einzeln geladen.
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const rows = await prisma.scenarioVersion.findMany({
|
||||
where: { scenarioId },
|
||||
orderBy: [{ major: "desc" }, { minor: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
major: true,
|
||||
minor: true,
|
||||
comment: true,
|
||||
isMajor: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
createdBy: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
currentMajor: scenario.currentMajor,
|
||||
versions: rows.map((r) => ({
|
||||
id: r.id,
|
||||
major: r.major,
|
||||
minor: r.minor,
|
||||
comment: r.comment,
|
||||
isMajor: r.isMajor,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
author: r.createdBy.username,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const majorSchema = z.object({ comment: z.string().min(3).max(500) });
|
||||
|
||||
// Legt den aktuellen Stand als HAUPTVERSION fest. Der Kommentar ist Pflicht -- eine
|
||||
// Hauptversion ohne Begründung wäre nur eine Zahl.
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ scenarioId: string }> }) {
|
||||
const userId = await getCurrentUserId();
|
||||
if (!userId) return NextResponse.json({ error: "Nicht authentifiziert." }, { status: 401 });
|
||||
const { scenarioId } = await params;
|
||||
|
||||
const scenario = await ownedScenario(scenarioId, userId);
|
||||
if (!scenario) return NextResponse.json({ error: "Szenario nicht gefunden." }, { status: 404 });
|
||||
|
||||
const parsed = majorSchema.safeParse(await request.json());
|
||||
if (!parsed.success || !isValidMajorComment(parsed.data.comment)) {
|
||||
return NextResponse.json({ error: "Bitte einen Kommentar zur Hauptversion angeben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ref = await createMajorVersion(scenarioId, userId, parsed.data.comment);
|
||||
if (!ref) return NextResponse.json({ error: "Hauptversion konnte nicht angelegt werden." }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ version: ref }, { status: 201 });
|
||||
}
|
||||
+129
-5
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Semantische Farb-Tokens. Drei umschaltbare Schemata ueber data-theme am
|
||||
Semantische Farb-Tokens. Drei umschaltbare Schemata über data-theme am
|
||||
<html>: "light", "dark", "warm". Ohne explizite Wahl folgt das Standard-
|
||||
:root der OS-Einstellung (prefers-color-scheme). Umschaltung: lib/theme.ts.
|
||||
------------------------------------------------------------------------- */
|
||||
@@ -26,7 +26,28 @@
|
||||
--danger-soft: #fef2f2;
|
||||
--success: #059669;
|
||||
--person-a: #4f46e5;
|
||||
--diff: #b45309;
|
||||
--diff-soft: #fef3c7;
|
||||
--diff-added: #15803d;
|
||||
--diff-added-soft: #dcfce7;
|
||||
--diff-removed: #71717a;
|
||||
--diff-removed-soft: #f4f4f5;
|
||||
--person-b: #0ea5e9;
|
||||
/* Attention: "hier fehlt eine Eingabe" -- bewusst getrennt vom Akzent, damit
|
||||
"offen" und "klickbar" nicht dieselbe Farbe tragen. */
|
||||
--attention: #d97706;
|
||||
--attention-fg: #ffffff;
|
||||
--attention-soft: #fef3c7;
|
||||
--attention-soft-fg: #92400e;
|
||||
/* Diagramm-Palette: bewusst gedämpfte Töne, je Farbschema abgestimmt. Recharts bekommt
|
||||
die Variablen direkt als Attributwert (var(--chart-1)) -- der Browser löst sie im SVG auf. */
|
||||
--chart-1: #5b5bd6;
|
||||
--chart-2: #3b8ea5;
|
||||
--chart-3: #4c9a6a;
|
||||
--chart-4: #c98a2e;
|
||||
--chart-5: #c25d6b;
|
||||
--chart-6: #8b6bb1;
|
||||
--chart-grid: #e4e4e7;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
@@ -48,10 +69,27 @@
|
||||
--danger-soft: rgba(220, 38, 38, 0.16);
|
||||
--success: #34d399;
|
||||
--person-a: #818cf8;
|
||||
--diff: #fbbf24;
|
||||
--diff-soft: rgba(251, 191, 36, 0.16);
|
||||
--diff-added: #4ade80;
|
||||
--diff-added-soft: rgba(74, 222, 128, 0.14);
|
||||
--diff-removed: #a1a1aa;
|
||||
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
||||
--person-b: #38bdf8;
|
||||
--attention: #f59e0b;
|
||||
--attention-fg: #1c1400;
|
||||
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||
--attention-soft-fg: #fcd34d;
|
||||
--chart-1: #818cf8;
|
||||
--chart-2: #5eb3c9;
|
||||
--chart-3: #6ec898;
|
||||
--chart-4: #e0b25c;
|
||||
--chart-5: #e08a9a;
|
||||
--chart-6: #b39ae0;
|
||||
--chart-grid: #3f3f46;
|
||||
}
|
||||
|
||||
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundaerton. */
|
||||
/* Warm / Sunset: cremefarbener Grund, Koralle-Akzent, Amber-Sekundärton. */
|
||||
:root[data-theme="warm"] {
|
||||
--bg: #fbf7f2;
|
||||
--surface: #fffdfa;
|
||||
@@ -71,7 +109,24 @@
|
||||
--danger-soft: #fbeae7;
|
||||
--success: #2e9e7b;
|
||||
--person-a: #e8663c;
|
||||
--diff: #b45309;
|
||||
--diff-soft: #fbeccb;
|
||||
--diff-added: #2e7d52;
|
||||
--diff-added-soft: #dcf0e2;
|
||||
--diff-removed: #8a7f72;
|
||||
--diff-removed-soft: #f0e8dd;
|
||||
--person-b: #f2a93b;
|
||||
--attention: #c77414;
|
||||
--attention-fg: #ffffff;
|
||||
--attention-soft: #fdeed3;
|
||||
--attention-soft-fg: #8f5410;
|
||||
--chart-1: #e8663c;
|
||||
--chart-2: #4e8fa3;
|
||||
--chart-3: #7ba05b;
|
||||
--chart-4: #d99441;
|
||||
--chart-5: #a9556b;
|
||||
--chart-6: #8c6a9e;
|
||||
--chart-grid: #eadfd2;
|
||||
}
|
||||
|
||||
/* Ohne gespeicherte Wahl der Dunkel-OS-Einstellung folgen. */
|
||||
@@ -95,7 +150,24 @@
|
||||
--danger-soft: rgba(220, 38, 38, 0.16);
|
||||
--success: #34d399;
|
||||
--person-a: #818cf8;
|
||||
--diff: #fbbf24;
|
||||
--diff-soft: rgba(251, 191, 36, 0.16);
|
||||
--diff-added: #4ade80;
|
||||
--diff-added-soft: rgba(74, 222, 128, 0.14);
|
||||
--diff-removed: #a1a1aa;
|
||||
--diff-removed-soft: rgba(161, 161, 170, 0.12);
|
||||
--person-b: #38bdf8;
|
||||
--attention: #f59e0b;
|
||||
--attention-fg: #1c1400;
|
||||
--attention-soft: rgba(245, 158, 11, 0.16);
|
||||
--attention-soft-fg: #fcd34d;
|
||||
--chart-1: #818cf8;
|
||||
--chart-2: #5eb3c9;
|
||||
--chart-3: #6ec898;
|
||||
--chart-4: #e0b25c;
|
||||
--chart-5: #e08a9a;
|
||||
--chart-6: #b39ae0;
|
||||
--chart-grid: #3f3f46;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +189,16 @@
|
||||
--color-danger: var(--danger);
|
||||
--color-danger-soft: var(--danger-soft);
|
||||
--color-success: var(--success);
|
||||
--color-diff: var(--diff);
|
||||
--color-diff-soft: var(--diff-soft);
|
||||
--color-diff-added: var(--diff-added);
|
||||
--color-diff-added-soft: var(--diff-added-soft);
|
||||
--color-diff-removed: var(--diff-removed);
|
||||
--color-diff-removed-soft: var(--diff-removed-soft);
|
||||
--color-attention: var(--attention);
|
||||
--color-attention-fg: var(--attention-fg);
|
||||
--color-attention-soft: var(--attention-soft);
|
||||
--color-attention-soft-fg: var(--attention-soft-fg);
|
||||
--color-person-a: var(--person-a);
|
||||
--color-person-b: var(--person-b);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
@@ -126,12 +208,54 @@
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Dokument-Darstellung fuer gerenderten Markdown (Spezifikation, /spezifikation).
|
||||
Bewusst ueber die semantischen Tokens, damit alle drei Farbschemata greifen.
|
||||
Micro-Interactions: kurze, dezente Übergänge für Dialoge, Panels, Toasts.
|
||||
150-220 ms -- schnell genug, um nie zu bremsen (Doherty-Schwelle).
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
@keyframes ui-fade-kf {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes ui-pop-kf {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes ui-slide-in-kf {
|
||||
from { opacity: 0.4; transform: translateX(24px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.ui-fade { animation: ui-fade-kf 150ms ease-out; }
|
||||
.ui-pop { animation: ui-pop-kf 180ms cubic-bezier(0.2, 0.9, 0.3, 1); }
|
||||
.ui-slide-in { animation: ui-slide-in-kf 200ms cubic-bezier(0.2, 0.9, 0.3, 1); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ui-fade, .ui-pop, .ui-slide-in { animation: none; }
|
||||
}
|
||||
|
||||
/* Hervorhebung des aktuellen Tour-Ziels. */
|
||||
/* Tour-Spotlight: Der abgedunkelte Bereich entsteht aus vier fixed-Flächen UM das Ziel herum
|
||||
(in Tour.tsx aus der Bounding-Box berechnet) -- das funktioniert unabhängig von z-index und
|
||||
overflow. Hier nur der pulsierende Rahmen, der als eigene fixed-Fläche über dem Loch liegt. */
|
||||
@keyframes tour-ring-kf {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 85%, transparent), 0 0 14px 2px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 50%, transparent), 0 0 22px 6px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
}
|
||||
.tour-ring {
|
||||
border: 2px solid var(--accent);
|
||||
animation: tour-ring-kf 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Dokument-Darstellung für gerenderten Markdown (Spezifikation, /spezifikation).
|
||||
Bewusst über die semantischen Tokens, damit alle drei Farbschemata greifen.
|
||||
------------------------------------------------------------------------- */
|
||||
|
||||
.md-doc {
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ const geistMono = Geist_Mono({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "FPT — Financial Planning Tool",
|
||||
description: "Persoenliche Finanzplanung ueber Lebensabschnittsphasen (AICDS)",
|
||||
description: "Persönliche Finanzplanung über Lebensabschnittsphasen (AICDS)",
|
||||
};
|
||||
|
||||
// Setzt data-theme aus localStorage noch vor dem ersten Paint (verhindert FOUC).
|
||||
|
||||
@@ -21,7 +21,7 @@ function LoginForm() {
|
||||
setError(null);
|
||||
|
||||
if (mode === "register" && password !== passwordConfirm) {
|
||||
setError("Die Passwoerter stimmen nicht ueberein.");
|
||||
setError("Die Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ function LoginForm() {
|
||||
Financial Planning Tool
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Persoenliche Finanzplanung ueber Lebensphasen
|
||||
Persönliche Finanzplanung über Lebensphasen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@ function LoginForm() {
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="Passwort bestaetigen"
|
||||
placeholder="Passwort bestätigen"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
className={inputClass}
|
||||
|
||||
+17
-2
@@ -12,15 +12,30 @@ export default function Home() {
|
||||
.get<{ user: { username: string } }>("/api/auth/me")
|
||||
.then((data) => setUsername(data.user.username))
|
||||
.catch(() => {
|
||||
// Session abgelaufen/ungueltig -- Middleware leitet beim naechsten Request um.
|
||||
// Session abgelaufen/ungültig -- Middleware leitet beim nächsten Request um.
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Nach dem Abmelden führt der Zurück-Knopf zu dieser Seite aus dem Browser-Cache (bfcache)
|
||||
// zurück -- OHNE neue Anfrage, also ohne dass Middleware oder Session-Prüfung greifen. Die
|
||||
// eingefrorene Ansicht darf nicht stehen bleiben: Bei einer aus dem bfcache zurückgeholten
|
||||
// Seite die Session erneut prüfen und bei fehlender Anmeldung sofort auf /login gehen.
|
||||
useEffect(() => {
|
||||
function onPageShow(e: PageTransitionEvent) {
|
||||
if (!e.persisted) return;
|
||||
api.get("/api/auth/me").catch(() => {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
}
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, []);
|
||||
|
||||
if (username === null) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<p className="text-sm text-muted">Laedt…</p>
|
||||
<p className="text-sm text-muted">Lädt…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, CalendarClock, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { actualKindOf, resolveActuals, toPlanYear, type ActualsSetInput } from "@/lib/actuals";
|
||||
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
interface StoredSet extends ActualsSetInput {
|
||||
author: string;
|
||||
}
|
||||
|
||||
// Ein Eingabefeld im Wizard -- je Wurzel-Element eines oder (bei Immobilien) zwei.
|
||||
interface Row {
|
||||
rootId: string;
|
||||
name: string;
|
||||
category: ElementCategory;
|
||||
scenarioNames: string[];
|
||||
planValue: number; // Vorbelegung aus dem Basisszenario für das gewählte Jahr
|
||||
planMortgage?: number;
|
||||
kind: ReturnType<typeof actualKindOf>;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00Z`).toLocaleDateString("de-CH", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
interface LoadedScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
isBase: boolean;
|
||||
plan: PlanInput;
|
||||
}
|
||||
|
||||
export function ActualsDialog({
|
||||
planId,
|
||||
planName,
|
||||
scenarioMetas,
|
||||
initial,
|
||||
embedded = false,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
planId: string;
|
||||
planName: string;
|
||||
// Alle Szenarien dieses Plans (nur Kopfdaten) -- die Pläne werden hier nachgeladen.
|
||||
scenarioMetas: { id: string; name: string; isBase: boolean }[];
|
||||
// Das bereits geöffnete Szenario, damit der Dialog sofort etwas anzeigen kann.
|
||||
initial: LoadedScenario;
|
||||
// Eingebettet in den Hauptbereich (Plan-Ansicht) statt als Pop-up -- ohne Overlay,
|
||||
// ohne Backdrop, ohne Schliessen-Knopf.
|
||||
embedded?: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({ [initial.id]: initial }));
|
||||
|
||||
// Die übrigen Szenarien nachladen: Ein Ist-Satz gilt für ALLE, also müssen auch Elemente
|
||||
// erscheinen, die es nur in einem Nebenszenario gibt.
|
||||
useEffect(() => {
|
||||
const missing = scenarioMetas.filter((m) => m.id !== initial.id);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
missing.map(async (m) => {
|
||||
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${m.id}`);
|
||||
return [m.id, { id: m.id, name: m.name, isBase: m.isBase, plan: data.plan }] as const;
|
||||
})
|
||||
);
|
||||
if (!cancelled) setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
||||
} catch {
|
||||
// Fehlende Nebenszenarien sind verschmerzbar -- der Wizard zeigt dann weniger Zeilen.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scenarioMetas.map((m) => m.id).join(","), initial.id]);
|
||||
|
||||
const scenarios = useMemo(
|
||||
() => scenarioMetas.map((m) => loaded[m.id]).filter((s): s is LoadedScenario => !!s),
|
||||
[scenarioMetas, loaded]
|
||||
);
|
||||
const [sets, setSets] = useState<StoredSet[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"list" | "wizard">("list");
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Schritt 1
|
||||
const [recordedOn, setRecordedOn] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [comment, setComment] = useState("");
|
||||
// Schritt 2
|
||||
const [values, setValues] = useState<Record<string, { value?: number; mortgage?: number }>>({});
|
||||
const [cash, setCash] = useState<number>(0);
|
||||
// null = neuer Datensatz; sonst die id des gerade bearbeiteten (dann PUT statt POST).
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
const year = Number(recordedOn.slice(0, 4));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||
if (!cancelled) setSets(data.sets);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
// Herkunft aller Elemente über alle Szenarien -- Grundlage der Wurzel-Auflösung.
|
||||
const origins = useMemo(
|
||||
() => scenarios.flatMap((s) => s.plan.elements.map((e) => ({ id: e.id, sourceElementId: e.sourceElementId ?? null }))),
|
||||
[scenarios]
|
||||
);
|
||||
|
||||
// Alle Elemente ALLER Szenarien, zusammengefasst auf ihre Wurzel. Vorbelegt mit dem
|
||||
// berechneten Stand des Basisszenarios im gewählten Jahr; fehlt das Element dort, wird es
|
||||
// aus dem erstbesten Szenario geholt, das es kennt.
|
||||
const rows: Row[] = useMemo(() => {
|
||||
const sourceById = new Map(origins.map((o) => [o.id, o.sourceElementId]));
|
||||
const byRoot = new Map<string, Row>();
|
||||
|
||||
const ordered = [base, ...scenarios.filter((s) => s.id !== base?.id)].filter(Boolean);
|
||||
for (const sc of ordered) {
|
||||
const planYear = toPlanYear(year, sc.plan.startYear);
|
||||
const computed = computePlan(sc.plan);
|
||||
for (const el of sc.plan.elements) {
|
||||
const kind = actualKindOf(el.category);
|
||||
const root = resolveRootElementId(el.id, sourceById);
|
||||
|
||||
// Jahresstand aus dem Verlauf: genau der Wert, den der Plan für dieses Jahr vorsieht.
|
||||
const yearly = computed.phases
|
||||
.flatMap((p) => p.elements.filter((e) => e.elementId === el.id).flatMap((e) => e.yearly))
|
||||
.find((y) => y.year === planYear);
|
||||
|
||||
// Die AHV ist nur erfassbar, wenn die Rente zum Stichtag bereits läuft -- vorher gibt
|
||||
// es keinen Stand, den man ablesen könnte.
|
||||
if (el.category === "AHV" && !(yearly && yearly.value > 0)) continue;
|
||||
|
||||
const existing = byRoot.get(root);
|
||||
if (existing) {
|
||||
if (!existing.scenarioNames.includes(sc.name)) existing.scenarioNames.push(sc.name);
|
||||
continue;
|
||||
}
|
||||
byRoot.set(root, {
|
||||
rootId: root,
|
||||
name: el.name,
|
||||
category: el.category,
|
||||
scenarioNames: [sc.name],
|
||||
// Bei einer Immobilie ist `yearly.value` das EIGENKAPITAL (Verkehrswert − Hypothek).
|
||||
// Erfasst und gerechnet wird aber der VERKEHRSWERT -- er steht in `propertyValue`.
|
||||
// Wurde hier bis 0.31 das Eigenkapital vorbelegt, setzte der Rechenkern es als
|
||||
// Verkehrswert ein, während die Hypothek stehen blieb: Das Eigenkapital brach im
|
||||
// Ist-Jahr schlagartig ein (typischerweise ins Negative).
|
||||
planValue:
|
||||
kind === "PROPERTY"
|
||||
? Math.round(yearly?.propertyValue ?? 0)
|
||||
: Math.round(Math.abs(yearly?.value ?? 0)),
|
||||
planMortgage: kind === "PROPERTY" ? Math.round(yearly?.mortgage ?? 0) : undefined,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byRoot.values()].sort((a, b) => {
|
||||
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
|
||||
});
|
||||
}, [scenarios, base, origins, year]);
|
||||
|
||||
// Der geplante Cash-Bestand im gewählten Jahr -- Vorbelegung für das Cash-Feld.
|
||||
const planCash = useMemo(() => {
|
||||
if (!base) return 0;
|
||||
const planYear = toPlanYear(year, base.plan.startYear);
|
||||
if (planYear === null) return 0;
|
||||
// Der Stand am Ende GENAU DIESES Jahres -- nicht am Ende der Phase. In einer Phase über
|
||||
// zehn Jahre, in der sich das Cash-Konto füllt, sind das zwei völlig verschiedene Zahlen.
|
||||
const computed = computePlan(base.plan);
|
||||
return computed.yearly.find((y) => y.year === planYear)?.cash ?? 0;
|
||||
}, [base, year]);
|
||||
|
||||
function startWizard() {
|
||||
setEditingId(null);
|
||||
setValues({});
|
||||
setCash(planCash);
|
||||
setComment("");
|
||||
setRecordedOn(new Date().toISOString().slice(0, 10));
|
||||
setStep(1);
|
||||
setMode("wizard");
|
||||
}
|
||||
|
||||
// Bestehenden Datensatz zum Bearbeiten oeffnen. Die gespeicherten Werte sind bereits auf
|
||||
// WURZEL-Element-IDs erfasst -- genau die Form, mit der der Wizard arbeitet.
|
||||
function startEdit(set: StoredSet) {
|
||||
setEditingId(set.id);
|
||||
setRecordedOn(set.recordedOn);
|
||||
setComment(set.comment ?? "");
|
||||
setValues({ ...set.values });
|
||||
setCash(typeof set.cash === "number" ? set.cash : 0);
|
||||
setStep(1);
|
||||
setMode("wizard");
|
||||
}
|
||||
|
||||
// Beim Wechsel auf Schritt 2 mit den Planwerten vorbelegen -- der Nutzer überschreibt nur,
|
||||
// was tatsächlich abweicht.
|
||||
function goToStep2() {
|
||||
// Beim Bearbeiten stehen die erfassten Werte bereits -- sie duerfen nicht durch die
|
||||
// Planwerte ersetzt werden. Nur Zeilen, fuer die nichts erfasst ist (z. B. ein spaeter
|
||||
// hinzugekommenes Element), werden ergaenzt.
|
||||
const prefill: Record<string, { value?: number; mortgage?: number }> = {};
|
||||
for (const r of rows) {
|
||||
if (editingId && values[r.rootId]) {
|
||||
prefill[r.rootId] = values[r.rootId];
|
||||
continue;
|
||||
}
|
||||
prefill[r.rootId] =
|
||||
r.kind === "PROPERTY" ? { value: r.planValue, mortgage: r.planMortgage ?? 0 } : { value: r.planValue };
|
||||
}
|
||||
setValues(prefill);
|
||||
if (!editingId) setCash(planCash);
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const data = await api.get<{ sets: StoredSet[] }>(`/api/plans/${planId}/actuals`);
|
||||
setSets(data.sets);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = { recordedOn, comment: comment.trim() || undefined, cash, values };
|
||||
if (editingId) {
|
||||
await api.put(`/api/plans/${planId}/actuals/${editingId}`, payload);
|
||||
} else {
|
||||
await api.post(`/api/plans/${planId}/actuals`, payload);
|
||||
}
|
||||
toast("success", `Effektive Werte für ${dt(recordedOn)} ${editingId ? "aktualisiert" : "erfasst"}.`);
|
||||
await reload();
|
||||
setEditingId(null);
|
||||
setMode("list");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(set: StoredSet) {
|
||||
const ok = await confirm({
|
||||
title: "Datensatz löschen?",
|
||||
message: `Die effektiven Werte vom ${dt(set.recordedOn)} werden entfernt. Der Plan selbst bleibt unverändert.`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/actuals/${set.id}`);
|
||||
await reload();
|
||||
onChanged();
|
||||
toast("success", "Datensatz gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wie viele Elemente weichen ab? Kleine Orientierungshilfe in der Liste.
|
||||
const deviationCount = (set: ActualsSetInput) => {
|
||||
const resolved = resolveActuals([set], base?.plan ?? scenarios[0].plan, origins);
|
||||
return resolved.length === 0 ? 0 : Object.keys(resolved[0].byElementId).length;
|
||||
};
|
||||
|
||||
const setRow = (rootId: string, patch: { value?: number; mortgage?: number }) =>
|
||||
setValues((prev) => ({ ...prev, [rootId]: { ...prev[rootId], ...patch } }));
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<CalendarClock className="h-5 w-5 text-accent" /> Effektive Werte
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Plan «{planName}». Was tatsächlich eingetreten ist – der Plan selbst bleibt unverändert.
|
||||
</p>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === "list" && (
|
||||
<>
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Ein Datensatz hält fest, wie es an einem Stichtag <strong className="text-fg">wirklich</strong> aussah.
|
||||
Die Berechnung läuft dann ein zweites Mal: gleiche Mechanik, aber ab jedem erfassten Jahr mit den
|
||||
echten Zahlen. Werte, die du weglässt, laufen unverändert auf ihrer Planlinie weiter.
|
||||
Ein Datensatz gilt für <strong className="text-fg">alle Szenarien</strong> dieses Plans – die
|
||||
Wirklichkeit ist dieselbe, egal wogegen man sie hält.
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!sets && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
|
||||
{sets && sets.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch keine effektiven Werte erfasst.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sets && sets.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{sets.map((s, i) => (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => startEdit(s)}
|
||||
title="Zum Bearbeiten anklicken"
|
||||
className={`flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border p-3 transition-colors hover:border-accent ${
|
||||
i === 0 ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||
{dt(s.recordedOn)}
|
||||
{i === 0 && (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">
|
||||
aktuellster
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
rechnet ab {s.year} · {deviationCount(s)} Werte · {s.author}
|
||||
{typeof s.cash === "number" && ` · Cash ${formatChf(s.cash)}`}
|
||||
</div>
|
||||
{s.comment && <div className="mt-0.5 truncate text-xs text-fg">«{s.comment}»</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit(s);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void remove(s);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Löschen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Button onClick={startWizard}>
|
||||
<Plus className="h-4 w-4" /> Effektive Werte erfassen
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "wizard" && step === 1 && (
|
||||
<>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 1 von 2 · Stichtag
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Datum der Erfassung
|
||||
<InfoBubble text="Das exakte Datum erscheint in der Liste und auf der Zeitachse. Für die Berechnung zählt nur die Jahreszahl – der Rechenkern arbeitet in ganzen Jahren ab Planbeginn." />
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={recordedOn}
|
||||
onChange={(e) => setRecordedOn(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Notiz (optional)</label>
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="z. B. «nach Jahresabschluss»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Gerechnet wird ab dem Jahr <strong className="text-fg">{year}</strong>. Die Vorbelegung im nächsten
|
||||
Schritt zeigt, was dein Plan für dieses Jahr vorsieht – du überschreibst nur, was tatsächlich anders ist.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button onClick={goToStep2}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === "wizard" && step === 2 && (
|
||||
<>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{editingId ? "Bearbeiten" : "Erfassen"} · Schritt 2 von 2 · Werte per {dt(recordedOn)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[52vh] overflow-y-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-surface-2">
|
||||
<tr className="text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Element</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Laut Plan {year}</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Effektiv</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
Cash
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-t border-border">
|
||||
<td className="px-3 py-2 font-medium text-fg">Cash-Konto</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(planCash)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumInput value={cash} onChange={setCash} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{rows.map((r, i) => {
|
||||
const header = i === 0 || rows[i - 1].category !== r.category ? r.category : null;
|
||||
const v = values[r.rootId] ?? {};
|
||||
return (
|
||||
<FragmentRow
|
||||
key={r.rootId}
|
||||
header={header}
|
||||
row={r}
|
||||
value={v}
|
||||
onChange={(patch) => setRow(r.rootId, patch)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-faint">
|
||||
Einkommen und Ausgaben bitte als <strong>Jahresbetrag</strong> erfassen (nominal, wie tatsächlich
|
||||
geflossen). Bei Immobilien zählt der Verkehrswert und die Restschuld getrennt. Was du auf dem
|
||||
Planwert stehen lässt, wird als «keine Abweichung» gewertet.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setStep(1)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={save}>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Eingebettet: schlichter Container im Hauptbereich. Sonst: Pop-up mit Overlay.
|
||||
if (embedded) {
|
||||
return <div className="flex flex-col gap-4">{body}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRow({
|
||||
header,
|
||||
row,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
header: ElementCategory | null;
|
||||
row: Row;
|
||||
value: { value?: number; mortgage?: number };
|
||||
onChange: (patch: { value?: number; mortgage?: number }) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{header && (
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td colSpan={3} className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
{CATEGORY_LABELS[header]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-fg">{row.name}</div>
|
||||
{row.scenarioNames.length > 1 && (
|
||||
<div className="text-[10px] text-faint">gilt für {row.scenarioNames.join(", ")}</div>
|
||||
)}
|
||||
{row.kind === "FLOW" && <div className="text-[10px] text-faint">Jahresbetrag</div>}
|
||||
{/* Klarstellen, dass hier der Verkehrswert steht -- nicht das Eigenkapital, das die
|
||||
Matrix zeigt. */}
|
||||
{row.kind === "PROPERTY" && <div className="text-[10px] text-faint">Verkehrswert + Restschuld</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-muted">
|
||||
{formatChf(row.planValue)}
|
||||
{row.kind === "PROPERTY" && (
|
||||
<div className="text-[11px] text-faint">Hypothek {formatChf(row.planMortgage ?? 0)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<NumInput value={value.value ?? 0} onChange={(v) => onChange({ value: v })} />
|
||||
{row.kind === "PROPERTY" && (
|
||||
<div className="mt-1">
|
||||
<NumInput
|
||||
value={value.mortgage ?? 0}
|
||||
onChange={(v) => onChange({ mortgage: v })}
|
||||
label="Hypothek"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NumInput({ value, onChange, label }: { value: number; onChange: (v: number) => void; label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{label && <span className="text-[10px] text-faint">{label}</span>}
|
||||
<input
|
||||
type="number"
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
onChange={(e) => onChange(Math.round(Number(e.target.value) || 0))}
|
||||
className="w-32 rounded border border-border bg-surface px-2 py-1 text-right text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
// Diagramm-Palette aus den Theme-Tokens (globals.css) statt fester Hex-Werte -- so passt sie
|
||||
// sich Hell/Dunkel/Warm an. Recharts reicht die Werte als SVG-Attribut durch, der Browser
|
||||
// löst `var(...)` dort auf.
|
||||
export const CHART_PALETTE = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--chart-4)",
|
||||
"var(--chart-5)",
|
||||
"var(--chart-6)",
|
||||
];
|
||||
|
||||
// Einheitliches Aussehen der Recharts-Tooltips (folgt dem Farbschema).
|
||||
export const TOOLTIP_STYLE = {
|
||||
background: "var(--surface)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: 12,
|
||||
color: "var(--fg)",
|
||||
} as const;
|
||||
|
||||
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
|
||||
// Gestapelte Vermögensaufteilung.
|
||||
//
|
||||
// Bis 0.30 waren das gestapelte Balken mit je zwei Säulen pro Phase (Beginn/Ende) und schräg
|
||||
// gestellter Beschriftung -- schwer lesbar, sobald es mehr als zwei Phasen gab. Neu:
|
||||
// * eine gestapelte FLÄCHE über alle Planjahre (zeigt den Verlauf statt zweier Stichproben),
|
||||
// Phasengrenzen als feine senkrechte Linien,
|
||||
// * daneben ein RING für ein wählbares Jahr, der die RELATIVE Aufteilung zeigt.
|
||||
// Ein Klick in die Fläche wählt das Jahr des Rings.
|
||||
export function AllocationChart({
|
||||
computed,
|
||||
height = 300,
|
||||
// Auf eine einzelne Lebensphase einschraenken (Detailansicht der Phase). Ohne Angabe wird
|
||||
// der ganze Plan gezeigt.
|
||||
onlyPhaseId,
|
||||
}: {
|
||||
computed: PlanComputed;
|
||||
height?: number;
|
||||
onlyPhaseId?: string;
|
||||
}) {
|
||||
const phases = useMemo(
|
||||
() => (onlyPhaseId ? computed.phases.filter((p) => p.id === onlyPhaseId) : computed.phases),
|
||||
[computed, onlyPhaseId]
|
||||
);
|
||||
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
||||
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
||||
const assetEls = useMemo(() => {
|
||||
const info = new Map<string, { name: string; any: boolean }>();
|
||||
for (const phase of phases) {
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
|
||||
cur.name = el.name;
|
||||
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
|
||||
info.set(el.elementId, cur);
|
||||
}
|
||||
}
|
||||
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||
}, [phases]);
|
||||
|
||||
// Je Planjahr eine Zeile mit dem Wert jedes Elements. Die Jahreswerte liegen bereits in
|
||||
// `ElementPhaseComputed.yearly` (seit 0.11) -- hier wird nur umsortiert, nichts gerechnet.
|
||||
const areaData = useMemo(() => {
|
||||
const byYear = new Map<number, Record<string, number>>();
|
||||
const ageOf = new Map<number, number>();
|
||||
for (const phase of phases) {
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
for (const y of el.yearly) {
|
||||
const row = byYear.get(y.year) ?? {};
|
||||
row[el.elementId] = Math.max(0, Math.round(y.value));
|
||||
byYear.set(y.year, row);
|
||||
ageOf.set(y.year, y.age);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...byYear.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([year, values]) => ({ year, age: ageOf.get(year) ?? 0, ...values }));
|
||||
}, [phases]);
|
||||
|
||||
// Phasengrenzen (kumulierte Dauer) für die Trennlinien. Bei einer einzelnen Phase gibt es keine.
|
||||
const boundaryAges = useMemo(() => {
|
||||
if (onlyPhaseId) return [];
|
||||
const out: number[] = [];
|
||||
let acc = 0;
|
||||
for (let i = 0; i < computed.phases.length - 1; i++) {
|
||||
acc += computed.phases[i].durationYears;
|
||||
const age = areaData.find((r) => r.year === acc)?.age;
|
||||
if (typeof age === "number") out.push(age);
|
||||
}
|
||||
return out;
|
||||
}, [computed, areaData, onlyPhaseId]);
|
||||
|
||||
// Gewähltes Jahr für den Ring -- Vorgabe: das letzte (Endzustand).
|
||||
const [pickedYear, setPickedYear] = useState<number | null>(null);
|
||||
const ringRow = areaData.find((r) => r.year === pickedYear) ?? areaData[areaData.length - 1];
|
||||
|
||||
const ringData = useMemo(() => {
|
||||
if (!ringRow) return [];
|
||||
return assetEls
|
||||
.map((el) => ({ name: el.name, value: Number(ringRow[el.id as keyof typeof ringRow] ?? 0) }))
|
||||
.filter((d) => d.value > 0);
|
||||
}, [assetEls, ringRow]);
|
||||
const ringTotal = ringData.reduce((s, d) => s + d.value, 0);
|
||||
|
||||
if (assetEls.length === 0) {
|
||||
return <p className="text-sm text-muted">Dieser Plan enthält keine Vermögenselemente.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:flex-row">
|
||||
{/* Verlauf */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 text-xs font-medium text-muted">
|
||||
{onlyPhaseId ? "Verlauf in dieser Lebensphase" : "Verlauf über alle Planjahre"}{" "}
|
||||
<span className="text-faint">· Klick wählt das Jahr für den Ring</span>
|
||||
</div>
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={areaData}
|
||||
margin={{ top: 8, right: 8, left: 8, bottom: 4 }}
|
||||
style={{ cursor: "pointer" }}
|
||||
// Recharts 3 reicht im Klick-Parameter NUR den aktiven Index durch -- das in
|
||||
// Version 2 übliche `activePayload` gibt es nicht mehr. Über den Index in
|
||||
// `areaData` kommt man an das Planjahr.
|
||||
onClick={(state) => {
|
||||
const raw = state?.activeIndex ?? state?.activeTooltipIndex;
|
||||
const idx = typeof raw === "number" ? raw : Number(raw);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= areaData.length) return;
|
||||
setPickedYear(areaData[idx].year);
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="age"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => `${v} J.`}
|
||||
stroke="var(--faint)"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--faint)"
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
{boundaryAges.map((age) => (
|
||||
<ReferenceLine key={age} x={age} stroke="var(--border-strong)" strokeDasharray="3 3" />
|
||||
))}
|
||||
{/* Markierung des Ring-Jahres. */}
|
||||
{ringRow && <ReferenceLine x={ringRow.age} stroke="var(--accent)" strokeWidth={1.5} />}
|
||||
{assetEls.map((el, i) => (
|
||||
<Area
|
||||
key={el.id}
|
||||
type="monotone"
|
||||
dataKey={el.id}
|
||||
name={el.name}
|
||||
stackId="a"
|
||||
stroke={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||||
fill={CHART_PALETTE[i % CHART_PALETTE.length]}
|
||||
fillOpacity={0.75}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Relative Aufteilung im gewählten Jahr */}
|
||||
<div className="lg:w-72 lg:shrink-0">
|
||||
<div className="mb-1 text-xs font-medium text-muted">
|
||||
Aufteilung mit {ringRow?.age ?? "–"} Jahren
|
||||
{pickedYear !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickedYear(null)}
|
||||
className="ml-2 text-[11px] font-normal text-accent hover:underline"
|
||||
>
|
||||
zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={ringData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius="55%"
|
||||
outerRadius="80%"
|
||||
paddingAngle={1}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{ringData.map((d, i) => (
|
||||
<Cell key={d.name} fill={CHART_PALETTE[i % CHART_PALETTE.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v) =>
|
||||
typeof v === "number"
|
||||
? `${formatChf(v)} · ${ringTotal > 0 ? Math.round((v / ringTotal) * 100) : 0} %`
|
||||
: v
|
||||
}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="-mt-2 text-center text-xs text-muted">
|
||||
Total <strong className="text-fg">{formatChf(ringTotal)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
resolveActuals,
|
||||
latestPlanYear,
|
||||
type ActualsSetInput,
|
||||
type ElementOrigin,
|
||||
type ResolvedActuals,
|
||||
} from "@/lib/actuals";
|
||||
import { DATA_SOURCE_OPTIONS, type DataSource } from "@/lib/dataview";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// In den Analysewerkzeugen ist nominal/real eine EINFACHauswahl, kein "beide".
|
||||
//
|
||||
// Grund: Der Vermögensverlauf zeichnet je Serie ohnehin schon eine Linie; mit "beide" wären
|
||||
// es zwei, mit Plan/Ist vier und bei zwei Szenarien acht. Das Stilbudget wird stattdessen
|
||||
// für Plan (gestrichelt) gegen Ist (durchgezogen) ausgegeben -- das ist der Vergleich, um
|
||||
// den es geht (siehe SPEZIFIKATION 9.29).
|
||||
export type Metric = "nominal" | "real";
|
||||
|
||||
export const METRIC_OPTIONS: { value: Metric; label: string }[] = [
|
||||
{ value: "nominal", label: "Nominal" },
|
||||
{ value: "real", label: "Real" },
|
||||
];
|
||||
|
||||
export interface AnalysisBasis {
|
||||
metric: Metric;
|
||||
setMetric: (m: Metric) => void;
|
||||
source: DataSource;
|
||||
setSource: (s: DataSource) => void;
|
||||
// Die für die gewählte Quelle massgebende Rechnung.
|
||||
computed: PlanComputed;
|
||||
// Immer die reine Plan-Rechnung -- Referenzlinie in den Grafiken.
|
||||
planComputed: PlanComputed;
|
||||
// Ist-Rechnung, null solange nichts erfasst ist.
|
||||
actualComputed: PlanComputed | null;
|
||||
hasActuals: boolean;
|
||||
// Auf dieses Szenario aufgeloeste Ist-Saetze -- fuer Werkzeuge, die selbst rechnen.
|
||||
resolvedActuals: ResolvedActuals[];
|
||||
// Erstes Jahr, das nicht mehr durch Ist-Werte belegt ist (Monte-Carlo-Startpunkt).
|
||||
simStartPlanYear: number;
|
||||
simStartCalendarYear: number | null;
|
||||
}
|
||||
|
||||
export function useAnalysisBasis(
|
||||
plan: PlanInput,
|
||||
actuals: ActualsSetInput[],
|
||||
origins: ElementOrigin[],
|
||||
initialMetric: Metric = "nominal"
|
||||
): AnalysisBasis {
|
||||
const [metric, setMetric] = useState<Metric>(initialMetric);
|
||||
const [source, setSource] = useState<DataSource>("PLAN");
|
||||
|
||||
const resolved = useMemo(() => resolveActuals(actuals, plan, origins), [actuals, plan, origins]);
|
||||
const planComputed = useMemo(() => computePlan(plan), [plan]);
|
||||
const actualComputed = useMemo(
|
||||
() => (resolved.length === 0 ? null : computePlan(plan, undefined, { actuals: resolved })),
|
||||
[plan, resolved]
|
||||
);
|
||||
|
||||
const hasActuals = actualComputed !== null;
|
||||
const effectiveSource: DataSource = hasActuals ? source : "PLAN";
|
||||
const latest = latestPlanYear(resolved);
|
||||
|
||||
return {
|
||||
metric,
|
||||
setMetric,
|
||||
resolvedActuals: effectiveSource === "ACTUAL" ? resolved : [],
|
||||
source: effectiveSource,
|
||||
setSource,
|
||||
computed: effectiveSource === "ACTUAL" && actualComputed ? actualComputed : planComputed,
|
||||
planComputed,
|
||||
actualComputed,
|
||||
hasActuals,
|
||||
// Im Ist-Modus beginnt die Simulation NACH dem jüngsten erfassten Jahr: Was erfasst ist,
|
||||
// ist bekannt und darf nicht gewürfelt werden.
|
||||
simStartPlanYear: effectiveSource === "ACTUAL" && latest !== null ? latest + 1 : 1,
|
||||
simStartCalendarYear:
|
||||
plan.startYear == null
|
||||
? null
|
||||
: plan.startYear + (effectiveSource === "ACTUAL" && latest !== null ? latest : 0),
|
||||
};
|
||||
}
|
||||
|
||||
// Einheitliche Leiste für alle vier Werkzeuge, damit die Bedienung überall dieselbe ist.
|
||||
export function AnalysisBar({
|
||||
basis,
|
||||
onChange,
|
||||
}: {
|
||||
basis: AnalysisBasis;
|
||||
// Wird nach jeder Umstellung gerufen -- die Werkzeuge verwerfen damit alte Ergebnisse.
|
||||
onChange?: () => void;
|
||||
}) {
|
||||
const pick = (fn: () => void) => () => {
|
||||
fn();
|
||||
onChange?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Werte</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{METRIC_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={pick(() => basis.setMetric(o.value))}
|
||||
className={`rounded-md px-2 py-0.5 font-medium ${
|
||||
basis.metric === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Nominal = Frankenbeträge im jeweiligen Jahr. Real = kaufkraftbereinigt auf den Planbeginn. In den Grafiken ist das bewusst eine Entweder-oder-Wahl: Beides gleichzeitig verdoppelt die Linien." />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Grundlage</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{DATA_SOURCE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
disabled={o.value === "ACTUAL" && !basis.hasActuals}
|
||||
onClick={pick(() => basis.setSource(o.value))}
|
||||
title={
|
||||
o.value === "ACTUAL" && !basis.hasActuals
|
||||
? "Für diesen Plan sind noch keine effektiven Werte erfasst."
|
||||
: o.label
|
||||
}
|
||||
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||
basis.source === o.value ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Planzahlen rechnen rein mit deinen Annahmen. Effektiv rechnet dieselbe Mechanik, springt aber in jedem Jahr, für das du Ist-Werte erfasst hast, auf die Realität." />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1211
-302
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
// Befehls-Palette (Cmd/Ctrl+K): schnelles Springen zwischen Plänen und Szenarien plus
|
||||
// die wichtigsten Aktionen -- der Effizienz-Pfad für geübte Nutzer, ohne der Maus-
|
||||
// Bedienung etwas wegzunehmen.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CornerDownLeft, Search } from "lucide-react";
|
||||
import type { PlanListItem } from "@/lib/types";
|
||||
|
||||
export interface PaletteAction {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
plans,
|
||||
actions,
|
||||
onOpenScenario,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
plans: PlanListItem[];
|
||||
actions: PaletteAction[];
|
||||
onOpenScenario: (id: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [index, setIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const items = useMemo<PaletteAction[]>(() => {
|
||||
const scenarioItems: PaletteAction[] = plans.flatMap((p) =>
|
||||
p.scenarios.map((s) => ({
|
||||
id: `s-${s.id}`,
|
||||
label: `${p.name} · ${s.name}`,
|
||||
hint: "Szenario öffnen",
|
||||
run: () => onOpenScenario(s.id),
|
||||
}))
|
||||
);
|
||||
const all = [...actions, ...scenarioItems];
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
return all.filter((i) => i.label.toLowerCase().includes(q));
|
||||
}, [plans, actions, query, onOpenScenario]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset beim Öffnen
|
||||
setQuery("");
|
||||
setIndex(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Auswahl folgt der Eingabe
|
||||
setIndex(0);
|
||||
}, [query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function choose(item: PaletteAction | undefined) {
|
||||
if (!item) return;
|
||||
onClose();
|
||||
item.run();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center bg-black/40 px-4 pt-[15vh]" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ui-pop w-full max-w-lg overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<Search className="h-4 w-4 shrink-0 text-faint" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setIndex((i) => Math.min(i + 1, items.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
choose(items[index]);
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
placeholder="Plan, Szenario oder Aktion suchen…"
|
||||
className="w-full bg-transparent text-sm text-fg outline-none placeholder:text-faint"
|
||||
/>
|
||||
<kbd className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">ESC</kbd>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto py-1">
|
||||
{items.length === 0 && <p className="px-4 py-3 text-sm text-muted">Nichts gefunden.</p>}
|
||||
{items.slice(0, 40).map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onMouseEnter={() => setIndex(i)}
|
||||
onClick={() => choose(item)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm ${
|
||||
i === index ? "bg-accent-soft text-accent-soft-fg" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<span className="flex items-center gap-1.5 text-[10px] text-faint">
|
||||
{item.hint}
|
||||
{i === index && <CornerDownLeft className="h-3 w-3" />}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+256
-121
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { BarChart3, Download, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BarChart3, LineChart as LineChartIcon } from "lucide-react";
|
||||
import { AllocationChart, CHART_PALETTE } from "@/components/AllocationChart";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { WealthChart, type TimelineSeries } from "@/components/WealthChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -10,24 +14,76 @@ import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
const PALETTE = ["#4f46e5", "#0ea5e9", "#16a34a", "#d97706", "#dc2626", "#7c3aed"];
|
||||
|
||||
interface PlanListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
type ChartId = "wealth" | "cashflow" | "allocation";
|
||||
const CHART_OPTIONS: { id: ChartId; label: string }[] = [
|
||||
{ id: "wealth", label: "Vermögensverlauf" },
|
||||
{ id: "cashflow", label: "Einkommen vs. Ausgaben" },
|
||||
{ id: "allocation", label: "Vermögensaufteilung" },
|
||||
];
|
||||
|
||||
export function Dashboard({
|
||||
plan,
|
||||
computed,
|
||||
allPlans,
|
||||
plan: openedPlan,
|
||||
planId,
|
||||
computed: openedComputed,
|
||||
siblings,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
planId: string;
|
||||
computed: PlanComputed;
|
||||
allPlans: PlanListItem[];
|
||||
// Die übrigen Szenarien desselben Plans -- nur die sind sinnvoll vergleichbar.
|
||||
siblings: PlanListItem[];
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
}) {
|
||||
// Eine Grafik zur Zeit -- man wählt zuerst, welche (Punkt 5 der Analysen-Ansicht).
|
||||
const [chartId, setChartId] = useState<ChartId>("wealth");
|
||||
|
||||
// Welches Szenario die Grafik zeigt. Gilt für ALLE drei Grafiken -- vorher war das fest an
|
||||
// das geöffnete Szenario gebunden, und nur der Vermögensverlauf konnte weitere dazunehmen.
|
||||
const allScenarios = useMemo<PlanListItem[]>(
|
||||
() => [{ id: openedPlan.id, name: openedPlan.name }, ...siblings],
|
||||
[openedPlan.id, openedPlan.name, siblings]
|
||||
);
|
||||
const [primaryId, setPrimaryId] = useState(openedPlan.id);
|
||||
const [loaded, setLoaded] = useState<Record<string, { plan: PlanInput; computed: PlanComputed }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (primaryId === openedPlan.id || loaded[primaryId]) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const d = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${primaryId}`);
|
||||
if (!cancelled) setLoaded((prev) => ({ ...prev, [primaryId]: { plan: d.plan, computed: d.computed } }));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [primaryId, openedPlan.id, loaded]);
|
||||
|
||||
const basePlan = primaryId === openedPlan.id ? openedPlan : (loaded[primaryId]?.plan ?? openedPlan);
|
||||
const baseComputed = primaryId === openedPlan.id ? openedComputed : (loaded[primaryId]?.computed ?? openedComputed);
|
||||
|
||||
// Gezeigt wird wahlweise der Arbeitsstand oder eine festgehaltene Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(basePlan.id, basePlan);
|
||||
// Der Snapshot ist ein PlanInput -- das Gerechnete entsteht hier lokal (computePlan ist rein).
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
// Version UND Datenquelle bestimmen, was gezeigt wird. Beim Arbeitsstand mit Plandaten
|
||||
// bleibt es beim bereits vom Server Gerechneten -- kein zweiter Lauf nötig.
|
||||
const computed = useMemo(
|
||||
() => (versionId === "current" && basis.source === "PLAN" ? baseComputed : basis.computed),
|
||||
[versionId, baseComputed, basis.source, basis.computed]
|
||||
);
|
||||
|
||||
const [compareIds, setCompareIds] = useState<string[]>([]);
|
||||
const [compareData, setCompareData] = useState<Record<string, PlanComputed>>({});
|
||||
// Vom Nutzer in der Legende gewählte Farben je Serie -- überschreibt die Palette.
|
||||
const [colors, setColors] = useState<Record<string, string>>({});
|
||||
|
||||
async function toggleCompare(id: string) {
|
||||
if (compareIds.includes(id)) {
|
||||
@@ -36,143 +92,222 @@ export function Dashboard({
|
||||
}
|
||||
setCompareIds((prev) => [...prev, id]);
|
||||
if (!compareData[id]) {
|
||||
const data = await api.get<{ computed: PlanComputed }>(`/api/plans/${id}`);
|
||||
const data = await api.get<{ computed: PlanComputed }>(`/api/scenarios/${id}`);
|
||||
setCompareData((prev) => ({ ...prev, [id]: data.computed }));
|
||||
}
|
||||
}
|
||||
|
||||
// Vergleichbar sind alle Szenarien ausser dem gerade gezeigten.
|
||||
const comparable = allScenarios.filter((s) => s.id !== primaryId);
|
||||
|
||||
const series: TimelineSeries[] = useMemo(() => {
|
||||
const result: TimelineSeries[] = [{ label: plan.name, color: PALETTE[0], computed }];
|
||||
const result: TimelineSeries[] = [
|
||||
{ id: plan.id, label: plan.name, color: colors[plan.id] ?? CHART_PALETTE[0], computed },
|
||||
];
|
||||
// Im Ist-Modus die reine Planlinie als gestrichelte Referenz daneben -- ohne Anker sagt
|
||||
// eine Ist-Linie allein nichts aus.
|
||||
if (basis.source === "ACTUAL") {
|
||||
const id = `${plan.id}__plan`;
|
||||
result.push({
|
||||
id,
|
||||
label: `${plan.name} (Plan)`,
|
||||
color: colors[id] ?? CHART_PALETTE[0],
|
||||
computed: basis.planComputed,
|
||||
dashed: true,
|
||||
});
|
||||
}
|
||||
compareIds.forEach((id, i) => {
|
||||
const c = compareData[id];
|
||||
const name = allPlans.find((p) => p.id === id)?.name ?? id;
|
||||
if (c) result.push({ label: name, color: PALETTE[(i + 1) % PALETTE.length], computed: c });
|
||||
const name = allScenarios.find((p) => p.id === id)?.name ?? id;
|
||||
if (c) {
|
||||
result.push({ id, label: name, color: colors[id] ?? CHART_PALETTE[(i + 1) % CHART_PALETTE.length], computed: c });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [plan.name, computed, compareIds, compareData, allPlans]);
|
||||
}, [plan.id, plan.name, computed, compareIds, compareData, allScenarios, basis.source, basis.planComputed, colors]);
|
||||
|
||||
const ASSET_CATS = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
|
||||
// Asset-Elemente (nach id, damit gleiche Namen nicht kollidieren), die irgendwann einen
|
||||
// positiven Wert haben -- in Reihenfolge ihres ersten Auftretens.
|
||||
const assetEls = useMemo(() => {
|
||||
const info = new Map<string, { name: string; any: boolean }>();
|
||||
for (const phase of computed.phases) {
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
const cur = info.get(el.elementId) ?? { name: el.name, any: false };
|
||||
cur.name = el.name;
|
||||
if (el.startValue > 0 || el.endValue > 0) cur.any = true;
|
||||
info.set(el.elementId, cur);
|
||||
}
|
||||
}
|
||||
return [...info.entries()].filter(([, v]) => v.any).map(([id, v]) => ({ id, name: v.name }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [computed]);
|
||||
|
||||
// Je Phase zwei Kategorien auf der x-Achse: Beginn und Ende. Werte je Asset-Element.
|
||||
const barData = useMemo(
|
||||
() =>
|
||||
computed.phases.flatMap((phase) => {
|
||||
const beginn: Record<string, number | string> = { label: `${phase.name} · Beginn` };
|
||||
const ende: Record<string, number | string> = { label: `${phase.name} · Ende` };
|
||||
for (const el of phase.elements) {
|
||||
if (!ASSET_CATS.includes(el.category)) continue;
|
||||
beginn[el.elementId] = Math.max(0, Math.round(el.startValue));
|
||||
ende[el.elementId] = Math.max(0, Math.round(el.endValue));
|
||||
}
|
||||
return [beginn, ende];
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[computed]
|
||||
);
|
||||
|
||||
const otherPlans = allPlans.filter((p) => p.id !== plan.id);
|
||||
const lastPhase = computed.phases[computed.phases.length - 1];
|
||||
const showScenarioPicker = allScenarios.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Endvermoegen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermoegen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
<StatCard label="Geschaetzter Nachlass" value={computed.nachlass} help="Endvermoegen der letzten Phase - potenziell vererbbar." />
|
||||
{/* Szenario-Auswahl: gilt für alle drei Grafiken. */}
|
||||
{showScenarioPicker && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted">Szenario</span>
|
||||
<select
|
||||
value={primaryId}
|
||||
onChange={(e) => setPrimaryId(e.target.value)}
|
||||
className="rounded-lg border border-border bg-input px-2.5 py-1.5 text-sm text-fg focus:border-accent focus:outline-none"
|
||||
>
|
||||
{allScenarios.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VersionBar scenarioId={basePlan.id} versionId={versionId} onChange={setVersionId} loading={versionLoading} />
|
||||
|
||||
<AnalysisBar basis={basis} />
|
||||
|
||||
{/* Grafikauswahl */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
|
||||
{CHART_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setChartId(o.id)}
|
||||
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
|
||||
chartId === o.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SaveAnalysisButton
|
||||
planId={planId}
|
||||
type="CHART"
|
||||
scenarioName={plan.name}
|
||||
versionLabel={versionId === "current" ? null : versionId}
|
||||
build={() => buildChartResult(chartId, computed, series, basis)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Einkommen vs. Ausgaben pro Jahr
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Die Flaeche zwischen Einkommen und nominalen Ausgaben ist die Spar- (gruen) bzw. Verzehrquote (rot).
|
||||
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||
</p>
|
||||
<SparquoteChart computed={computed} />
|
||||
</section>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard label="Endvermögen (nominal)" value={lastPhase ? lastPhase.endWealthNominal : 0} />
|
||||
<StatCard label="Endvermögen (real, kaufkraftbereinigt)" value={lastPhase ? lastPhase.endWealthReal : 0} />
|
||||
</div>
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
{chartId === "cashflow" && (
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Vermoegensverlauf nach Alter
|
||||
Einkommen vs. Ausgaben pro Jahr · {plan.name}
|
||||
</h3>
|
||||
<a
|
||||
href={`/api/plans/${plan.id}/export`}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted hover:bg-surface-2"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
CSV-Export
|
||||
</a>
|
||||
</div>
|
||||
{otherPlans.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted">Vergleichen mit:</span>
|
||||
{otherPlans.map((p) => (
|
||||
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={compareIds.includes(p.id)}
|
||||
onChange={() => toggleCompare(p.id)}
|
||||
/>
|
||||
{p.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<WealthChart series={series} />
|
||||
</section>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Die Fläche zwischen Einkommen und nominalen Ausgaben ist die Spar- bzw. Verzehrquote.
|
||||
Die blasse Linie sind die realen Ausgaben – der Abstand zur nominalen Linie ist der Inflationsanteil.
|
||||
</p>
|
||||
<SparquoteChart computed={computed} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<BarChart3 className="h-4 w-4 text-accent" />
|
||||
Vermoegensaufteilung pro Phase (Beginn & Ende)
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Je Phase links die Aufteilung zu Beginn, rechts am Ende. Das Ende einer Phase entspricht im
|
||||
Gesamtvolumen dem Beginn der naechsten – die Aufteilung kann durch Umschichtung abweichen.
|
||||
</p>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={barData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 10 }} interval={0} angle={-30} textAnchor="end" height={70} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip formatter={(v) => (typeof v === "number" ? formatChf(v) : v)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{assetEls.map((el, i) => (
|
||||
<Bar key={el.id} dataKey={el.id} name={el.name} stackId="a" fill={PALETTE[i % PALETTE.length]} />
|
||||
{chartId === "wealth" && (
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<LineChartIcon className="h-4 w-4 text-accent" />
|
||||
Vermögensverlauf nach Alter
|
||||
</h3>
|
||||
</div>
|
||||
{comparable.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<span className="text-xs text-muted">Weitere Szenarien überlagern:</span>
|
||||
{comparable.map((p) => (
|
||||
<label key={p.id} className="flex items-center gap-1 text-xs text-muted">
|
||||
<input type="checkbox" checked={compareIds.includes(p.id)} onChange={() => toggleCompare(p.id)} />
|
||||
{p.name}
|
||||
</label>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
<WealthChart
|
||||
series={series}
|
||||
metric={basis.metric}
|
||||
onColorChange={(id, color) => setColors((prev) => ({ ...prev, [id]: color }))}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{chartId === "allocation" && (
|
||||
<section className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-fg">
|
||||
<BarChart3 className="h-4 w-4 text-accent" />
|
||||
Vermögensaufteilung im Zeitverlauf · {plan.name}
|
||||
</h3>
|
||||
<p className="mb-3 text-xs text-muted">
|
||||
Links der Verlauf über alle Planjahre (gestapelt; die gestrichelten Linien sind die Phasengrenzen), rechts
|
||||
die relative Aufteilung zu einem wählbaren Zeitpunkt.
|
||||
</p>
|
||||
<AllocationChart computed={computed} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Momentaufnahme der gerade gezeigten Grafik als Zahlen (siehe analyses.ts).
|
||||
function buildChartResult(
|
||||
chartId: ChartId,
|
||||
computed: PlanComputed,
|
||||
series: TimelineSeries[],
|
||||
basis: { metric: string; source: string },
|
||||
): { metric?: string; source?: string; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
const label = CHART_OPTIONS.find((o) => o.id === chartId)!.label;
|
||||
const common = {
|
||||
metric: basis.metric,
|
||||
source: basis.source,
|
||||
summary: label,
|
||||
inputs: { params: [{ label: "Grafik", value: label }, { label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }] },
|
||||
};
|
||||
if (chartId === "wealth") {
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: [{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" }],
|
||||
chart: {
|
||||
kind: "line" as const,
|
||||
xLabel: "Alter",
|
||||
yFormat: "chf" as const,
|
||||
series: series.map((s) => ({
|
||||
label: s.label,
|
||||
dashed: s.dashed,
|
||||
points: s.computed.yearly.map((y) => ({ x: y.age, y: basis.metric === "real" ? y.wealthReal : y.wealthNominal })),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (chartId === "cashflow") {
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: common.inputs.params,
|
||||
chart: {
|
||||
kind: "line" as const,
|
||||
xLabel: "Jahr",
|
||||
yFormat: "chf" as const,
|
||||
series: [
|
||||
{ label: "Einkommen", color: "var(--chart-3)", points: computed.yearly.map((y) => ({ x: y.year, y: y.income })) },
|
||||
{ label: "Ausgaben", color: "var(--chart-5)", points: computed.yearly.map((y) => ({ x: y.year, y: y.expenseNominal })) },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
// Allokation: Endvermögen je Phase als Balken (kompakte, druckbare Zusammenfassung).
|
||||
return {
|
||||
...common,
|
||||
result: {
|
||||
params: common.inputs.params,
|
||||
table: {
|
||||
columns: ["Phase", "Vermögen Ende (nom.)", "real"],
|
||||
rows: computed.phases.map((p) => [p.name, formatChf(p.endWealthNominal), formatChf(p.endWealthReal)]),
|
||||
},
|
||||
chart: {
|
||||
kind: "bar" as const,
|
||||
yFormat: "chf" as const,
|
||||
series: [{ label: "Vermögen Phasenende", points: computed.phases.map((p, i) => ({ x: i + 1, y: p.endWealthNominal })) }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function StatCard({ label, value, help }: { label: string; value: number; help?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { BookOpen, X } from "lucide-react";
|
||||
import { AllocationChart } from "@/components/AllocationChart";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type {
|
||||
CashBridge,
|
||||
ElementYearPoint,
|
||||
PhaseComputed,
|
||||
PlanComputed,
|
||||
Trace,
|
||||
TraceStep,
|
||||
WealthBridge,
|
||||
} from "@/lib/calculations";
|
||||
|
||||
// --- Wasserfall ---------------------------------------------------------------------------
|
||||
// Bewusst NICHT mit Recharts, sondern als eigene HTML/CSS-Darstellung. Ein Wasserfall lebt
|
||||
// von drei Dingen, die Recharts hier nicht hergibt: Verbindungslinien zwischen den Balken
|
||||
// (ohne sie sieht man nicht, dass jeder Balken dort ansetzt, wo der vorherige aufhört),
|
||||
// Wertbeschriftung an jedem Balken, und eine klare optische Trennung von Zwischenständen
|
||||
// und Veränderungen.
|
||||
//
|
||||
// Liegend statt stehend: Die Beschriftungen sind lang ("Wertsteigerung Immobilie"), stehend
|
||||
// müssten sie gedreht werden. Liegend ist es ausserdem konsistent zum Tornado.
|
||||
|
||||
interface WaterfallItem {
|
||||
label: string;
|
||||
value: number;
|
||||
total?: boolean; // Zwischen-/Endsumme: absoluter Stand statt Veränderung
|
||||
section?: string; // optionale Abschnitts-Überschrift VOR diesem Eintrag
|
||||
}
|
||||
|
||||
interface WaterfallRow {
|
||||
label: string;
|
||||
section?: string;
|
||||
from: number;
|
||||
to: number;
|
||||
value: number;
|
||||
running: number; // Stand NACH diesem Schritt
|
||||
kind: "total" | "pos" | "neg";
|
||||
}
|
||||
|
||||
function waterfallRows(items: WaterfallItem[]): WaterfallRow[] {
|
||||
let running = 0;
|
||||
return items.map((it) => {
|
||||
if (it.total) {
|
||||
running = it.value;
|
||||
return { label: it.label, section: it.section, from: 0, to: it.value, value: it.value, running, kind: "total" as const };
|
||||
}
|
||||
const from = running;
|
||||
running += it.value;
|
||||
return {
|
||||
label: it.label,
|
||||
section: it.section,
|
||||
from,
|
||||
to: running,
|
||||
value: it.value,
|
||||
running,
|
||||
kind: (it.value >= 0 ? "pos" : "neg") as "pos" | "neg",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const WF_FILL = { total: "var(--accent)", pos: "#16a34a", neg: "#dc2626" };
|
||||
|
||||
const ROW_H = 34;
|
||||
const BAR_H = 20;
|
||||
|
||||
function Waterfall({ items }: { items: WaterfallItem[] }) {
|
||||
const rows = useMemo(() => waterfallRows(items), [items]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const lo = Math.min(0, ...rows.map((r) => Math.min(r.from, r.to)));
|
||||
const hi = Math.max(0, ...rows.map((r) => Math.max(r.from, r.to)));
|
||||
const span = hi - lo || 1;
|
||||
const pos = (v: number) => ((v - lo) / span) * 100;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
{rows.map((r, i) => {
|
||||
const left = pos(Math.min(r.from, r.to));
|
||||
const width = Math.max(0.4, Math.abs(pos(r.to) - pos(r.from)));
|
||||
const isLast = i === rows.length - 1;
|
||||
return (
|
||||
<div key={`${r.label}-${i}`}>
|
||||
{r.section && (
|
||||
<div className="border-b border-border bg-surface-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
||||
{r.section}
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-stretch ${r.kind === "total" ? "bg-surface-2" : ""}`}>
|
||||
<div
|
||||
className={`w-44 shrink-0 border-r border-border px-3 py-2 text-[11px] leading-tight ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</div>
|
||||
<div className="relative min-w-0 flex-1" style={{ height: ROW_H }}>
|
||||
{/* Nulllinie */}
|
||||
<div
|
||||
className="absolute top-0 h-full border-l border-dashed border-border"
|
||||
style={{ left: `${pos(0)}%` }}
|
||||
/>
|
||||
{/* Balken */}
|
||||
<div
|
||||
className="absolute rounded-sm"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: (ROW_H - BAR_H) / 2,
|
||||
height: BAR_H,
|
||||
backgroundColor: WF_FILL[r.kind],
|
||||
opacity: r.kind === "total" ? 0.85 : 1,
|
||||
}}
|
||||
title={`${r.label}: ${formatChf(r.value)}`}
|
||||
/>
|
||||
{/* Verbindungslinie zum nächsten Balken: auf dem Stand NACH diesem Schritt */}
|
||||
{!isLast && (
|
||||
<div
|
||||
className="absolute border-l border-dotted border-faint"
|
||||
style={{
|
||||
left: `${pos(r.to)}%`,
|
||||
top: (ROW_H + BAR_H) / 2,
|
||||
height: (ROW_H - BAR_H) / 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`w-28 shrink-0 border-l border-border px-3 py-2 text-right text-[11px] tabular-nums ${
|
||||
r.kind === "total" ? "font-semibold text-fg" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? formatChf(r.value) : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Zahlen mit laufendem Zwischenstand -- bei stark unterschiedlichen Grössenordnungen
|
||||
ist die Tabelle der Grafik überlegen. */}
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-[11px] text-muted hover:text-fg">Zahlen mit Zwischenstand anzeigen</summary>
|
||||
<div className="mt-2 overflow-x-auto rounded-lg border border-border">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-[10px] uppercase tracking-wide text-faint">
|
||||
<th className="px-3 py-1.5 text-left font-semibold">Schritt</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Betrag</th>
|
||||
<th className="px-3 py-1.5 text-right font-semibold">Zwischenstand</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className={`border-t border-border ${r.kind === "total" ? "bg-surface-2 font-semibold" : ""}`}>
|
||||
<td className="px-3 py-1.5 text-fg">{r.label}</td>
|
||||
<td
|
||||
className={`px-3 py-1.5 text-right tabular-nums ${
|
||||
r.kind === "total" ? "text-faint" : r.kind === "neg" ? "text-danger" : "text-success"
|
||||
}`}
|
||||
>
|
||||
{r.kind === "total" ? "—" : `${r.value >= 0 ? "+" : "−"}${formatChf(Math.abs(r.value))}`}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right tabular-nums text-fg">{formatChf(r.running)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Kontrollgrösse: Ist die Zerlegung vollständig, muss die Differenz zwischen Endwert und
|
||||
// der Summe der Schritte 0 sein. Sichtbar machen statt verstecken -- ein Wasserfall, der
|
||||
// nicht aufgeht, ist ein Fehler und kein Schönheitsproblem.
|
||||
function ResidualNote({ residual }: { residual: number }) {
|
||||
if (Math.abs(residual) <= 2) return null;
|
||||
return (
|
||||
<p className="mt-2 rounded-lg border border-danger bg-danger-soft px-3 py-2 text-[11px] text-danger">
|
||||
<strong>Die Zerlegung geht nicht auf.</strong> Nicht zugeordnete Differenz: {formatChf(residual)}. Bitte melden –
|
||||
das ist ein Fehler in der Berechnung, nicht in der Darstellung.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeItems(w: WealthBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
if (!isFirst) {
|
||||
items.push({ label: "Vermögen Ende Vorphase", value: w.openingWealth, total: true, section: "Am Übergang in diese Phase" });
|
||||
if (w.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: w.oneOffInflow });
|
||||
if (w.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -w.oneOffOutflow });
|
||||
if (w.transitionTax) items.push({ label: "Steuern am Übergang", value: -w.transitionTax });
|
||||
if (w.pensionConversion) items.push({ label: "PK in Rente umgewandelt", value: -w.pensionConversion });
|
||||
if (w.saleGainLoss) items.push({ label: "Verkaufsdifferenz", value: w.saleGainLoss });
|
||||
}
|
||||
items.push({
|
||||
label: "Vermögen Phasenbeginn",
|
||||
value: w.startWealth,
|
||||
total: true,
|
||||
section: isFirst ? undefined : "Innerhalb der Phase",
|
||||
});
|
||||
if (w.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: w.quotaTotal });
|
||||
if (w.investmentReturn) items.push({ label: "Kapitalerträge", value: w.investmentReturn });
|
||||
if (w.propertyAppreciation) items.push({ label: "Wertsteigerung Immobilie", value: w.propertyAppreciation });
|
||||
if (w.pensionFundContribution) items.push({ label: "PK-Beiträge", value: w.pensionFundContribution });
|
||||
items.push({ label: "Vermögen Phasenende", value: w.endWealth, total: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
function cashItems(c: CashBridge, isFirst: boolean): WaterfallItem[] {
|
||||
const items: WaterfallItem[] = [];
|
||||
items.push({
|
||||
label: isFirst ? "Cash-Anfangswert" : "Cash Ende Vorphase",
|
||||
value: c.openingCash,
|
||||
total: true,
|
||||
section: isFirst ? undefined : "Am Übergang in diese Phase",
|
||||
});
|
||||
if (c.capitalInflow) items.push({ label: "Kapitalzufluss", value: c.capitalInflow });
|
||||
if (c.oneOffInflow) items.push({ label: "Einmaliger Zufluss", value: c.oneOffInflow });
|
||||
if (c.immediateRepay) items.push({ label: "Sofort-Tilgung", value: -c.immediateRepay });
|
||||
if (c.oneOffOutflow) items.push({ label: "Einmalige Kosten", value: -c.oneOffOutflow });
|
||||
if (c.investments) items.push({ label: "Investitionen", value: -c.investments });
|
||||
items.push({ label: "Cash Phasenbeginn", value: c.cashStart, total: true, section: "Innerhalb der Phase" });
|
||||
if (c.quotaTotal) items.push({ label: "Spar-/Verzehrquote", value: c.quotaTotal });
|
||||
if (c.savingRates) items.push({ label: "Sparraten", value: -c.savingRates });
|
||||
if (c.debtRates) items.push({ label: "Amort./Tilgung", value: -c.debtRates });
|
||||
if (c.withdrawals) items.push({ label: "Bezugsraten", value: c.withdrawals });
|
||||
items.push({ label: "Cash Phasenende", value: c.cashEnd, total: true });
|
||||
return items;
|
||||
}
|
||||
|
||||
// --- Rechenweg ----------------------------------------------------------------------------
|
||||
|
||||
function TraceStepRow({ step }: { step: TraceStep }) {
|
||||
const unit = step.unit ?? "CHF";
|
||||
const value =
|
||||
unit === "" ? step.substituted ?? "" : unit === "%" ? `${step.result} %` : unit === "Jahre" ? `${step.result}` : formatChf(step.result);
|
||||
return (
|
||||
<tr className="border-t border-border align-top">
|
||||
<td className="px-3 py-1.5 text-fg">{step.label}</td>
|
||||
<td className="px-3 py-1.5 text-[11px] text-muted">
|
||||
{step.formula && <div className="font-mono">{step.formula}</div>}
|
||||
{step.substituted && unit !== "" && <div className="font-mono text-faint">{step.substituted}</div>}
|
||||
{step.note && <div className="mt-0.5 italic text-faint">{step.note}</div>}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-1.5 text-right font-medium text-fg">{value}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceBlock({ trace, onOpenSpec }: { trace: Trace; onOpenSpec?: (anchor: string) => void }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-surface-2 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-fg">{trace.title}</span>
|
||||
{trace.specAnchor && onOpenSpec && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSpec(trace.specAnchor!)}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-muted hover:bg-surface"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" /> in der Spezifikation
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
{trace.steps.map((s, i) => (
|
||||
<TraceStepRow key={i} step={s} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Gemeinsame Dialog-Huelle mit Reitern --------------------------------------------------
|
||||
|
||||
function DetailShell({
|
||||
title,
|
||||
subtitle,
|
||||
tabs,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs: { key: string; label: string; content: React.ReactNode }[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [active, setActive] = useState(tabs[0]?.key);
|
||||
const current = tabs.find((t) => t.key === active) ?? tabs[0];
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border border-border bg-surface p-0.5 text-xs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setActive(t.key)}
|
||||
className={`rounded-md px-3 py-1 font-medium ${
|
||||
current?.key === t.key ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">{current?.content}</div>
|
||||
|
||||
<p className="text-[11px] text-faint">Nur-Lese-Ansicht. Werte werden hier nicht verändert.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Element-Detailansicht ----------------------------------------------------------------
|
||||
|
||||
const MULTI_SERIES: ElementCategory[] = ["REAL_ESTATE"];
|
||||
|
||||
export function ElementDetailDialog({
|
||||
elementId,
|
||||
name,
|
||||
category,
|
||||
computed,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
elementId: string;
|
||||
name: string;
|
||||
category: ElementCategory;
|
||||
computed: PlanComputed;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
// Verlauf über ALLE Phasen zusammensetzen.
|
||||
const points = useMemo(() => {
|
||||
const out: ElementYearPoint[] = [];
|
||||
for (const ph of computed.phases) {
|
||||
const ec = ph.elements.find((e) => e.elementId === elementId);
|
||||
if (ec) out.push(...ec.yearly);
|
||||
}
|
||||
return out;
|
||||
}, [computed, elementId]);
|
||||
|
||||
const perPhase = useMemo(
|
||||
() =>
|
||||
computed.phases
|
||||
.map((ph) => ({ phase: ph, ec: ph.elements.find((e) => e.elementId === elementId) }))
|
||||
.filter((x) => x.ec),
|
||||
[computed, elementId]
|
||||
);
|
||||
|
||||
const isFlow = category === "INCOME" || category === "EXPENSE";
|
||||
const valueLabel = isFlow
|
||||
? category === "INCOME"
|
||||
? "Einkommen (nominal)"
|
||||
: "Ausgaben (nominal)"
|
||||
: category === "OTHER_DEBT"
|
||||
? "Beitrag zum Vermögen"
|
||||
: category === "REAL_ESTATE"
|
||||
? "Eigenkapital"
|
||||
: "Wert";
|
||||
|
||||
// Zweite Achse nur, wenn dieses Element überhaupt eine Rate trägt (AHV und Schulden nicht).
|
||||
const rateLabel =
|
||||
category === "REAL_ESTATE"
|
||||
? "Wertsteigerung"
|
||||
: isFlow
|
||||
? category === "INCOME"
|
||||
? "Lohnentwicklung"
|
||||
: "Reale Mehrausgaben"
|
||||
: "Erwartete Rendite";
|
||||
const hasRate = points.some((p) => typeof p.rate === "number");
|
||||
|
||||
const verlauf = (
|
||||
<>
|
||||
{points.length === 0 ? (
|
||||
<p className="text-sm text-muted">Für dieses Element gibt es in diesem Plan keinen Verlauf.</p>
|
||||
) : (
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={points} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis yAxisId="chf" tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
{hasRate && (
|
||||
<YAxis
|
||||
yAxisId="rate"
|
||||
orientation="right"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => `${v} %`}
|
||||
// Etwas Luft nach oben und unten, damit eine konstante Rate nicht als
|
||||
// Linie direkt auf der Achse klebt.
|
||||
domain={([min, max]: readonly [number, number]) => [Math.min(0, min - 1), max + 1]}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
formatter={(v, n) =>
|
||||
typeof v !== "number" ? [v, n] : n === rateLabel ? [`${v} %`, n] : [formatChf(v), n]
|
||||
}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line yAxisId="chf" dataKey="value" name={valueLabel} stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
{MULTI_SERIES.includes(category) && (
|
||||
<>
|
||||
<Line yAxisId="chf" dataKey="propertyValue" name="Verkehrswert" stroke="#0ea5e9" strokeWidth={1.5} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="chf" dataKey="mortgage" name="Restschuld" stroke="#dc2626" strokeWidth={1.5} strokeDasharray="5 3" dot={false} isAnimationActive={false} />
|
||||
</>
|
||||
)}
|
||||
{hasRate && (
|
||||
// Stufenlinie, nicht interpoliert: Die Rate ist innerhalb einer Phase
|
||||
// konstant und springt an der Phasengrenze. Eine weiche Kurve würde einen
|
||||
// gleitenden Übergang suggerieren, den die Berechnung nicht macht.
|
||||
<Line
|
||||
yAxisId="rate"
|
||||
type="stepAfter"
|
||||
dataKey="rate"
|
||||
name={rateLabel}
|
||||
stroke="#d97706"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 2"
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Lebensphase</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Beginn</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ende</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Hinweis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{perPhase.map(({ phase, ec }) => (
|
||||
<tr key={phase.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 text-fg">{phase.name}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.startValue)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(ec!.endValue)}</td>
|
||||
<td className="px-3 py-2 text-[11px] text-faint">{ec!.note ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const rechenweg = (
|
||||
<>
|
||||
{perPhase.every(({ ec }) => !ec!.trace && !ec!.transitionTrace) && (
|
||||
<p className="text-sm text-muted">Für dieses Element gibt es keinen eigenen Rechenweg.</p>
|
||||
)}
|
||||
{perPhase.map(({ phase, ec }) => (
|
||||
<div key={phase.id} className="flex flex-col gap-2">
|
||||
{(ec!.trace || ec!.transitionTrace) && (
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{phase.name}</div>
|
||||
)}
|
||||
{ec!.trace && <TraceBlock trace={ec!.trace} onOpenSpec={onOpenSpec} />}
|
||||
{ec!.transitionTrace && <TraceBlock trace={ec!.transitionTrace} onOpenSpec={onOpenSpec} />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
title={name}
|
||||
subtitle={CATEGORY_LABELS[category]}
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{ key: "verlauf", label: "Verlauf", content: verlauf },
|
||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Phasen-Detailansicht ------------------------------------------------------------------
|
||||
|
||||
const ASSET_CATS: ElementCategory[] = ["PENSION_FUND", "PILLAR_3A", "REAL_ESTATE", "OTHER_ASSET"];
|
||||
|
||||
export function PhaseDetailDialog({
|
||||
phase,
|
||||
computed,
|
||||
isFirst,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
phase: PhaseComputed;
|
||||
// Der ganze Plan -- die Aufteilungs-Grafik zeichnet daraus die Jahreswerte dieser Phase.
|
||||
computed: PlanComputed;
|
||||
isFirst: boolean;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
const allocEls = useMemo(
|
||||
() => phase.elements.filter((e) => ASSET_CATS.includes(e.category) && (e.startValue > 0 || e.endValue > 0)),
|
||||
[phase]
|
||||
);
|
||||
|
||||
const übersicht = (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Kpi label="Typ" text={phase.type === "ERWERB" ? "Erwerb" : phase.type === "PENSION" ? "Pension" : "Misch"} />
|
||||
<Kpi label="Dauer" text={`${phase.durationYears} Jahre`} />
|
||||
<Kpi label="Vermögen Beginn" text={formatChf(phase.startWealthNominal)} />
|
||||
<Kpi label="Vermögen Ende" text={formatChf(phase.endWealthNominal)} />
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Vermögensaufteilung</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">Zusammensetzung des Anlagevermögens über die Jahre dieser Phase; rechts die relative Aufteilung zu einem wählbaren Zeitpunkt.</p>
|
||||
{allocEls.length === 0 ? (
|
||||
<p className="text-sm text-muted">In dieser Phase gibt es kein Anlagevermögen.</p>
|
||||
) : (
|
||||
// Dieselbe Darstellung wie im Grafiken-Bereich, nur auf diese Phase eingeschränkt.
|
||||
<AllocationChart computed={computed} onlyPhaseId={phase.id} height={240} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const wasserfall = (
|
||||
<>
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Woher kommt die Vermögensänderung?</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Nur <strong>echte</strong> Zu- und Abgänge. Sparraten, Amortisationen und Zusatzinvestitionen erscheinen hier
|
||||
bewusst <strong>nicht</strong>: Sie verschieben Geld vom Cash in einen Vermögenswert, ohne das Vermögen zu
|
||||
verändern – als Balken gezeichnet würden sie einen Verlust vortäuschen, den es nicht gibt.
|
||||
</p>
|
||||
<Waterfall items={bridgeItems(phase.wealthBridge, isFirst)} />
|
||||
<ResidualNote residual={phase.wealthBridge.residual} />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 text-xs font-semibold text-fg">Wohin ist das Cash geflossen?</h3>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Hier erscheinen die Umbuchungen: Sparraten, Amortisationen und Investitionen verlassen das Cash-Konto, auch
|
||||
wenn sie das Vermögen nicht mindern.
|
||||
</p>
|
||||
<Waterfall items={cashItems(phase.cashBridge, isFirst)} />
|
||||
<ResidualNote residual={phase.cashBridge.residual} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
const rechenweg = (
|
||||
<>
|
||||
{(phase.traces ?? []).length === 0 && <p className="text-sm text-muted">Kein Rechenweg verfügbar.</p>}
|
||||
{(phase.traces ?? []).map((t, i) => (
|
||||
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
title={phase.name}
|
||||
subtitle={`Lebensphase ${phase.sequenceNumber} · ${phase.durationYears} Jahre`}
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{ key: "übersicht", label: "Übersicht", content: übersicht },
|
||||
{ key: "wasserfall", label: "Wasserfall", content: wasserfall },
|
||||
{ key: "rechenweg", label: "Rechenweg", content: rechenweg },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({ label, text }: { label: string; text: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[11px] text-muted">{label}</div>
|
||||
<div className="text-sm font-semibold text-fg">{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Plan-weite Rechenwege (Deflatoren, AHV-Karriere, Ruinalter) ---------------------------
|
||||
|
||||
export function PlanTraceDialog({
|
||||
computed,
|
||||
onClose,
|
||||
onOpenSpec,
|
||||
}: {
|
||||
computed: PlanComputed;
|
||||
onClose: () => void;
|
||||
onOpenSpec?: (anchor: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<DetailShell
|
||||
title="Rechenwege dieses Szenarios"
|
||||
subtitle="Plan-weite Grössen, die in alle Phasen hineinwirken"
|
||||
onClose={onClose}
|
||||
tabs={[
|
||||
{
|
||||
key: "plan",
|
||||
label: "Plan-Ebene",
|
||||
content: (
|
||||
<>
|
||||
{(computed.traces ?? []).map((t, i) => (
|
||||
<TraceBlock key={i} trace={t} onOpenSpec={onOpenSpec} />
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
"use client";
|
||||
|
||||
// Zwei Verteil-Werkzeuge im Phasenkopf:
|
||||
// 1. "Kapital verteilen" -- der beim Übergang zugeflossene Topf (Verkäufe, Bezüge,
|
||||
// Erbschaft, Cash der Vorphase) auf Zusatzeinlagen, Sonderamortisation und Sofort-
|
||||
// Tilgung verteilen; der Rest bleibt automatisch auf dem Cash.
|
||||
// 2. "Sparquote verteilen" -- die laufende Spar- bzw. Verzehrquote auf jährliche Raten
|
||||
// verteilen.
|
||||
//
|
||||
// Beide schreiben ausschliesslich BESTEHENDE Felder über die bestehenden Endpunkte. Die
|
||||
// Live-Vorschau entsteht, indem der Plan mit den Entwurfswerten kopiert und erneut durch
|
||||
// computePlan geschickt wird -- die angezeigte Wirkung ist dadurch per Konstruktion exakt
|
||||
// die spätere, inklusive aller Kappungen.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Coins, PiggyBank } from "lucide-react";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { MoneyField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { CarryWarning } from "@/components/ElementDetail";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { PILLAR_3A_MAX_ANNUAL, PILLAR_3A_MAX_SELF_EMPLOYED } from "@/lib/constants";
|
||||
import {
|
||||
CATEGORY_LABELS,
|
||||
inheritedPhaseValues,
|
||||
num,
|
||||
ownerLabel,
|
||||
type PhaseData,
|
||||
type TransitionData,
|
||||
} from "@/lib/elements";
|
||||
import {
|
||||
applyPhasePatches,
|
||||
applyTransitionPatches,
|
||||
capitalPot,
|
||||
quotaSummary,
|
||||
type PhasePatch,
|
||||
type TransitionPatch,
|
||||
} from "@/lib/distribution";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// --- gemeinsame Bausteine -----------------------------------------------------------------
|
||||
|
||||
function SummaryRow({
|
||||
label,
|
||||
value,
|
||||
help,
|
||||
strong,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
help?: string;
|
||||
strong?: boolean;
|
||||
tone?: "danger" | "success" | "muted";
|
||||
}) {
|
||||
const color = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : strong ? "text-fg" : "text-muted";
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 whitespace-nowrap">
|
||||
<span className={`flex items-center text-xs ${strong ? "font-semibold text-fg" : "text-muted"}`}>
|
||||
{label}
|
||||
{help && <InfoBubble text={help} />}
|
||||
</span>
|
||||
<span className={`tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${color}`}>
|
||||
{formatChf(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 1. Kapital verteilen -----------------------------------------------------------------
|
||||
|
||||
interface CapitalTarget {
|
||||
elementId: string;
|
||||
name: string;
|
||||
owner: string;
|
||||
category: string;
|
||||
kind: "investment" | "amortization" | "repayment";
|
||||
max: number | undefined; // Kappung (Restschuld); bei Investitionen unbegrenzt
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export function CapitalDistributionDialog({
|
||||
plan,
|
||||
computed,
|
||||
phaseId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: ReturnType<typeof computePlan>;
|
||||
phaseId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const phaseIndex = computed.phases.findIndex((p) => p.id === phaseId);
|
||||
const phase = computed.phases[phaseIndex];
|
||||
const prevPhase = computed.phases[phaseIndex - 1];
|
||||
|
||||
// Ziele bestimmen. Zusatzeinlagen liegen in DIESER Phase, Sonderamortisation und
|
||||
// Sofort-Tilgung dagegen am ÜBERGANG davor (also an der Vorphase) -- deshalb werden beim
|
||||
// Speichern zwei verschiedene Endpunkte angesprochen.
|
||||
const targets = useMemo<CapitalTarget[]>(() => {
|
||||
const out: CapitalTarget[] = [];
|
||||
for (const e of plan.elements) {
|
||||
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||
const prevCe = prevPhase?.elements.find((x) => x.elementId === e.id);
|
||||
if (!ce || ce.status !== "ACTIVE") continue;
|
||||
// Die Rechnung ignoriert Einlagen in eine bezogene PK bzw. 3a. Solche Zeilen dürfen
|
||||
// deshalb gar nicht erst erscheinen -- sonst trägt man Beträge ein, die folgenlos
|
||||
// bleiben, und der Entscheid aus dem Übergang wirkt hier scheinbar nicht.
|
||||
if (ce.acceptsCapital === false) continue;
|
||||
|
||||
if (e.category === "PENSION_FUND" || e.category === "PILLAR_3A" || e.category === "OTHER_ASSET") {
|
||||
out.push({
|
||||
elementId: e.id,
|
||||
name: e.name,
|
||||
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||
category: e.category,
|
||||
kind: "investment",
|
||||
max: undefined,
|
||||
hint: "Zusatzeinlage aus dem verfügbaren Kapital – erhöht den Startwert in dieser Phase.",
|
||||
});
|
||||
} else if (e.category === "REAL_ESTATE") {
|
||||
const rest = prevCe?.mortgageEnd ?? 0;
|
||||
// Beim Verkauf gibt es nichts mehr zu amortisieren.
|
||||
const sold = e.transitionValues[prevPhase?.id ?? ""]?.decision === "SELL";
|
||||
if (rest > 0 && !sold) {
|
||||
out.push({
|
||||
elementId: e.id,
|
||||
name: e.name,
|
||||
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||
category: e.category,
|
||||
kind: "amortization",
|
||||
max: rest,
|
||||
hint: `Sonderamortisation: Einmaltilgung der Hypothek am Übergang. Maximal ${formatChf(rest)} (Resthypothek).`,
|
||||
});
|
||||
}
|
||||
} else if (e.category === "OTHER_DEBT") {
|
||||
const owed = prevCe ? Math.abs(Math.min(0, prevCe.endValue)) : 0;
|
||||
if (owed > 0) {
|
||||
out.push({
|
||||
elementId: e.id,
|
||||
name: e.name,
|
||||
owner: ownerLabel(plan.persons, e.ownerRole),
|
||||
category: e.category,
|
||||
kind: "repayment",
|
||||
max: owed,
|
||||
hint: `Sofortige Tilgung am Übergang. Maximal ${formatChf(owed)} (Restschuld).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase/prevPhase folgen phaseId
|
||||
}, [plan.elements, phaseId]);
|
||||
|
||||
// Entwurf mit den aktuell gespeicherten Werten vorbelegen.
|
||||
const [draft, setDraft] = useState<Record<string, number>>(() => {
|
||||
const d: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||
d[t.elementId] =
|
||||
t.kind === "investment"
|
||||
? Math.round(num(e.phaseValues[phaseId]?.additionalInvestment))
|
||||
: t.kind === "amortization"
|
||||
? Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.extraAmortization))
|
||||
: Math.round(num(e.transitionValues[prevPhase?.id ?? ""]?.immediateRepayment));
|
||||
}
|
||||
return d;
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Live-Vorschau: Plan mit den Entwurfswerten neu rechnen.
|
||||
const preview = useMemo(() => {
|
||||
const phasePatches: PhasePatch[] = targets
|
||||
.filter((t) => t.kind === "investment")
|
||||
.map((t) => ({ elementId: t.elementId, field: "additionalInvestment" as const, value: draft[t.elementId] ?? 0 }));
|
||||
const transPatches: TransitionPatch[] = targets
|
||||
.filter((t) => t.kind !== "investment")
|
||||
.map((t) => ({
|
||||
elementId: t.elementId,
|
||||
field: (t.kind === "amortization" ? "extraAmortization" : "immediateRepayment") as keyof TransitionData,
|
||||
value: draft[t.elementId] ?? 0,
|
||||
}));
|
||||
let next = applyPhasePatches(plan, phaseId, phasePatches);
|
||||
if (prevPhase) next = applyTransitionPatches(next, prevPhase.id, transPatches);
|
||||
const c = computePlan(next);
|
||||
return c.phases.find((p) => p.id === phaseId)!;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draft, plan, phaseId, targets]);
|
||||
|
||||
const pot = capitalPot(preview);
|
||||
const laterPhases = computed.phases.length - (phase?.sequenceNumber ?? 0);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const t of targets) {
|
||||
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||
const value = draft[t.elementId] ?? 0;
|
||||
if (t.kind === "investment") {
|
||||
// Bestehende Felder der Phase erhalten -- der Endpunkt ersetzt den ganzen Satz.
|
||||
const merged: PhaseData = { ...(e.phaseValues[phaseId] ?? {}), additionalInvestment: value };
|
||||
await api.put(`/api/elements/${e.id}/phase/${phaseId}`, merged);
|
||||
} else if (prevPhase) {
|
||||
const existing: TransitionData = e.transitionValues[prevPhase.id] ?? {};
|
||||
const merged: TransitionData =
|
||||
t.kind === "amortization"
|
||||
? { ...existing, extraAmortization: value }
|
||||
: { ...existing, immediateRepayment: value };
|
||||
await api.put(`/api/elements/${e.id}/transition/${prevPhase.id}`, merged);
|
||||
}
|
||||
}
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Kapital verteilen"
|
||||
subtitle={phase ? `${phase.name} · verfügbares Kapital beim Übergang in diese Phase` : undefined}
|
||||
onClose={onClose}
|
||||
wide
|
||||
>
|
||||
{/* Herkunft des Topfs */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
<Coins className="h-4 w-4" /> Woher das Kapital kommt
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SummaryRow label="Cash-Ende der Vorphase" value={pot.fromPreviousCash} />
|
||||
{pot.capitalInflow !== 0 && (
|
||||
<SummaryRow label="Kapitalzufluss (Verkäufe, PK-/3a-Bezüge)" value={pot.capitalInflow} />
|
||||
)}
|
||||
{pot.oneOffInflow !== 0 && <SummaryRow label="Einmaliger Zufluss" value={pot.oneOffInflow} />}
|
||||
{pot.oneOffOutflow !== 0 && <SummaryRow label="Einmalige Kosten" value={-pot.oneOffOutflow} tone="danger" />}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<SummaryRow label="Verfügbares Kapital" value={pot.total} strong />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
In dieser Phase gibt es keine Ziele, auf die sich Kapital verteilen lässt. Das Kapital bleibt vollständig
|
||||
auf dem Cash-Konto.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{targets.map((t) => {
|
||||
const pc = preview.elements.find((x) => x.elementId === t.elementId);
|
||||
const sources = pc?.capitalFromTransferSources ?? [];
|
||||
const fromTransfer = pc?.capitalFromTransfer ?? 0;
|
||||
const manual = draft[t.elementId] ?? 0;
|
||||
return (
|
||||
<div key={t.elementId} className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-fg">{t.name}</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
|
||||
<span className="text-xs text-faint">
|
||||
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
|
||||
</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">
|
||||
{t.kind === "investment"
|
||||
? "Zusatzeinlage"
|
||||
: t.kind === "amortization"
|
||||
? "Sonderamortisation"
|
||||
: "Sofortige Tilgung"}
|
||||
</span>
|
||||
<InfoBubble text={t.hint} />
|
||||
</div>
|
||||
{/* Was der Übergang bereits hierher geleitet hat. Ohne diese Zeile sieht man im
|
||||
Feld eine 0 und hält die prozentuale Zuteilung für wirkungslos. */}
|
||||
{fromTransfer > 0 && (
|
||||
<div className="mb-2 rounded-lg border border-dashed border-border bg-surface-2 p-2">
|
||||
<SummaryRow
|
||||
label="Bereits aus Kapitalbezug zugeteilt"
|
||||
value={fromTransfer}
|
||||
help="Stammt aus der prozentualen Verteilung, die du beim Bezug der Pensionskasse bzw. der Säule 3a festgelegt hast. Der Betrag ist hier nicht editierbar – ändere ihn dort."
|
||||
/>
|
||||
{sources.map((src) => (
|
||||
<div key={src.name} className="flex justify-between gap-3 pl-3 text-[11px] text-faint">
|
||||
<span>aus {src.name}</span>
|
||||
<span className="tabular-nums">{formatChf(src.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MoneyField
|
||||
label={fromTransfer > 0 ? "Zusätzlich aus dem Kapital (CHF)" : "Betrag (CHF)"}
|
||||
value={manual}
|
||||
max={t.max}
|
||||
onChange={(v) => setDraft((prev) => ({ ...prev, [t.elementId]: v }))}
|
||||
/>
|
||||
{fromTransfer > 0 && (
|
||||
<div className="mt-1 pl-1">
|
||||
<SummaryRow label="Zusatzeinlage total" value={fromTransfer + manual} strong />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live-Bilanz */}
|
||||
<div
|
||||
className={`rounded-xl border p-3 ${
|
||||
pot.rest < 0 ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SummaryRow label="Verfügbares Kapital" value={pot.total} />
|
||||
{pot.allocatedInvestments !== 0 && <SummaryRow label="davon investiert" value={-pot.allocatedInvestments} />}
|
||||
{pot.allocatedRepayments !== 0 && (
|
||||
<SummaryRow label="davon in Schulden getilgt" value={-pot.allocatedRepayments} />
|
||||
)}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<SummaryRow
|
||||
label={pot.rest < 0 ? "Fehlbetrag (Cash wird negativ)" : "Rest bleibt auf dem Cash"}
|
||||
value={pot.rest}
|
||||
strong
|
||||
tone={pot.rest < 0 ? "danger" : undefined}
|
||||
help="Was du nicht verteilst, bleibt automatisch auf dem Cash-Konto – dort steht es weiterhin zur Verfügung."
|
||||
/>
|
||||
</div>
|
||||
{pot.rest < 0 && (
|
||||
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Du verteilst mehr, als zur Verfügung steht. Das Cash-Konto startet negativ – das Tool meldet die Phase
|
||||
als Liquiditätslücke.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{laterPhases > 0 && (
|
||||
<div className="grid grid-cols-1">
|
||||
<CarryWarning laterPhaseCount={laterPhases} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 border-t border-border pt-3">
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "…" : "Verteilung speichern"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 2. Spar-/Verzehrquote verteilen ------------------------------------------------------
|
||||
|
||||
interface RateTarget {
|
||||
elementId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
// Zuordnung als Klartext -- ohne sie sind zwei gleichnamige Elemente derselben Kategorie
|
||||
// (z. B. "ETF" bei Person A und Person B) in der Liste nicht unterscheidbar.
|
||||
owner: string;
|
||||
field: "annualContribution" | "annualWithdrawal" | "amortization" | "annualRepayment";
|
||||
label: string;
|
||||
// "out"/"in" = fliesst aus der bzw. in die Quote. "gross" = weder noch: der PK-Beitrag
|
||||
// stammt aus dem BRUTTOLOHN und berührt das Cash-Konto nicht. Er steht seit 0.40 trotzdem
|
||||
// hier, weil er sonst nirgends mehr erfassbar wäre -- aber in einem eigenen Block, damit
|
||||
// niemand ihn für einen Teil der Quote hält.
|
||||
direction: "out" | "in" | "gross";
|
||||
hint: string;
|
||||
// Obergrenze (Säule 3a). Ohne sie liesse sich ein steuerlich unzulässiger Betrag erfassen.
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function RateDistributionDialog({
|
||||
plan,
|
||||
computed,
|
||||
phaseId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: ReturnType<typeof computePlan>;
|
||||
phaseId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const phase = computed.phases.find((p) => p.id === phaseId)!;
|
||||
|
||||
// «Grosse Säule 3a» je Element: bestimmt die Obergrenze der Einzahlung. Der Schalter steht
|
||||
// seit 0.40 hier statt in der Phasenzelle -- getrennt von dem Feld, dessen Grenze er
|
||||
// festlegt, war er eine Einstellung ohne sichtbare Wirkung.
|
||||
const [flags, setFlags] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(plan.elements.map((e) => [e.id, !!e.phaseValues[phaseId]?.selfEmployed3a]))
|
||||
);
|
||||
|
||||
const targets = useMemo<RateTarget[]>(() => {
|
||||
const out: RateTarget[] = [];
|
||||
for (const e of plan.elements) {
|
||||
const ce = phase?.elements.find((x) => x.elementId === e.id);
|
||||
if (!ce || ce.status !== "ACTIVE") continue;
|
||||
// Eine bezogene Säule 3a nimmt keine Einzahlung mehr auf; die Rechnung ignoriert sie.
|
||||
// Die Zeile hier trotzdem anzubieten hiess, den Entscheid aus dem Übergang zu
|
||||
// widersprechen -- inklusive eines geerbten Betrags, der wirkungslos weiterläuft.
|
||||
if (ce.acceptsCapital === false) continue;
|
||||
if (e.category === "PENSION_FUND") {
|
||||
// Der PK-Beitrag ist NICHT Teil der Quote (er stammt aus dem Bruttolohn). Er steht in
|
||||
// einem eigenen Block -- aber er steht hier, weil dies seit 0.40 der einzige Ort ist,
|
||||
// an dem jährliche Beträge entstehen. Ohne ihn wäre er unerreichbar und fiele still
|
||||
// auf 0, obwohl er das Endvermögen um sechsstellige Beträge bewegt. Eine bereits
|
||||
// bezogene PK ist oben über `acceptsCapital` schon ausgeschieden.
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
|
||||
label: "Jährliche Einzahlung", direction: "gross",
|
||||
hint: "Arbeitnehmer- und Arbeitgeberbeiträge aus dem Bruttolohn. Sie sind im Nettolohn bereits abgezogen und schmälern die Sparquote deshalb NICHT.",
|
||||
});
|
||||
} else if (e.category === "PILLAR_3A") {
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
|
||||
label: "Jährliche Einzahlung", direction: "out",
|
||||
hint: "Fliesst jährlich vom Cash in die Säule 3a.",
|
||||
max: flags[e.id] ? PILLAR_3A_MAX_SELF_EMPLOYED : PILLAR_3A_MAX_ANNUAL,
|
||||
});
|
||||
} else if (e.category === "OTHER_ASSET") {
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualContribution",
|
||||
label: "Jährlicher Sparbeitrag", direction: "out",
|
||||
hint: "Fliesst jährlich vom Cash ins Vermögen.",
|
||||
});
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualWithdrawal",
|
||||
label: "Jährliche Bezugsrate", direction: "in",
|
||||
hint: "Entnahme aus dem Vermögen ins Cash. Wird jährlich am vorhandenen Bestand gekappt.",
|
||||
});
|
||||
} else if (e.category === "REAL_ESTATE") {
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "amortization",
|
||||
label: "Amortisation pro Jahr", direction: "out",
|
||||
hint: "Reduziert die Hypothek. Endet automatisch, sobald sie abbezahlt ist.",
|
||||
});
|
||||
} else if (e.category === "OTHER_DEBT") {
|
||||
out.push({
|
||||
elementId: e.id, name: e.name, category: e.category, owner: ownerLabel(plan.persons, e.ownerRole), field: "annualRepayment",
|
||||
label: "Tilgung pro Jahr", direction: "out",
|
||||
hint: "Reduziert die Restschuld. Endet automatisch, sobald sie getilgt ist.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- phase folgt phaseId
|
||||
}, [plan.elements, phaseId, flags]);
|
||||
|
||||
const key = (t: RateTarget) => `${t.elementId}:${t.field}`;
|
||||
|
||||
// Vorbelegung mit dem EFFEKTIVEN Wert -- also inklusive der Vererbung aus der Vorphase
|
||||
// (Roadmap Nr. 44, Punkt A). Bis 0.32 wurde nur der roh gespeicherte Wert der Phase
|
||||
// gelesen: Eine 3a-Einzahlung von 7'000, die aus Phase 1 geerbt wurde, erschien hier als 0,
|
||||
// obwohl die Rechnung korrekt mit 7'000 lief.
|
||||
const orderedPhaseIds = useMemo(
|
||||
() => [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id),
|
||||
[plan.phases]
|
||||
);
|
||||
const [draft, setDraft] = useState<Record<string, number>>(() => {
|
||||
const d: Record<string, number> = {};
|
||||
for (const t of targets) {
|
||||
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||
const own = (e.phaseValues[phaseId] ?? {})[t.field];
|
||||
const inherited = inheritedPhaseValues(e.phaseValues, orderedPhaseIds, phaseId)[t.field];
|
||||
d[key(t)] = Math.round(typeof own === "number" ? own : num(inherited));
|
||||
}
|
||||
return d;
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
const patches: PhasePatch[] = targets.map((t) => ({
|
||||
elementId: t.elementId,
|
||||
field: t.field,
|
||||
value: draft[key(t)] ?? 0,
|
||||
}));
|
||||
return computePlan(applyPhasePatches(plan, phaseId, patches)).phases.find((p) => p.id === phaseId)!;
|
||||
}, [draft, plan, phaseId, targets]);
|
||||
|
||||
const q = quotaSummary(preview);
|
||||
const laterPhases = computed.phases.length - phase.sequenceNumber;
|
||||
const titel = q.isConsumption ? "Verzehrquote verteilen" : "Sparquote verteilen";
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Je Element EIN Aufruf, auch wenn mehrere Felder betroffen sind.
|
||||
const byElement = new Map<string, PhaseData>();
|
||||
for (const t of targets) {
|
||||
const e = plan.elements.find((x) => x.id === t.elementId)!;
|
||||
const merged = byElement.get(t.elementId) ?? { ...(e.phaseValues[phaseId] ?? {}) };
|
||||
(merged as Record<string, number>)[t.field] = draft[key(t)] ?? 0;
|
||||
if (t.category === "PILLAR_3A") merged.selfEmployed3a = !!flags[t.elementId];
|
||||
byElement.set(t.elementId, merged);
|
||||
}
|
||||
for (const [elementId, data] of byElement) {
|
||||
await api.put(`/api/elements/${elementId}/phase/${phaseId}`, data);
|
||||
}
|
||||
// Die Verteilung zählt eigens als offener Punkt: Man kann jede Zelle angeschaut und die
|
||||
// Quote trotzdem nie verteilt haben -- dann bliebe alles still auf dem Cash-Konto.
|
||||
await api.put(`/api/phases/${phaseId}`, { ratesConfirmed: true });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={titel} subtitle={`${phase.name} · ${phase.durationYears} Jahre`} onClose={onClose} wide>
|
||||
{/* Die drei Bezugsgrössen */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
<PiggyBank className="h-4 w-4" /> {q.isConsumption ? "Deine Verzehrquote" : "Deine Sparquote"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SummaryRow
|
||||
label="Erstes Phasenjahr"
|
||||
value={q.start}
|
||||
help="Einkommen minus nominale Ausgaben im ersten Jahr dieser Phase."
|
||||
/>
|
||||
<SummaryRow
|
||||
label="Letztes Phasenjahr"
|
||||
value={q.end}
|
||||
help="Die Quote verändert sich über die Phase: Das Einkommen wächst mit der Lohnerhöhung, die real erfassten Ausgaben mit der Inflation."
|
||||
/>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<SummaryRow
|
||||
label={q.isConsumption ? "Absolut über die ganze Phase (Bedarf)" : "Absolut über die ganze Phase"}
|
||||
value={q.total}
|
||||
strong
|
||||
help="Summe über alle Phasenjahre – der Betrag, der insgesamt zur Verfügung steht bzw. fehlt. Bewusst nominal summiert, genau wie das Cash-Konto rechnet."
|
||||
/>
|
||||
</div>
|
||||
{!q.isConsumption && q.end < q.start && (
|
||||
<p className="mt-2 text-[11px] text-muted">
|
||||
Achtung: Die Quote <strong className="text-fg">sinkt</strong> von {formatChf(q.start)} auf{" "}
|
||||
{formatChf(q.end)}. Eine flache Jahresrate über {formatChf(q.end)} lässt sich in den späteren Jahren
|
||||
nicht mehr aus der laufenden Quote decken – sie zehrt dann am Cash-Bestand.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
In dieser Phase gibt es keine Elemente mit jährlichen Raten. Die ganze Quote läuft aufs Cash-Konto.
|
||||
</p>
|
||||
) : (
|
||||
// Bewusst nach RICHTUNG gruppiert: erst alles, was das Cash verlässt, dann alles, was
|
||||
// hineinfliesst, zuletzt der Bruttolohn-Block. Gemischt war die Liste schwer zu lesen.
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["out", "in", "gross"] as const).map((dir) => {
|
||||
const rows = targets.filter((t) => t.direction === dir);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div key={dir} className="flex flex-col gap-2">
|
||||
<div
|
||||
className={`flex items-center gap-2 text-xs font-semibold uppercase tracking-wide ${
|
||||
dir === "in" ? "text-success" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{dir === "in"
|
||||
? "Ins Cash (Entnahmen)"
|
||||
: dir === "gross"
|
||||
? "Aus dem Bruttolohn (ausserhalb der Quote)"
|
||||
: "Vom Cash (Einzahlungen und Tilgungen)"}
|
||||
</div>
|
||||
{dir === "gross" && (
|
||||
<p className="rounded-lg bg-surface-2 px-3 py-2 text-[11px] leading-relaxed text-muted">
|
||||
Diese Beiträge sind im Nettolohn <strong className="text-fg">bereits abgezogen</strong>. Sie
|
||||
schmälern die Sparquote nicht und tauchen in der Bilanz unten deshalb nicht auf – sie stehen
|
||||
hier, weil sie das Vorsorgekapital gleichwohl jährlich erhöhen.
|
||||
</p>
|
||||
)}
|
||||
{rows.map((t) => (
|
||||
<div key={key(t)} className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-fg">{t.name}</span>
|
||||
<span className="text-xs text-faint">
|
||||
{CATEGORY_LABELS[t.category as keyof typeof CATEGORY_LABELS]}
|
||||
</span>
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-muted">{t.owner}</span>
|
||||
<InfoBubble text={t.hint} />
|
||||
</div>
|
||||
{/* Das Häkchen steht ÜBER dem Betrag, weil es dessen Obergrenze bestimmt. */}
|
||||
{t.category === "PILLAR_3A" && (
|
||||
<label className="mb-2 flex cursor-pointer items-center gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!flags[t.elementId]}
|
||||
onChange={(e) => setFlags((prev) => ({ ...prev, [t.elementId]: e.target.checked }))}
|
||||
/>
|
||||
Selbstständig ohne Pensionskasse («grosse Säule 3a»)
|
||||
<InfoBubble
|
||||
text={`Selbstständigerwerbende ohne PK dürfen bis 20 % des Nettoerwerbseinkommens einzahlen, gedeckelt auf ca. ${PILLAR_3A_MAX_SELF_EMPLOYED.toLocaleString("de-CH")} CHF – statt der ${PILLAR_3A_MAX_ANNUAL.toLocaleString("de-CH")} CHF mit PK.`}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<MoneyField
|
||||
label={`${t.label} (CHF/Jahr)`}
|
||||
help={
|
||||
t.max !== undefined
|
||||
? `Höchstens CHF ${t.max.toLocaleString("de-CH")} (2026) und höchstens die Sparquote.`
|
||||
: undefined
|
||||
}
|
||||
value={draft[key(t)] ?? 0}
|
||||
max={t.max}
|
||||
onChange={(v) => setDraft((prev) => ({ ...prev, [key(t)]: v }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live-Bilanz über die ganze Phase */}
|
||||
<div
|
||||
className={`rounded-xl border p-3 ${
|
||||
preview.cashNegative ? "border-danger bg-danger-soft" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<SummaryRow label={q.isConsumption ? "Absoluter Bedarf" : "Absolut verfügbar"} value={q.total} />
|
||||
{q.allocatedOut !== 0 && (
|
||||
<SummaryRow
|
||||
label="davon in Raten verplant"
|
||||
value={-q.allocatedOut}
|
||||
help="Summe aller jährlichen Raten über die Phasenjahre – inklusive der automatischen Kappung, sobald eine Schuld abbezahlt ist."
|
||||
/>
|
||||
)}
|
||||
{q.allocatedIn !== 0 && (
|
||||
<SummaryRow
|
||||
label="Bezugsraten ins Cash"
|
||||
value={q.allocatedIn}
|
||||
tone="success"
|
||||
help="Tatsächlich entnommener Betrag – am vorhandenen Bestand gekappt."
|
||||
/>
|
||||
)}
|
||||
<div className="my-1 border-t border-border" />
|
||||
<SummaryRow
|
||||
label={q.netToCash < 0 ? "Zehrt am Cash-Bestand" : "Geht aufs Cash-Konto"}
|
||||
value={q.netToCash}
|
||||
strong
|
||||
tone={q.netToCash < 0 ? "danger" : undefined}
|
||||
help="Was nicht in Raten verplant ist, landet auf dem Cash-Konto. Ein negativer Wert bedeutet, dass der Cash-Bestand in dieser Phase abnimmt."
|
||||
/>
|
||||
<SummaryRow label="Cash am Phasenende" value={preview.cashEnd} tone={preview.cashEnd < 0 ? "danger" : undefined} />
|
||||
</div>
|
||||
{preview.cashNegative && (
|
||||
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Mit dieser Verteilung fällt das Cash-Konto in dieser Phase unter 0 – das ist eine Liquiditätslücke.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{laterPhases > 0 && (
|
||||
<div className="grid grid-cols-1">
|
||||
<CarryWarning laterPhaseCount={laterPhases} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex gap-2 border-t border-border pt-3">
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "…" : "Verteilung speichern"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+1026
-258
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,53 @@ export function NumberField({
|
||||
);
|
||||
}
|
||||
|
||||
// Pflicht-Zahlenfeld, das wirklich LEER sein kann (NumberField erzwingt eine Zahl und hätte
|
||||
// damit immer einen Default -- genau das soll bei Annahmen vermieden werden, siehe
|
||||
// SPEZIFIKATION 9.15/9.18: ein stiller Default wird nicht hinterfragt). Der Wert wird als
|
||||
// String geführt; die Validierung liegt beim Aufrufer.
|
||||
export function RequiredNumberField({
|
||||
label,
|
||||
help,
|
||||
value,
|
||||
onChange,
|
||||
step = 0.1,
|
||||
suffix,
|
||||
placeholder = "Pflicht",
|
||||
}: {
|
||||
label: string;
|
||||
help?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
step?: number;
|
||||
suffix?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const empty = value.trim() === "";
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel label={label} help={help} />
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
step={step}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`${baseInputClass} ${suffix ? "pr-8" : ""} ${
|
||||
empty ? "border-danger focus:border-danger" : ""
|
||||
}`}
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="pointer-events-none absolute inset-y-0 right-2.5 flex items-center text-xs text-faint">
|
||||
{suffix}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{empty && <p className="mt-1 text-[11px] text-danger">Pflichtfeld – bitte ausfüllen.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ganzzahliges Betragsfeld. Zeigt den Wert unfokussiert mit 1'000er-Trennzeichen an,
|
||||
// akzeptiert fokussiert beliebige ganze Zahlen (keine Nachkommastellen) und bietet
|
||||
// Pfeil-Buttons mit Klick-und-Halten-BESCHLEUNIGUNG (1 -> 10 -> 100 -> 1'000 -> ...).
|
||||
@@ -101,7 +148,7 @@ export function MoneyInput({
|
||||
}
|
||||
|
||||
// Klick = 1 Schritt. Halten: nach 400 ms Wiederholung im 70-ms-Takt, wobei die
|
||||
// Schrittweite mit der Haltedauer waechst (immer schneller).
|
||||
// Schrittweite mit der Haltedauer wächst (immer schneller).
|
||||
function startHold(sign: number) {
|
||||
stepBy(sign);
|
||||
const start = Date.now();
|
||||
@@ -130,7 +177,7 @@ export function MoneyInput({
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
const current = Math.round(value || 0);
|
||||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn ueberschreiben kann.
|
||||
// Default-0 sofort leeren; sonst Wert markieren, damit man ihn überschreiben kann.
|
||||
setText(current === 0 ? "" : String(current));
|
||||
requestAnimationFrame(() => inputRef.current?.select());
|
||||
}}
|
||||
@@ -153,7 +200,7 @@ export function MoneyInput({
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Erhoehen"
|
||||
aria-label="Erhöhen"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
startHold(1);
|
||||
@@ -273,3 +320,54 @@ export function SelectField<T extends string>({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- "Aus Vorphase uebernehmen" (Roadmap Nr. 44, Punkt A) --------------------------------
|
||||
//
|
||||
// Wiederkehr-Parameter (Raten, Beitraege, Amortisation) gelten weiter, bis man sie bewusst
|
||||
// aendert. Bisher war das unsichtbar: Beim Anlegen einer Phase wurde der Wert KOPIERT, und
|
||||
// spaetere Aenderungen an der Vorphase kamen deshalb nie an. Jetzt entscheidet ein sichtbares
|
||||
// Haekchen: angehakt = Feld leer lassen und live erben; abgehakt = eigener Wert fuer diese
|
||||
// Phase. In der ersten Phase gibt es nichts zu erben -- dort erscheint das Haekchen nicht.
|
||||
export function InheritableField({
|
||||
label,
|
||||
help,
|
||||
canInherit,
|
||||
isInheriting,
|
||||
inheritedDisplay,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
help?: string;
|
||||
canInherit: boolean;
|
||||
isInheriting: boolean;
|
||||
inheritedDisplay: string;
|
||||
onToggle: (inherit: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{isInheriting ? (
|
||||
<>
|
||||
<FieldLabel label={label} help={help} />
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{inheritedDisplay} <span className="text-xs text-faint">(aus der Vorphase)</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{canInherit && (
|
||||
<label className="mt-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isInheriting}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
className="h-3.5 w-3.5 accent-accent"
|
||||
/>
|
||||
Aus Vorphase übernehmen
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
// Hilfetext-Bubble.
|
||||
//
|
||||
// Der Text wird über ein PORTAL an den <body> gehängt und `fixed` positioniert. Vorher lag er
|
||||
// als `absolute` Kind im Fluss -- in einem scrollenden Dialog (Verteil-Dialoge, Assistent)
|
||||
// schnitt dessen `overflow` die Box ab, teilweise nach wenigen Zeilen. Fix positioniert
|
||||
// bezieht sich auf den Viewport und entkommt damit jedem Overflow-Container.
|
||||
//
|
||||
// Zusätzlich: Die Box klappt nach OBEN, wenn unten kein Platz mehr ist, und wird an den
|
||||
// Bildschirmrändern geklemmt, damit sie nie halb ausserhalb steht.
|
||||
|
||||
const WIDTH = 256; // entspricht w-64
|
||||
const MARGIN = 8;
|
||||
|
||||
export function InfoBubble({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Position aus der Lage des Knopfes berechnen -- erst wenn geöffnet, und erneut, sobald die
|
||||
// tatsächliche Höhe der Box bekannt ist (für das Hochklappen).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Position gilt nur im offenen Zustand
|
||||
setPos(null);
|
||||
return;
|
||||
}
|
||||
const place = () => {
|
||||
const btn = btnRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const height = boxRef.current?.offsetHeight ?? 0;
|
||||
const spaceBelow = window.innerHeight - r.bottom;
|
||||
const above = height > 0 && spaceBelow < height + MARGIN && r.top > height + MARGIN;
|
||||
const left = Math.min(
|
||||
Math.max(MARGIN, r.left + r.width / 2 - WIDTH / 2),
|
||||
Math.max(MARGIN, window.innerWidth - WIDTH - MARGIN)
|
||||
);
|
||||
setPos({ top: above ? r.top - height - 6 : r.bottom + 6, left });
|
||||
};
|
||||
place();
|
||||
// Nach dem ersten Zeichnen erneut -- dann steht die Höhe fest.
|
||||
const raf = requestAnimationFrame(place);
|
||||
window.addEventListener("scroll", place, true);
|
||||
window.addEventListener("resize", place);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", place, true);
|
||||
window.removeEventListener("resize", place);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex align-middle ml-1">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-label="Hilfe anzeigen"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen((o) => !o);
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full bg-accent-soft text-accent-soft-fg hover:opacity-80"
|
||||
>
|
||||
<Info className="h-2.5 w-2.5" strokeWidth={2.5} />
|
||||
</button>
|
||||
{open && (
|
||||
<span className="absolute left-1/2 top-6 z-20 w-64 -translate-x-1/2 rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{open &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={boxRef}
|
||||
role="tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: pos?.top ?? -9999,
|
||||
left: pos?.left ?? -9999,
|
||||
width: WIDTH,
|
||||
// Vor der ersten Messung unsichtbar, damit die Box nicht kurz aufblitzt.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="pointer-events-none z-[60] rounded-lg border border-border bg-surface p-2.5 text-xs leading-snug text-fg shadow-lg"
|
||||
>
|
||||
{text}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
"use client";
|
||||
|
||||
// Die Bestandsaufnahme: alles erfassen, was heute da ist.
|
||||
//
|
||||
// Der einzige Sammel-Dialog, der geblieben ist. Er arbeitet ausschliesslich über die
|
||||
// bestehenden Endpunkte -- was er schreibt, hätte man auch einzeln über die Matrix erfassen
|
||||
// können; er nimmt einem nur das Suchen ab.
|
||||
//
|
||||
// Erfasst werden NUR Tatsachen: Kontostand, Guthaben, Kaufpreis, Restschuld. Renditen,
|
||||
// Lohnentwicklung und Teuerung stehen bewusst nicht hier -- sie sind Annahmen, und eine
|
||||
// Annahme gilt immer nur für einen Zeitraum. Sie werden in den Phasenzellen der Matrix
|
||||
// gesetzt und dort bestätigt (SPEZIFIKATION 3.14).
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Modal, useConfirm } from "@/components/ui";
|
||||
import { MoneyField, NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { CATEGORY_LABELS, ownerLabel, num, type PhaseData } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
type Scope = "HOUSEHOLD" | "PERSON_A" | "PERSON_B";
|
||||
|
||||
interface Kind {
|
||||
key: string;
|
||||
category: string;
|
||||
// Beschriftung des Knopfs. Bewusst der KATEGORIENAME und keine erfundene Umgangssprache:
|
||||
// Die Matrix, die Rechenwege und der PDF-Bericht sprechen dieselbe Sprache -- wer hier eine
|
||||
// andere Vokabel lernt, sucht sie spaeter vergeblich. Die Alltagssprache steht in `hint`.
|
||||
label: string;
|
||||
// Vorschlag fuer den Namen des angelegten Elements. Hier darf es konkret werden: Die
|
||||
// Kategorie ist "Ausgaben", die Zeile heisst "Lebenshaltung".
|
||||
defaultName: string;
|
||||
hint: string;
|
||||
personOnly?: boolean;
|
||||
// SCHRITT 1 -- der Bestand von heute. Nur Zahlen, die man nachschlagen kann: Kontostand,
|
||||
// Guthaben, Kaufpreis, Restschuld.
|
||||
baseFields: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||
// SCHRITT 2 -- die Annahmen fuer EINE Lebensphase. Alles, was pro Jahr geschieht und sich
|
||||
// von Phase zu Phase aendern darf: Renditen, Lohnentwicklung, Wertsteigerung, Zins.
|
||||
//
|
||||
// Die Trennung ist der Kern: Ein Guthaben ist eine Tatsache, eine Rendite eine Annahme --
|
||||
// und Annahmen gelten immer nur fuer einen Zeitraum. Bis 0.37 standen beide zusammen in der
|
||||
// Bestandsaufnahme, wodurch eine Annahme wie eine Tatsache aussah.
|
||||
phaseFields?: (v: PhaseData, set: (p: Partial<PhaseData>) => void) => React.ReactNode;
|
||||
}
|
||||
|
||||
const KINDS: Kind[] = [
|
||||
{
|
||||
key: "income",
|
||||
category: "INCOME",
|
||||
label: "Einkommen",
|
||||
defaultName: "Lohn",
|
||||
hint: "Dein Nettolohn – das, was auf dem Konto ankommt.",
|
||||
personOnly: true,
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Nettolohn pro Jahr"
|
||||
help="Was tatsächlich aufs Konto kommt, nach allen Abzügen. FPT rechnet für die AHV intern auf den Bruttolohn hoch."
|
||||
value={num(v.amount)}
|
||||
onChange={(x) => set({ amount: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Jährliche Lohnerhöhung (%)"
|
||||
help="Nominal, also inklusive Teuerung. 1 % ist ein vorsichtiger Ausgangswert."
|
||||
step={0.5}
|
||||
value={num(v.teuerungsausgleich, 1)}
|
||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "expense",
|
||||
category: "EXPENSE",
|
||||
label: "Ausgaben",
|
||||
defaultName: "Lebenshaltung",
|
||||
hint: "Alles, was du im Jahr ausgibst – Wohnen, Essen, Versicherungen, Freizeit.",
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Ausgaben pro Jahr"
|
||||
help="In heutiger Kaufkraft. Die Teuerung rechnet FPT selbst dazu."
|
||||
value={num(v.amount)}
|
||||
onChange={(x) => set({ amount: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Jährliche Teuerung (%)"
|
||||
help="Leer lassen heisst: die Inflationsannahme des Szenarios. Eine eigene Zahl nur, wenn du für diese Ausgaben etwas anderes erwartest."
|
||||
step={0.25}
|
||||
value={num(v.teuerungsausgleich)}
|
||||
onChange={(x) => set({ teuerungsausgleich: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pk",
|
||||
category: "PENSION_FUND",
|
||||
label: "Pensionskasse",
|
||||
defaultName: "Pensionskasse",
|
||||
hint: "Dein Altersguthaben laut PK-Ausweis.",
|
||||
personOnly: true,
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField
|
||||
label="Altersguthaben heute"
|
||||
help="Steht auf deinem Vorsorgeausweis."
|
||||
value={num(v.currentValue)}
|
||||
onChange={(x) => set({ currentValue: x })}
|
||||
/>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<>
|
||||
<NumberField
|
||||
label="Verzinsung (%/Jahr)"
|
||||
help="Der BVG-Mindestzins liegt aktuell tief; viele Kassen verzinsen etwas darüber."
|
||||
step={0.25}
|
||||
value={num(v.expectedReturn, 1.25)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
{/* Der PK-Beitrag steht hier und nicht bei der Sparquote: Er stammt aus dem
|
||||
Bruttolohn und belastet das Cash-Konto nicht -- aus der Quote liesse er sich
|
||||
gar nicht verteilen (SPEZIFIKATION 4.6.3). */}
|
||||
<MoneyField
|
||||
label="Jährlicher Beitrag (Arbeitnehmer + Arbeitgeber)"
|
||||
help="Die Altersgutschrift laut Vorsorgeausweis. Sie kommt aus dem Bruttolohn und schmälert deine Sparquote deshalb nicht."
|
||||
value={num(v.annualContribution)}
|
||||
onChange={(x) => set({ annualContribution: x })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "p3a",
|
||||
category: "PILLAR_3A",
|
||||
label: "Säule 3a",
|
||||
defaultName: "Säule 3a",
|
||||
hint: "Ein Konto oder Depot der gebundenen Vorsorge. Mehrere Konten legst du einzeln an.",
|
||||
personOnly: true,
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Guthaben heute" value={num(v.currentValue)} onChange={(x) => set({ currentValue: x })} />
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Auf einem Konto praktisch 0, in einer Wertschriftenlösung deutlich mehr."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 2)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "etf",
|
||||
category: "OTHER_ASSET",
|
||||
label: "Sonstiges Vermögen",
|
||||
defaultName: "Wertschriften",
|
||||
hint: "Depot, ETF, Sparkonto – alles freie Vermögen ausser dem Cash-Konto.",
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Wert heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<NumberField
|
||||
label="Erwartete Rendite (%/Jahr)"
|
||||
help="Langfristiger Durchschnitt. Für ein gemischtes Portfolio sind 3 bis 5 % ein üblicher Ansatz."
|
||||
step={0.5}
|
||||
value={num(v.expectedReturn, 4)}
|
||||
onChange={(x) => set({ expectedReturn: x })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "re",
|
||||
category: "REAL_ESTATE",
|
||||
label: "Immobilie",
|
||||
defaultName: "Wohneigentum",
|
||||
hint: "Haus oder Wohnung samt Hypothek.",
|
||||
baseFields: (v, set) => (
|
||||
<>
|
||||
<MoneyField
|
||||
label="Kaufpreis"
|
||||
help="Der ursprüngliche Kaufpreis – Grundlage der Grundstückgewinnsteuer bei einem späteren Verkauf."
|
||||
value={num(v.purchasePrice)}
|
||||
onChange={(x) => set({ purchasePrice: x })}
|
||||
/>
|
||||
<MoneyField label="Hypothek heute" value={num(v.mortgage)} onChange={(x) => set({ mortgage: x })} />
|
||||
</>
|
||||
),
|
||||
phaseFields: (v, set) => (
|
||||
<>
|
||||
<NumberField
|
||||
label="Hypothekarzins (%)"
|
||||
step={0.25}
|
||||
value={num(v.interestRate, 2)}
|
||||
onChange={(x) => set({ interestRate: x })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Wertsteigerung (%/Jahr)"
|
||||
help="Auf den Wert der Liegenschaft, nicht auf dein Eigenkapital – dort wirkt sie gehebelt."
|
||||
step={0.25}
|
||||
value={num(v.valueGrowth, 1)}
|
||||
onChange={(x) => set({ valueGrowth: x })}
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<SelectField
|
||||
label="Sind die Hypothekarzinsen in deinen Ausgaben schon enthalten?"
|
||||
help="Wenn ja, rechnet FPT sie nicht noch einmal dazu – sonst zählten sie doppelt."
|
||||
value={v.interestHandling ?? "INCLUDED"}
|
||||
onChange={(x: "INCLUDED" | "ADD") => set({ interestHandling: x })}
|
||||
options={[
|
||||
{ value: "INCLUDED", label: "Ja, sind in der Lebenshaltung enthalten" },
|
||||
{ value: "ADD", label: "Nein, bitte zusätzlich rechnen" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "debt",
|
||||
category: "OTHER_DEBT",
|
||||
label: "Sonstige Schulden",
|
||||
defaultName: "Schulden",
|
||||
hint: "Kredite ausserhalb der Hypothek.",
|
||||
baseFields: (v, set) => (
|
||||
<MoneyField label="Restschuld heute" value={num(v.startValue)} onChange={(x) => set({ startValue: x })} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Der Entwurf des GANZEN Schritts, nicht der einzelnen Karte.
|
||||
//
|
||||
// Zwei Gruende dafuer, dass er hier oben liegt und nicht in der Karte: Beim Wechsel des
|
||||
// Reiters verschwinden die Karten des vorigen Bereichs vom Bildschirm -- mit ihrem eigenen
|
||||
// Zustand waeren alle getippten Zahlen still weg. Und die Summe "Vermoegen heute" laesst sich
|
||||
// nur berechnen, wenn alle Entwuerfe an EINEM Ort liegen.
|
||||
export interface InventoryDraft {
|
||||
base: Record<string, PhaseData>;
|
||||
names: Record<string, string>;
|
||||
// Cash ist kein Element, sondern eine Eigenschaft des Szenarios (`Scenario.initialCash`) --
|
||||
// deshalb kein Plus-Knopf, sondern ein festes Feld. Es fehlte bis 0.39 ganz: Die Summe
|
||||
// «Vermögen heute» rechnete es bereits mit, erfassen konnte man es hier aber nirgends.
|
||||
// `null` = unberührt (dann wird beim Speichern auch nichts geschrieben).
|
||||
cash: number | null;
|
||||
}
|
||||
|
||||
export function emptyInventoryDraft(): InventoryDraft {
|
||||
return { base: {}, names: {}, cash: null };
|
||||
}
|
||||
|
||||
// Bestand eines Elements aus Stammdaten -- dieselbe Rechnung wie in der Kachel «Kennzahlen»,
|
||||
// hier nur auf dem Entwurf statt auf dem gespeicherten Stand.
|
||||
function wealthOf(category: string, d: PhaseData): number {
|
||||
if (category === "OTHER_ASSET") return num(d.startValue);
|
||||
if (category === "PENSION_FUND" || category === "PILLAR_3A") return num(d.currentValue);
|
||||
if (category === "REAL_ESTATE") return num(d.purchasePrice) - num(d.mortgage);
|
||||
if (category === "OTHER_DEBT") return -num(d.startValue);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Die Stammdaten-Felder einer Kategorie. Eigene Komponente, weil sie an ZWEI Orten
|
||||
// erscheinen: im Assistenten-Schritt 1 (als Liste) und in der Matrix-Spalte «Start» (einzeln).
|
||||
// Zwei Implementierungen derselben Felder liefen garantiert auseinander.
|
||||
export function BaseFields({
|
||||
category,
|
||||
values,
|
||||
set,
|
||||
}: {
|
||||
category: string;
|
||||
values: PhaseData;
|
||||
set: (patch: Partial<PhaseData>) => void;
|
||||
}) {
|
||||
const kind = KINDS.find((k) => k.category === category);
|
||||
return <>{kind?.baseFields(values, set)}</>;
|
||||
}
|
||||
|
||||
export function InventoryFields({
|
||||
plan,
|
||||
draft,
|
||||
setDraft,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
draft: InventoryDraft;
|
||||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const scopes: Scope[] =
|
||||
plan.householdType === "COUPLE" ? ["HOUSEHOLD", "PERSON_A", "PERSON_B"] : ["PERSON_A"];
|
||||
const [page, setPage] = useState(0);
|
||||
const scope = scopes[page];
|
||||
const cash = draft.cash ?? Math.round(plan.initialCash || 0);
|
||||
|
||||
// Live-Summe aus dem ENTWURF: Sie soll sich beim Tippen bewegen, nicht erst beim Speichern.
|
||||
const today = plan.elements.reduce(
|
||||
(sum, e) => sum + wealthOf(e.category, draft.base[e.id] ?? e.baseData ?? {}),
|
||||
cash
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Cash steht ausserhalb der Reiter: Es ist kein Element und gehört keiner Person --
|
||||
es ist der Topf, aus dem alles fliesst und in den alles zurückfliesst. */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<MoneyField
|
||||
label="Cash heute (Konto- und Sparguthaben)"
|
||||
help="Alles, was sofort verfügbar ist. Aus diesem Topf zahlt FPT die Ausgaben; was du sparst und nicht zuteilst, bleibt hier liegen."
|
||||
value={cash}
|
||||
onChange={(x) => setDraft((d) => ({ ...d, cash: x }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{scopes.length > 1 && (
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-surface-2 p-1 text-xs">
|
||||
{scopes.map((sc, i) => (
|
||||
<button
|
||||
key={sc}
|
||||
type="button"
|
||||
onClick={() => setPage(i)}
|
||||
className={`flex-1 rounded-md px-2 py-1 font-medium ${
|
||||
page === i ? "bg-accent text-accent-fg" : "text-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{sc === "HOUSEHOLD" ? "Gemeinsam" : ownerLabel(plan.persons, sc)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScopeElements plan={plan} scope={scope} draft={draft} setDraft={setDraft} onChanged={onChanged} />
|
||||
|
||||
{/* Die einzige Zahl, die schon vor jeder Zeitplanung etwas aussagt -- und die erste
|
||||
Rueckmeldung auf die Bestandsaufnahme. */}
|
||||
<div className="sticky bottom-0 flex items-baseline justify-between gap-3 border-t border-border bg-surface px-1 pt-3">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">Vermögen heute</span>
|
||||
<span className="text-lg font-semibold tabular-nums text-fg">{formatChf(today)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeElements({
|
||||
plan,
|
||||
scope,
|
||||
draft,
|
||||
setDraft,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
scope: Scope;
|
||||
draft: InventoryDraft;
|
||||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const kinds = KINDS.filter((k) => (scope === "HOUSEHOLD" ? !k.personOnly : true));
|
||||
const mine = plan.elements.filter((e) => (e.ownerRole ?? "HOUSEHOLD") === scope);
|
||||
|
||||
// Das Element entsteht SOFORT -- es braucht eine Id, an der die Werte haengen. Nur die
|
||||
// Zahlen werden bis zum Abschluss des Schritts zurueckgehalten.
|
||||
async function add(kind: Kind) {
|
||||
setBusy(kind.key);
|
||||
setError(null);
|
||||
try {
|
||||
const suffix =
|
||||
plan.householdType === "COUPLE" && scope !== "HOUSEHOLD" ? ` ${ownerLabel(plan.persons, scope)}` : "";
|
||||
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
||||
category: kind.category,
|
||||
name: `${kind.defaultName}${suffix}`,
|
||||
ownerRole: scope,
|
||||
});
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Anlegen fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Der Knopf traegt den Kategorienamen, die Erklaerung steht darunter -- so lernt man
|
||||
beim ersten Kontakt die Vokabel, die auch die Matrix verwendet. */}
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{kinds.map((k) => (
|
||||
<button
|
||||
key={k.key}
|
||||
type="button"
|
||||
disabled={busy === k.key}
|
||||
onClick={() => add(k)}
|
||||
className="flex items-start gap-1.5 rounded-lg border border-dashed border-border px-2.5 py-1.5 text-left transition-colors hover:border-accent hover:bg-accent-soft/20"
|
||||
>
|
||||
<Plus className="mt-0.5 h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-semibold text-fg">{k.label}</span>
|
||||
<span className="block text-[11px] leading-snug text-muted">{k.hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mine.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-sm text-muted">
|
||||
Noch nichts erfasst. Wähle oben, was zutrifft – du kannst jederzeit weitere Positionen ergänzen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{mine.map((el) => (
|
||||
<BaseDataCard
|
||||
key={el.id}
|
||||
plan={plan}
|
||||
elementId={el.id}
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseDataCard({
|
||||
plan,
|
||||
elementId,
|
||||
draft,
|
||||
setDraft,
|
||||
onChanged,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
elementId: string;
|
||||
draft: InventoryDraft;
|
||||
setDraft: (fn: (d: InventoryDraft) => InventoryDraft) => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const el = plan.elements.find((x) => x.id === elementId)!;
|
||||
const kind = KINDS.find((k) => k.category === el.category);
|
||||
const values = draft.base[el.id] ?? el.baseData ?? {};
|
||||
const name = draft.names[el.id] ?? el.name;
|
||||
|
||||
const set = (patch: Partial<PhaseData>) =>
|
||||
setDraft((d) => ({ ...d, base: { ...d.base, [el.id]: { ...(d.base[el.id] ?? el.baseData ?? {}), ...patch } } }));
|
||||
|
||||
async function remove() {
|
||||
await api.delete(`/api/elements/${el.id}`);
|
||||
setDraft((d) => {
|
||||
const base = { ...d.base };
|
||||
const names = { ...d.names };
|
||||
delete base[el.id];
|
||||
delete names[el.id];
|
||||
return { ...d, base, names };
|
||||
});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{CATEGORY_LABELS[el.category]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="ml-auto rounded p-1 text-faint hover:bg-danger-soft hover:text-danger"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<TextField
|
||||
label="Bezeichnung"
|
||||
value={name}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, names: { ...d.names, [el.id]: v } }))}
|
||||
/>
|
||||
</div>
|
||||
{kind ? (
|
||||
kind.baseFields(values, set)
|
||||
) : (
|
||||
// Die AHV ist die einzige Kategorie ohne Bestand: Es gibt kein Guthaben zum
|
||||
// Nachschlagen, nur eine Beitragskarriere. Eine leere Karte sähe nach einem Fehler
|
||||
// aus -- deshalb steht hier, warum nichts zu tun ist.
|
||||
<p className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Hier gibt es nichts zu erfassen. Die AHV-Rente entsteht aus deiner Beitragskarriere – FPT leitet sie
|
||||
aus den Einkommen dieses Plans ab. Beim Übergang in die Pension kannst du sie prüfen und um
|
||||
Beitragslücken vor Planbeginn ergänzen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Schreibt den gesammelten Entwurf. Wird vom Schritt-Dialog beim Abschliessen aufgerufen --
|
||||
// es gibt bewusst keinen Speichern-Knopf je Karte mehr.
|
||||
export async function saveInventoryDraft(plan: PlanInput, draft: InventoryDraft): Promise<void> {
|
||||
for (const el of plan.elements) {
|
||||
const nextName = draft.names[el.id]?.trim();
|
||||
if (nextName && nextName !== el.name) {
|
||||
await api.patch(`/api/elements/${el.id}`, { name: nextName });
|
||||
}
|
||||
const base = draft.base[el.id];
|
||||
if (base) await api.put(`/api/elements/${el.id}/base`, base);
|
||||
}
|
||||
// Cash hängt am Szenario, nicht an einem Element -- eigener Endpunkt.
|
||||
if (draft.cash !== null && draft.cash !== plan.initialCash) {
|
||||
await api.patch(`/api/scenarios/${plan.id}`, { initialCash: draft.cash });
|
||||
}
|
||||
|
||||
// Die AHV entsteht am Schluss von selbst -- je Person eine.
|
||||
//
|
||||
// Sie hat als einzige Kategorie KEINEN Bestand: Es gibt kein Guthaben, das man nachschlagen
|
||||
// könnte, nur eine Beitragskarriere. Ein Plus-Knopf mit einer leeren Karte darunter wäre
|
||||
// deshalb sinnlos gewesen. Weglassen ist aber keine Option: In der Schweiz hat jede Person
|
||||
// eine AHV, und ohne das Element rechnet der Plan ab der Pensionierung mit einer Rente von 0.
|
||||
//
|
||||
// IDEMPOTENT: Wer die Bestandsaufnahme später korrigiert und erneut abschliesst, bekommt
|
||||
// kein zweites Element -- geprüft wird je Person, nicht global.
|
||||
for (const person of plan.persons) {
|
||||
const exists = plan.elements.some((e) => e.category === "AHV" && e.ownerRole === person.role);
|
||||
if (exists) continue;
|
||||
const name = person.name?.trim() ? `AHV ${person.name.trim()}` : "AHV";
|
||||
await api.post(`/api/scenarios/${plan.id}/elements`, {
|
||||
category: "AHV",
|
||||
name,
|
||||
ownerRole: person.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Der Dialog drumherum -------------------------------------------------------------------
|
||||
//
|
||||
// Gespeichert wird EINMAL, beim Abschliessen. Ein Speichern-Knopf je Element hätte bedeutet,
|
||||
// dass ein Reiter-Wechsel die getippten Zahlen verwirft -- deshalb liegt der Entwurf hier
|
||||
// oben und nicht in den einzelnen Karten.
|
||||
export function InventoryDialog({
|
||||
plan,
|
||||
onClose,
|
||||
onChanged,
|
||||
onSaved,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
onClose: () => void;
|
||||
// Der Plan hat sich geaendert (Element angelegt oder geloescht) -- neu laden, aber den
|
||||
// Dialog OFFEN lassen. Getrennt von onSaved, denn das schliesst ihn.
|
||||
onChanged: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<InventoryDraft>(emptyInventoryDraft);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const dirty =
|
||||
Object.keys(draft.base).length > 0 ||
|
||||
Object.keys(draft.names).length > 0 ||
|
||||
(draft.cash !== null && draft.cash !== plan.initialCash);
|
||||
|
||||
async function closeGuarded() {
|
||||
if (!dirty) return onClose();
|
||||
const ok = await confirm({
|
||||
title: "Eingaben verwerfen?",
|
||||
message:
|
||||
"Du hast Angaben erfasst, die noch nicht gespeichert sind. Schliesst du jetzt, gehen sie verloren. " +
|
||||
"Mit «Bestandsaufnahme abschliessen» werden sie übernommen.",
|
||||
confirmLabel: "Verwerfen",
|
||||
danger: true,
|
||||
});
|
||||
if (ok) onClose();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await saveInventoryDraft(plan, draft);
|
||||
setDraft(emptyInventoryDraft());
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Bestandsaufnahme"
|
||||
subtitle="Alles, was du heute hast – Einkommen, Ausgaben, Guthaben, Immobilien, Schulden"
|
||||
onClose={closeGuarded}
|
||||
xwide
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Hier stehen nur Zahlen, die du <strong className="text-fg">nachschlagen</strong> kannst. Renditen,
|
||||
Lohnentwicklung und Teuerung kommen später – sie sind Annahmen, und eine Annahme gilt immer nur für
|
||||
einen bestimmten Zeitraum. Du triffst sie in den Lebensphasen.
|
||||
</div>
|
||||
|
||||
<InventoryFields
|
||||
plan={plan}
|
||||
draft={draft}
|
||||
setDraft={(fn) => setDraft((d) => fn(d))}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||
<Button variant="ghost" onClick={closeGuarded}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={save}>
|
||||
<Check className="h-4 w-4" /> {saving ? "Wird gespeichert…" : "Bestandsaufnahme abschliessen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, RotateCcw, SlidersHorizontal, X } from "lucide-react";
|
||||
import { AllocationChart } from "@/components/AllocationChart";
|
||||
import { SparquoteChart } from "@/components/SparquoteChart";
|
||||
import { WealthChart } from "@/components/WealthChart";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
buildSliders,
|
||||
describeSettings,
|
||||
isNeutral,
|
||||
kpisOf,
|
||||
runLive,
|
||||
type SliderDef,
|
||||
type SliderValues,
|
||||
} from "@/lib/livesim";
|
||||
import { UNIT_SUFFIX } from "@/lib/sensitivity";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
type ChartId = "wealth" | "allocation" | "cashflow";
|
||||
|
||||
const CHARTS: { id: ChartId; label: string; hint: string }[] = [
|
||||
{
|
||||
id: "wealth",
|
||||
label: "Vermögensverlauf",
|
||||
hint: "Gesamtvermögen über das Alter. Die blasse Linie ist dein unveränderter Plan.",
|
||||
},
|
||||
{
|
||||
id: "allocation",
|
||||
label: "Vermögensaufteilung",
|
||||
hint: "Wie sich das Vermögen zu Beginn und am Ende jeder Phase auf die Anlagen verteilt.",
|
||||
},
|
||||
{
|
||||
id: "cashflow",
|
||||
label: "Einkommen vs. Ausgaben",
|
||||
hint: "Die Fläche dazwischen ist die Spar- bzw. Verzehrquote.",
|
||||
},
|
||||
];
|
||||
|
||||
const BASE_COLOR = "var(--faint)";
|
||||
const LIVE_COLOR = "var(--chart-1)";
|
||||
|
||||
export function LiveSimDialog({
|
||||
plan: currentPlan,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Geregelt wird wahlweise am Arbeitsstand oder an einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
const [expandReturns, setExpandReturns] = useState(false);
|
||||
const [values, setValues] = useState<SliderValues>({});
|
||||
// Vom Nutzer überschriebene Reglerbereiche (Schlüssel -> [min, max]).
|
||||
const [bounds, setBounds] = useState<Record<string, [number, number]>>({});
|
||||
const [chart, setChart] = useState<ChartId>("wealth");
|
||||
const [showRanges, setShowRanges] = useState(false);
|
||||
|
||||
const sliders = useMemo(() => {
|
||||
const base = buildSliders(plan, expandReturns);
|
||||
return base.map((s) => {
|
||||
const b = bounds[s.key];
|
||||
return b ? { ...s, min: b[0], max: b[1] } : s;
|
||||
});
|
||||
}, [plan, expandReturns, bounds]);
|
||||
|
||||
// Referenz: der unveränderte Plan. Wird nur neu gerechnet, wenn sich der Plan ändert.
|
||||
const base = useMemo(() => {
|
||||
const computed = basis.computed;
|
||||
return { computed, kpis: kpisOf(computed) };
|
||||
}, [basis.computed]);
|
||||
|
||||
// Der Kern: bei JEDER Reglerbewegung synchron neu rechnen (~0.2 ms, siehe livesim.ts).
|
||||
const live = useMemo(
|
||||
() => runLive(plan, sliders, values, basis.resolvedActuals),
|
||||
[plan, sliders, values, basis.resolvedActuals]
|
||||
);
|
||||
|
||||
const neutral = isNeutral(sliders, values);
|
||||
const settings = describeSettings(sliders, values);
|
||||
|
||||
function setValue(key: string, v: number) {
|
||||
setValues((prev) => ({ ...prev, [key]: v }));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setValues({});
|
||||
}
|
||||
|
||||
// Beim Auf-/Zuklappen die Rendite-Regler zurücksetzen: Sammel- und Element-Regler bewegen
|
||||
// dieselben Werte, ein Übertrag würde doppelt zählen.
|
||||
function toggleExpand() {
|
||||
setValues((prev) => {
|
||||
const next: SliderValues = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
if (k === "d:returns" || k === "d:propertyGrowth" || k.startsWith("e:")) continue;
|
||||
next[k] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setExpandReturns((v) => !v);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="ui-pop flex w-full max-w-6xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<SlidersHorizontal className="h-5 w-5 text-accent" /> Live-Simulation
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VersionBar
|
||||
scenarioId={currentPlan.id}
|
||||
versionId={versionId}
|
||||
onChange={(id) => {
|
||||
setVersionId(id);
|
||||
setValues({});
|
||||
}}
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
<AnalysisBar basis={basis} onChange={() => setValues({})} />
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Dreh an den Reglern und sieh sofort, was passiert. <strong className="text-fg">Nichts davon wird
|
||||
gespeichert</strong> – dein Plan bleibt unverändert, du brauchst für kein Durchspielen eine
|
||||
Szenario-Kopie. Die Regler benutzen dieselben Umrechnungen wie die Einflussfaktoren-Analyse.
|
||||
</p>
|
||||
|
||||
{/* Kennzahlenleiste: die eigentliche Antwort. Die Grafik zeigt WANN, das hier WIE VIEL. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<DeltaCard
|
||||
label={basis.metric === "real" ? "Endvermögen (real)" : "Endvermögen (nominal)"}
|
||||
base={basis.metric === "real" ? base.kpis.endReal : base.kpis.endNominal}
|
||||
live={basis.metric === "real" ? live.kpis.endReal : live.kpis.endNominal}
|
||||
/>
|
||||
<DeltaCard
|
||||
label={basis.metric === "real" ? "Endvermögen (nominal)" : "Endvermögen (real)"}
|
||||
base={basis.metric === "real" ? base.kpis.endNominal : base.kpis.endReal}
|
||||
live={basis.metric === "real" ? live.kpis.endNominal : live.kpis.endReal}
|
||||
/>
|
||||
<RuinCard baseAge={base.kpis.ruinAge} liveAge={live.kpis.ruinAge} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
|
||||
{/* --- Links: Regler --- */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Parameter
|
||||
<InfoBubble text="Es erscheinen nur Regler, die in diesem Plan überhaupt etwas bewegen. Die Standardbereiche sind Vorschläge – du kannst beide Enden anpassen." />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
disabled={neutral}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" /> Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="flex items-center gap-1.5 self-start rounded-lg border border-dashed border-border px-2.5 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
{expandReturns ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
{expandReturns ? "Renditen wieder zusammenfassen" : "Renditen einzeln aufschlüsseln"}
|
||||
</button>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-muted">
|
||||
<input type="checkbox" checked={showRanges} onChange={() => setShowRanges((v) => !v)} />
|
||||
Bereiche anpassen
|
||||
</label>
|
||||
|
||||
<div className="flex max-h-[46vh] flex-col gap-3 overflow-y-auto pr-1">
|
||||
{sliders.map((s) => (
|
||||
<Slider
|
||||
key={s.key}
|
||||
def={s}
|
||||
value={values[s.key] ?? s.neutral}
|
||||
showRange={showRanges}
|
||||
onChange={(v) => setValue(s.key, v)}
|
||||
onBounds={(min, max) => setBounds((prev) => ({ ...prev, [s.key]: [min, max] }))}
|
||||
/>
|
||||
))}
|
||||
{sliders.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Dieser Plan enthält noch keine Elemente, an denen sich etwas regeln liesse.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">
|
||||
Aktive Einstellung
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted">{settings.join(" · ")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- Rechts: Grafik --- */}
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<div className="inline-flex flex-wrap gap-0.5 self-start rounded-lg border border-border bg-surface-2 p-0.5 text-xs">
|
||||
{CHARTS.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setChart(c.id)}
|
||||
className={`rounded-md px-2.5 py-1 font-medium transition-colors ${
|
||||
chart === c.id ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted">{CHARTS.find((c) => c.id === chart)!.hint}</p>
|
||||
|
||||
<div className="min-w-0 rounded-xl border border-border bg-surface p-3">
|
||||
{chart === "wealth" && (
|
||||
<WealthChart
|
||||
series={[
|
||||
// Referenz zuerst, damit die Live-Linie darüber liegt.
|
||||
{ id: "base", label: "Dein Plan", color: BASE_COLOR, computed: base.computed },
|
||||
{ id: "live", label: "Simuliert", color: LIVE_COLOR, computed: live.computed },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{chart === "allocation" && <AllocationChart computed={live.computed} />}
|
||||
{chart === "cashflow" && <SparquoteChart computed={live.computed} />}
|
||||
</div>
|
||||
|
||||
{chart !== "wealth" && (
|
||||
<p className="text-[11px] text-faint">
|
||||
Diese Grafik zeigt nur den simulierten Stand. Die Gegenüberstellung mit deinem
|
||||
unveränderten Plan liefert der Vermögensverlauf – und die Zahlen oben.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ein Regler. Die Zahl steht bewusst neben dem Schieber und ist auch direkt eingebbar --
|
||||
// mit der Maus trifft man 5.2 % nicht zuverlässig.
|
||||
function Slider({
|
||||
def,
|
||||
value,
|
||||
showRange,
|
||||
onChange,
|
||||
onBounds,
|
||||
}: {
|
||||
def: SliderDef;
|
||||
value: number;
|
||||
showRange: boolean;
|
||||
onChange: (v: number) => void;
|
||||
onBounds: (min: number, max: number) => void;
|
||||
}) {
|
||||
const suffix = UNIT_SUFFIX[def.unit];
|
||||
const touched = value !== def.neutral;
|
||||
return (
|
||||
<div className={`rounded-lg border p-2.5 ${touched ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"}`}>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="flex min-w-0 items-center text-xs font-medium text-fg">
|
||||
<span className="truncate">{def.label}</span>
|
||||
<InfoBubble text={def.help} />
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
value={value}
|
||||
step={def.step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-16 rounded border border-border bg-surface px-1 py-0.5 text-right text-xs text-fg"
|
||||
/>
|
||||
<span className="text-[10px] text-faint">{suffix}</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full accent-[var(--accent)]"
|
||||
/>
|
||||
{showRange ? (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-[10px] text-faint">
|
||||
<input
|
||||
type="number"
|
||||
value={def.min}
|
||||
step={def.step}
|
||||
onChange={(e) => onBounds(Number(e.target.value), def.max)}
|
||||
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
|
||||
/>
|
||||
<span>bis</span>
|
||||
<input
|
||||
type="number"
|
||||
value={def.max}
|
||||
step={def.step}
|
||||
onChange={(e) => onBounds(def.min, Number(e.target.value))}
|
||||
className="w-14 rounded border border-border bg-surface px-1 py-0.5 text-right text-fg"
|
||||
/>
|
||||
<span className="ml-auto">neutral: {def.neutral} {suffix}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 flex justify-between text-[10px] text-faint">
|
||||
<span>{def.min}</span>
|
||||
<span>{def.max}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeltaCard({ label, base, live }: { label: string; base: number; live: number }) {
|
||||
const diff = live - base;
|
||||
const tone = diff === 0 ? "text-fg" : diff > 0 ? "text-success" : "text-danger";
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{label}</div>
|
||||
<div className="mt-0.5 text-lg font-semibold text-fg">{formatChf(live)}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
Plan: {formatChf(base)}
|
||||
{diff !== 0 && (
|
||||
<span className={`ml-1.5 font-semibold ${tone}`}>
|
||||
{diff > 0 ? "+" : "−"}
|
||||
{formatChf(Math.abs(diff))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ein gekippter Plan ist die wichtigste Einzelinformation -- einer Verlaufslinie sieht man
|
||||
// nicht zuverlässig an, dass das Kapital zwischendurch unter null gefallen ist.
|
||||
function RuinCard({ baseAge, liveAge }: { baseAge: number | null; liveAge: number | null }) {
|
||||
const broken = liveAge !== null;
|
||||
return (
|
||||
<div className={`rounded-xl border p-3 ${broken ? "border-danger bg-danger-soft" : "border-border bg-surface-2"}`}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">Kapital reicht</div>
|
||||
<div className={`mt-0.5 text-lg font-semibold ${broken ? "text-danger" : "text-success"}`}>
|
||||
{broken ? `aufgebraucht mit ${liveAge}` : "bis Planende"}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{baseAge === null ? "Plan: reicht bis Planende" : `Plan: aufgebraucht mit ${baseAge}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Area, CartesianGrid, ComposedChart, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Dices, X } from "lucide-react";
|
||||
import { NumberField, SelectField, MoneyField, RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { CURRENT, loadVersionPlan, VersionSelect } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
buildElementGroups,
|
||||
defaultVolatilityLevel,
|
||||
floorFor,
|
||||
paramsForScenario,
|
||||
plannedReturnOf,
|
||||
probabilityAtLeast,
|
||||
runMonteCarloMulti,
|
||||
INFLATION_VOLATILITY_LEVELS,
|
||||
RETURN_BEARING,
|
||||
RETURN_VOLATILITY_LEVELS,
|
||||
type ElementGroup,
|
||||
type ElementMcParams,
|
||||
type InflationVolatilityLevel,
|
||||
type ReturnVolatilityLevel,
|
||||
type ScenarioMcResult,
|
||||
type ScenarioRunInput,
|
||||
} from "@/lib/montecarlo";
|
||||
import { CATEGORY_LABELS } from "@/lib/elements";
|
||||
import type { PlanInput, ScenarioMeta } from "@/lib/types";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
// Farben der Szenario-Serien -- wie im Vermögensverlauf, damit die Zuordnung vertraut bleibt.
|
||||
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
|
||||
|
||||
// Ergebnis je Szenario: ZWEI Welten (historische vs. geplante Renditen), aus jeder werden
|
||||
// ZWEI Schwellen abgelesen (Plan-Endbetrag und Zielbetrag) -- die vier Fälle aus 4.12.7.
|
||||
interface Outcome {
|
||||
scenarioId: string;
|
||||
name: string;
|
||||
planEnd: number;
|
||||
target: number;
|
||||
f1: number; // historische Welt, Schwelle Plan-Endbetrag -> Realismus der Planung
|
||||
f2: number; // geplante Welt, Schwelle Plan-Endbetrag -> Nullpunkt für das Urteil
|
||||
f3: number; // historische Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie früher läuft
|
||||
f4: number; // geplante Welt, Schwelle Zielbetrag -> Ziel, wenn der Markt wie geplant läuft
|
||||
medianHist: number;
|
||||
medianPlan: number;
|
||||
ruin: number; // aus der historischen Welt (das ehrliche Risikobild)
|
||||
runs: number;
|
||||
}
|
||||
|
||||
const RETURN_LEVEL_OPTIONS: { value: ReturnVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "moderat", label: "Moderat" },
|
||||
{ value: "hoch", label: "Hoch" },
|
||||
{ value: "sehr_hoch", label: "Sehr hoch" },
|
||||
{ value: "manuell", label: "Manuell" },
|
||||
];
|
||||
const INFLATION_LEVEL_OPTIONS: { value: InflationVolatilityLevel; label: string }[] = [
|
||||
{ value: "sehr_niedrig", label: "Sehr niedrig" },
|
||||
{ value: "niedrig", label: "Niedrig" },
|
||||
{ value: "manuell", label: "Manuell" },
|
||||
];
|
||||
|
||||
const RETURN_HELP =
|
||||
"Wie stark die Jahresrendite schwankt. Sehr niedrig ≈ Staatsanleihen/Geldmarkt · Niedrig ≈ Immobilien, defensive Mischportfolios · Moderat ≈ breit diversifizierte Aktien-ETFs/Fonds · Hoch ≈ Einzelaktien, Branchen-/Schwellenländerfonds · Sehr hoch ≈ Kryptowährungen, hochspekulative Anlagen.";
|
||||
const INFLATION_HELP =
|
||||
"Wie stark die Inflation schwankt. Für die Schweiz ist sie historisch sehr stabil (Sehr niedrig ≈ 1 %). Höhere Stufen wären Hyperinflations-Annahmen.";
|
||||
|
||||
interface ElementDraft {
|
||||
mean: string;
|
||||
level: ReturnVolatilityLevel;
|
||||
manualSigma: string;
|
||||
}
|
||||
|
||||
interface LoadedScenario {
|
||||
id: string;
|
||||
name: string;
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
}
|
||||
|
||||
function returnSigma(el: ElementDraft): number {
|
||||
return el.level === "manuell" ? Number(el.manualSigma) || 0 : RETURN_VOLATILITY_LEVELS[el.level];
|
||||
}
|
||||
function inflationSigma(level: InflationVolatilityLevel, manual: string): number {
|
||||
return level === "manuell" ? Number(manual) || 0 : INFLATION_VOLATILITY_LEVELS[level];
|
||||
}
|
||||
function orderedPhaseIds(plan: PlanInput): string[] {
|
||||
return [...plan.phases].sort((a, b) => a.sequenceNumber - b.sequenceNumber).map((p) => p.id);
|
||||
}
|
||||
|
||||
// Deterministische Planungslinie eines Szenarios (Alterspunkte = Phasengrenzen).
|
||||
function detPointsOf(plan: PlanInput, computed: PlanComputed): { age: number; det: number }[] {
|
||||
const startAge = plan.persons.find((p) => p.role === "PERSON_A")?.age ?? plan.persons[0]?.age ?? 0;
|
||||
if (computed.phases.length === 0) return [];
|
||||
const pts = [{ age: startAge, det: computed.phases[0].startWealthNominal }];
|
||||
let acc = 0;
|
||||
for (const ph of computed.phases) {
|
||||
acc += ph.durationYears;
|
||||
pts.push({ age: startAge + acc, det: ph.endWealthNominal });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
export function MonteCarloDialog({
|
||||
plan,
|
||||
computed,
|
||||
meta,
|
||||
scenarios,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
meta: ScenarioMeta;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
// Alle Szenarien dieses Plans (auch nicht ausgewählte) -- sie werden geladen, damit sich
|
||||
// die Herkunfts-Kette der Elemente auch über übersprungene Zwischen-Szenarien auflöst.
|
||||
scenarios: ScenarioMeta[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState<Record<string, LoadedScenario>>(() => ({
|
||||
[meta.id]: { id: meta.id, name: meta.name, plan, computed },
|
||||
}));
|
||||
const basis = useAnalysisBasis(plan, actuals, origins);
|
||||
const [loading, setLoading] = useState(scenarios.some((s) => s.id !== meta.id));
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([meta.id]);
|
||||
|
||||
// EIN Zielbetrag für alle Szenarien, vorbelegt mit dem Plan-Endbetrag des offenen Szenarios.
|
||||
const [manualTarget, setManualTarget] = useState<number>(Math.max(0, computed.nachlass));
|
||||
|
||||
const [runs, setRuns] = useState(1000);
|
||||
const [inflMean, setInflMean] = useState("");
|
||||
const [inflLevel, setInflLevel] = useState<InflationVolatilityLevel>("sehr_niedrig");
|
||||
const [inflManual, setInflManual] = useState("1");
|
||||
const [drafts, setDrafts] = useState<Record<string, ElementDraft>>({});
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const [progress, setProgress] = useState({ index: 0, count: 1, fraction: 0 });
|
||||
// Gewaehlter Stand je Szenario (Default: Arbeitsstand) und die dazu geladenen Snapshots.
|
||||
const [versionByScenario, setVersionByScenario] = useState<Record<string, string>>({});
|
||||
const [versionPlans, setVersionPlans] = useState<Record<string, PlanInput>>({});
|
||||
|
||||
const [outcomes, setOutcomes] = useState<Outcome[] | null>(null);
|
||||
// Fächer/Bänder stammen aus der historischen Welt.
|
||||
const [fanRes, setFanRes] = useState<ScenarioMcResult[] | null>(null);
|
||||
|
||||
const scenarioKey = scenarios.map((s) => s.id).join(",");
|
||||
|
||||
useEffect(() => {
|
||||
const missing = scenarios.filter((s) => s.id !== meta.id);
|
||||
if (missing.length === 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
missing.map(async (s) => {
|
||||
const data = await api.get<{ plan: PlanInput; computed: PlanComputed }>(`/api/scenarios/${s.id}`);
|
||||
return [s.id, { id: s.id, name: s.name, plan: data.plan, computed: data.computed }] as const;
|
||||
})
|
||||
);
|
||||
if (cancelled) return;
|
||||
setLoaded((prev) => ({ ...prev, ...Object.fromEntries(entries) }));
|
||||
} catch (e) {
|
||||
if (!cancelled) setLoadError(e instanceof Error ? e.message : "Szenarien konnten nicht geladen werden.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scenarioKey, meta.id]);
|
||||
|
||||
async function chooseVersion(scenarioId: string, versionId: string) {
|
||||
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: versionId }));
|
||||
clearResults();
|
||||
if (versionId === CURRENT) return;
|
||||
const key = `${scenarioId}:${versionId}`;
|
||||
if (versionPlans[key]) return;
|
||||
try {
|
||||
const loadedPlan = await loadVersionPlan(scenarioId, versionId);
|
||||
if (loadedPlan) setVersionPlans((prev) => ({ ...prev, [key]: loadedPlan }));
|
||||
} catch {
|
||||
setVersionByScenario((prev) => ({ ...prev, [scenarioId]: CURRENT }));
|
||||
}
|
||||
}
|
||||
|
||||
const allLoaded = useMemo(
|
||||
() => scenarios.map((s) => loaded[s.id]).filter((s): s is LoadedScenario => !!s),
|
||||
[scenarios, loaded]
|
||||
);
|
||||
|
||||
// Ersetzt je Szenario den Arbeitsstand durch den gewaehlten Snapshot. Der Snapshot ist
|
||||
// bereits ein PlanInput, das Gerechnete entsteht lokal.
|
||||
const resolved = useMemo(() => {
|
||||
const out: Record<string, LoadedScenario> = {};
|
||||
for (const sc of allLoaded) {
|
||||
const vid = versionByScenario[sc.id] ?? CURRENT;
|
||||
const snap = vid === CURRENT ? null : versionPlans[`${sc.id}:${vid}`];
|
||||
out[sc.id] = snap ? { ...sc, plan: snap, computed: computePlan(snap) } : sc;
|
||||
}
|
||||
return out;
|
||||
}, [allLoaded, versionByScenario, versionPlans]);
|
||||
|
||||
const resolvedList = useMemo(() => Object.values(resolved), [resolved]);
|
||||
|
||||
const groups = useMemo(() => buildElementGroups(resolvedList, selectedIds), [resolvedList, selectedIds]);
|
||||
|
||||
function draftFor(g: ElementGroup): ElementDraft {
|
||||
return drafts[g.rootId] ?? { mean: "", level: defaultVolatilityLevel(g.category), manualSigma: "10" };
|
||||
}
|
||||
function setDraft(rootId: string, group: ElementGroup, patch: Partial<ElementDraft>) {
|
||||
setDrafts((prev) => ({ ...prev, [rootId]: { ...draftFor(group), ...prev[rootId], ...patch } }));
|
||||
clearResults();
|
||||
}
|
||||
function clearResults() {
|
||||
setOutcomes(null);
|
||||
setFanRes(null);
|
||||
}
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
clearResults();
|
||||
}
|
||||
|
||||
const selected = selectedIds.map((id) => resolved[id]).filter((s): s is LoadedScenario => !!s);
|
||||
const anyReturnBearing = selected.some((s) => s.plan.elements.some((e) => RETURN_BEARING.includes(e.category)));
|
||||
|
||||
const missingHist = inflMean.trim() === "" || groups.some((g) => draftFor(g).mean.trim() === "");
|
||||
const missing = missingHist || manualTarget <= 0;
|
||||
const canRun = !missing && selected.length > 0 && anyReturnBearing && !loading;
|
||||
|
||||
// Immer zwei Durchgänge: historische Welt und geplante Welt.
|
||||
const estSeconds = Math.max(1, Math.round((runs * Math.max(1, selected.length) * 2) / 6000));
|
||||
|
||||
async function run() {
|
||||
setRunning(true);
|
||||
clearResults();
|
||||
setProgress({ index: 0, count: selected.length * 2, fraction: 0 });
|
||||
try {
|
||||
// Streuung (Sigma) je logischem Element -- in BEIDEN Welten dieselbe. Nur der
|
||||
// Mittelpunkt unterscheidet sie.
|
||||
const sigmaByRoot: Record<string, number> = Object.fromEntries(
|
||||
groups.map((g) => [g.rootId, returnSigma(draftFor(g))])
|
||||
);
|
||||
const floorByRoot: Record<string, number> = Object.fromEntries(groups.map((g) => [g.rootId, floorFor(g.category)]));
|
||||
const rootByMember = new Map<string, string>();
|
||||
for (const g of groups) for (const id of g.memberIds) rootByMember.set(id, g.rootId);
|
||||
|
||||
// Gemeinsamer Seed für beide Welten und alle Szenarien.
|
||||
const seed = (Math.random() * 2 ** 32) >>> 0;
|
||||
const inflSig = inflationSigma(inflLevel, inflManual);
|
||||
const inputs: ScenarioRunInput[] = selected.map((s) => ({
|
||||
scenarioId: s.id,
|
||||
name: s.name,
|
||||
plan: s.plan,
|
||||
// Der mitgegebene Zielbetrag ist nur ein Vorgabewert; die vier Fälle werden
|
||||
// anschliessend direkt aus der Verteilung abgelesen.
|
||||
target: manualTarget,
|
||||
}));
|
||||
const onProg = (pass: number) => (index: number, _count: number, fraction: number) =>
|
||||
setProgress({ index: pass * selected.length + index, count: selected.length * 2, fraction });
|
||||
|
||||
// --- Welt A: HISTORISCHE Renditen (das realistische Bild) ---
|
||||
const paramByRoot: Record<string, ElementMcParams> = Object.fromEntries(
|
||||
groups.map((g) => [g.rootId, { mean: Number(draftFor(g).mean) || 0, sigma: sigmaByRoot[g.rootId], floor: floorByRoot[g.rootId] }])
|
||||
);
|
||||
const hist = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: () => Number(inflMean) || 0,
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => paramsForScenario(p, groups, paramByRoot),
|
||||
},
|
||||
onProg(0)
|
||||
);
|
||||
|
||||
// --- Welt B: GEPLANTE Renditen (die Welt der eigenen Annahmen) ---
|
||||
const planned = await runMonteCarloMulti(
|
||||
inputs,
|
||||
{
|
||||
runs,
|
||||
inflationMeanFor: (p) => p.inflationRateDefault,
|
||||
inflationSigma: inflSig,
|
||||
seed,
|
||||
elementsFor: (p) => {
|
||||
const ids = orderedPhaseIds(p);
|
||||
const out: Record<string, ElementMcParams> = {};
|
||||
for (const e of p.elements) {
|
||||
if (!RETURN_BEARING.includes(e.category)) continue;
|
||||
const root = rootByMember.get(e.id);
|
||||
const sigma = root ? sigmaByRoot[root] : returnSigma({ mean: "", level: defaultVolatilityLevel(e.category), manualSigma: "10" });
|
||||
out[e.id] = { mean: plannedReturnOf(e, ids), sigma, floor: floorFor(e.category) };
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
onProg(1)
|
||||
);
|
||||
|
||||
// Vier Fälle aus zwei Verteilungen ablesen. Weil Fall 1 und 3 aus DERSELBEN Verteilung
|
||||
// stammen, gilt zwingend: tieferes Ziel -> nie kleinere Wahrscheinlichkeit.
|
||||
const out: Outcome[] = selected.map((s) => {
|
||||
const h = hist.find((r) => r.scenarioId === s.id)!;
|
||||
const b = planned.find((r) => r.scenarioId === s.id)!;
|
||||
const planEnd = Math.max(0, s.computed.nachlass);
|
||||
return {
|
||||
scenarioId: s.id,
|
||||
name: s.name,
|
||||
planEnd,
|
||||
target: manualTarget,
|
||||
f1: probabilityAtLeast(h.finalWealthSorted, planEnd),
|
||||
f2: probabilityAtLeast(b.finalWealthSorted, planEnd),
|
||||
f3: probabilityAtLeast(h.finalWealthSorted, manualTarget),
|
||||
f4: probabilityAtLeast(b.finalWealthSorted, manualTarget),
|
||||
medianHist: h.finalWealthMedian,
|
||||
medianPlan: b.finalWealthMedian,
|
||||
ruin: h.ruinProbability,
|
||||
runs: h.runs,
|
||||
};
|
||||
});
|
||||
setOutcomes(out);
|
||||
setFanRes(hist);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
const overallProgress = progress.count > 0 ? (progress.index + progress.fraction) / progress.count : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Dices className="h-5 w-5 text-accent" /> Monte-Carlo-Simulation
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnalysisBar basis={basis} onChange={clearResults} />
|
||||
|
||||
{/* Startpunkt: bewusst abgeleitet statt eingebbar. Ein frei gesetztes Jahr würde
|
||||
Jahre als sicher behandeln, die nie erfasst wurden. */}
|
||||
<p className="text-[11px] text-muted">
|
||||
{basis.source === "ACTUAL" && basis.simStartCalendarYear
|
||||
? `Simuliert ab ${basis.simStartCalendarYear}. Die Jahre davor sind durch deine effektiven Werte belegt und werden nicht gewürfelt.`
|
||||
: plan.startYear
|
||||
? `Simuliert ab ${plan.startYear} (Planbeginn).`
|
||||
: "Simuliert ab Planbeginn."}
|
||||
</p>
|
||||
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was ist das?</strong> Dein Plan rechnet mit einer festen Rendite und Inflation
|
||||
pro Jahr. Real schwanken beide. Die Simulation würfelt <strong className="text-fg">viele tausend mögliche
|
||||
Verläufe</strong> und zeigt, wie oft ein Betrag erreicht wird.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Zwei Welten, ein Lauf:</strong> Gerechnet wird einmal um die{" "}
|
||||
<strong className="text-fg">historischen</strong> Renditen (wie der Markt bisher lief) und einmal um deine{" "}
|
||||
<strong className="text-fg">geplanten</strong> Werte (wie du rechnest). Aus beiden Verteilungen werden
|
||||
derselbe Plan-Endbetrag und dein Zielbetrag abgelesen – deshalb können sich die Zahlen nicht widersprechen.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-fg">Verteilung:</strong> Renditen werden mit «fetten Rändern» gezogen (Extremcrashs so
|
||||
häufig wie in der Realität, nicht wie in der Glockenkurve), und alle riskanten Anlagen fallen in einem
|
||||
Marktschock gemeinsam. Alle Szenarien laufen mit demselben Zufalls-Seed, damit Unterschiede nicht blosses
|
||||
Rauschen sind. <em>Die Prozentzahl gilt immer nur relativ zu deinen Annahmen.</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Szenario-Auswahl */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Szenarien
|
||||
<InfoBubble text="Wähle, für welche Szenarien dieses Plans gerechnet werden soll. Streuung und historische Renditen werden nur einmal je logischem Element erfasst und für alle Szenarien verwendet." />
|
||||
</div>
|
||||
{loading && <p className="text-xs text-muted">Szenarien werden geladen…</p>}
|
||||
{loadError && <p className="text-xs text-danger">{loadError}</p>}
|
||||
<div className="flex flex-col gap-2">
|
||||
{scenarios.map((s) => {
|
||||
const isLoaded = !!loaded[s.id];
|
||||
const checked = selectedIds.includes(s.id);
|
||||
const nachlass = loaded[s.id]?.computed.nachlass;
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||||
checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<label className="flex min-w-0 flex-1 items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} disabled={!isLoaded} onChange={() => toggleScenario(s.id)} />
|
||||
<span className="truncate font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && <span className="rounded bg-surface px-1.5 text-[10px] text-muted">Basis</span>}
|
||||
{!isLoaded && <span className="text-[11px] text-faint">lädt…</span>}
|
||||
</label>
|
||||
{checked && (
|
||||
<VersionSelect
|
||||
compact
|
||||
scenarioId={s.id}
|
||||
value={versionByScenario[s.id] ?? CURRENT}
|
||||
onChange={(vid) => void chooseVersion(s.id, vid)}
|
||||
/>
|
||||
)}
|
||||
{checked && typeof nachlass === "number" && (
|
||||
<span className="text-[11px] text-muted" title="Planungs-Endbetrag dieses Szenarios (read-only)">
|
||||
Planungs-Endbetrag: <strong className="text-fg">{formatChf(Math.max(0, nachlass))}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selected.length === 0 && <p className="mt-2 text-xs text-danger">Bitte mindestens ein Szenario auswählen.</p>}
|
||||
</div>
|
||||
|
||||
{/* Zielbetrag */}
|
||||
<div className="sm:max-w-xs">
|
||||
<MoneyField
|
||||
// Das Feld dreht mit der gewählten Grösse mit -- sonst prüft man einen nominalen
|
||||
// Zielbetrag gegen ein reales Endvermögen und vergleicht Äpfel mit Birnen.
|
||||
label={
|
||||
basis.metric === "real"
|
||||
? "Zielbetrag (Endvermögen, REAL)"
|
||||
: "Zielbetrag (Endvermögen, NOMINAL)"
|
||||
}
|
||||
help={
|
||||
basis.metric === "real"
|
||||
? "Dein frei gewähltes Ziel in HEUTIGER Kaufkraft. Geprüft wird gegen das reale Endvermögen."
|
||||
: "Dein frei gewähltes Ziel in Franken des Zieljahres (nominal). Geprüft wird gegen das nominale Endvermögen."
|
||||
}
|
||||
value={manualTarget}
|
||||
onChange={(v) => {
|
||||
setManualTarget(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Inflation */}
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Inflation (Plan-Ebene)</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<RequiredNumberField
|
||||
label="Ø Inflation letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt der historischen Welt. Die geplante Welt würfelt um deine Planannahme von ${plan.inflationRateDefault} %. CH langfristig ~2 %.`}
|
||||
value={inflMean}
|
||||
onChange={(v) => {
|
||||
setInflMean(v);
|
||||
clearResults();
|
||||
}}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={INFLATION_HELP}
|
||||
value={inflLevel}
|
||||
onChange={(v: InflationVolatilityLevel) => {
|
||||
setInflLevel(v);
|
||||
clearResults();
|
||||
}}
|
||||
options={INFLATION_LEVEL_OPTIONS}
|
||||
/>
|
||||
{inflLevel === "manuell" ? (
|
||||
<NumberField label="Standardabw. (%)" step={0.1} value={Number(inflManual) || 0} onChange={(v) => { setInflManual(String(v)); clearResults(); }} />
|
||||
) : (
|
||||
<ReadOnlySigma value={INFLATION_VOLATILITY_LEVELS[inflLevel]} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Renditetragende Elemente */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Renditetragende Elemente
|
||||
{selected.length > 1 && (
|
||||
<InfoBubble text="Ein Element, das in mehreren Szenarien vorkommt, erscheint hier nur einmal – erkannt über seine Herkunft beim Kopieren des Szenarios." />
|
||||
)}
|
||||
</div>
|
||||
{groups.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Die ausgewählten Szenarien haben keine renditetragenden Elemente (PK, 3a, Sonstiges Vermögen, Immobilie).
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{groups.map((g) => {
|
||||
const d = draftFor(g);
|
||||
const partial = g.scenarioIds.length < selected.length;
|
||||
const planned = plannedReturnOf(
|
||||
(loaded[g.scenarioIds[0]]?.plan.elements ?? []).find((e) => g.memberIds.includes(e.id)) ??
|
||||
({ category: g.category, phaseValues: {} } as never),
|
||||
orderedPhaseIds(loaded[g.scenarioIds[0]]?.plan ?? plan)
|
||||
);
|
||||
return (
|
||||
<div key={g.rootId} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-fg">{g.name}</span>
|
||||
<span className="text-xs text-faint">{CATEGORY_LABELS[g.category]}</span>
|
||||
{partial && (
|
||||
<span className="rounded bg-surface px-1.5 py-0.5 text-[10px] text-muted">
|
||||
nur in: {g.scenarioIds.map((id) => loaded[id]?.name ?? id).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<RequiredNumberField
|
||||
label="Ø Rendite letzte 20 J. (%)"
|
||||
help={`Der Mittelpunkt der historischen Welt – die realistische Rendite dieser Anlage. Deine Planung rechnet mit ${planned} %; damit läuft die geplante Welt.`}
|
||||
value={d.mean}
|
||||
onChange={(v) => setDraft(g.rootId, g, { mean: v })}
|
||||
/>
|
||||
<SelectField
|
||||
label="Streuung"
|
||||
help={RETURN_HELP}
|
||||
value={d.level}
|
||||
onChange={(v: ReturnVolatilityLevel) => setDraft(g.rootId, g, { level: v })}
|
||||
options={RETURN_LEVEL_OPTIONS}
|
||||
/>
|
||||
{d.level === "manuell" ? (
|
||||
<NumberField label="Standardabw. (%)" step={0.5} value={Number(d.manualSigma) || 0} onChange={(v) => setDraft(g.rootId, g, { manualSigma: String(v) })} />
|
||||
) : (
|
||||
<ReadOnlySigma value={RETURN_VOLATILITY_LEVELS[d.level]} />
|
||||
)}
|
||||
</div>
|
||||
{(g.category === "PENSION_FUND" || g.category === "PILLAR_3A") && (
|
||||
<p className="mt-2 text-[11px] text-faint">Boden 0 %: {CATEGORY_LABELS[g.category]} schreibt keine negative Rendite gut.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lauf-Parameter */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<SelectField
|
||||
label="Anzahl Simulationen"
|
||||
value={String(runs)}
|
||||
onChange={(v: string) => { setRuns(Number(v)); clearResults(); }}
|
||||
options={[
|
||||
{ value: "1000", label: "1'000 (schnell)" },
|
||||
{ value: "5000", label: "5'000" },
|
||||
{ value: "10000", label: "10'000 (genau)" },
|
||||
]}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{selected.length > 1 ? `${selected.length} Szenarien · ` : ""}
|
||||
2 Welten · geschätzt ~{estSeconds} s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missing && (
|
||||
<p className="text-xs text-danger">
|
||||
{missingHist
|
||||
? "Bitte alle Pflichtfelder (historische Ø-Werte) ausfüllen."
|
||||
: "Bitte einen Zielbetrag grösser 0 angeben."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun || running}
|
||||
onClick={run}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{running ? `Berechne… ${Math.round(overallProgress * 100)} %` : "Berechnung jetzt durchführen"}
|
||||
</button>
|
||||
{running && (
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div className="h-full bg-accent transition-all" style={{ width: `${overallProgress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{outcomes && fanRes && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SaveAnalysisButton
|
||||
planId={meta.planId}
|
||||
type="MONTE_CARLO"
|
||||
scenarioName={outcomes.length === 1 ? outcomes[0].name : `${outcomes.length} Szenarien`}
|
||||
versionLabel={null}
|
||||
build={() => buildMcResult(outcomes, fanRes, manualTarget, basis)}
|
||||
/>
|
||||
</div>
|
||||
<MonteCarloResults
|
||||
outcomes={outcomes}
|
||||
fanRes={fanRes}
|
||||
detPoints={
|
||||
fanRes.length === 1 && loaded[fanRes[0].scenarioId]
|
||||
? detPointsOf(loaded[fanRes[0].scenarioId].plan, loaded[fanRes[0].scenarioId].computed)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Baut die generische, druckfähige Momentaufnahme des MC-Ergebnisses (siehe analyses.ts).
|
||||
// Bewusst OHNE finalWealthSorted -- das sind zehntausende Zahlen je Lauf.
|
||||
function buildMcResult(
|
||||
outcomes: Outcome[],
|
||||
fanRes: ScenarioMcResult[],
|
||||
target: number,
|
||||
basis: { metric: string; source: string }
|
||||
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
const single = outcomes.length === 1;
|
||||
const table = {
|
||||
columns: ["Szenario", "Planung hist. (Plan)", "Ziel hist. (Plan)", "Ruin", "Median"],
|
||||
rows: outcomes.map((o) => [
|
||||
o.name,
|
||||
`${Math.round(o.f1 * 100)} % (${Math.round(o.f2 * 100)} %)`,
|
||||
`${Math.round(o.f3 * 100)} % (${Math.round(o.f4 * 100)} %)`,
|
||||
`${Math.round(o.ruin * 100)} %`,
|
||||
formatChf(o.medianHist),
|
||||
]),
|
||||
};
|
||||
// Fächer nur beim Einzelszenario; bei mehreren die Median-Linien.
|
||||
const chart = {
|
||||
kind: "line" as const,
|
||||
xLabel: "Alter",
|
||||
yFormat: "chf" as const,
|
||||
series: fanRes.map((r, i) => ({
|
||||
label: r.name,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
points: r.bands.map((b) => ({ x: b.age, y: b.p50 })),
|
||||
})),
|
||||
};
|
||||
return {
|
||||
metric: basis.metric,
|
||||
source: basis.source,
|
||||
scenarioName: single ? outcomes[0].name : `${outcomes.length} Szenarien`,
|
||||
summary: single ? `Realismus ${Math.round(outcomes[0].f1 * 100)} %` : null,
|
||||
inputs: {
|
||||
params: [
|
||||
{ label: "Zielbetrag", value: formatChf(target) },
|
||||
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "–" },
|
||||
{ label: "Grösse", value: basis.metric === "real" ? "real" : "nominal" },
|
||||
{ label: "Grundlage", value: basis.source === "ACTUAL" ? "effektiv" : "Plan" },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
params: [
|
||||
{ label: "Zielbetrag", value: formatChf(target) },
|
||||
{ label: "Läufe/Szenario", value: outcomes[0]?.runs.toLocaleString("de-CH") ?? "–" },
|
||||
],
|
||||
metrics: single
|
||||
? [
|
||||
{ label: "Realismus der Planung", value: `${Math.round(outcomes[0].f1 * 100)} %` },
|
||||
{ label: "Ziel erreicht (historisch)", value: `${Math.round(outcomes[0].f3 * 100)} %` },
|
||||
{ label: "Median (historisch)", value: formatChf(outcomes[0].medianHist) },
|
||||
]
|
||||
: [],
|
||||
table,
|
||||
chart,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ReadOnlySigma({ value }: { value: number }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Standardabweichung
|
||||
<InfoBubble text="Die hinter der gewählten Streuungsstufe hinterlegte Standardabweichung. Nur bei Manuell selbst eingebbar." />
|
||||
</label>
|
||||
<div className="w-full rounded-lg border border-dashed border-border bg-surface-2 px-2.5 py-1.5 text-sm text-muted">
|
||||
{value} %
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Toleranzband für das Urteil, in Prozentpunkten.
|
||||
const VERDICT_BAND = 5;
|
||||
|
||||
// Urteil über die Planung. Der Nullpunkt ist NICHT 50 %, sondern Fall 2: die Wahrscheinlichkeit,
|
||||
// den Plan-Endbetrag in der eigenen geplanten Welt zu erreichen. Die liegt wegen des
|
||||
// Volatilitäts-Drags systematisch unter 50 % (bei hoher Streuung deutlich). Verglichen wird
|
||||
// also Fall 1 gegen Fall 2 -- beides derselbe Schwellwert, nur andere Renditeannahme.
|
||||
function planVerdict(f1: number, f2: number): { text: string; tone: "danger" | "success" | "muted" } {
|
||||
const p1 = Math.round(f1 * 100);
|
||||
const diff = (f1 - f2) * 100;
|
||||
if (diff > VERDICT_BAND)
|
||||
return {
|
||||
text: `Deine Planung ist zurückhaltend gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – häufiger als mit deinen eigenen Annahmen.`,
|
||||
tone: "success",
|
||||
};
|
||||
if (diff < -VERDICT_BAND)
|
||||
return {
|
||||
text: `Deine Planung ist tendenziell zu optimistisch: Mit den historischen Renditen wird dein geplanter Endbetrag nur in ${p1} % der Fälle erreicht – seltener als mit deinen eigenen Annahmen.`,
|
||||
tone: "danger",
|
||||
};
|
||||
return {
|
||||
text: `Deine Planung ist realistisch gerechnet: Mit den historischen Renditen wird dein geplanter Endbetrag in ${p1} % der Fälle erreicht – praktisch gleich häufig wie mit deinen eigenen Annahmen.`,
|
||||
tone: "muted",
|
||||
};
|
||||
}
|
||||
|
||||
// Untergeordnetes Fall-3/4-Paar: dieselbe Zielschwelle, einmal in der geplanten und einmal in der
|
||||
// historischen Welt. Die Formulierung dreht automatisch, je nachdem welche Welt besser dasteht.
|
||||
function targetSentence(f3: number, f4: number): string {
|
||||
const p3 = Math.round(f3 * 100);
|
||||
const p4 = Math.round(f4 * 100);
|
||||
const planWord = f4 < f3 ? "schlecht" : "gut";
|
||||
const histWord = f4 < f3 ? "gut" : "zurückhaltend";
|
||||
return (
|
||||
`Wenn der Markt so «${planWord}» läuft, wie du es in deiner Planung vorgibst, liegt die Wahrscheinlichkeit, ` +
|
||||
`deinen Zielbetrag zu erreichen, bei ${p4} %. Läuft er so «${histWord}» wie in der Vergangenheit, liegt sie bei ${p3} %.`
|
||||
);
|
||||
}
|
||||
|
||||
function VerdictCard({ title, prob, verdict }: { title: string; prob: number; verdict: { text: string; tone: "danger" | "success" | "muted" } }) {
|
||||
const color = verdict.tone === "danger" ? "text-danger" : verdict.tone === "success" ? "text-success" : "text-fg";
|
||||
const border = verdict.tone === "danger" ? "border-danger bg-danger-soft" : "border-border bg-surface-2";
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${border}`}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-faint">{title}</span>
|
||||
<span className={`text-3xl font-semibold ${color}`}>{Math.round(prob * 100)} %</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{verdict.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonteCarloResults({
|
||||
outcomes,
|
||||
fanRes,
|
||||
detPoints,
|
||||
}: {
|
||||
outcomes: Outcome[];
|
||||
fanRes: ScenarioMcResult[]; // Fächer/Bänder aus der historischen Welt
|
||||
detPoints: { age: number; det: number }[];
|
||||
}) {
|
||||
const single = fanRes.length === 1;
|
||||
|
||||
const singleData = useMemo(() => {
|
||||
if (!single) return [];
|
||||
return fanRes[0].bands.map((b) => ({
|
||||
age: b.age,
|
||||
band: [b.p10, b.p90] as [number, number],
|
||||
median: b.p50,
|
||||
det: detPoints.find((d) => d.age === b.age)?.det ?? null,
|
||||
}));
|
||||
}, [fanRes, single, detPoints]);
|
||||
|
||||
const compareData = useMemo(() => {
|
||||
if (single) return [];
|
||||
const ages = Array.from(new Set(fanRes.flatMap((r) => r.bands.map((b) => b.age)))).sort((a, b) => a - b);
|
||||
return ages.map((age) => {
|
||||
const row: Record<string, number | null> = { age };
|
||||
for (const r of fanRes) row[r.scenarioId] = r.bands.find((b) => b.age === age)?.p50 ?? null;
|
||||
return row;
|
||||
});
|
||||
}, [fanRes, single]);
|
||||
|
||||
const one = outcomes[0];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||
{/* Deutung -- nur bei einem einzelnen Szenario; bei mehreren zählt die Tabelle. */}
|
||||
{single && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Prominent: Fall 1 */}
|
||||
<VerdictCard title="Realismus der Planung" prob={one.f1} verdict={planVerdict(one.f1, one.f2)} />
|
||||
|
||||
{/* Untergeordnet: Fall 3 und 4 als Paar */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-1 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Dein Zielbetrag {formatChf(one.target)}
|
||||
<InfoBubble text="Dieselbe Zielschwelle in beiden Welten. Fall 3 stammt aus derselben Verteilung wie das Urteil oben – liegt dein Ziel unter dem Plan-Endbetrag, kann seine Wahrscheinlichkeit deshalb nie tiefer sein." />
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted">{targetSentence(one.f3, one.f4)}</p>
|
||||
</div>
|
||||
|
||||
{/* Kleingedruckt: Fall 2 als Referenz plus typischer Ausgang je Welt */}
|
||||
<p className="text-[11px] leading-relaxed text-faint">
|
||||
Referenz: Mit deinen eigenen Planannahmen und Schwankung wird der Plan-Endbetrag von{" "}
|
||||
{formatChf(one.planEnd)} in {Math.round(one.f2 * 100)} % der Fälle erreicht – das ist der Nullpunkt für
|
||||
das Urteil oben, nicht 50 % (Schwankung frisst Rendite). Typischer Ausgang (Median):{" "}
|
||||
{formatChf(one.medianHist)} historisch, {formatChf(one.medianPlan)} nach Plan.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vergleichstabelle */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center text-xs font-semibold text-fg">
|
||||
Ergebnis je Szenario
|
||||
<InfoBubble text="«Planung» = P(Plan-Endbetrag erreicht) in der historischen Welt (Fall 1), in Klammern derselbe Schwellwert in der geplanten Welt (Fall 2). «Ziel» = P(Zielbetrag erreicht), historisch (Fall 3) und nach Plan (Fall 4). Ruin, Bandbreite und Median stammen aus der historischen Welt." />
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Planung hist. (Plan)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ziel hist. (Plan)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Ruin</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Pessim. (10 %)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Median</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Optim. (90 %)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{outcomes.map((o, i) => {
|
||||
const fan = fanRes.find((r) => r.scenarioId === o.scenarioId)!;
|
||||
return (
|
||||
<tr key={o.scenarioId} className="border-t border-border">
|
||||
<td className="px-3 py-2">
|
||||
<span className="flex items-center gap-2 font-medium text-fg">
|
||||
{!single && <span className="inline-block h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: PALETTE[i % PALETTE.length] }} />}
|
||||
{o.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-success">
|
||||
{Math.round(o.f1 * 100)} % <span className="font-normal text-faint">({Math.round(o.f2 * 100)} %)</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-accent">
|
||||
{Math.round(o.f3 * 100)} % <span className="font-normal text-faint">({Math.round(o.f4 * 100)} %)</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-danger">{Math.round(o.ruin * 100)} %</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP10)}</td>
|
||||
<td className="px-3 py-2 text-right text-fg">{formatChf(fan.finalWealthMedian)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(fan.finalWealthP90)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
{one.runs.toLocaleString("de-CH")} Läufe je Szenario und Welt · gemeinsamer Zufalls-Seed · Zielbetrag{" "}
|
||||
{formatChf(one.target)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fächer */}
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-fg">
|
||||
{single ? "Vermögensfächer nach Alter (nominal)" : "Vermögensverlauf im Median nach Alter (nominal)"}
|
||||
{" · historische Renditen"}
|
||||
</div>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
{single
|
||||
? "Das Band reicht vom pessimistischen (10 %) bis zum optimistischen (90 %) Fall, die dunkle Linie ist der Median. Die gestrichelte Linie ist deine deterministische Planung – sie liegt meist leicht über dem Median (Schwankung frisst Rendite)."
|
||||
: "Je Szenario die mittlere Entwicklung (Median). Wähle ein einzelnes Szenario, um den vollen Fächer zu sehen."}
|
||||
</p>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{single ? (
|
||||
<ComposedChart data={singleData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area dataKey="band" name="10–90 % Band" stroke="none" fill="var(--accent)" fillOpacity={0.16} isAnimationActive={false} />
|
||||
<Line dataKey="median" name="Median" stroke="var(--accent)" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line dataKey="det" name="Planung (deterministisch)" stroke="var(--muted)" strokeWidth={1.5} strokeDasharray="5 3" dot={false} connectNulls isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
) : (
|
||||
<LineChart data={compareData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="age" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v} J.`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)} />
|
||||
<Tooltip formatter={(v, name) => [typeof v === "number" ? formatChf(v) : v, name]} labelFormatter={(v) => `Alter ${v}`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{fanRes.map((r, i) => (
|
||||
<Line key={r.scenarioId} dataKey={r.scenarioId} name={r.name} stroke={PALETTE[i % PALETTE.length]} strokeWidth={2} dot={false} connectNulls isAnimationActive={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
"use client";
|
||||
|
||||
// Die Kartenansicht: eine Karte je Lebensphase (SPEZIFIKATION 3.15).
|
||||
//
|
||||
// Sie loest das Grundproblem der Matrix, nicht dessen Symptome. Die Matrix stellt Phasen
|
||||
// NEBENEINANDER -- gut zum Vergleichen, schlecht zum Arbeiten: Ab der vierten Phase scrollt
|
||||
// man horizontal, und das rechte Bearbeitungs-Panel legte sich ausgerechnet ueber die Spalte,
|
||||
// zu der man gerade gescrollt hatte. Eine Karte zeigt genau eine Phase, ueber die ganze
|
||||
// Breite, und getippt wird in der Karte selbst. Horizontales Scrollen gibt es nicht mehr.
|
||||
//
|
||||
// Die Karte erzaehlt die Phase als ZEITABLAUF in drei Abschnitten:
|
||||
// 1. Eintritt -- was beim Wechsel in diese Phase geschah (Uebergang aus der Vorphase).
|
||||
// Bei der ersten Phase stattdessen der Bestand aus der Bestandsaufnahme.
|
||||
// 2. Laufend -- was in diesen Jahren gilt: die Elemente mit ihren Annahmen.
|
||||
// 3. Ergebnis -- was am Ende dasteht.
|
||||
//
|
||||
// Der Uebergang gehoert bewusst zur FOLGEphase und nicht ans Ende der Vorphase: Das dabei
|
||||
// frei werdende Kapital wird in der Folgephase verteilt (seit 0.41 auch in der Anzeige, Kap.
|
||||
// 3.12.5). Der Preis ist, dass man den Hausverkauf gedanklich am Ende von Phase N trifft, ihn
|
||||
// aber auf Karte N+1 findet -- bewusst in Kauf genommen, weil dort die Folgen stehen.
|
||||
//
|
||||
// Die Matrix bleibt als VERGLEICHSANSICHT bestehen (Diff, Plan/Ist, Verlauf ueber alle
|
||||
// Phasen) -- dasselbe Datenmaterial, zwei Sichten.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Coins,
|
||||
Maximize2,
|
||||
Pencil,
|
||||
PiggyBank,
|
||||
Plus,
|
||||
Trash2,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
import {
|
||||
CashTransitionFields,
|
||||
ElementDetail,
|
||||
cashTransitionSummary,
|
||||
isCashTransitionAnswered,
|
||||
withCashTransitionDefaults,
|
||||
type CellContext,
|
||||
} from "@/components/ElementDetail";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { CATEGORY_LABELS, ownerLabel, type CashTransitionData, type PhaseData } from "@/lib/elements";
|
||||
import { capitalPot } from "@/lib/distribution";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { unconfirmedCells } from "@/lib/review";
|
||||
import type { PhaseComputed, PlanComputed, ElementPhaseComputed } from "@/lib/calculations";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
import type { PhaseRef } from "@/lib/ratefields";
|
||||
|
||||
export type ValueMode = "nominal" | "both" | "real";
|
||||
|
||||
// Alles, was die Karte an ihre Umgebung zurueckmeldet. Bewusst ein flaches Buendel statt
|
||||
// vieler Einzel-Props: Die Karte ist eine Ansicht, keine Datenhaltung.
|
||||
export interface CardActions {
|
||||
onEditPhase: (phaseId: string) => void;
|
||||
onDeletePhase: (phaseId: string, name: string) => void;
|
||||
onAddPhase: () => void;
|
||||
onAddElement: () => void;
|
||||
onDeleteElement: (elementId: string, name: string) => void;
|
||||
onDistributeRates: (phaseId: string) => void;
|
||||
onDistributeCapital: (phaseId: string) => void;
|
||||
onCashTransition: (fromPhaseId: string) => void;
|
||||
onOpenPhaseDetail: (phaseId: string) => void;
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
mode: ValueMode;
|
||||
activePhaseId: string | null;
|
||||
onSelectPhase: (phaseId: string) => void;
|
||||
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
|
||||
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
|
||||
// Elemente, die am Uebergang NACH einer Phase ueberhaupt etwas zu entscheiden haben.
|
||||
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
|
||||
cashTransitionFor: (phaseId: string) => CashTransitionData;
|
||||
actions: CardActions;
|
||||
}
|
||||
|
||||
const real = (v: number, d: number) => Math.round(v / (d || 1));
|
||||
|
||||
function val(v: number, deflator: number, mode: ValueMode): string {
|
||||
if (mode === "real") return formatChf(real(v, deflator));
|
||||
if (mode === "both") return `${formatChf(v)} (${formatChf(real(v, deflator))})`;
|
||||
return formatChf(v);
|
||||
}
|
||||
|
||||
export function PhaseCards({
|
||||
plan,
|
||||
computed,
|
||||
mode,
|
||||
activePhaseId,
|
||||
onSelectPhase,
|
||||
phaseContext,
|
||||
transitionContext,
|
||||
transitionElements,
|
||||
cashTransitionFor,
|
||||
actions,
|
||||
}: Props) {
|
||||
const phases = computed.phases;
|
||||
const index = Math.max(0, phases.findIndex((p) => p.id === activePhaseId));
|
||||
const phase = phases[index];
|
||||
|
||||
// Pfeiltasten. Der dritte Weg neben Zeitachse und den Knoepfen unten -- wer eine Phase nach
|
||||
// der anderen durchgeht, will die Hand nicht an der Maus haben. Eingabefelder duerfen davon
|
||||
// nicht betroffen sein, sonst springt die Karte beim Bearbeiten eines Betrags weg.
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const next = e.key === "ArrowLeft" ? index - 1 : index + 1;
|
||||
if (next < 0 || next >= phases.length) return;
|
||||
e.preventDefault();
|
||||
onSelectPhase(phases[next].id);
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [index, phases, onSelectPhase]);
|
||||
|
||||
if (!phase) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Die Karte laeuft NICHT ueber die volle Breite. Eine Zeile aus 1300 px liest sich
|
||||
schlecht -- das Auge verliert beim Zeilenwechsel den Anschluss, und Beschriftung und
|
||||
Wert driften so weit auseinander, dass man sie nicht mehr zusammenbringt. Links und
|
||||
rechts bleibt Luft, und die traegt die beiden grossen Pfeile. */}
|
||||
<div className="flex items-stretch justify-center gap-3">
|
||||
<SideArrow
|
||||
dir="left"
|
||||
label={phases[index - 1]?.name}
|
||||
disabled={index === 0}
|
||||
onClick={() => index > 0 && onSelectPhase(phases[index - 1].id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1" style={{ maxWidth: "46rem" }}>
|
||||
<PhaseCard
|
||||
key={phase.id}
|
||||
plan={plan}
|
||||
computed={computed}
|
||||
phase={phase}
|
||||
previous={phases[index - 1]}
|
||||
mode={mode}
|
||||
phaseContext={phaseContext}
|
||||
transitionContext={transitionContext}
|
||||
transitionElements={transitionElements}
|
||||
cashTransitionFor={cashTransitionFor}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
<SideArrow
|
||||
dir="right"
|
||||
label={phases[index + 1]?.name}
|
||||
disabled={index === phases.length - 1}
|
||||
onClick={() => index < phases.length - 1 && onSelectPhase(phases[index + 1].id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-muted">
|
||||
<button
|
||||
type="button"
|
||||
disabled={index === 0}
|
||||
onClick={() => onSelectPhase(phases[index - 1].id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2 disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{phases[index - 1]?.name ?? "Anfang"}
|
||||
</button>
|
||||
<span className="px-2 tabular-nums">
|
||||
Phase {index + 1} von {phases.length}
|
||||
</span>
|
||||
{index === phases.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.onAddPhase}
|
||||
className="flex items-center gap-1 rounded-lg border border-dashed border-accent px-2.5 py-1.5 font-medium text-accent transition-colors hover:bg-accent hover:text-accent-fg"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Lebensphase
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectPhase(phases[index + 1].id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2.5 py-1.5 transition-colors hover:bg-surface-2"
|
||||
>
|
||||
{phases[index + 1].name}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Die Karte -----------------------------------------------------------------------------
|
||||
|
||||
function PhaseCard({
|
||||
plan,
|
||||
computed,
|
||||
phase,
|
||||
previous,
|
||||
mode,
|
||||
phaseContext,
|
||||
transitionContext,
|
||||
transitionElements,
|
||||
cashTransitionFor,
|
||||
actions,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
phase: PhaseComputed;
|
||||
previous: PhaseComputed | undefined;
|
||||
mode: ValueMode;
|
||||
phaseContext: (phase: PhaseComputed, el: ElementInput) => CellContext;
|
||||
transitionContext: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined, el: ElementInput) => CellContext;
|
||||
transitionElements: (fromPhase: PhaseComputed, toPhase: PhaseComputed | undefined) => ElementInput[];
|
||||
cashTransitionFor: (phaseId: string) => CashTransitionData;
|
||||
actions: CardActions;
|
||||
}) {
|
||||
// Welche Zeilen sind aufgeklappt? Beim Wechsel der Karte faengt es wieder bei zu an --
|
||||
// die Karte wird ueber `key` neu montiert.
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({});
|
||||
const rows = phase.elements.filter((e) => e.status !== "SOLD" || e.startValue !== 0);
|
||||
const openCount = unconfirmedCells(plan, computed, phase.id).length;
|
||||
const ratesOpen = plan.phases.find((p) => p.id === phase.id)?.ratesConfirmed !== true;
|
||||
const pot = capitalPot(phase);
|
||||
const capitalOpen = pot.total > 0 && pot.allocatedInvestments + pot.allocatedRepayments === 0;
|
||||
|
||||
const transEls = previous ? transitionElements(previous, phase) : [];
|
||||
const ct = previous ? cashTransitionFor(previous.id) : null;
|
||||
const cashOpen = ct ? !isCashTransitionAnswered(ct) : false;
|
||||
|
||||
// Ein Knopf fuer alle Zeilen der Karte. Bei acht Elementen ist das der Unterschied zwischen
|
||||
// «einmal durchsehen» und acht Klicks.
|
||||
const allKeys = [...transEls.map((e) => `t:${e.id}`), ...rows.map((e) => `p:${e.elementId}`)];
|
||||
const anyOpen = allKeys.some((k) => open[k]);
|
||||
const toggleAll = () =>
|
||||
setOpen(anyOpen ? {} : Object.fromEntries(allKeys.map((k) => [k, true])));
|
||||
|
||||
const dS = phase.cumulativeInflationStart;
|
||||
const dE = phase.cumulativeInflationEnd;
|
||||
|
||||
const years =
|
||||
plan.startYear != null
|
||||
? (() => {
|
||||
const before = computed.phases
|
||||
.slice(0, computed.phases.findIndex((p) => p.id === phase.id))
|
||||
.reduce((s, p) => s + p.durationYears, 0);
|
||||
return `${plan.startYear + before}–${plan.startYear + before + phase.durationYears - 1}`;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<article className="ui-fade overflow-hidden rounded-2xl border border-border bg-surface shadow-sm">
|
||||
{/* Kopf: ruhig und gross. Er beantwortet «wo bin ich» in einem Blick. */}
|
||||
<header className="flex flex-wrap items-start gap-3 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-lg font-semibold text-fg">{phase.name}</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{years ? `${years} · ` : ""}
|
||||
{phase.durationYears} {phase.durationYears === 1 ? "Jahr" : "Jahre"}
|
||||
{phase.persons.length > 0 ? " · " : ""}
|
||||
{phase.persons
|
||||
.map((p) => `${ownerLabel(plan.persons, p.role)} ${p.startAge}–${p.endAge}`)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0) > 0 && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-attention-soft px-2 py-0.5 text-[11px] font-semibold text-attention-soft-fg">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{openCount + (ratesOpen ? 1 : 0) + (cashOpen ? 1 : 0)} offen
|
||||
</span>
|
||||
)}
|
||||
{allKeys.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
{anyOpen ? "Alle zuklappen" : "Alle aufklappen"}
|
||||
</button>
|
||||
)}
|
||||
<IconBtn label="Bezeichnung und Dauer ändern" onClick={() => actions.onEditPhase(phase.id)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</IconBtn>
|
||||
<IconBtn label="Detailansicht" onClick={() => actions.onOpenPhaseDetail(phase.id)}>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</IconBtn>
|
||||
{phase.sequenceNumber === computed.phases.length && (
|
||||
<IconBtn label="Lebensphase löschen" danger onClick={() => actions.onDeletePhase(phase.id, phase.name)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 1 · EINTRITT */}
|
||||
<Section
|
||||
step={1}
|
||||
title={previous ? "Eintritt" : "Start"}
|
||||
hint={
|
||||
previous
|
||||
? `Was beim Wechsel aus «${previous.name}» geschah`
|
||||
: "Der Bestand bei Planbeginn – erfasst in der Bestandsaufnahme"
|
||||
}
|
||||
>
|
||||
{!previous ? (
|
||||
<p className="px-1 py-2 text-xs text-muted">
|
||||
Diese Phase beginnt mit dem heutigen Stand. Startwerte änderst du in der
|
||||
Bestandsaufnahme oder über die Zeile selbst.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{ct && (
|
||||
// Cash verhielt sich bis 0.42.1 als einziger Eintritts-Punkt anders: Ein Klick
|
||||
// oeffnete das rechte Panel statt aufzuklappen. Auf einer Karte, die genau davon
|
||||
// wegwollte, war das ein Fremdkoerper.
|
||||
<Row
|
||||
open={!!open["cash"]}
|
||||
onToggle={() => setOpen((pr) => ({ ...pr, cash: !pr.cash }))}
|
||||
icon={<Wallet className="h-4 w-4" />}
|
||||
name="Cash"
|
||||
meta="einmalige Sonderein-/ausgaben"
|
||||
attention={cashOpen}
|
||||
right={<span className="tabular-nums">{cashTransitionSummary(ct)}</span>}
|
||||
>
|
||||
<CashTransitionEditor
|
||||
fromPhaseId={previous.id}
|
||||
initial={ct}
|
||||
deflatorEnd={previous.cumulativeInflationEnd}
|
||||
onSaved={actions.onChanged}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{transEls.length === 0 && !cashOpen && (
|
||||
<p className="px-1 py-2 text-xs text-muted">An diesem Übergang gibt es nichts zu entscheiden.</p>
|
||||
)}
|
||||
{transEls.map((el) => {
|
||||
const key = `t:${el.id}`;
|
||||
const ctx = transitionContext(previous, phase, el);
|
||||
return (
|
||||
<Row
|
||||
key={key}
|
||||
open={!!open[key]}
|
||||
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
|
||||
icon={<Coins className="h-4 w-4" />}
|
||||
name={el.name}
|
||||
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
|
||||
right={null}
|
||||
>
|
||||
<ElementDetail
|
||||
compact
|
||||
element={el}
|
||||
context={ctx}
|
||||
phaseData={el.phaseValues[phase.id] ?? {}}
|
||||
transitionData={el.transitionValues[previous.id] ?? {}}
|
||||
onSaved={actions.onChanged}
|
||||
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1">
|
||||
<QuotaButton
|
||||
label="Kapital verteilen"
|
||||
open={capitalOpen}
|
||||
hidden={!pot.total && !pot.allocatedInvestments && !pot.allocatedRepayments}
|
||||
sub={`${formatChf(pot.total)} verfügbar · ${formatChf(pot.rest)} bleibt Cash`}
|
||||
onClick={() => actions.onDistributeCapital(phase.id)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 2 · LAUFEND */}
|
||||
<Section
|
||||
step={2}
|
||||
title="Laufend"
|
||||
hint={`Was in diesen ${phase.durationYears} Jahren gilt`}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={actions.onAddElement}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Element
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{rows.length === 0 && (
|
||||
<p className="px-1 py-2 text-xs text-muted">Noch keine Elemente erfasst.</p>
|
||||
)}
|
||||
{rows.map((ce) => {
|
||||
const el = plan.elements.find((x) => x.id === ce.elementId);
|
||||
if (!el) return null;
|
||||
const key = `p:${el.id}`;
|
||||
const isOpen = unconfirmedCells(plan, computed, phase.id).includes(el.id);
|
||||
return (
|
||||
<Row
|
||||
key={key}
|
||||
open={!!open[key]}
|
||||
onToggle={() => setOpen((p) => ({ ...p, [key]: !p[key] }))}
|
||||
icon={<PiggyBank className="h-4 w-4" />}
|
||||
name={el.name}
|
||||
meta={`${CATEGORY_LABELS[el.category]} · ${ownerLabel(plan.persons, el.ownerRole)}`}
|
||||
attention={isOpen}
|
||||
right={<CellValue ce={ce} dS={dS} dE={dE} mode={mode} />}
|
||||
>
|
||||
<ElementDetail
|
||||
compact
|
||||
element={el}
|
||||
context={phaseContext(phase, el)}
|
||||
phaseData={el.phaseValues[phase.id] ?? {}}
|
||||
transitionData={{}}
|
||||
allPhases={plan.phases.map<PhaseRef>((p) => ({ id: p.id, name: p.name, sequenceNumber: p.sequenceNumber }))}
|
||||
phaseDataByPhase={Object.fromEntries(
|
||||
plan.phases.map((p) => [p.id, (el.phaseValues[p.id] ?? {}) as PhaseData])
|
||||
)}
|
||||
onSaved={actions.onChanged}
|
||||
onDeleteElement={() => actions.onDeleteElement(el.id, el.name)}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
<div className="pt-1">
|
||||
<QuotaButton
|
||||
label={phase.isConsumption ? "Bezug verteilen" : "Sparquote verteilen"}
|
||||
open={ratesOpen}
|
||||
sub={`${phase.isConsumption ? "Verzehrquote" : "Sparquote"} ${val(phase.quotaStart, dS, mode)} → ${val(
|
||||
phase.quotaEnd,
|
||||
phase.flowDeflatorEnd,
|
||||
mode
|
||||
)}`}
|
||||
onClick={() => actions.onDistributeRates(phase.id)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 3 · ERGEBNIS */}
|
||||
<Section step={3} title="Ergebnis" hint="Stand am Ende dieser Phase" tone="result">
|
||||
<div className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
<Figure label="Vermögen" from={val(phase.startWealthNominal, dS, mode)} to={val(phase.endWealthNominal, dE, mode)} strong />
|
||||
<Figure
|
||||
label="Cash"
|
||||
from={val(phase.cashStart, dS, mode)}
|
||||
to={val(phase.cashEnd, dE, mode)}
|
||||
danger={phase.cashNegative}
|
||||
/>
|
||||
</div>
|
||||
{phase.cashNegative && (
|
||||
<p className="mt-2 flex items-start gap-2 text-xs text-danger">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Das Cash-Konto fällt in dieser Phase unter 0 – eine Liquiditätslücke.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bausteine -----------------------------------------------------------------------------
|
||||
|
||||
function IconBtn({
|
||||
label,
|
||||
onClick,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`rounded-lg p-1.5 text-faint transition-colors ${
|
||||
danger ? "hover:bg-danger-soft hover:text-danger" : "hover:bg-surface-2 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Ein Akt der Karte. Die drei sind bewusst deutlich voneinander abgesetzt: ein getoenter
|
||||
// Kopfstreifen ueber die volle Breite, eine Nummer im Kreis, darunter der Inhalt. Beim ersten
|
||||
// Anlauf trennte sie nur eine Haarlinie -- man las die Karte als eine lange Liste statt als
|
||||
// drei Schritte.
|
||||
function Section({
|
||||
step,
|
||||
title,
|
||||
hint,
|
||||
action,
|
||||
tone = "plain",
|
||||
children,
|
||||
}: {
|
||||
step: number;
|
||||
title: string;
|
||||
hint: string;
|
||||
action?: React.ReactNode;
|
||||
// "plain" = weisser Inhalt, "result" = durchgehend getoent (der Schlusspunkt).
|
||||
tone?: "plain" | "result";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={`border-t-2 border-border ${tone === "result" ? "bg-surface-2" : ""}`}>
|
||||
<div className={`flex items-center gap-2 px-5 py-2 ${tone === "result" ? "" : "bg-surface-2/70"}`}>
|
||||
<span
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${
|
||||
tone === "result" ? "bg-accent text-accent-fg" : "bg-surface text-muted ring-1 ring-border"
|
||||
}`}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-fg">{title}</span>
|
||||
<span className="min-w-0 truncate text-[11px] text-faint">{hint}</span>
|
||||
{action && <span className="ml-auto shrink-0">{action}</span>}
|
||||
</div>
|
||||
<div className="px-5 py-2.5">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Der grosse Pfeil neben der Karte. Er sitzt in der Luft, die durch die begrenzte Kartenbreite
|
||||
// ohnehin entsteht -- ein Bedienelement, das keinen zusaetzlichen Platz kostet.
|
||||
function SideArrow({
|
||||
dir,
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
dir: "left" | "right";
|
||||
label?: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={dir === "left" ? `Zurück zu ${label ?? "vorheriger Phase"}` : `Weiter zu ${label ?? "nächster Phase"}`}
|
||||
title={label}
|
||||
className="hidden w-12 shrink-0 items-center justify-center rounded-2xl border border-transparent text-faint transition-colors hover:border-border hover:bg-surface hover:text-accent disabled:pointer-events-none disabled:opacity-0 lg:flex"
|
||||
>
|
||||
{dir === "left" ? <ChevronLeft className="h-7 w-7" /> : <ChevronRight className="h-7 w-7" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Eine aufklappbare Zeile. Zugeklappt ist sie eine Zusammenfassung, aufgeklappt der ganze
|
||||
// Editor -- und zwar IN der Karte. Kein Panel legt sich mehr ueber das, was man bearbeitet.
|
||||
function Row({
|
||||
open,
|
||||
onToggle,
|
||||
icon,
|
||||
name,
|
||||
meta,
|
||||
right,
|
||||
attention,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
meta: string;
|
||||
right: React.ReactNode;
|
||||
attention?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`border-b border-border last:border-b-0 ${open ? "bg-surface-2/40" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2.5 py-2 text-left transition-colors hover:bg-surface-2/60"
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${attention ? "bg-attention" : "bg-success"}`} />
|
||||
<span className={attention ? "text-attention" : "text-faint"}>{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm text-fg">{name}</span>
|
||||
<span className="block truncate text-[11px] text-faint">{meta}</span>
|
||||
</span>
|
||||
<span className="ml-auto shrink-0 text-xs text-muted">{right}</span>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-faint transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{open && <div className="pb-4 pl-6 pr-1">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Der Cash-Uebergang in der Karte. Dieselben Felder wie im Panel (`CashTransitionFields`),
|
||||
// nur mit eigenem Speichern -- eine zweite Implementierung der Felder waere garantiert
|
||||
// auseinandergelaufen.
|
||||
function CashTransitionEditor({
|
||||
fromPhaseId,
|
||||
initial,
|
||||
deflatorEnd,
|
||||
onSaved,
|
||||
}: {
|
||||
fromPhaseId: string;
|
||||
initial: CashTransitionData;
|
||||
deflatorEnd: number;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
// Vorbelegen, damit ein blosses Speichern den sichtbaren Default (1:1) festhaelt.
|
||||
const [ct, setCt] = useState<CashTransitionData>(() => withCashTransitionDefaults(initial));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/phases/${fromPhaseId}/cash-transition`, ct);
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<CashTransitionFields ct={ct} setC={(patch) => setCt((prev) => ({ ...prev, ...patch }))} deflatorEnd={deflatorEnd} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div>
|
||||
<Button disabled={saving} onClick={save}>
|
||||
{saving ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellValue({
|
||||
ce,
|
||||
dS,
|
||||
dE,
|
||||
mode,
|
||||
}: {
|
||||
ce: ElementPhaseComputed;
|
||||
dS: number;
|
||||
dE: number;
|
||||
mode: ValueMode;
|
||||
}) {
|
||||
if (ce.summary) return <span className="tabular-nums">{ce.summary}</span>;
|
||||
if (ce.note) return <span>{ce.note}</span>;
|
||||
return (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{val(ce.startValue, dS, mode)} <span className="text-faint">→</span> {val(ce.endValue, dE, mode)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Figure({
|
||||
label,
|
||||
from,
|
||||
to,
|
||||
strong,
|
||||
danger,
|
||||
}: {
|
||||
label: string;
|
||||
from: string;
|
||||
to: string;
|
||||
strong?: boolean;
|
||||
danger?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-border/60 py-1 last:border-b-0">
|
||||
<span className="text-xs text-muted">{label}</span>
|
||||
<span
|
||||
className={`whitespace-nowrap tabular-nums ${strong ? "text-sm font-semibold" : "text-xs"} ${
|
||||
danger ? "text-danger" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
{from} <span className="text-faint">→</span> {to}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Der Weg in einen Verteil-Dialog. Gruen mit Haken, sobald erledigt -- dieselbe Sprache wie
|
||||
// im Phasenkopf der Matrix (SPEZIFIKATION 3.6.9).
|
||||
function QuotaButton({
|
||||
label,
|
||||
sub,
|
||||
open,
|
||||
hidden,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
sub: string;
|
||||
open: boolean;
|
||||
hidden?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (hidden) return null;
|
||||
return (
|
||||
<Button variant={open ? "primary" : "ghost"} onClick={onClick} className="w-full justify-between">
|
||||
<span className="flex flex-col items-start">
|
||||
<span>
|
||||
{label}
|
||||
{open ? "" : " ✓"}
|
||||
</span>
|
||||
<span className="text-[11px] font-normal opacity-80">{sub}</span>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -3,19 +3,28 @@
|
||||
import { useState } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { NumberField, TextField } from "@/components/FormField";
|
||||
import { CarryWarning } from "@/components/ElementDetail";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { planDurationChange } from "@/lib/phaseplan";
|
||||
import type { PhaseInput } from "@/lib/types";
|
||||
|
||||
export function PhaseDetail({
|
||||
phase,
|
||||
allPhases,
|
||||
maxDurationYears,
|
||||
isLast,
|
||||
laterPhaseCount,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
phase: PhaseInput;
|
||||
// Alle Phasen des Szenarios -- nötig, um die Auswirkung einer geänderten Dauer auf die
|
||||
// Folgephase VORHER zu zeigen (siehe planDurationChange).
|
||||
allPhases: PhaseInput[];
|
||||
maxDurationYears: number | null;
|
||||
isLast: boolean;
|
||||
laterPhaseCount: number;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
@@ -23,10 +32,32 @@ export function PhaseDetail({
|
||||
const [durationYears, setDurationYears] = useState(phase.durationYears);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const cap = maxDurationYears;
|
||||
// Vorschau: Was passiert mit der Folgephase? Wird live berechnet, damit die Auswirkung
|
||||
// sichtbar ist, BEVOR gespeichert wird.
|
||||
const change = planDurationChange(allPhases, phase.id, durationYears);
|
||||
|
||||
async function save() {
|
||||
// Verschiebt sich eine Folgephase, wird das vorher bestätigt -- sonst ändert ein Klick
|
||||
// stillschweigend zwei Phasen.
|
||||
if (change.ok && change.neighbourId && change.delta !== 0) {
|
||||
const jahre = Math.abs(change.delta);
|
||||
const ok = await confirm({
|
||||
title: "Folgephase wird angepasst",
|
||||
message:
|
||||
`Wenn du «${phase.name}» um ${jahre} Jahr${jahre === 1 ? "" : "e"} ` +
|
||||
`${change.delta > 0 ? "verlängerst" : "verkürzst"}, wird «${change.neighbourName}» automatisch auf ` +
|
||||
`${change.neighbourNewDuration} Jahr${change.neighbourNewDuration === 1 ? "" : "e"} ` +
|
||||
`${change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich. ` +
|
||||
`Ohne diesen Ausgleich würde eine spätere Lebensphase eine Pensionierung überspannen.`,
|
||||
confirmLabel: "Fortfahren",
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -40,55 +71,70 @@ export function PhaseDetail({
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Phase "${phase.name}" wirklich loeschen?`)) return;
|
||||
const ok = await confirm({
|
||||
title: "Lebensphase löschen?",
|
||||
message: `«${phase.name}» wird mit allen erfassten Werten und Übergangs-Entscheiden entfernt.`,
|
||||
confirmLabel: "Endgültig löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/phases/${phase.id}`);
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Loeschen fehlgeschlagen.");
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">
|
||||
Lebensphase
|
||||
</div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-accent-soft-fg">Lebensphase</div>
|
||||
{isLast && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={remove}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-xs text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Phase loeschen
|
||||
<Trash2 className="h-3.5 w-3.5" /> Phase löschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{/* Eine geänderte Dauer verschiebt alle Folgephasen (Alter, Renten, Vermögen). */}
|
||||
<CarryWarning laterPhaseCount={laterPhaseCount} />
|
||||
<TextField label="Bezeichnung" value={name} onChange={setName} />
|
||||
<NumberField
|
||||
label={`Dauer (Jahre)${cap != null ? ` · max. ${cap}` : ""}`}
|
||||
help={cap != null ? "Die Dauer ist ans naechste Pensionsereignis gekappt." : undefined}
|
||||
help={cap != null ? "Die Dauer ist ans nächste Pensionsereignis gekappt." : undefined}
|
||||
value={durationYears}
|
||||
min={1}
|
||||
max={cap ?? undefined}
|
||||
onChange={(v) => setDurationYears(cap != null ? Math.min(v, cap) : v)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-faint">Die Inflationsrate gilt plan-weit und wird in den Plan-Einstellungen gesetzt.</p>
|
||||
|
||||
{/* Auswirkung auf die Folgephase -- sichtbar, bevor gespeichert wird. */}
|
||||
{change.error && (
|
||||
<p className="rounded-lg border border-danger bg-danger-soft px-3 py-2 text-xs text-danger">{change.error}</p>
|
||||
)}
|
||||
{change.ok && change.neighbourId && change.delta !== 0 && (
|
||||
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
«{change.neighbourName}» wird dadurch auf <strong>{change.neighbourNewDuration} Jahre</strong>{" "}
|
||||
{change.delta > 0 ? "gekürzt" : "verlängert"} – die Gesamtdauer des Plans bleibt gleich.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-faint">
|
||||
Die Inflationsrate gilt für das ganze Szenario und wird im Grundprofil gesetzt.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={save}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Speichern..." : "Speichern"}
|
||||
</button>
|
||||
<Button disabled={saving || !!change.error} onClick={save}>
|
||||
{saving ? "Speichern…" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { NumberField, SelectField, TextField } from "@/components/FormField";
|
||||
import { AHV_REFERENCE_AGE } from "@/lib/constants";
|
||||
import type { HouseholdType, PersonRole } from "@/lib/types";
|
||||
|
||||
export interface ProfileDraft {
|
||||
householdType: HouseholdType;
|
||||
inflationRateDefault: number;
|
||||
startYear: number;
|
||||
persons: { role: PersonRole; name: string; age: number; retirementAge: number }[];
|
||||
}
|
||||
|
||||
@@ -13,18 +15,29 @@ export function emptyProfileDraft(): ProfileDraft {
|
||||
return {
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: 65 }],
|
||||
startYear: new Date().getFullYear(),
|
||||
// Das Pensionsalter wird beim Anlegen nicht gefragt: Es ist der erste Entscheid der
|
||||
// Pensionsplanung. Bis dahin gilt das Referenzalter.
|
||||
persons: [{ role: "PERSON_A", name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }],
|
||||
};
|
||||
}
|
||||
|
||||
// Gemeinsame Formularfelder fuer das Grundprofil eines Plans (Haushaltsform, Personen,
|
||||
// Inflation). Wird beim Plan-Erstellen und in den Plan-Einstellungen verwendet.
|
||||
// Gemeinsame Formularfelder für das Grundprofil eines Plans (Haushaltsform, Personen,
|
||||
// Startjahr, Inflation). Wird beim Plan-Erstellen und in den Grundeinstellungen verwendet.
|
||||
//
|
||||
// Das PENSIONSALTER fehlt hier seit 0.36 bewusst. Es ist kein Stammdatum, sondern der erste
|
||||
// Entscheid der Pensionsplanung -- und es erzeugt eine Phasengrenze. Es wird deshalb dort
|
||||
// gesetzt («Pensionsplanung» je Person) und hier höchstens angezeigt.
|
||||
export function PlanProfileFields({
|
||||
draft,
|
||||
onChange,
|
||||
// Blendet den Hinweis aus, dass diese Angaben plan-weit gelten -- beim Anlegen eines Plans
|
||||
// gibt es noch keine anderen Szenarien, die betroffen sein könnten.
|
||||
hideScopeNote = false,
|
||||
}: {
|
||||
draft: ProfileDraft;
|
||||
onChange: (next: ProfileDraft) => void;
|
||||
hideScopeNote?: boolean;
|
||||
}) {
|
||||
function setType(type: HouseholdType) {
|
||||
if (type === "SINGLE") {
|
||||
@@ -32,7 +45,7 @@ export function PlanProfileFields({
|
||||
} else {
|
||||
const persons =
|
||||
draft.persons.length < 2
|
||||
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: 65 }]
|
||||
? [...draft.persons, { role: "PERSON_B" as PersonRole, name: "", age: 35, retirementAge: AHV_REFERENCE_AGE }]
|
||||
: draft.persons;
|
||||
onChange({ ...draft, householdType: type, persons });
|
||||
}
|
||||
@@ -47,9 +60,19 @@ export function PlanProfileFields({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Seit V7 beschreiben Haushaltsform, Personen und Startjahr den PLAN und gelten für
|
||||
ALLE Szenarien. Das muss sichtbar sein -- sonst ändert man beim Bearbeiten eines
|
||||
Nebenszenarios unbemerkt auch alle anderen. */}
|
||||
{!hideScopeNote && (
|
||||
<p className="rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
Haushaltsform, Personen und Planstart gelten für <strong>alle Szenarien</strong> dieses
|
||||
Plans. Unterscheiden sie sich, ist es ein anderer Plan.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SelectField
|
||||
label="Haushaltsform"
|
||||
help="Waehlen Sie, ob Sie alleine oder gemeinsam mit einer Partnerin / einem Partner planen."
|
||||
label="Haushaltsform (plan-weit)"
|
||||
help="Planst du alleine oder gemeinsam mit einer Partnerin / einem Partner?"
|
||||
value={draft.householdType}
|
||||
onChange={setType}
|
||||
options={[
|
||||
@@ -61,37 +84,39 @@ export function PlanProfileFields({
|
||||
{draft.persons.map((person, index) => (
|
||||
<div key={person.role} className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="col-span-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Ihre Angaben"}
|
||||
{draft.householdType === "COUPLE" ? (person.role === "PERSON_A" ? "Person A" : "Person B") : "Deine Angaben"}
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<TextField
|
||||
label="Name (optional)"
|
||||
label="Name (optional, plan-weit)"
|
||||
value={person.name}
|
||||
placeholder={person.role === "PERSON_A" ? "Person A" : "Person B"}
|
||||
onChange={(v) => updatePerson(index, { name: v })}
|
||||
/>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Aktuelles Alter"
|
||||
label="Aktuelles Alter (plan-weit)"
|
||||
value={person.age}
|
||||
min={0}
|
||||
max={120}
|
||||
onChange={(v) => updatePerson(index, { age: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Pensionierungsalter"
|
||||
help="Steuert die Ableitung des Phasentyps (Erwerb/Pension)."
|
||||
value={person.retirementAge}
|
||||
min={30}
|
||||
max={100}
|
||||
onChange={(v) => updatePerson(index, { retirementAge: Math.round(v) })}
|
||||
/>
|
||||
{/* Das Pensionsalter steht bewusst NICHT hier -- siehe Kommentar oben. */}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<NumberField
|
||||
label="Erwartete Inflationsrate (%)"
|
||||
help="Langfristige Annahme zur jaehrlichen Teuerung. Gilt plan-weit fuer alle Lebensphasen."
|
||||
label="Planstart (Jahr, plan-weit)"
|
||||
help="Kalenderjahr, in dem die Planung beginnt (Jahr 1). Wird auf der Zeitachse und in den Grafiken angezeigt; die Berechnung selbst rechnet in Jahren ab Planbeginn."
|
||||
value={draft.startYear}
|
||||
min={1900}
|
||||
max={2200}
|
||||
onChange={(v) => onChange({ ...draft, startYear: Math.round(v) })}
|
||||
/>
|
||||
|
||||
<NumberField
|
||||
label="Erwartete Inflationsrate (%, nur dieses Szenario)"
|
||||
help="Langfristige Annahme zur jährlichen Teuerung. Gilt plan-weit für alle Lebensphasen."
|
||||
value={draft.inflationRateDefault}
|
||||
step={0.1}
|
||||
onChange={(v) => onChange({ ...draft, inflationRateDefault: v })}
|
||||
|
||||
+2312
-426
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Dices,
|
||||
Eye,
|
||||
FileSpreadsheet,
|
||||
GitBranch,
|
||||
History,
|
||||
Layers,
|
||||
LineChart as LineChartIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
SlidersHorizontal,
|
||||
Tornado,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||||
import type { PersonRole } from "@/lib/types";
|
||||
|
||||
export type PlanTab = "dashboard" | "scenarios" | "actuals" | "analyses" | "reports";
|
||||
|
||||
// --- Typen der Dashboard-Antwort ---
|
||||
interface DashboardResponse {
|
||||
plan: {
|
||||
id: string;
|
||||
name: string;
|
||||
householdType: "SINGLE" | "COUPLE";
|
||||
startYear: number | null;
|
||||
persons: { role: PersonRole; name: string | null; age: number }[];
|
||||
};
|
||||
counts: { scenarios: number; actuals: number; analyses: number };
|
||||
base: {
|
||||
scenarioId: string;
|
||||
name: string;
|
||||
phaseCount: number;
|
||||
endNominal: number;
|
||||
endReal: number;
|
||||
ruinAge: number | null;
|
||||
actualEndNominal: number | null;
|
||||
actualYears: number[];
|
||||
} | null;
|
||||
scenarios: {
|
||||
id: string;
|
||||
name: string;
|
||||
isBase: boolean;
|
||||
parentScenarioId: string | null;
|
||||
version: string | null;
|
||||
versionComment: string | null;
|
||||
elementCount: number;
|
||||
phaseCount: number;
|
||||
versionCount: number;
|
||||
endNominal: number;
|
||||
endReal: number;
|
||||
ruinAge: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function useDashboard(planId: string, reloadKey: number) {
|
||||
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const d = await api.get<DashboardResponse>(`/api/plans/${planId}/dashboard`);
|
||||
if (!cancelled) setData(d);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId, reloadKey]);
|
||||
return { data, error };
|
||||
}
|
||||
|
||||
// Anklickbare Kennzahl-Kachel. Ohne `onClick` bleibt sie eine reine Anzeige.
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
tone?: "danger" | "success";
|
||||
icon?: typeof Layers;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const toneClass = tone === "danger" ? "text-danger" : tone === "success" ? "text-success" : "text-fg";
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||||
{Icon && <Icon className="h-3.5 w-3.5 text-accent" />}
|
||||
{label}
|
||||
</div>
|
||||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${toneClass}`}>{value}</div>
|
||||
{hint && <div className="mt-0.5 text-[11px] text-faint">{hint}</div>}
|
||||
</>
|
||||
);
|
||||
if (!onClick) {
|
||||
return <div className="rounded-2xl border border-border bg-surface p-4 shadow-sm">{body}</div>;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
|
||||
>
|
||||
{body}
|
||||
<div className="mt-1 text-[11px] font-medium text-accent opacity-0 transition-opacity group-hover:opacity-100">
|
||||
Öffnen →
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// Plan-Dashboard
|
||||
// =========================================================================================
|
||||
export function PlanDashboardView({
|
||||
planId,
|
||||
onNavigate,
|
||||
onRenamed,
|
||||
}: {
|
||||
planId: string;
|
||||
onNavigate: (tab: PlanTab) => void;
|
||||
onRenamed: () => void;
|
||||
}) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const { data, error } = useDashboard(planId, reloadKey);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||||
|
||||
const { plan, counts, base } = data;
|
||||
const persons = plan.persons
|
||||
.map((p) => `${p.name || (p.role === "PERSON_A" ? "Person A" : "Person B")} (${p.age})`)
|
||||
.join(" · ");
|
||||
|
||||
// Jüngster erfasster Ist-Datensatz -- er bestimmt, gegen welchen Stand verglichen wird.
|
||||
const latestActualYear = base && base.actualYears.length > 0 ? Math.max(...base.actualYears) : null;
|
||||
const delta = base && base.actualEndNominal !== null ? base.actualEndNominal - base.endNominal : null;
|
||||
|
||||
async function saveName() {
|
||||
const name = draftName.trim();
|
||||
if (!name) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.patch(`/api/plans/${planId}`, { name });
|
||||
setEditing(false);
|
||||
setReloadKey((k) => k + 1);
|
||||
onRenamed();
|
||||
toast("success", "Plan umbenannt.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Umbenennen fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
{editing ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={draftName}
|
||||
onChange={(e) => setDraftName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveName();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
maxLength={120}
|
||||
className="rounded-lg border border-border bg-input px-3 py-1.5 text-lg font-semibold text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25"
|
||||
/>
|
||||
<Button size="sm" onClick={saveName} disabled={saving}>
|
||||
<Check className="h-4 w-4" /> Speichern
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-fg">{plan.name}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Plan umbenennen"
|
||||
title="Plan umbenennen"
|
||||
onClick={() => {
|
||||
setDraftName(plan.name);
|
||||
setEditing(true);
|
||||
}}
|
||||
className="rounded-md p-1 text-faint opacity-60 transition-opacity hover:bg-surface-2 hover:text-fg group-hover:opacity-100"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-muted">
|
||||
{plan.householdType === "COUPLE" ? "Paar" : "Einzelperson"} · {persons}
|
||||
{plan.startYear ? ` · Planstart ${plan.startYear}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Anklickbare Kacheln -- jede führt in ihren Bereich. */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Stat
|
||||
label="Szenarien"
|
||||
value={String(counts.scenarios)}
|
||||
hint="Varianten dieser Planung"
|
||||
icon={Layers}
|
||||
onClick={() => onNavigate("scenarios")}
|
||||
/>
|
||||
<Stat
|
||||
label="Effektive Werte"
|
||||
value={String(counts.actuals)}
|
||||
hint="erfasste Ist-Datensätze"
|
||||
icon={CalendarClock}
|
||||
onClick={() => onNavigate("actuals")}
|
||||
/>
|
||||
<Stat
|
||||
label="Gespeicherte Analysen"
|
||||
value={String(counts.analyses)}
|
||||
hint="Grafiken, Simulationen, Tornados"
|
||||
icon={BarChart3}
|
||||
onClick={() => onNavigate("analyses")}
|
||||
/>
|
||||
<Stat
|
||||
label="Berichte"
|
||||
value="PDF"
|
||||
hint="erzeugen und wieder herunterladen"
|
||||
icon={FileSpreadsheet}
|
||||
onClick={() => onNavigate("reports")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{base && (
|
||||
<div className="rounded-2xl border border-border bg-surface p-5 shadow-sm">
|
||||
<h3 className="mb-3 flex flex-wrap items-baseline gap-2 text-sm font-semibold text-fg">
|
||||
Basisszenario «{base.name}»
|
||||
<span className="text-[11px] font-normal text-faint">alle Kennzahlen beziehen sich hierauf</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Endvermögen (nominal)" value={formatChf(base.endNominal)} />
|
||||
<Stat label="Endvermögen (real)" value={formatChf(base.endReal)} />
|
||||
<Stat label="Lebensphasen" value={String(base.phaseCount)} />
|
||||
{base.ruinAge !== null ? (
|
||||
<Stat label="Kapital reicht" value={`bis Alter ${base.ruinAge}`} tone="danger" hint="danach aufgebraucht" />
|
||||
) : (
|
||||
<Stat label="Kapital reicht" value="bis Planende" tone="success" />
|
||||
)}
|
||||
</div>
|
||||
{base.actualEndNominal !== null && delta !== null && (
|
||||
<p
|
||||
className={`mt-4 rounded-xl border px-3 py-2 text-xs ${
|
||||
delta >= 0 ? "border-success bg-success/10 text-fg" : "border-danger bg-danger-soft text-fg"
|
||||
}`}
|
||||
>
|
||||
Mit den effektiven Werten{latestActualYear ? ` von ${latestActualYear}` : ""} liegt das Endvermögen
|
||||
(nominal) bei <strong>{formatChf(base.actualEndNominal)}</strong> – eine Abweichung von{" "}
|
||||
<strong className={delta >= 0 ? "text-success" : "text-danger"}>
|
||||
{delta >= 0 ? "+" : ""}
|
||||
{formatChf(delta)}
|
||||
</strong>{" "}
|
||||
gegenüber dem Plan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// Szenario-Liste
|
||||
// =========================================================================================
|
||||
export function ScenarioListView({
|
||||
planId,
|
||||
reloadKey = 0,
|
||||
onOpenMatrix,
|
||||
onOpenHistory,
|
||||
onCopyFrom,
|
||||
onDelete,
|
||||
}: {
|
||||
planId: string;
|
||||
reloadKey?: number;
|
||||
onOpenMatrix: (scenarioId: string) => void;
|
||||
onOpenHistory: (scenarioId: string) => void;
|
||||
// Kopiervorlage ist frei wählbar -- nicht mehr zwingend das Basisszenario.
|
||||
onCopyFrom: (scenarioId: string) => void;
|
||||
onDelete: (scenarioId: string) => void;
|
||||
}) {
|
||||
const { data, error } = useDashboard(planId, reloadKey);
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (!data) return <p className="text-sm text-muted">Wird geladen…</p>;
|
||||
|
||||
const nameById = new Map(data.scenarios.map((s) => [s.id, s.name]));
|
||||
const baseId = data.scenarios.find((s) => s.isBase)?.id ?? data.scenarios[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-fg">Szenarien</h2>
|
||||
{baseId && (
|
||||
<Button onClick={() => onCopyFrom(baseId)}>
|
||||
<Plus className="h-4 w-4" /> Neues Szenario
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Szenario</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Version</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Phasen</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Elemente</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Endvermögen (nom.)</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Kapital reicht</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.scenarios.map((s) => (
|
||||
// Die ganze Zeile öffnet die Matrix -- der frühere «Matrix»-Knopf entfällt.
|
||||
<tr
|
||||
key={s.id}
|
||||
onClick={() => onOpenMatrix(s.id)}
|
||||
className={`cursor-pointer border-t border-border transition-colors hover:bg-accent-soft/40 ${
|
||||
s.isBase ? "bg-accent-soft/20" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && (
|
||||
<span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>
|
||||
)}
|
||||
</div>
|
||||
{s.parentScenarioId && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-faint">
|
||||
<GitBranch className="h-3 w-3" /> aus {nameById.get(s.parentScenarioId) ?? "…"}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="tabular-nums text-muted">{s.version ?? "–"}</div>
|
||||
{s.versionComment && (
|
||||
<div className="max-w-48 truncate text-[11px] text-faint" title={s.versionComment}>
|
||||
«{s.versionComment}»
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.phaseCount}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted">{s.elementCount}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-fg">{formatChf(s.endNominal)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{s.ruinAge !== null ? (
|
||||
<span className="text-danger">Alter {s.ruinAge}</span>
|
||||
) : (
|
||||
<span className="text-success">Planende</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Aktionen: stopPropagation, sonst öffnet der Zeilenklick die Matrix. */}
|
||||
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
title="Änderungshistorie"
|
||||
onClick={() => onOpenHistory(s.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" /> Historie
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Neues Szenario aus diesem"
|
||||
onClick={() => onCopyFrom(s.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> Kopie
|
||||
</button>
|
||||
{!s.isBase && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Szenario löschen"
|
||||
title="Szenario löschen"
|
||||
onClick={() => onDelete(s.id)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================================
|
||||
// Analysen: vier Kacheln + Liste der gespeicherten
|
||||
// =========================================================================================
|
||||
export type ToolKind = "CHART" | "MONTE_CARLO" | "SENSITIVITY" | "LIVESIM";
|
||||
|
||||
const TILES: { kind: ToolKind; title: string; icon: typeof BarChart3; blurb: string }[] = [
|
||||
{
|
||||
kind: "CHART",
|
||||
title: "Grafiken",
|
||||
icon: LineChartIcon,
|
||||
blurb:
|
||||
"Vermögensverlauf, Einkommen vs. Ausgaben oder Vermögensaufteilung – wähle eine Grafik und ob nominal/real und Plan/effektiv gezeigt werden.",
|
||||
},
|
||||
{
|
||||
kind: "LIVESIM",
|
||||
title: "Live-Simulation",
|
||||
icon: SlidersHorizontal,
|
||||
blurb: "Dreh an Reglern (Rendite, Ausgaben, Inflation …) und sieh die Wirkung sofort – ohne etwas zu speichern.",
|
||||
},
|
||||
{
|
||||
kind: "MONTE_CARLO",
|
||||
title: "Monte-Carlo",
|
||||
icon: Dices,
|
||||
blurb: "Tausende Zufallspfade zeigen, wie wahrscheinlich deine Planung hält und dein Ziel erreicht wird.",
|
||||
},
|
||||
{
|
||||
kind: "SENSITIVITY",
|
||||
title: "Einflussfaktoren",
|
||||
icon: Tornado,
|
||||
blurb: "Welche deiner Annahmen entscheidet überhaupt über das Ergebnis? Ein Tornado ordnet sie nach Wirkung.",
|
||||
},
|
||||
];
|
||||
|
||||
function typeIcon(t: AnalysisType) {
|
||||
return t === "MONTE_CARLO" ? Dices : t === "SENSITIVITY" ? Tornado : BarChart3;
|
||||
}
|
||||
|
||||
export function AnalysesView({
|
||||
planId,
|
||||
onLaunch,
|
||||
onOpenSaved,
|
||||
}: {
|
||||
planId: string;
|
||||
onLaunch: (kind: ToolKind) => void;
|
||||
onOpenSaved: (id: string) => void;
|
||||
}) {
|
||||
const [saved, setSaved] = useState<SavedAnalysisMeta[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
|
||||
setSaved(d.analyses);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const d = await api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`);
|
||||
if (!cancelled) setSaved(d.analyses);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
async function remove(a: SavedAnalysisMeta) {
|
||||
const ok = await confirm({ title: "Analyse löschen?", message: `«${a.name}» wird entfernt.`, confirmLabel: "Löschen", danger: true });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/analyses/${a.id}`);
|
||||
await load();
|
||||
toast("success", "Analyse gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h2 className="text-lg font-semibold text-fg">Analysen</h2>
|
||||
|
||||
{/* Vier Kacheln, die beim Darüberfahren (oder Antippen) umklappen. */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{TILES.map((t) => (
|
||||
<FlipTile key={t.kind} tile={t} onClick={() => onLaunch(t.kind)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-fg">Gespeicherte Analysen</h3>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!saved && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
{saved && saved.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch nichts gespeichert. In jedem Werkzeug oben kannst du ein Ergebnis festhalten – es erscheint dann hier.
|
||||
</p>
|
||||
)}
|
||||
{saved && saved.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{saved.map((a) => {
|
||||
const Icon = typeIcon(a.type);
|
||||
return (
|
||||
<div key={a.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<Icon className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-fg">{a.name}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{ANALYSIS_TYPE_LABEL[a.type]} · {dt(a.createdAt)}
|
||||
{a.scenarioName ? ` · ${a.scenarioName}${a.versionLabel ? ` ${a.versionLabel}` : ""}` : ""} ·{" "}
|
||||
{a.metric === "real" ? "real" : "nominal"} · {sourceLabel(a.source)}
|
||||
{a.summary ? ` · ${a.summary}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSaved(a.id)}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" /> Anzeigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(a)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlipTile({ tile, onClick }: { tile: (typeof TILES)[number]; onClick: () => void }) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setFlipped(true)}
|
||||
onMouseLeave={() => setFlipped(false)}
|
||||
// Touch hat kein Hover -- ein Antippen des Info-Bereichs klappt um, statt gleich zu starten.
|
||||
className="group relative flex h-40 flex-col items-start justify-between overflow-hidden rounded-2xl border border-border bg-surface p-4 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-accent hover:shadow-md"
|
||||
>
|
||||
{!flipped ? (
|
||||
<>
|
||||
<Icon className="h-8 w-8 text-accent" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-fg">{tile.title}</div>
|
||||
<div className="text-[11px] text-faint">Zum Starten klicken</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-1 flex items-center gap-1.5 text-sm font-semibold text-accent">
|
||||
<Icon className="h-4 w-4" /> {tile.title}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted">{tile.blurb}</p>
|
||||
<span className="mt-auto text-[11px] font-medium text-accent">Starten →</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { KeyRound, LogOut, Palette, UserCircle2 } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { getEffectiveTheme, setTheme, THEMES, type Theme } from "@/lib/theme";
|
||||
|
||||
export function ProfileMenu({ username }: { username: string }) {
|
||||
@@ -12,6 +13,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- einmalige Übernahme aus localStorage
|
||||
setThemeState(getEffectiveTheme());
|
||||
}, []);
|
||||
|
||||
@@ -74,7 +76,7 @@ export function ProfileMenu({ username }: { username: string }) {
|
||||
|
||||
<MenuItem
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
label="Passwort aendern"
|
||||
label="Passwort ändern"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setShowPasswordDialog(true);
|
||||
@@ -121,7 +123,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (newPassword !== newPasswordConfirm) {
|
||||
setError("Die neuen Passwoerter stimmen nicht ueberein.");
|
||||
setError("Die neuen Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
@@ -130,7 +132,7 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
setDone(true);
|
||||
setTimeout(onClose, 1200);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Passwort aendern fehlgeschlagen.");
|
||||
setError(err instanceof Error ? err.message : "Passwort ändern fehlgeschlagen.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -139,28 +141,21 @@ function ChangePasswordDialog({ onClose }: { onClose: () => void }) {
|
||||
const inputClass =
|
||||
"w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-fg shadow-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25";
|
||||
|
||||
// Über die zentrale Modal-Komponente: bringt Esc, Fokus-Falle und aria-modal mit
|
||||
// (SPEZIFIKATION 3.7.6) -- der Dialog war zuvor von Hand gebaut und ignorierte Esc.
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-sm flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<h2 className="text-base font-semibold text-fg">Passwort aendern</h2>
|
||||
<Modal title="Passwort ändern" onClose={onClose}>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<input type="password" placeholder="Aktuelles Passwort" autoComplete="current-password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort" autoComplete="new-password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort bestaetigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||
<input type="password" placeholder="Neues Passwort bestätigen" autoComplete="new-password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} className={inputClass} />
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{done && <p className="text-sm text-success">Passwort geaendert.</p>}
|
||||
{done && <p className="text-sm text-success">Passwort geändert.</p>}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button type="submit" disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50">
|
||||
{saving ? "..." : "Speichern"}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted hover:bg-surface-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "..." : "Speichern"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, Download, FileText, Plus, Trash2 } from "lucide-react";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { ANALYSIS_TYPE_LABEL, type AnalysisType, type SavedAnalysisMeta } from "@/lib/analyses";
|
||||
import { MAX_REPORT_SCENARIOS, type ReportMetric, type ReportSource } from "@/lib/report";
|
||||
|
||||
interface StoredReport {
|
||||
id: string;
|
||||
title: string;
|
||||
config: { metric?: string; source?: string; scenarioIds?: string[]; analysisIds?: string[]; comment?: string | null };
|
||||
pdfBytes: number;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
export function ReportsView({
|
||||
planId,
|
||||
scenarios,
|
||||
hasActuals,
|
||||
}: {
|
||||
planId: string;
|
||||
scenarios: { id: string; name: string; isBase: boolean }[];
|
||||
hasActuals: boolean;
|
||||
}) {
|
||||
const [reports, setReports] = useState<StoredReport[] | null>(null);
|
||||
const [analyses, setAnalyses] = useState<SavedAnalysisMeta[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<"list" | "new">("list");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Formular
|
||||
const [title, setTitle] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [metric, setMetric] = useState<ReportMetric>("nominal");
|
||||
const [source, setSource] = useState<ReportSource>("PLAN");
|
||||
const [scenarioIds, setScenarioIds] = useState<string[]>([]);
|
||||
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
}, [planId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const [r, a] = await Promise.all([
|
||||
api.get<{ reports: StoredReport[] }>(`/api/plans/${planId}/reports`),
|
||||
api.get<{ analyses: SavedAnalysisMeta[] }>(`/api/plans/${planId}/analyses`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setReports(r.reports);
|
||||
setAnalyses(a.analyses);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
function startNew() {
|
||||
const base = scenarios.find((s) => s.isBase) ?? scenarios[0];
|
||||
setTitle(`Finanzplanung ${new Date().getFullYear()}`);
|
||||
setComment("");
|
||||
setMetric("nominal");
|
||||
setSource("PLAN");
|
||||
setScenarioIds(base ? [base.id] : []);
|
||||
setAnalysisIds([]);
|
||||
setMode("new");
|
||||
}
|
||||
|
||||
function toggleScenario(id: string) {
|
||||
setScenarioIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : prev.length >= MAX_REPORT_SCENARIOS ? prev : [...prev, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.post(`/api/plans/${planId}/reports`, {
|
||||
title: title.trim() || "Finanzplanung",
|
||||
metric,
|
||||
source,
|
||||
scenarioIds,
|
||||
analysisIds,
|
||||
comment: comment.trim() || undefined,
|
||||
});
|
||||
await load();
|
||||
setMode("list");
|
||||
toast("success", "Bericht erstellt.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Bericht konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(r: StoredReport) {
|
||||
const ok = await confirm({
|
||||
title: "Bericht löschen?",
|
||||
message: `«${r.title}» wird endgültig entfernt. Die Datei lässt sich danach nicht mehr herunterladen.`,
|
||||
confirmLabel: "Löschen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api.delete(`/api/plans/${planId}/reports/${r.id}`);
|
||||
await load();
|
||||
toast("success", "Bericht gelöscht.");
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Löschen fehlgeschlagen.");
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "new") {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-fg">Neuer Bericht</h2>
|
||||
<p className="text-sm text-muted">
|
||||
Der Bericht wird als PDF erzeugt und abgelegt. Er hält den Stand von heute fest – spätere Änderungen am
|
||||
Plan verändern ihn nicht mehr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Titel</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted">Notiz auf dem Deckblatt (optional)</label>
|
||||
<input
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="z. B. «Stand nach Beratungsgespräch»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Werte</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["nominal", "real"] as ReportMetric[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMetric(m)}
|
||||
className={`rounded-md px-2 py-0.5 font-medium ${metric === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"}`}
|
||||
>
|
||||
{m === "real" ? "Real" : "Nominal"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<InfoBubble text="Der Bericht führt durchgängig EINE Leitgrösse. Beides nebeneinander würde jede Tabellenspalte verdoppeln." />
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<span className="font-medium">Grundlage</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-surface p-0.5">
|
||||
{(["PLAN", "ACTUAL"] as ReportSource[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={s === "ACTUAL" && !hasActuals}
|
||||
onClick={() => setSource(s)}
|
||||
title={s === "ACTUAL" && !hasActuals ? "Für diesen Plan sind keine effektiven Werte erfasst." : undefined}
|
||||
className={`rounded-md px-2 py-0.5 font-medium disabled:opacity-40 ${
|
||||
source === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "ACTUAL" ? "Effektiv" : "Plan"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Szenarien (max. {MAX_REPORT_SCENARIOS})
|
||||
<InfoBubble text="Mehr als drei Szenarien machen die Vergleichstabelle unlesbar. Das Basisszenario führt den Bericht an und liefert die Zusammenfassung." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{scenarios.map((s) => {
|
||||
const checked = scenarioIds.includes(s.id);
|
||||
const full = !checked && scenarioIds.length >= MAX_REPORT_SCENARIOS;
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={`flex items-center gap-2 rounded-xl border p-3 text-sm ${
|
||||
checked ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
} ${full ? "opacity-40" : ""}`}
|
||||
>
|
||||
<input type="checkbox" checked={checked} disabled={full} onChange={() => toggleScenario(s.id)} />
|
||||
<span className="font-medium text-fg">{s.name}</span>
|
||||
{s.isBase && <span className="rounded bg-accent px-1.5 py-0.5 text-[10px] font-semibold text-accent-fg">Basis</span>}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Gespeicherte Analysen einbinden
|
||||
<InfoBubble text="Übernommen werden die beim Speichern eingefrorenen Zahlen – es wird nichts neu gerechnet." />
|
||||
</div>
|
||||
{analyses.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-3 text-xs text-muted">
|
||||
Noch keine Analysen gespeichert. Unter «Analysen» kannst du Ergebnisse festhalten.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{analyses.map((a) => (
|
||||
<label key={a.id} className="flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={analysisIds.includes(a.id)}
|
||||
onChange={() =>
|
||||
setAnalysisIds((prev) => (prev.includes(a.id) ? prev.filter((x) => x !== a.id) : [...prev, a.id]))
|
||||
}
|
||||
/>
|
||||
<span className="font-medium text-fg">{a.name}</span>
|
||||
<span className="text-faint">{ANALYSIS_TYPE_LABEL[a.type as AnalysisType]}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => setMode("list")}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
<Button disabled={busy || scenarioIds.length === 0} onClick={create}>
|
||||
{busy ? "Wird erstellt…" : "Bericht erstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-fg">Berichte</h2>
|
||||
<Button onClick={startNew} disabled={scenarios.length === 0}>
|
||||
<Plus className="h-4 w-4" /> Neuer Bericht
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-border bg-surface-2 p-3 text-xs leading-relaxed text-muted">
|
||||
Ein Bericht ist ein <strong className="text-fg">festes Dokument</strong>: Die erzeugte PDF-Datei wird abgelegt
|
||||
und lässt sich jederzeit unverändert wieder herunterladen – auch wenn du den Plan danach weiterentwickelst.
|
||||
</p>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!reports && !error && <p className="text-xs text-muted">Wird geladen…</p>}
|
||||
|
||||
{reports && reports.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Noch kein Bericht erstellt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{reports && reports.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reports.map((r) => (
|
||||
<div key={r.id} className="flex flex-wrap items-center gap-3 rounded-xl border border-border bg-surface-2 p-3">
|
||||
<FileText className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-fg">{r.title}</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{dt(r.createdAt)} · {r.author} · {r.config.metric === "real" ? "real" : "nominal"} ·{" "}
|
||||
{r.config.source === "ACTUAL" ? "effektiv" : "Plan"} · {(r.config.scenarioIds ?? []).length} Szenario
|
||||
{(r.config.scenarioIds ?? []).length === 1 ? "" : "s"} · {Math.round(r.pdfBytes / 1024)} KB
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/api/plans/${planId}/reports/${r.id}`}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:bg-surface hover:text-fg"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" /> PDF
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(r)}
|
||||
className="rounded-lg border border-border px-2 py-1 text-[11px] text-muted hover:border-danger hover:bg-danger-soft hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { limitsText, retirementBoundaries, shiftRetirement, sortedPhases } from "@/lib/retirement";
|
||||
import { Button, useConfirm } from "@/components/ui";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Pensionsalter anpassen (Roadmap Nr. 44).
|
||||
//
|
||||
// Warum eine eigene Bedienung statt eines Zahlenfeldes im Profil: Eine Pensionierung liegt
|
||||
// immer auf einer Phasengrenze. Ein frei änderbares Alter würde diese Grenze zerreissen --
|
||||
// die Phasen blieben, wo sie sind, und der Phasentyp (Erwerb/Pension) käme mitten in einer
|
||||
// Phase ins Rutschen. Hier wird stattdessen die GRENZE verschoben: Die Phase davor wird
|
||||
// länger, die danach kürzer, die Gesamtdauer des Plans bleibt gleich.
|
||||
|
||||
function personLabel(plan: PlanInput, role: string): string {
|
||||
const p = plan.persons.find((x) => x.role === role);
|
||||
if (p?.name) return p.name;
|
||||
if (plan.householdType === "SINGLE") return "Du";
|
||||
return role === "PERSON_A" ? "Person A" : "Person B";
|
||||
}
|
||||
|
||||
export function RetirementAdjuster({ plan, onSaved }: { plan: PlanInput; onSaved: () => void }) {
|
||||
const confirm = useConfirm();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const boundaries = useMemo(() => retirementBoundaries(plan), [plan]);
|
||||
|
||||
async function shift(role: "PERSON_A" | "PERSON_B", delta: number) {
|
||||
setError(null);
|
||||
const preview = shiftRetirement(plan, role, delta);
|
||||
if (!preview) {
|
||||
setError("So weit lässt sich das Pensionsalter hier nicht verschieben.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Zusammenlegung: Die entfallende Phase nimmt einen ganzen Übergang mit. Das muss VORHER
|
||||
// klar sein -- nachher liesse es sich nur durch erneutes Erfassen rückgängig machen.
|
||||
if (preview.removedPhaseId) {
|
||||
const removed = sortedPhases(plan).find((p) => p.id === preview.removedPhaseId);
|
||||
const target = sortedPhases(plan).find((p) => p.id === preview.mergedIntoPhaseId);
|
||||
const ok = await confirm({
|
||||
title: "Lebensphase fällt weg",
|
||||
message:
|
||||
`Bei dieser Verschiebung entfällt die Lebensphase «${removed?.name ?? ""}» vollständig. ` +
|
||||
(target
|
||||
? `Ihre Übergangs-Entscheide werden in den Übergang nach «${target.name}» gezogen: Bereits ` +
|
||||
`getroffene Entscheide bleiben, leere Felder werden von dort ergänzt, und einmalige ` +
|
||||
`Cash-Beträge werden addiert. `
|
||||
: "") +
|
||||
"Fortfahren?",
|
||||
confirmLabel: "Phase zusammenlegen",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
setBusy(role);
|
||||
try {
|
||||
await api.post(`/api/scenarios/${plan.id}/retirement`, { role, delta, confirmMerge: true });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Anpassung fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{boundaries.map((b) => {
|
||||
const label = personLabel(plan, b.role);
|
||||
const canMergeDown = b.mergeDeltaDown !== null;
|
||||
const canMergeUp = b.mergeDeltaUp !== null;
|
||||
return (
|
||||
<div key={b.role} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-faint">{label}</div>
|
||||
<div className="text-lg font-semibold text-fg">
|
||||
Pension mit {b.retirementAge}
|
||||
{b.blocked ? "" : <span className="ml-2 text-xs font-normal text-muted">Planjahr {b.planYear}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{!b.blocked && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.minDelta === 0 && !canMergeDown)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", -1)}
|
||||
>
|
||||
−1 Jahr
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={busy !== null || (b.maxDelta === 0 && !canMergeUp)}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", 1)}
|
||||
>
|
||||
+1 Jahr
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hilfebox: Was ist möglich -- und warum nicht mehr. Ohne diese Erklärung wirkt
|
||||
ein deaktivierter Knopf wie ein Fehler. */}
|
||||
<p className="mt-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
{limitsText(b, label)}
|
||||
</p>
|
||||
|
||||
{!b.blocked && (b.minDelta < -1 || b.maxDelta > 1) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{[-5, -3, -2, 2, 3, 5]
|
||||
.filter((d) => d >= (b.mergeDeltaDown ?? b.minDelta) && d <= (b.mergeDeltaUp ?? b.maxDelta))
|
||||
.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => shift(b.role as "PERSON_A" | "PERSON_B", d)}
|
||||
className="rounded-full border border-border px-2 py-0.5 text-xs text-muted hover:border-accent hover:text-fg disabled:opacity-50"
|
||||
>
|
||||
{d > 0 ? "+" : ""}
|
||||
{d} → {b.retirementAge + d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
// Die drei Säulen-Bausteine des Pensionierungs-Entscheids.
|
||||
//
|
||||
// Eigenes Modul, weil sie an ZWEI Orten erscheinen: im Pensionierungs-Bildschirm (alle
|
||||
// beieinander) und in der Matrix-Zelle am Pensions-Übergang (je einer). Das ist kein
|
||||
// Duplikat, sondern zwei Ansichten auf dasselbe Objekt -- deshalb auch genau eine
|
||||
// Implementierung. Wären es zwei, liefen sie garantiert auseinander.
|
||||
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { MoneyField, NumberField, SelectField } from "@/components/FormField";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ownerLabel } from "@/lib/elements";
|
||||
import {
|
||||
AHV_DEFER_MAX_MONTHS,
|
||||
AHV_EARLY_MAX_MONTHS,
|
||||
AHV_REFERENCE_AGE,
|
||||
PILLAR_3A_MAX_WITHDRAWAL_AGE,
|
||||
PILLAR_3A_MIN_WITHDRAWAL_AGE,
|
||||
PK_BUYIN_BLOCKING_YEARS,
|
||||
} from "@/lib/constants";
|
||||
import { ahvDrawLabel, withRetirementDefaults, type AhvDraw, type RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { RetirementPersonSummary } from "@/lib/calculations";
|
||||
import type { ElementInput, PlanInput } from "@/lib/types";
|
||||
|
||||
export type PatchFn = (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
|
||||
// Dispatcher: rendert den passenden Baustein zur Kategorie. Wird von der Matrix-Zelle
|
||||
// genutzt, die immer genau ein Element vor sich hat.
|
||||
export function RetirementFields({
|
||||
plan,
|
||||
el,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
// Der bereits mit Vorgaben aufgefüllte Entscheid. Der Pensionierungs-Bildschirm reicht hier
|
||||
// seinen Entwurf herein (ungespeicherte Änderungen); die Matrix-Zelle lässt ihn weg und
|
||||
// bekommt den gespeicherten Stand.
|
||||
rd: rdOverride,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
el: ElementInput;
|
||||
patch: PatchFn;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
rd?: RetirementDecision;
|
||||
}) {
|
||||
const person = plan.persons.find((p) => p.role === el.ownerRole);
|
||||
const retirementAge = person?.retirementAge ?? AHV_REFERENCE_AGE;
|
||||
const rd = rdOverride ?? withRetirementDefaults(el.category, retirementAge, el.retirementDecision);
|
||||
const siblings = plan.elements
|
||||
.filter((x) => x.category === "PILLAR_3A" && x.ownerRole === el.ownerRole && x.id !== el.id)
|
||||
.map((x) => withRetirementDefaults("PILLAR_3A", retirementAge, x.retirementDecision));
|
||||
|
||||
if (el.category === "AHV") return <AhvBlock el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||
if (el.category === "PENSION_FUND")
|
||||
return <PkBlock plan={plan} el={el} rd={rd} patch={patch} summary={summary} defaultOpen={defaultOpen} />;
|
||||
if (el.category === "PILLAR_3A")
|
||||
return (
|
||||
<Pillar3aBlock
|
||||
plan={plan}
|
||||
el={el}
|
||||
rd={rd}
|
||||
patch={patch}
|
||||
retirementAge={retirementAge}
|
||||
siblings={siblings}
|
||||
defaultOpen={defaultOpen}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Säulen-Blöcke -------------------------------------------------------------------------
|
||||
|
||||
export function Pillar({
|
||||
title,
|
||||
subtitle,
|
||||
confirmed,
|
||||
onConfirm,
|
||||
children,
|
||||
defaultOpen,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
confirmed: boolean;
|
||||
onConfirm: (v: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen ?? false);
|
||||
return (
|
||||
<div className="rounded-xl border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 text-faint" /> : <ChevronRight className="h-4 w-4 text-faint" />}
|
||||
<span className="text-sm font-semibold text-fg">{title}</span>
|
||||
<span className="flex-1 truncate text-xs text-muted">{subtitle}</span>
|
||||
{/* Dieselbe Vokabel wie bei den Phasenzellen (SPEZIFIKATION 3.14.2): «angeschaut».
|
||||
Es ist derselbe Gedanke -- dort auf den Werten der Vorphase, hier auf einer Vorgabe
|
||||
des Tools -- und zwei Wörter für eine Sache haben nur verwirrt. */}
|
||||
{confirmed ? (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-success">
|
||||
<Check className="h-3.5 w-3.5" /> angeschaut
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap rounded-full border border-border px-2 py-0.5 text-[11px] text-muted">
|
||||
<Info className="h-3.5 w-3.5" /> Vorgabe ungeprüft
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border px-3 py-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">{children}</div>
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => onConfirm(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
<strong className="text-fg">Angeschaut.</strong> FPT muss hier etwas rechnen, auch bevor du entschieden
|
||||
hast – deshalb gibt es eine Vorgabe (volle Rente, Bezug im Pensionsjahr). Der Haken hält fest, dass du
|
||||
sie gesehen und für richtig befunden hast. <strong className="text-fg">Er ändert keine Zahl.</strong>{" "}
|
||||
Ohne ihn rechnet FPT genauso weiter, weist die Vorgabe aber als «ungeprüft» aus, damit sie nicht
|
||||
unbemerkt durchgeht.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AhvBlock({
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
}: {
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const draw = rd.ahvDraw ?? "REFERENCE";
|
||||
const months = rd.ahvMonths ?? 0;
|
||||
const maxMonths = draw === "EARLY" ? AHV_EARLY_MAX_MONTHS : AHV_DEFER_MAX_MONTHS;
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title="AHV"
|
||||
subtitle={`${ahvDrawLabel(rd)} · ${formatChf(summary?.ahvAnnual ?? 0)} / Jahr`}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<SelectField
|
||||
label="Bezug"
|
||||
help={`Referenzalter ist ${AHV_REFERENCE_AGE}. Ein Vorbezug kürzt die Rente lebenslang um 6,8 % pro Jahr, ein Aufschub erhöht sie (nach 5 Jahren um 31,5 %).`}
|
||||
value={draw}
|
||||
onChange={(v: AhvDraw) =>
|
||||
patch(el.id, { ahvDraw: v, ahvMonths: v === "REFERENCE" ? 0 : months || 12 })
|
||||
}
|
||||
options={[
|
||||
{ value: "REFERENCE", label: `Ab Referenzalter (${AHV_REFERENCE_AGE})` },
|
||||
{ value: "EARLY", label: "Vorbeziehen" },
|
||||
{ value: "DEFERRED", label: "Aufschieben" },
|
||||
]}
|
||||
/>
|
||||
{draw !== "REFERENCE" && (
|
||||
<NumberField
|
||||
label={draw === "EARLY" ? "Vorbezug (Monate)" : "Aufschub (Monate)"}
|
||||
help={
|
||||
draw === "EARLY"
|
||||
? `Höchstens ${AHV_EARLY_MAX_MONTHS} Monate (3 Jahre), seit AHV 21 monatsgenau.`
|
||||
: `Mindestens 12, höchstens ${AHV_DEFER_MAX_MONTHS} Monate (5 Jahre).`
|
||||
}
|
||||
value={months}
|
||||
min={draw === "EARLY" ? 1 : 12}
|
||||
max={maxMonths}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { ahvMonths: Math.max(0, Math.min(maxMonths, Math.round(v))) })}
|
||||
/>
|
||||
)}
|
||||
{draw !== "REFERENCE" && summary && (
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Rente ab Alter <strong className="text-fg">{summary.ahvFromAge}</strong>:{" "}
|
||||
<strong className="text-fg">{formatChf(summary.ahvAnnual)}</strong> pro Jahr. Die Beitragspflicht endet davon
|
||||
unabhängig erst mit {AHV_REFERENCE_AGE} – wer vorher aufhört zu arbeiten, zahlt bis dahin als
|
||||
Nichterwerbstätige(r) weiter.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">Grundlage der Schätzung</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<MoneyField
|
||||
label="Durchschnittseinkommen vor Planbeginn (brutto, heutige Kaufkraft)"
|
||||
help="Aus der AHV-Rentenvorausberechnung. Bleibt das Feld leer, schätzt das Tool den Wert aus dem geplanten Durchschnitt – das ist besser als 0, aber ungenauer als dein echter Auszug."
|
||||
value={rd.avgIncomeBefore ?? 0}
|
||||
onChange={(v) => patch(el.id, { avgIncomeBefore: v })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Beitragslücken vor Planbeginn (Jahre)"
|
||||
help="Jahre ohne AHV-Beitrag, etwa durch Auslandaufenthalt. Jedes fehlende Jahr kürzt die Rente um rund 1/44."
|
||||
value={rd.gapYearsBefore ?? 0}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { gapYearsBefore: Math.max(0, Math.round(v)) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
export function PkBlock({
|
||||
plan,
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
summary,
|
||||
defaultOpen,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
summary?: RetirementPersonSummary;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const share = rd.capitalSharePct ?? 0;
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title={`Pensionskasse · ${el.name}`}
|
||||
subtitle={
|
||||
share === 0
|
||||
? `Volle Rente · ${formatChf(summary?.pkPensionAnnual ?? 0)} / Jahr`
|
||||
: share === 100
|
||||
? "Volles Kapital"
|
||||
: `${100 - share} % Rente / ${share} % Kapital`
|
||||
}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="mb-1 flex items-center text-xs font-medium text-muted">
|
||||
Kapitalanteil
|
||||
<InfoBubble text="0 % = volle Rente, 100 % = volles Kapital, alles dazwischen ist die Kombination. Bewusst eine Quote und kein Frankenbetrag: Verschiebst du das Pensionsalter, ändert sich das Guthaben – die Quote skaliert mit, ein fixer Betrag würde still ein anderes Verhältnis bedeuten." />
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={share}
|
||||
onChange={(e) => patch(el.id, { capitalSharePct: Number(e.target.value) })}
|
||||
// KEIN appearance-none: Damit zeichnet der Browser die Schiene nicht mehr selbst,
|
||||
// und accent-color greift nicht -- uebrig blieb der blosse Knopf. Im warmen Schema
|
||||
// war die Schiene dadurch unsichtbar.
|
||||
// Gleiche Schreibweise wie der Regler der Live-Simulation, der immer funktioniert
|
||||
// hat: keine erzwungene Hoehe, kein appearance-none.
|
||||
className="flex-1 cursor-pointer accent-[var(--accent)]"
|
||||
/>
|
||||
<span className="w-28 whitespace-nowrap text-right text-sm tabular-nums text-fg">
|
||||
{share} % Kapital
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-faint">
|
||||
<span>volle Rente</span>
|
||||
<span>volles Kapital</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bei 100 % Kapital wird nichts verrentet -- dann ist der Satz gegenstandslos, genau wie
|
||||
die Kapitalbezugssteuer bei 0 % Kapital. */}
|
||||
{share < 100 && (
|
||||
<NumberField
|
||||
label="Umwandlungssatz (%)"
|
||||
help="Aus deinem PK-Ausweis. Der gesetzliche Mindestsatz von 6,8 % gilt nur für den obligatorischen Teil – umhüllende Kassen liegen effektiv oft bei 5 bis 5,6 %, bei einer Frühpensionierung tiefer."
|
||||
value={rd.conversionRate ?? 0}
|
||||
step={0.1}
|
||||
min={0}
|
||||
max={20}
|
||||
onChange={(v) => patch(el.id, { conversionRate: v })}
|
||||
/>
|
||||
)}
|
||||
{share > 0 && (
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz. Die tatsächliche Steuer ist kantonal und progressiv – und alle Kapitalbezüge desselben Jahres werden zusammengezählt, bei Ehepaaren auch die des Partners. Wer staffelt, zahlt weniger."
|
||||
value={rd.capitalTaxRate ?? 0}
|
||||
step={0.5}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{share > 0 && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="flex cursor-pointer items-start gap-2 text-xs text-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rd.recentBuyIn === true}
|
||||
onChange={(e) => patch(el.id, { recentBuyIn: e.target.checked })}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
In den letzten {PK_BUYIN_BLOCKING_YEARS} Jahren in die Pensionskasse eingekauft
|
||||
</span>
|
||||
</label>
|
||||
{rd.recentBuyIn && (
|
||||
// attention-fg ist WEISS -- gedacht fuer vollflaechigen Attention-Grund. Auf
|
||||
// 10 % Deckkraft war der Text im warmen und im hellen Schema unlesbar.
|
||||
<p className="mt-1 flex items-start gap-2 rounded-lg border border-attention bg-attention-soft px-3 py-2 text-xs text-attention-soft-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Ein Kapitalbezug innerhalb von {PK_BUYIN_BLOCKING_YEARS} Jahren nach einem Einkauf lässt den
|
||||
Steuerabzug für diesen Einkauf nachträglich entfallen (Art. 79b Abs. 3 BVG). Das Tool rechnet diesen
|
||||
Effekt nicht – prüfe die Fristen mit deiner Kasse.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
|
||||
</>
|
||||
)}
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pillar3aBlock({
|
||||
plan,
|
||||
el,
|
||||
rd,
|
||||
patch,
|
||||
retirementAge,
|
||||
siblings,
|
||||
defaultOpen,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
el: ElementInput;
|
||||
rd: RetirementDecision;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
retirementAge: number;
|
||||
siblings: RetirementDecision[];
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const age = rd.withdrawalAge ?? retirementAge;
|
||||
const clash = siblings.some((s) => (s.withdrawalAge ?? retirementAge) === age);
|
||||
|
||||
return (
|
||||
<Pillar
|
||||
title={`Säule 3a · ${el.name}`}
|
||||
subtitle={`Bezug mit ${age}`}
|
||||
confirmed={rd.confirmed === true}
|
||||
onConfirm={(v) => patch(el.id, { confirmed: v })}
|
||||
defaultOpen={defaultOpen}
|
||||
>
|
||||
<NumberField
|
||||
label="Bezugsalter"
|
||||
help={`Frühestens ${PILLAR_3A_MIN_WITHDRAWAL_AGE}, spätestens ${PILLAR_3A_MAX_WITHDRAWAL_AGE} – und nach dem Referenzalter nur, solange du erwerbstätig bleibst. Der Bezug erfolgt an der ersten Phasengrenze bei oder nach diesem Alter.`}
|
||||
value={age}
|
||||
min={PILLAR_3A_MIN_WITHDRAWAL_AGE}
|
||||
max={PILLAR_3A_MAX_WITHDRAWAL_AGE}
|
||||
step={1}
|
||||
onChange={(v) => patch(el.id, { withdrawalAge: Math.round(v) })}
|
||||
/>
|
||||
<NumberField
|
||||
label="Kapitalbezugssteuer (%)"
|
||||
help="Pauschalsatz. Alle Kapitalbezüge desselben Jahres werden zusammengezählt – deshalb lohnt sich das Staffeln über mehrere Jahre und mehrere Konten."
|
||||
value={rd.capitalTaxRate ?? 0}
|
||||
step={0.5}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(v) => patch(el.id, { capitalTaxRate: v })}
|
||||
/>
|
||||
<div className="sm:col-span-2 rounded-lg bg-surface-2 px-3 py-2 text-xs text-muted">
|
||||
Ein 3a-Konto lässt sich bei der Pensionierung nur <strong className="text-fg">ganz</strong> auflösen. Gestaffelt
|
||||
wird über mehrere Konten mit unterschiedlichen Bezugsjahren.
|
||||
{age > AHV_REFERENCE_AGE && (
|
||||
<>
|
||||
{" "}
|
||||
Ein Bezug nach {AHV_REFERENCE_AGE} setzt voraus, dass du weiterhin erwerbstätig bist.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{clash && (
|
||||
<p className="sm:col-span-2 flex items-start gap-2 text-xs text-attention-soft-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
Ein weiteres 3a-Konto wird im selben Jahr bezogen. Die Beträge werden steuerlich zusammengezählt – ein
|
||||
anderes Bezugsjahr senkt die Progression.
|
||||
</p>
|
||||
)}
|
||||
{/* Die Frage stellt sich hier sogar zwingender als bei der PK: Ein 3a-Konto wird IMMER
|
||||
vollständig bezogen, es fliesst also in jedem Fall ein grosser Betrag. Bis 0.40 gab
|
||||
es die Maske nur bei der Pensionskasse -- das 3a-Kapital landete stumm auf dem Cash. */}
|
||||
<CapitalUse rd={rd} elementId={el.id} patch={patch} plan={plan} />
|
||||
</Pillar>
|
||||
);
|
||||
}
|
||||
|
||||
// Eine Zeile der Kapitalverwendung: Quote plus, sobald sie > 0 ist, das Ziel. Eigene Komponente
|
||||
// auf Modulebene -- innerhalb von CapitalUse deklariert wuerde sie bei jedem Tastendruck neu
|
||||
// erzeugt und verloere ihren Zustand (der React Compiler weist das zu Recht zurueck).
|
||||
function CapitalUseRow({
|
||||
title,
|
||||
help,
|
||||
value,
|
||||
max,
|
||||
onChange,
|
||||
targets,
|
||||
targetValue,
|
||||
onTarget,
|
||||
targetLabel,
|
||||
missing,
|
||||
label,
|
||||
}: {
|
||||
title: string;
|
||||
help: string;
|
||||
value: number;
|
||||
max: number;
|
||||
onChange: (v: number) => void;
|
||||
targets: ElementInput[];
|
||||
targetValue?: string;
|
||||
onTarget: (id: string) => void;
|
||||
targetLabel: string;
|
||||
missing: string;
|
||||
label: (e: ElementInput) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<NumberField label={title} help={help} value={value} step={5} min={0} max={max} onChange={onChange} />
|
||||
{value > 0 && targets.length > 0 && (
|
||||
<SelectField
|
||||
label={targetLabel}
|
||||
value={targetValue ?? targets[0].id}
|
||||
onChange={(v: string) => onTarget(v)}
|
||||
options={targets.map((t) => ({ value: t.id, label: label(t) }))}
|
||||
/>
|
||||
)}
|
||||
{value > 0 && targets.length === 0 && (
|
||||
<p className="flex items-start gap-2 self-end text-xs text-attention-soft-fg">
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{missing}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Verwendung des bezogenen Kapitals (Punkt C). Dieselbe Frage wie in der Matrix-Zelle -- hier
|
||||
// nur an dem Ort, an dem man ohnehin über den Bezug nachdenkt.
|
||||
//
|
||||
// Drei gleichrangige Zeilen: Schuldentilgung, Investition, Cash. Cash ist rechnerisch der
|
||||
// REST, wird aber wie eine Wahl dargestellt und nicht wie eine Fussnote -- «ich lasse es
|
||||
// liegen und entscheide später» ist ein legitimer Entscheid. Alle drei frei eintippbar zu
|
||||
// machen hiesse, dass beim Tippen still eine der anderen Zahlen wandert; das überrascht mehr,
|
||||
// als es hilft.
|
||||
function CapitalUse({
|
||||
rd,
|
||||
elementId,
|
||||
patch,
|
||||
plan,
|
||||
}: {
|
||||
rd: RetirementDecision;
|
||||
elementId: string;
|
||||
patch: (elementId: string, p: Partial<RetirementDecision>) => void;
|
||||
plan: PlanInput;
|
||||
}) {
|
||||
const amort = Math.max(0, Math.min(100, rd.capitalUseAmortizationPct ?? 0));
|
||||
const invest = Math.max(0, Math.min(100 - amort, rd.capitalUseInvestPct ?? 0));
|
||||
const cash = Math.max(0, 100 - amort - invest);
|
||||
|
||||
// Tilgungs-Ziele: Hypotheken UND sonstige Schulden. Wer einen Konsumkredit zu 6 % neben
|
||||
// einer Hypothek zu 1,5 % trägt, tilgt zuerst den Kredit.
|
||||
const debtTargets = plan.elements.filter((e) => e.category === "REAL_ESTATE" || e.category === "OTHER_DEBT");
|
||||
const investTargets = plan.elements.filter((e) => e.category === "OTHER_ASSET");
|
||||
const label = (e: ElementInput) => `${e.name} · ${ownerLabel(plan.persons, e.ownerRole)}`;
|
||||
|
||||
return (
|
||||
<div className="sm:col-span-2 mt-1 border-t border-border pt-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Wohin fliesst das bezogene Kapital?
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<CapitalUseRow
|
||||
label={label}
|
||||
title="Schuldentilgung (%)"
|
||||
help="Einmalige Tilgung. Am Restsaldo gekappt – ist die Schuld kleiner als der Anteil, bleibt der Rest Cash."
|
||||
value={amort}
|
||||
max={100}
|
||||
onChange={(v) => patch(elementId, { capitalUseAmortizationPct: Math.max(0, Math.min(100, v)) })}
|
||||
targets={debtTargets}
|
||||
targetValue={rd.capitalUseDebtTargetElementId}
|
||||
onTarget={(v) => patch(elementId, { capitalUseDebtTargetElementId: v })}
|
||||
targetLabel="Welche Schuld?"
|
||||
missing="Es gibt weder Immobilie noch Schuld in diesem Szenario – der Anteil bliebe auf dem Cash-Konto."
|
||||
/>
|
||||
<CapitalUseRow
|
||||
label={label}
|
||||
title="Investition (%)"
|
||||
help="Fliesst als Zusatzeinlage in das gewählte Vermögens-Element und wächst dort weiter."
|
||||
value={invest}
|
||||
max={100 - amort}
|
||||
onChange={(v) => patch(elementId, { capitalUseInvestPct: Math.max(0, Math.min(100 - amort, v)) })}
|
||||
targets={investTargets}
|
||||
targetValue={rd.capitalUseTargetElementId}
|
||||
onTarget={(v) => patch(elementId, { capitalUseTargetElementId: v })}
|
||||
targetLabel="Wohin investieren?"
|
||||
missing="Es gibt kein Element «Sonstiges Vermögen» – der Anteil bliebe auf dem Cash-Konto."
|
||||
/>
|
||||
{/* Cash ist der Rest, aber als vollwertige Zeile dargestellt. */}
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-2 p-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-xs font-medium text-muted">Cash (%)</span>
|
||||
<span className="text-sm font-semibold tabular-nums text-fg">{cash} %</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-faint">
|
||||
Was du nicht zuteilst, bleibt auf dem Cash-Konto – unverzinst, aber jederzeit verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
// Die Übersicht der offenen Punkte -- oben rechts, wo bis 0.38 der Assistent stand.
|
||||
//
|
||||
// Sie listet keine Schritte auf, sondern zeigt einen ZUSTAND: Was ist in diesem Plan noch
|
||||
// nicht entschieden oder nicht bestätigt? Der Unterschied fällt auf, sobald man einen
|
||||
// bestehenden Plan öffnet -- der Assistent bot dort Schritte an, die längst erledigt waren.
|
||||
//
|
||||
// Zwei Sonderzustände ganz am Anfang: Ohne Elemente und ohne Lebensphasen gibt es
|
||||
// naturgemäss nichts Offenes, obwohl der Plan leer ist. Eine "0" wäre dort eine Lüge; deshalb
|
||||
// steht dort eine Aufforderung statt einer Zahl.
|
||||
|
||||
import { CheckCircle2, ClipboardList, Sparkles } from "lucide-react";
|
||||
import type { PlanReview } from "@/lib/review";
|
||||
|
||||
export function ReviewTile({
|
||||
review,
|
||||
onOpenInventory,
|
||||
onAddPhase,
|
||||
onJump,
|
||||
}: {
|
||||
review: PlanReview;
|
||||
onOpenInventory: () => void;
|
||||
onAddPhase: () => void;
|
||||
onJump: (group: { id: string; kind: "phase" | "transition" }) => void;
|
||||
}) {
|
||||
const done = review.stage === "DONE";
|
||||
const starting = review.stage === "NO_ELEMENTS" || review.stage === "NO_PHASES";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tour="assistant"
|
||||
// KEIN h-full: In der rechten Leiste ist die Kachel das einzige Element mit Inhalt --
|
||||
// mit h-full zog sie sich ueber den ganzen Bildschirm, obwohl ein einziger Eintrag
|
||||
// darin stand.
|
||||
className={`flex flex-col rounded-xl border px-3 py-2.5 shadow-sm transition-colors ${
|
||||
done ? "border-success bg-success-soft" : "border-attention bg-attention-soft"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{done ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<ClipboardList className="h-4 w-4 text-attention" />
|
||||
)}
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-fg">Offene Punkte</span>
|
||||
{!starting && (
|
||||
<span className={`ml-auto text-[11px] font-semibold ${done ? "text-success" : "text-muted"}`}>
|
||||
{done ? "keine" : review.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{review.stage === "NO_ELEMENTS" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<p className="text-[11px] leading-snug text-muted">
|
||||
Dein Plan ist noch leer. Erfasse zuerst, was du heute hast – danach planst du deine erste Lebensphase.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInventory}
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" /> Bestandsaufnahme
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.stage === "NO_PHASES" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<p className="text-[11px] leading-snug text-muted">
|
||||
Deine Bestandsaufnahme steht. Jetzt fehlt die erste Lebensphase – erst dann gibt es etwas zu
|
||||
entscheiden.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddPhase}
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-lg bg-accent px-2 py-1.5 text-[11px] font-semibold text-accent-fg hover:opacity-90"
|
||||
>
|
||||
Erste Lebensphase anlegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.stage === "DONE" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-center gap-1">
|
||||
<p className="text-sm font-semibold text-success">Alles entschieden</p>
|
||||
<p className="text-[11px] leading-snug text-muted">
|
||||
In allen Lebensphasen und Übergängen ist nichts mehr offen. Änderst du etwas, erscheint es hier wieder.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.stage === "OPEN" && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-auto">
|
||||
{review.groups.map((g) => (
|
||||
<button
|
||||
key={`${g.kind}-${g.id}`}
|
||||
type="button"
|
||||
onClick={() => onJump(g)}
|
||||
className="rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-surface/60"
|
||||
>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-semibold text-fg">{g.title}</span>
|
||||
<span className="shrink-0 text-[11px] font-semibold text-attention">{g.open}</span>
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted">{g.reasons.join(" · ")}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bookmark, Check } from "lucide-react";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { defaultAnalysisName, saveAnalysis, type AnalysisType, type SaveAnalysisPayload } from "@/lib/analyses";
|
||||
|
||||
// Einheitlicher Speichern-Knopf für die Analysewerkzeuge. `build` wird erst beim Klick
|
||||
// aufgerufen -- so wird das (evtl. grosse) Ergebnis nicht bei jedem Render zusammengebaut,
|
||||
// sondern nur, wenn wirklich gespeichert wird.
|
||||
export function SaveAnalysisButton({
|
||||
planId,
|
||||
type,
|
||||
scenarioName,
|
||||
versionLabel,
|
||||
disabled,
|
||||
build,
|
||||
}: {
|
||||
planId: string;
|
||||
type: AnalysisType;
|
||||
scenarioName: string | null;
|
||||
versionLabel: string | null;
|
||||
disabled?: boolean;
|
||||
build: () => Omit<SaveAnalysisPayload, "name" | "type"> | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
function start() {
|
||||
setName(defaultAnalysisName(type, scenarioName, versionLabel));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = build();
|
||||
if (!payload) {
|
||||
toast("error", "Es gibt noch kein Ergebnis zum Speichern.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveAnalysis(planId, { ...payload, name: name.trim() || defaultAnalysisName(type, scenarioName, versionLabel), type });
|
||||
setOpen(false);
|
||||
setDone(true);
|
||||
toast("success", "Analyse gespeichert.");
|
||||
setTimeout(() => setDone(false), 2000);
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent bg-accent-soft/20 p-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && save()}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface px-2 py-1 text-sm text-fg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={save}
|
||||
className="rounded-lg bg-accent px-3 py-1 text-xs font-medium text-accent-fg hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Speichern…" : "Speichern"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-border px-2 py-1 text-xs text-muted hover:bg-surface-2">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={start}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
>
|
||||
{done ? <Check className="h-3.5 w-3.5 text-success" /> : <Bookmark className="h-3.5 w-3.5" />}
|
||||
{done ? "Gespeichert" : "Analyse speichern"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Eye, X } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { ANALYSIS_TYPE_LABEL, sourceLabel, type AnalysisType, type SavedResult } from "@/lib/analyses";
|
||||
|
||||
interface Full {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AnalysisType;
|
||||
scenarioName: string | null;
|
||||
versionLabel: string | null;
|
||||
metric: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
result: SavedResult;
|
||||
}
|
||||
|
||||
// Nur-Lese-Ansicht einer gespeicherten Analyse. Es wird NICHTS neu gerechnet -- die beim
|
||||
// Speichern festgehaltenen Zahlen werden nur gezeichnet.
|
||||
export function SavedAnalysisView({ planId, analysisId, onClose }: { planId: string; analysisId: string; onClose: () => void }) {
|
||||
const [data, setData] = useState<Full | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const d = await api.get<{ analysis: Full }>(`/api/plans/${planId}/analyses/${analysisId}`);
|
||||
if (!cancelled) setData(d.analysis);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [planId, analysisId]);
|
||||
|
||||
const r = data?.result;
|
||||
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Eye className="h-5 w-5 text-accent" />
|
||||
<span className="truncate">{data?.name ?? "Analyse"}</span>
|
||||
</h2>
|
||||
{data && (
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
{ANALYSIS_TYPE_LABEL[data.type]} · gespeichert{" "}
|
||||
{new Date(data.createdAt).toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" })}
|
||||
{data.scenarioName ? ` · ${data.scenarioName}${data.versionLabel ? ` ${data.versionLabel}` : ""}` : ""} ·{" "}
|
||||
{data.metric === "real" ? "real" : "nominal"} · {sourceLabel(data.source)}
|
||||
<span className="ml-1 rounded bg-surface-2 px-1.5 py-0.5 text-[10px] text-faint">nur Ansicht</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
{!data && !error && <p className="text-sm text-muted">Wird geladen…</p>}
|
||||
|
||||
{r && (
|
||||
<>
|
||||
{r.metrics && r.metrics.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{r.metrics.map((m, i) => (
|
||||
<div key={i} className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-faint">{m.label}</div>
|
||||
<div className={`mt-0.5 text-lg font-semibold ${m.tone === "danger" ? "text-danger" : m.tone === "success" ? "text-success" : "text-fg"}`}>
|
||||
{m.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.chart && <SavedChart chart={r.chart} />}
|
||||
|
||||
{r.table && (
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
{r.table.columns.map((c, i) => (
|
||||
<th key={i} className={`px-3 py-2 font-semibold ${i === 0 ? "text-left" : "text-right"}`}>{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.table.rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-border">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className={`px-3 py-2 ${ci === 0 ? "text-left font-medium text-fg" : "text-right tabular-nums text-muted"}`}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-faint">Parameter</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
|
||||
{r.params.map((p, i) => (
|
||||
<span key={i}>
|
||||
{p.label}: <strong className="text-fg">{p.value}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedChart({ chart }: { chart: NonNullable<SavedResult["chart"]> }) {
|
||||
const fmt = (v: number) => (chart.yFormat === "pct" ? `${v} %` : Intl.NumberFormat("de-CH", { notation: "compact" }).format(v));
|
||||
const tip = (v: unknown) => (typeof v !== "number" ? String(v ?? "") : chart.yFormat === "pct" ? `${v} %` : formatChf(v));
|
||||
|
||||
// Punkte aller Serien auf gemeinsame x-Werte bringen.
|
||||
const xs = Array.from(new Set(chart.series.flatMap((s) => s.points.map((p) => p.x)))).sort((a, b) => a - b);
|
||||
const data = xs.map((x) => {
|
||||
const row: Record<string, number | string> = { x };
|
||||
for (const s of chart.series) row[s.label] = s.points.find((p) => p.x === x)?.y ?? (null as unknown as number);
|
||||
return row;
|
||||
});
|
||||
const PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"];
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{chart.kind === "bar" ? (
|
||||
<BarChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="x" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
|
||||
<Tooltip formatter={tip} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{chart.series.map((s, i) => (
|
||||
<Bar key={s.label} dataKey={s.label} fill={s.color ?? PALETTE[i % PALETTE.length]} isAnimationActive={false} />
|
||||
))}
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="x" type="number" domain={["dataMin", "dataMax"]} tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmt} />
|
||||
<Tooltip formatter={tip} labelFormatter={(v) => (chart.xLabel ? `${chart.xLabel} ${v}` : `${v}`)} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{chart.series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
dataKey={s.label}
|
||||
stroke={s.color ?? PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { Tornado, X } from "lucide-react";
|
||||
import { RequiredNumberField } from "@/components/FormField";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { useVersionedPlan, VersionBar } from "@/components/VersionPicker";
|
||||
import { AnalysisBar, useAnalysisBasis } from "@/components/AnalysisControls";
|
||||
import { SaveAnalysisButton } from "@/components/SaveAnalysisButton";
|
||||
import type { ActualsSetInput, ElementOrigin } from "@/lib/actuals";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import {
|
||||
computeTornado,
|
||||
DRIVERS,
|
||||
UNIT_SUFFIX,
|
||||
type DriverDef,
|
||||
type DriverId,
|
||||
type TornadoMetric,
|
||||
type TornadoResult,
|
||||
} from "@/lib/sensitivity";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
interface RangeDraft {
|
||||
checked: boolean;
|
||||
low: string;
|
||||
high: string;
|
||||
}
|
||||
|
||||
// Bewusst OHNE Defaults: Die Balkenlänge hängt direkt von der eingegebenen Bandbreite ab.
|
||||
// Ein stiller Default würde nicht hinterfragt und das Ranking wäre dann eine Aussage über
|
||||
// unsere Vorgabe statt über den Plan (gleiche Begründung wie bei den Monte-Carlo-Mittelwerten).
|
||||
const emptyDraft: RangeDraft = { checked: false, low: "", high: "" };
|
||||
|
||||
function formatRange(low: number, high: number, unit: DriverDef["unit"]): string {
|
||||
const suffix = UNIT_SUFFIX[unit];
|
||||
const sign = (v: number) => (unit === "abs_pct" ? `${v}` : v > 0 ? `+${v}` : `${v}`);
|
||||
return `${sign(low)} ${suffix} → ${sign(high)} ${suffix}`;
|
||||
}
|
||||
|
||||
export function SensitivityDialog({
|
||||
plan: currentPlan,
|
||||
planId,
|
||||
actuals = [],
|
||||
origins = [],
|
||||
onClose,
|
||||
}: {
|
||||
plan: PlanInput;
|
||||
planId: string;
|
||||
actuals?: ActualsSetInput[];
|
||||
origins?: ElementOrigin[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Gerechnet wird wahlweise auf dem Arbeitsstand oder auf einer festgehaltenen Version.
|
||||
const { versionId, setVersionId, plan, loading: versionLoading } = useVersionedPlan(currentPlan.id, currentPlan);
|
||||
const available = useMemo(() => DRIVERS.filter((d) => d.applies(plan)), [plan]);
|
||||
|
||||
const basis = useAnalysisBasis(plan, actuals, origins, "real");
|
||||
const metric: TornadoMetric = basis.metric;
|
||||
const [drafts, setDrafts] = useState<Record<string, RangeDraft>>({});
|
||||
const [result, setResult] = useState<TornadoResult | null>(null);
|
||||
|
||||
function draftFor(id: DriverId): RangeDraft {
|
||||
return drafts[id] ?? emptyDraft;
|
||||
}
|
||||
function setDraft(id: DriverId, patch: Partial<RangeDraft>) {
|
||||
setDrafts((prev) => ({ ...prev, [id]: { ...draftFor(id), ...patch } }));
|
||||
setResult(null);
|
||||
}
|
||||
|
||||
const checked = available.filter((d) => draftFor(d.id).checked);
|
||||
const incomplete = checked.filter((d) => {
|
||||
const dr = draftFor(d.id);
|
||||
return dr.low.trim() === "" || dr.high.trim() === "";
|
||||
});
|
||||
const degenerate = checked.filter((d) => {
|
||||
const dr = draftFor(d.id);
|
||||
return dr.low.trim() !== "" && dr.high.trim() !== "" && Number(dr.low) === Number(dr.high);
|
||||
});
|
||||
|
||||
// Mindestens zwei Treiber: Ein Tornado ist eine RANGLISTE. Mit einem einzigen Balken gibt
|
||||
// es nichts zu ordnen und die Grafik würde eine Aussage suggerieren, die sie nicht hat.
|
||||
const canRun =
|
||||
checked.length >= 2 && incomplete.length === 0 && degenerate.length === 0 && plan.phases.length > 0;
|
||||
|
||||
function run() {
|
||||
setResult(
|
||||
computeTornado(
|
||||
plan,
|
||||
metric,
|
||||
checked.map((d) => {
|
||||
const dr = draftFor(d.id);
|
||||
return { id: d.id, low: Number(dr.low), high: Number(dr.high) };
|
||||
}),
|
||||
// Mit Ist-Werten wirken die Treiber nur noch auf die NICHT belegten Jahre -- was
|
||||
// erfasst ist, steht fest. Die Balken fallen dadurch zu Recht kuerzer aus.
|
||||
basis.resolvedActuals
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!result) return [];
|
||||
// Von unten nach oben gezeichnet -> für die Trichterform (grösster Balken zuoberst)
|
||||
// muss die Reihenfolge umgekehrt in die Grafik.
|
||||
return [...result.bars].reverse().map((b) => ({
|
||||
label: b.shortLabel,
|
||||
range: [b.min, b.max] as [number, number],
|
||||
swing: b.swing,
|
||||
}));
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-full max-w-4xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Tornado className="h-5 w-5 text-accent" /> Einflussfaktoren (Sensitivitätsanalyse)
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VersionBar
|
||||
scenarioId={currentPlan.id}
|
||||
versionId={versionId}
|
||||
onChange={(id) => {
|
||||
setVersionId(id);
|
||||
setResult(null);
|
||||
}}
|
||||
loading={versionLoading}
|
||||
/>
|
||||
|
||||
{/* Erklärung */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 text-xs leading-relaxed text-muted">
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was ist das?</strong> Nicht «wie viel Geld habe ich am Schluss», sondern
|
||||
<strong className="text-fg"> «welche meiner Annahmen entscheidet überhaupt über das Ergebnis»</strong>. Bei
|
||||
manchen Annahmen ist es egal, ob du sie exakt triffst – bei anderen kippt eine kleine Abweichung den ganzen Plan.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Wie es funktioniert:</strong> Zuerst wird dein Plan wie erfasst gerechnet
|
||||
(Basisfall). Dann wird <em>ein einziger</em> Parameter auf seinen tiefen und seinen hohen Wert gesetzt, alle
|
||||
übrigen bleiben unverändert – das ergibt zwei Ergebnisse. Das für jeden Parameter wiederholt und nach
|
||||
<strong className="text-fg"> Spannweite</strong> sortiert ergibt die Trichterform: längster Balken zuoberst.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
<strong className="text-fg">Was du davon hast:</strong> Die Reihenfolge ist die Botschaft. Bei den obersten
|
||||
Balken lohnt sich Genauigkeit (dort exakte Zahlen beschaffen) – und sie sind meist auch die, die du selbst
|
||||
steuern kannst. Bei den untersten darfst du grob schätzen.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-fg">Zwei ehrliche Grenzen.</strong> Erstens hängt die Balkenlänge von den
|
||||
Bandbreiten ab, die du unten eingibst – wähle sie so, dass sie <em>ähnlich plausibel</em> sind, nicht ähnlich
|
||||
gross. Zweitens wird immer nur ein Parameter auf einmal variiert; <em>Kombinationen</em> (schlechte Renditen
|
||||
<em> und</em> hohe Ausgaben) treffen härter als die Summe der Einzelbalken – dafür ist die
|
||||
Monte-Carlo-Simulation zuständig.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zielgrösse und Datengrundlage */}
|
||||
<AnalysisBar basis={basis} onChange={() => setResult(null)} />
|
||||
|
||||
{/* Parameter */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Parameter und Bandbreiten
|
||||
<InfoBubble text="Hake an, welche Parameter in die Analyse einfliessen sollen, und gib je Parameter eine tiefe und eine hohe Ausprägung an. Es gibt bewusst keine Vorgabewerte: Die Balkenlänge hängt direkt von diesen Bandbreiten ab, deshalb sollst du sie bewusst setzen. Die Hilfe je Parameter nennt plausible Grössenordnungen." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{available.map((d) => {
|
||||
const dr = draftFor(d.id);
|
||||
const suffix = UNIT_SUFFIX[d.unit];
|
||||
const unitHint =
|
||||
d.unit === "abs_pct"
|
||||
? "absoluter Wert in %"
|
||||
: d.unit === "delta_pp"
|
||||
? "Verschiebung in Prozentpunkten"
|
||||
: d.unit === "rel_pct"
|
||||
? "Abweichung vom Planwert in %"
|
||||
: "Verschiebung in Jahren";
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`rounded-xl border p-3 ${dr.checked ? "border-accent bg-accent-soft/30" : "border-border bg-surface-2"}`}
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={dr.checked} onChange={(e) => setDraft(d.id, { checked: e.target.checked })} />
|
||||
<span className="font-medium text-fg">{d.label}</span>
|
||||
<InfoBubble text={d.help} />
|
||||
<span className="text-[11px] text-faint">({unitHint})</span>
|
||||
</label>
|
||||
{dr.checked && (
|
||||
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<RequiredNumberField
|
||||
label="tief"
|
||||
value={dr.low}
|
||||
suffix={suffix}
|
||||
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
|
||||
onChange={(v) => setDraft(d.id, { low: v })}
|
||||
/>
|
||||
<RequiredNumberField
|
||||
label="hoch"
|
||||
value={dr.high}
|
||||
suffix={suffix}
|
||||
step={d.unit === "delta_years" || d.unit === "delta_months" ? 1 : 0.1}
|
||||
onChange={(v) => setDraft(d.id, { high: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] text-faint">
|
||||
Das <strong>Pensionsalter</strong> ist bewusst nicht enthalten: Es lässt sich nicht sinnvoll variieren, ohne
|
||||
gleichzeitig die Phasengrenzen mitzuverschieben – sonst arbeitet die Person im Modell unverändert weiter
|
||||
bzw. der Pensions-Übergang entfällt ganz. Der verwandte Treiber «Lebensdauer» ist dagegen sauber abgebildet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{checked.length < 2 && (
|
||||
<p className="text-xs text-danger">Bitte mindestens zwei Parameter auswählen – der Tornado ist eine Rangliste.</p>
|
||||
)}
|
||||
{incomplete.length > 0 && (
|
||||
<p className="text-xs text-danger">Bitte für jeden ausgewählten Parameter eine tiefe und eine hohe Ausprägung angeben.</p>
|
||||
)}
|
||||
{degenerate.length > 0 && (
|
||||
<p className="text-xs text-danger">
|
||||
Tiefer und hoher Wert sind identisch bei: {degenerate.map((d) => d.label).join(", ")}. Damit gibt es keine Spannweite.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canRun}
|
||||
onClick={run}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-fg shadow-sm hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
Einflussfaktoren berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SaveAnalysisButton
|
||||
planId={planId}
|
||||
type="SENSITIVITY"
|
||||
scenarioName={currentPlan.name}
|
||||
versionLabel={versionId === "current" ? null : versionId}
|
||||
build={() => buildSensitivityResult(result, metric, currentPlan.name, basis)}
|
||||
/>
|
||||
</div>
|
||||
<TornadoResults result={result} metric={metric} chartData={chartData} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generische Momentaufnahme des Tornados (siehe analyses.ts).
|
||||
function buildSensitivityResult(
|
||||
result: TornadoResult,
|
||||
metric: TornadoMetric,
|
||||
scenarioName: string,
|
||||
basis: { source: string }
|
||||
): { metric?: string; source?: string; scenarioName?: string | null; summary?: string | null; inputs: Record<string, unknown>; result: Record<string, unknown> } {
|
||||
return {
|
||||
metric,
|
||||
source: basis.source,
|
||||
scenarioName,
|
||||
summary: result.bars[0] ? `Stärkster Hebel: ${result.bars[0].shortLabel}` : null,
|
||||
inputs: {
|
||||
params: [
|
||||
{ label: "Zielgrösse", value: metric === "real" ? "Endvermögen real" : "Endvermögen nominal" },
|
||||
{ label: "Basiswert", value: formatChf(result.base) },
|
||||
],
|
||||
},
|
||||
result: {
|
||||
params: [{ label: "Basiswert (unverändert)", value: formatChf(result.base) }],
|
||||
table: {
|
||||
columns: ["Treiber", "Bandbreite", "tief", "hoch", "Spannweite"],
|
||||
rows: result.bars.map((b) => [
|
||||
b.shortLabel,
|
||||
`${b.low}…${b.high} ${UNIT_SUFFIX[b.unit]}`,
|
||||
formatChf(b.lowResult),
|
||||
formatChf(b.highResult),
|
||||
formatChf(b.swing),
|
||||
]),
|
||||
},
|
||||
chart: {
|
||||
kind: "bar" as const,
|
||||
yFormat: "chf" as const,
|
||||
series: [{ label: "Spannweite", points: result.bars.map((b, i) => ({ x: i, y: b.swing })) }],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function TornadoResults({
|
||||
result,
|
||||
metric,
|
||||
chartData,
|
||||
}: {
|
||||
result: TornadoResult;
|
||||
metric: TornadoMetric;
|
||||
chartData: { label: string; range: [number, number]; swing: number }[];
|
||||
}) {
|
||||
const metricLabel = metric === "real" ? "Endvermögen real" : "Endvermögen nominal";
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
||||
<div className="rounded-xl border border-border bg-surface-2 px-4 py-3">
|
||||
<div className="text-xs text-muted">Basisfall · {metricLabel}</div>
|
||||
<div className="mt-0.5 text-xl font-semibold text-accent">{formatChf(result.base)} CHF</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-fg">Einflussfaktoren nach Spannweite</div>
|
||||
<p className="mb-2 text-[11px] text-muted">
|
||||
Jeder Balken zeigt, zwischen welchen Werten das {metricLabel} schwankt, wenn nur dieser eine Parameter
|
||||
innerhalb seiner Bandbreite variiert. Die senkrechte Linie ist der Basisfall.
|
||||
</p>
|
||||
<div className="w-full" style={{ height: Math.max(200, chartData.length * 46 + 60) }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ top: 8, right: 24, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<YAxis type="category" dataKey="label" tick={{ fontSize: 11 }} width={130} />
|
||||
<Tooltip
|
||||
formatter={(v) =>
|
||||
Array.isArray(v)
|
||||
? `${formatChf(Number(v[0]))} – ${formatChf(Number(v[1]))}`
|
||||
: typeof v === "number"
|
||||
? formatChf(v)
|
||||
: v
|
||||
}
|
||||
/>
|
||||
<ReferenceLine x={result.base} stroke="var(--muted)" strokeDasharray="4 3" />
|
||||
<Bar dataKey="range" name={metricLabel} fill="var(--accent)" radius={[3, 3, 3, 3]} isAnimationActive={false} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="px-3 py-2 text-left font-semibold">Parameter</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">Bandbreite</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">bei «tief»</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">bei «hoch»</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Spannweite</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.bars.map((b) => (
|
||||
<tr key={b.id} className="border-t border-border">
|
||||
<td className="px-3 py-2 font-medium text-fg">
|
||||
{b.label}
|
||||
{b.note && <div className="mt-0.5 text-[11px] font-normal text-muted">{b.note}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted">{formatRange(b.low, b.high, b.unit)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.lowResult)}</td>
|
||||
<td className="px-3 py-2 text-right text-muted">{formatChf(b.highResult)}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-fg">{formatChf(b.swing)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-[11px] text-faint">
|
||||
Die Reihenfolge – nicht der absolute Betrag – ist die Aussage. Sie hängt von den eingegebenen Bandbreiten ab.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
// Mini-Verlaufskurve für die Matrix-Zeilen: der Trend eines Elements über alle Planjahre
|
||||
// auf einen Blick. Die per-Jahr-Werte existieren seit 0.11 (ElementPhaseComputed.yearly) --
|
||||
// hier werden sie nur gezeichnet, nicht neu gerechnet. Reines SVG, keine Chart-Bibliothek.
|
||||
|
||||
export function Sparkline({
|
||||
values,
|
||||
width = 64,
|
||||
height = 16,
|
||||
className = "",
|
||||
}: {
|
||||
values: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (values.length < 2) return null;
|
||||
const min = Math.min(...values, 0);
|
||||
const max = Math.max(...values, 0);
|
||||
if (max === min) return null; // flache Linie trägt keine Information
|
||||
|
||||
const pad = 1.5;
|
||||
const x = (i: number) => pad + (i / (values.length - 1)) * (width - 2 * pad);
|
||||
const y = (v: number) => pad + (1 - (v - min) / (max - min)) * (height - 2 * pad);
|
||||
const points = values.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(" ");
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 ${className}`}
|
||||
>
|
||||
{/* Nulllinie, falls die Werte das Vorzeichen wechseln (z. B. Schulden). */}
|
||||
{min < 0 && max > 0 && (
|
||||
<line x1={0} x2={width} y1={y(0)} y2={y(0)} stroke="var(--border-strong)" strokeWidth="1" strokeDasharray="2 2" />
|
||||
)}
|
||||
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
const INCOME_COLOR = "#16a34a";
|
||||
const EXPENSE_COLOR = "#dc2626";
|
||||
const REAL_COLOR = "#9ca3af";
|
||||
const INCOME_COLOR = "var(--chart-3)";
|
||||
const EXPENSE_COLOR = "var(--chart-5)";
|
||||
const REAL_COLOR = "var(--faint)";
|
||||
|
||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Flaeche
|
||||
// dazwischen ist die Spar-/Verzehrquote (gruen = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||
// Verlauf pro Jahr: Einkommen (nominal, inkl. Renten) vs. nominale Ausgaben; die Fläche
|
||||
// dazwischen ist die Spar-/Verzehrquote (grün = Sparen, rot = Verzehr). Reale Ausgaben als
|
||||
// blasse Referenzlinie -> der Keil zu den nominalen Ausgaben ist "das, was die Inflation frisst".
|
||||
export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
||||
if (computed.yearly.length === 0) return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||
@@ -31,7 +31,7 @@ export function SparquoteChart({ computed }: { computed: PlanComputed }) {
|
||||
income: y.income,
|
||||
expenseNominal: y.expenseNominal,
|
||||
expenseReal: y.expenseReal,
|
||||
// Range-Flaechen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Luecke).
|
||||
// Range-Flächen [unten, oben]; nur dort gesetzt, wo zutreffend (sonst Lücke).
|
||||
sparBand: surplus ? [y.expenseNominal, y.income] : undefined,
|
||||
verzehrBand: surplus ? undefined : [y.income, y.expenseNominal],
|
||||
};
|
||||
|
||||
@@ -7,9 +7,9 @@ import rehypeSlug from "rehype-slug";
|
||||
import { FileText } from "lucide-react";
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling laeuft ueber
|
||||
// die Klasse .md-doc in globals.css und folgt damit dem gewaehlten Farbschema.
|
||||
export function SpecView() {
|
||||
// Rendert SPEZIFIKATION.md (via /api/spec) als lesbares Dokument. Das Styling läuft über
|
||||
// die Klasse .md-doc in globals.css und folgt damit dem gewählten Farbschema.
|
||||
export function SpecView({ anchor }: { anchor?: string | null }) {
|
||||
const [markdown, setMarkdown] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -20,8 +20,16 @@ export function SpecView() {
|
||||
.catch((e) => setError(e instanceof Error ? e.message : "Laden fehlgeschlagen."));
|
||||
}, []);
|
||||
|
||||
// Sprungmarke aus einem Rechenweg: erst nach dem Rendern des Markdowns existiert die
|
||||
// Überschrift mit der von rehype-slug erzeugten id.
|
||||
useEffect(() => {
|
||||
if (!anchor || markdown === null) return;
|
||||
const el = document.getElementById(anchor);
|
||||
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, [anchor, markdown]);
|
||||
|
||||
if (error) return <p className="text-sm text-danger">{error}</p>;
|
||||
if (markdown === null) return <p className="text-sm text-muted">Laedt…</p>;
|
||||
if (markdown === null) return <p className="text-sm text-muted">Lädt…</p>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import { SYSTEM_PARAMETERS, type ParameterGroup, type SystemParameter } from "@/lib/constants";
|
||||
|
||||
const GROUPS: { key: ParameterGroup; title: string; intro: string }[] = [
|
||||
{
|
||||
key: "AHV",
|
||||
title: "AHV",
|
||||
intro:
|
||||
"Diese Werte legt der Bund fest. Sie ändern sich periodisch – der Stand ist je Parameter ausgewiesen.",
|
||||
},
|
||||
{
|
||||
key: "Vorsorge",
|
||||
title: "Berufliche und gebundene Vorsorge",
|
||||
intro: "Höchstbeträge und Umwandlung. Der Umwandlungssatz ist ein Vorschlag und pro Ereignis überschreibbar.",
|
||||
},
|
||||
{
|
||||
key: "Steuern",
|
||||
title: "Steuersätze",
|
||||
intro:
|
||||
"Bewusst pauschale Vorschlagswerte, keine Steuerberechnung: Die tatsächlichen Sätze sind kantonal, progressiv und vom Einzelfall abhängig. Sie lassen sich pro Ereignis überschreiben.",
|
||||
},
|
||||
];
|
||||
|
||||
function formatValue(p: SystemParameter): string {
|
||||
switch (p.unit) {
|
||||
case "CHF":
|
||||
return `${formatChf(p.value)} CHF`;
|
||||
case "%":
|
||||
return `${p.value} %`;
|
||||
case "Jahre":
|
||||
return `${p.value} Jahre`;
|
||||
default:
|
||||
return String(p.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Zeigt die Systemparameter mit Wert, Bedeutung, Herleitung und Quelle (Roadmap Nr. 41).
|
||||
// Die Einträge stammen direkt aus constants.ts -- dieselbe Datenquelle, aus der auch
|
||||
// gerechnet wird. Ein Abdriften zwischen Anzeige und Rechnung ist damit ausgeschlossen.
|
||||
export function SystemParametersView() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-5 w-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-fg">Systemparameter</h2>
|
||||
</div>
|
||||
|
||||
<p className="max-w-3xl text-sm text-muted">
|
||||
Alle fest hinterlegten Grössen, mit denen das Tool rechnet – inklusive Herleitung und Quelle. Sie stammen aus
|
||||
derselben Datei, aus der auch die Berechnung liest; was hier steht, ist also garantiert das, was gerechnet wird.
|
||||
</p>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const rows = SYSTEM_PARAMETERS.filter((p) => p.group === g.key);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<section key={g.key} className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-fg">{g.title}</h3>
|
||||
<p className="mb-3 mt-0.5 text-xs text-muted">{g.intro}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{rows.map((p) => (
|
||||
<div key={p.key} className="rounded-lg border border-border bg-surface-2 p-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="text-sm font-medium text-fg">{p.label}</span>
|
||||
<span className="text-sm font-semibold text-accent">{formatValue(p)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted">{p.meaning}</p>
|
||||
{p.derivation && (
|
||||
<p className="mt-1 font-mono text-[11px] text-faint">Herleitung: {p.derivation}</p>
|
||||
)}
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-faint">
|
||||
<span>Quelle: {p.source}</span>
|
||||
<span>Stand: {p.validFrom}</span>
|
||||
{p.editablePerEvent && <span className="text-accent">pro Ereignis überschreibbar</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="max-w-3xl text-xs text-faint">
|
||||
Die drei Steuersätze werden sowohl als Vorschlag im Eingabefeld als auch in der Berechnung als Rückfallwert
|
||||
verwendet – damit ein nicht angetippter Wert nicht fälschlich als 0 % gerechnet wird.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+185
-67
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Flag } from "lucide-react";
|
||||
import { CalendarCheck, Flag } from "lucide-react";
|
||||
import type { PhaseComputed } from "@/lib/calculations";
|
||||
|
||||
interface PersonAxis {
|
||||
@@ -11,97 +11,215 @@ interface PersonAxis {
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Horizontale Zeitachse: Alter von links nach rechts, mit deutlich markiertem
|
||||
// Pensionsalter je Person und Trennlinien an den Phasengrenzen.
|
||||
// Die Zeitachse: ein durchgehender Balken, die Lebensphasen als Segmente, Breite nach Dauer.
|
||||
//
|
||||
// Seit 0.42 ist sie zugleich der NAVIGATOR der Kartenansicht -- ein Klick springt zur Phase.
|
||||
// Zeit laeuft von links nach rechts; das versteht man ohne Erklaerung.
|
||||
//
|
||||
// 0.42.3 gibt ihr die Form, die man von Zeitstrahlen kennt: Die Beschriftungen stehen
|
||||
// ABWECHSELND ober- und unterhalb des Balkens, mit Dreieck und Verbindungslinie. Das ist nicht
|
||||
// Zierde, sondern loest ein Platzproblem: Nebeneinander gesetzte Beschriftungen kollidieren,
|
||||
// sobald eine Phase kurz ist. Abwechselnd hat jede den doppelten Raum -- Nachbarn auf
|
||||
// derselben Seite liegen zwei Phasen auseinander.
|
||||
export function Timeline({
|
||||
phases,
|
||||
persons,
|
||||
ruinAge,
|
||||
startYear,
|
||||
actualYears = [],
|
||||
activePhaseId,
|
||||
onSelectPhase,
|
||||
}: {
|
||||
phases: PhaseComputed[];
|
||||
persons: PersonAxis[];
|
||||
ruinAge?: number | null;
|
||||
startYear?: number | null;
|
||||
// Kalenderjahre, für die effektive Werte erfasst sind (aufsteigend). Der jüngste Satz
|
||||
// wird hervorgehoben, ältere bleiben blass -- sie sind überholt, aber nicht bedeutungslos.
|
||||
actualYears?: number[];
|
||||
activePhaseId?: string | null;
|
||||
onSelectPhase?: (phaseId: string) => void;
|
||||
}) {
|
||||
if (phases.length === 0 || persons.length === 0) return null;
|
||||
|
||||
const totalYears = phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
const minAge = Math.min(...persons.map((p) => p.currentAge));
|
||||
const maxAge = minAge + totalYears;
|
||||
const span = Math.max(1, maxAge - minAge);
|
||||
const span = Math.max(1, totalYears);
|
||||
|
||||
const pct = (age: number) => `${(Math.max(0, Math.min(span, age - minAge)) / span) * 100}%`;
|
||||
// Position eines Alters auf dem Balken, in Prozent seiner Länge.
|
||||
const pctOfAge = (age: number) => (Math.max(0, Math.min(span, age - minAge)) / span) * 100;
|
||||
const yearOf = (yearsFromStart: number) => (startYear ? startYear + yearsFromStart : null);
|
||||
|
||||
// Phasengrenzen (kumulierte Jahre).
|
||||
const boundaries: { year: number; label: string }[] = [];
|
||||
let acc = 0;
|
||||
for (const p of phases) {
|
||||
boundaries.push({ year: acc, label: p.name });
|
||||
acc += p.durationYears;
|
||||
}
|
||||
const segments = phases.map((p, i) => {
|
||||
const from = phases.slice(0, i).reduce((s, x) => s + x.durationYears, 0);
|
||||
return { phase: p, from, to: from + p.durationYears, index: i };
|
||||
});
|
||||
|
||||
const clickable = !!onSelectPhase;
|
||||
const BAR = "var(--fg)";
|
||||
|
||||
// Ein Beschriftungsblock. Oben steht er auf dem Kopf (Karte, Linie, Dreieck), unten in
|
||||
// umgekehrter Reihenfolge -- so zeigt das Dreieck immer zum Balken hin.
|
||||
const labelFor = (s: (typeof segments)[number], above: boolean) => {
|
||||
const active = activePhaseId === s.phase.id;
|
||||
const from = yearOf(s.from);
|
||||
const to = yearOf(s.to);
|
||||
const tri = (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-0 w-0 border-x-[7px] border-x-transparent"
|
||||
style={
|
||||
above
|
||||
? { borderBottomWidth: 9, borderBottomStyle: "solid", borderBottomColor: active ? "var(--accent)" : BAR }
|
||||
: { borderTopWidth: 9, borderTopStyle: "solid", borderTopColor: active ? "var(--accent)" : BAR }
|
||||
}
|
||||
/>
|
||||
);
|
||||
const line = <span className="w-px flex-1" style={{ backgroundColor: active ? "var(--accent)" : "var(--border-strong)" }} />;
|
||||
const card = (
|
||||
<span
|
||||
className={`max-w-[9.5rem] rounded-xl border px-2.5 py-1 text-center transition-colors ${
|
||||
active
|
||||
? "border-accent bg-accent-soft text-accent-soft-fg shadow-sm"
|
||||
: "border-border bg-surface text-muted group-hover/tl:border-accent group-hover/tl:text-accent-soft-fg"
|
||||
}`}
|
||||
>
|
||||
<span className={`block whitespace-nowrap text-[13px] font-semibold ${active ? "" : "text-fg"}`}>
|
||||
{from ? (s.phase.durationYears === 1 ? `${from}` : `${from}–${to! - 1}`) : `Phase ${s.index + 1}`}
|
||||
</span>
|
||||
<span className="block truncate text-[11px]">{s.phase.name}</span>
|
||||
<span className="block text-[10px] text-faint">
|
||||
{s.phase.durationYears} {s.phase.durationYears === 1 ? "Jahr" : "Jahre"}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={s.phase.id}
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={() => onSelectPhase?.(s.phase.id)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
|
||||
className={`group/tl absolute bottom-0 top-0 flex -translate-x-1/2 flex-col items-center ${
|
||||
clickable ? "cursor-pointer" : "cursor-default"
|
||||
}`}
|
||||
style={{ left: `${((s.from + s.phase.durationYears / 2) / span) * 100}%` }}
|
||||
>
|
||||
{above ? (
|
||||
<>
|
||||
{card}
|
||||
{line}
|
||||
{tri}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{tri}
|
||||
{line}
|
||||
{card}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-fg">Zeitachse</h3>
|
||||
<div className="flex gap-3 text-xs text-muted">
|
||||
{persons.map((p) => (
|
||||
<span key={p.role} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label} (heute {p.currentAge})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-stretch gap-3">
|
||||
{/* Das Startjahr steht markant am linken Ende -- es ist der Nullpunkt, auf den sich alle
|
||||
realen Werte und die ganze Achse beziehen. */}
|
||||
<div className="flex shrink-0 flex-col justify-center pb-1 text-right">
|
||||
<span className="text-xl font-semibold leading-none text-fg">{startYear ?? minAge}</span>
|
||||
<span className="mt-0.5 text-[10px] uppercase tracking-wide text-faint">
|
||||
{startYear ? "Start" : "Jahre"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative pt-6">
|
||||
{/* Pensionsmarker je Person */}
|
||||
{persons.map((p, i) =>
|
||||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||||
<div
|
||||
key={p.role}
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: pct(p.retirementAge) }}
|
||||
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
|
||||
>
|
||||
<Flag className="h-3.5 w-3.5" style={{ color: p.color }} fill={p.color} />
|
||||
<span className="whitespace-nowrap text-[10px] font-medium" style={{ color: p.color }}>
|
||||
{p.retirementAge}
|
||||
</span>
|
||||
<div className="mt-0.5 h-3 w-px" style={{ backgroundColor: p.color, marginTop: i * 2 }} />
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
<div className="relative min-w-0 flex-1">
|
||||
{/* Beschriftungen oberhalb (Phase 1, 3, 5 …) */}
|
||||
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 0).map((s) => labelFor(s, true))}</div>
|
||||
|
||||
{/* Ruin-Marker (Person A) */}
|
||||
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
||||
<div
|
||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center"
|
||||
style={{ left: pct(ruinAge) }}
|
||||
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
||||
>
|
||||
<span className="text-[10px] font-semibold" style={{ color: "var(--danger)" }}>
|
||||
Ruin {ruinAge}
|
||||
</span>
|
||||
<div className="mt-0.5 h-4 w-px" style={{ backgroundColor: "var(--danger)" }} />
|
||||
</div>
|
||||
)}
|
||||
{/* Der Balken. Die Segmente teilen sich die Breite streng nach Dauer -- eine Phase von
|
||||
fünf Jahren ist halb so breit wie eine von zehn. */}
|
||||
<div className="relative flex h-9 gap-[3px] overflow-hidden rounded-full">
|
||||
{segments.map((s) => {
|
||||
const active = activePhaseId === s.phase.id;
|
||||
return (
|
||||
<button
|
||||
key={s.phase.id}
|
||||
type="button"
|
||||
disabled={!clickable}
|
||||
onClick={() => onSelectPhase?.(s.phase.id)}
|
||||
aria-label={`${s.phase.name}, ${s.phase.durationYears} Jahre`}
|
||||
title={`${s.phase.name} · ${s.phase.durationYears} Jahre`}
|
||||
className={`min-w-0 transition-all ${clickable ? "cursor-pointer hover:brightness-110" : ""}`}
|
||||
style={{
|
||||
width: `${(s.phase.durationYears / span) * 100}%`,
|
||||
backgroundColor: active ? "var(--accent)" : BAR,
|
||||
opacity: active ? 1 : 0.85,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Achse */}
|
||||
<div className="relative h-2 w-full rounded-full bg-gradient-to-r from-accent-soft to-accent">
|
||||
{boundaries.slice(1).map((b) => (
|
||||
<div
|
||||
key={b.year}
|
||||
className="absolute top-0 h-2 w-px bg-surface/70"
|
||||
style={{ left: pct(minAge + b.year) }}
|
||||
{/* Marker auf dem Balken: Pensionierung je Person, Ruinalter, erfasste Ist-Jahre.
|
||||
Sie liegen AUF dem Balken statt darüber -- oberhalb kollidierten sie mit den
|
||||
Beschriftungen. */}
|
||||
{persons.map((p) =>
|
||||
p.retirementAge > minAge && p.retirementAge < maxAge ? (
|
||||
<span
|
||||
key={p.role}
|
||||
className="pointer-events-none absolute inset-y-1 w-0.5 -translate-x-1/2 rounded-full"
|
||||
style={{ left: `${pctOfAge(p.retirementAge)}%`, backgroundColor: p.color }}
|
||||
title={`${p.label}: Pensionierung mit ${p.retirementAge}`}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{ruinAge != null && ruinAge > minAge && ruinAge <= maxAge && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-y-0 w-1 -translate-x-1/2"
|
||||
style={{ left: `${pctOfAge(ruinAge)}%`, backgroundColor: "var(--danger)" }}
|
||||
title={`Kapital aufgebraucht mit Alter ${ruinAge}`}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{startYear &&
|
||||
actualYears.map((y) => {
|
||||
const age = minAge + (y - startYear);
|
||||
if (age < minAge || age > maxAge) return null;
|
||||
const isLatest = y === actualYears[actualYears.length - 1];
|
||||
return (
|
||||
<span
|
||||
key={y}
|
||||
className="pointer-events-none absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2"
|
||||
style={{
|
||||
left: `${pctOfAge(age)}%`,
|
||||
backgroundColor: isLatest ? "var(--attention)" : "var(--faint)",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
["--tw-ring-color" as any]: "var(--surface)",
|
||||
}}
|
||||
title={isLatest ? `Effektive Werte für ${y} (aktuell)` : `Effektive Werte für ${y} (überholt)`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Alters-Beschriftung */}
|
||||
<div className="mt-1 flex justify-between text-[11px] text-muted">
|
||||
<span>{minAge} J.</span>
|
||||
<span>{maxAge} J.</span>
|
||||
</div>
|
||||
{/* Beschriftungen unterhalb (Phase 2, 4, 6 …) */}
|
||||
<div className="relative h-[68px]">{segments.filter((s) => s.index % 2 === 1).map((s) => labelFor(s, false))}</div>
|
||||
</div>
|
||||
|
||||
{/* Rechts das Planende und, falls vorhanden, die Marker-Erklärung in Kurzform. */}
|
||||
<div className="flex shrink-0 flex-col justify-center pb-1">
|
||||
<span className="text-sm font-semibold leading-none text-muted">
|
||||
{startYear ? startYear + totalYears - 1 : maxAge}
|
||||
</span>
|
||||
<span className="mt-0.5 flex items-center gap-1 text-[10px] uppercase tracking-wide text-faint">
|
||||
Ende
|
||||
{persons.some((p) => p.retirementAge > minAge && p.retirementAge < maxAge) && (
|
||||
<Flag className="h-2.5 w-2.5" aria-label="Pensionierung markiert" />
|
||||
)}
|
||||
{actualYears.length > 0 && <CalendarCheck className="h-2.5 w-2.5" aria-label="Effektive Werte markiert" />}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
// Die Tour: eine kurze Demo statt eines Spotlights.
|
||||
//
|
||||
// Bis 0.35 legte sich die Tour über den ECHTEN Bildschirm und hob Bereiche hervor. Das hatte
|
||||
// zwei Nachteile, die sich nicht beheben liessen: Auf einem frisch angelegten, leeren Plan gab
|
||||
// es kaum etwas hervorzuheben -- gerade dann, wenn die Tour am nötigsten ist. Und das
|
||||
// Ausschneiden der Hervorhebung kämpfte dauernd mit Scroll-Containern und Z-Ebenen.
|
||||
//
|
||||
// Neu zeigt die Tour einen NACHGEBAUTEN Bildschirm mit erfundenen Zahlen. Er ist bewusst
|
||||
// keine echte Ansicht, sondern eine Illustration: gerade so detailliert, dass man den Aufbau
|
||||
// erkennt, und so einfach, dass sie sich in einem Popup erklären lässt. Der Preis ist, dass
|
||||
// die Attrappe bei UI-Änderungen nachgeführt werden muss -- dafür funktioniert die Tour ab
|
||||
// der ersten Sekunde und unabhängig davon, was im Plan schon steht.
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Sparkles, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
export const TOUR_DONE_KEY = "fpt-tour-done";
|
||||
|
||||
type Focus = "none" | "settings" | "figures" | "actions" | "assistant" | "timeline" | "matrix" | "sidebar";
|
||||
|
||||
interface TourStep {
|
||||
title: string;
|
||||
text: string;
|
||||
focus: Focus;
|
||||
}
|
||||
|
||||
const STEPS: TourStep[] = [
|
||||
{
|
||||
title: "Willkommen bei FPT",
|
||||
text: "In zwei Minuten siehst du, wie das Tool aufgebaut ist. Alles, was hier steht, sind erfundene Beispielzahlen – dein eigener Plan ist noch leer.",
|
||||
focus: "none",
|
||||
},
|
||||
{
|
||||
title: "Oben stellst du ein, unten siehst du das Ergebnis",
|
||||
text: "Der Bildschirm hat zwei Hälften. Oben die Steuerung: Grundeinstellungen, Kennzahlen, Schnellaktionen und der Assistent. Unten die Matrix mit allen Zahlen.",
|
||||
focus: "none",
|
||||
},
|
||||
{
|
||||
title: "Die Grundeinstellungen",
|
||||
text: "Wer plant, ab wann und wie lange. Alter, Startjahr und Inflation gelten für den ganzen Plan; Planungshorizont und Pensionsalter gehören zum Szenario. Je Person führt ein Knopf in die Pensionsplanung.",
|
||||
focus: "settings",
|
||||
},
|
||||
{
|
||||
title: "Die Kennzahlen",
|
||||
text: "Was unter dem Strich herauskommt: dein Vermögen heute, das Endvermögen nominal und real – und ob das Kapital über die ganze Planung reicht.",
|
||||
focus: "figures",
|
||||
},
|
||||
{
|
||||
title: "Der FPT-Assistent",
|
||||
text: "Dein Wegweiser. Sieben Schritte führen von der Bestandsaufnahme bis zum fertigen Plan. Jeder Schritt erklärt zuerst, worum es geht, und bietet danach das passende Werkzeug an. Wann ein Schritt erledigt ist, entscheidest du.",
|
||||
focus: "assistant",
|
||||
},
|
||||
{
|
||||
title: "Die Zeitachse",
|
||||
text: "Deine Planung im Überblick: Lebensphasen, die Zeitpunkte der Pensionierung – und, falls das Geld nicht reicht, das Jahr, in dem es knapp wird.",
|
||||
focus: "timeline",
|
||||
},
|
||||
{
|
||||
title: "Die Matrix",
|
||||
text: "Das Herz des Tools. Jede Zeile ist ein finanzielles Element, jede Spalte eine Lebensphase. Die schmalen Spalten dazwischen sind die Übergänge – dort entscheidest du, was beim Wechsel geschieht.",
|
||||
focus: "matrix",
|
||||
},
|
||||
{
|
||||
title: "Pläne und Szenarien",
|
||||
text: "Links verwaltest du alles. Ein Szenario ist eine Variante deines Plans – etwa mit anderem Pensionsalter. Du kannst beliebig viele anlegen und vergleichen.",
|
||||
focus: "sidebar",
|
||||
},
|
||||
{
|
||||
title: "Jetzt bist du dran",
|
||||
text: "Starte mit dem FPT-Assistenten oben rechts. Schritt 1 ist die Bestandsaufnahme: alles, was du heute besitzt. Von dort führt dich der Assistent weiter.",
|
||||
focus: "assistant",
|
||||
},
|
||||
];
|
||||
|
||||
export function Tour({ onClose }: { onClose: () => void }) {
|
||||
const [index, setIndex] = useState(0);
|
||||
const step = STEPS[index];
|
||||
const last = index === STEPS.length - 1;
|
||||
|
||||
function finish() {
|
||||
try {
|
||||
localStorage.setItem(TOUR_DONE_KEY, "1");
|
||||
} catch {
|
||||
// Privater Modus o. Ä. -- die Tour lässt sich dann eben erneut öffnen.
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-3 sm:p-6">
|
||||
<div className="flex h-full max-h-[900px] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-2xl">
|
||||
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<Sparkles className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate text-sm font-semibold text-fg">{step.title}</h2>
|
||||
<p className="text-[11px] text-muted">
|
||||
Schritt {index + 1} von {STEPS.length}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={finish}
|
||||
aria-label="Tour schliessen"
|
||||
className="rounded-lg p-1.5 text-faint hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Die Attrappe. Rein illustrativ -- nichts darin ist klickbar. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-surface-2/40 p-4">
|
||||
<MockScreen focus={step.focus} />
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-border px-4 py-3">
|
||||
<p className="mb-3 text-sm leading-relaxed text-fg">{step.text}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-1 gap-1">
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${i <= index ? "bg-accent" : "bg-surface-2"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled={index === 0} onClick={() => setIndex((i) => i - 1)}>
|
||||
<ArrowLeft className="h-4 w-4" /> Zurück
|
||||
</Button>
|
||||
{last ? (
|
||||
<Button size="sm" onClick={finish}>
|
||||
<Sparkles className="h-4 w-4" /> Mit dem Assistenten starten
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setIndex((i) => i + 1)}>
|
||||
Weiter <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Die Attrappe ---------------------------------------------------------------------------
|
||||
//
|
||||
// Feste, erfundene Zahlen. Sie müssen weder stimmen noch zueinander passen -- ihr einziger
|
||||
// Zweck ist, den Aufbau greifbar zu machen.
|
||||
|
||||
function MockScreen({ focus }: { focus: Focus }) {
|
||||
const dim = (area: Focus) => (focus === "none" || focus === area ? "opacity-100" : "opacity-20");
|
||||
const ring = (area: Focus) => (focus === area ? "ring-2 ring-accent" : "");
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 text-[10px]">
|
||||
{/* Seitenleiste */}
|
||||
<div
|
||||
className={`hidden w-36 shrink-0 flex-col gap-1 rounded-lg border border-border bg-surface p-2 transition-opacity sm:flex ${dim("sidebar")} ${ring("sidebar")}`}
|
||||
>
|
||||
<div className="mb-1 font-semibold uppercase tracking-wide text-faint">Meine Pläne</div>
|
||||
<div className="rounded bg-accent-soft px-1.5 py-1 font-medium text-accent-soft-fg">Familie Muster</div>
|
||||
<div className="pl-2 text-muted">Szenarien</div>
|
||||
<div className="rounded bg-surface-2 px-1.5 py-0.5 pl-3 text-fg">Basis</div>
|
||||
<div className="px-1.5 py-0.5 pl-3 text-muted">Pension mit 62</div>
|
||||
<div className="pl-2 text-muted">Effektive Werte</div>
|
||||
<div className="pl-2 text-muted">Analysen</div>
|
||||
<div className="pl-2 text-muted">Berichte</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
{/* Oberer Bereich */}
|
||||
<div className="rounded-lg border border-border bg-surface-2/60 p-2">
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<MockTile title="Grundeinstellungen" cls={`${dim("settings")} ${ring("settings")}`}>
|
||||
<div>Anna · 42 J.</div>
|
||||
<div>Beat · 45 J.</div>
|
||||
<div className="text-faint">Start 2026 · Inflation 1,5 %</div>
|
||||
<div className="mt-1 border-t border-border pt-1">Horizont 40 Jahre · bis 2065</div>
|
||||
<div className="flex items-center gap-1">
|
||||
Pension 65 · Ende 82
|
||||
<span className="ml-auto rounded border border-border px-1 text-[9px]">Pensionsplanung</span>
|
||||
</div>
|
||||
</MockTile>
|
||||
|
||||
<MockTile title="Kennzahlen" cls={`${dim("figures")} ${ring("figures")}`}>
|
||||
<MockFigure label="Vermögen heute" value="412'000" />
|
||||
<MockFigure label="Endvermögen nominal" value="1'284'000" />
|
||||
<MockFigure label="Endvermögen real" value="702'000" />
|
||||
<div className="mt-1 rounded bg-success-soft px-1.5 py-0.5 text-success">
|
||||
Kapital reicht über die ganze Planung
|
||||
</div>
|
||||
</MockTile>
|
||||
|
||||
<MockTile title="Schnellaktionen" cls={`${dim("actions")} ${ring("actions")}`}>
|
||||
{["Neues Szenario", "Tour", "Änderungshistorie", "Rechenwege", "CSV-Export"].map((a) => (
|
||||
<div key={a} className="rounded border border-border px-1.5 py-0.5 text-muted">
|
||||
{a}
|
||||
</div>
|
||||
))}
|
||||
</MockTile>
|
||||
|
||||
<div
|
||||
className={`flex flex-col gap-1 rounded-lg border border-attention bg-attention-soft px-2 py-1.5 transition-opacity ${dim("assistant")} ${ring("assistant")}`}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<Sparkles className="h-3 w-3 text-attention-fg" />
|
||||
<span className="font-semibold uppercase tracking-wide text-fg">FPT-Assistent</span>
|
||||
<span className="ml-auto font-semibold text-muted">2 / 7</span>
|
||||
</div>
|
||||
{[
|
||||
["3. Lebensphasen", "4 Lebensphasen"],
|
||||
["4. Erwerbsjahre planen", ""],
|
||||
["5. Pensionierung planen", ""],
|
||||
].map(([t, sub]) => (
|
||||
<div key={t} className="flex items-start gap-1">
|
||||
<span className="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-sm border border-border bg-surface" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-semibold text-fg">{t}</span>
|
||||
{sub && <span className="block truncate text-muted">{sub}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{["1. Bestandsaufnahme", "2. Eckdaten"].map((t) => (
|
||||
<div key={t} className="flex items-start gap-1 opacity-50">
|
||||
<span className="mt-0.5 flex h-2.5 w-2.5 shrink-0 items-center justify-center rounded-sm bg-success text-[7px] text-white">
|
||||
✓
|
||||
</span>
|
||||
<span className="truncate text-muted line-through">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zeitachse */}
|
||||
<div
|
||||
className={`mt-2 rounded-lg border border-border bg-surface p-2 transition-opacity ${dim("timeline")} ${ring("timeline")}`}
|
||||
>
|
||||
<div className="mb-1 flex justify-between text-faint">
|
||||
<span>2026</span>
|
||||
<span>2065</span>
|
||||
</div>
|
||||
<div className="flex h-4 overflow-hidden rounded">
|
||||
<div className="flex w-[45%] items-center justify-center bg-accent/70 text-[8px] text-white">
|
||||
Erwerbsjahre
|
||||
</div>
|
||||
<div className="flex w-[15%] items-center justify-center bg-attention/70 text-[8px] text-white">
|
||||
Übergangszeit
|
||||
</div>
|
||||
<div className="flex w-[40%] items-center justify-center bg-success/60 text-[8px] text-white">
|
||||
Ruhestand
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-faint">
|
||||
<span>Anna 42 · Beat 45</span>
|
||||
<span>Pension 65</span>
|
||||
<span>Anna 82 · Beat 85</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Matrix */}
|
||||
<div
|
||||
className={`overflow-hidden rounded-lg border border-border bg-surface transition-opacity ${dim("matrix")} ${ring("matrix")}`}
|
||||
>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-surface-2">
|
||||
<th className="border-b border-r border-border px-2 py-1.5 text-left align-top">
|
||||
<div className="font-semibold uppercase tracking-wide text-faint">Matrix</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
|
||||
+ Element
|
||||
</span>
|
||||
<span className="rounded border border-dashed border-border px-1 text-[9px] font-normal text-muted">
|
||||
+ Phase
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
{["Erwerbsjahre", "Übergang", "Ruhestand I", "Übergang", "Ruhestand II"].map((h, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className={`border-b border-r border-border px-2 py-1.5 text-center font-medium ${
|
||||
h === "Übergang" ? "bg-attention text-attention-fg" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
{h === "Übergang" && <div className="text-[8px] font-normal">2 offen</div>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
["Lohn Anna", "96'000", "→", "—", "→", "—"],
|
||||
["Lebenshaltung", "78'000", "→", "72'000", "→", "68'000"],
|
||||
["Pensionskasse Anna", "310'000", "40 % Kapital", "Rente 18'400", "→", "18'400"],
|
||||
["Säule 3a Anna", "84'000", "Bezug mit 64", "—", "→", "—"],
|
||||
["Wertschriften", "120'000", "→", "486'000", "→", "310'000"],
|
||||
].map((row, i) => (
|
||||
<tr key={i} className={i % 2 ? "bg-surface-2/40" : ""}>
|
||||
<td className="border-b border-r border-border px-2 py-1 text-fg">{row[0]}</td>
|
||||
{row.slice(1).map((c, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={`border-b border-r border-border px-2 py-1 text-center tabular-nums ${
|
||||
j % 2 === 1 ? "bg-attention-soft text-attention-soft-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MockTile({ title, cls, children }: { title: string; cls: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 rounded-lg border border-border bg-surface px-2 py-1.5 transition-opacity ${cls}`}>
|
||||
<div className="mb-0.5 font-semibold uppercase tracking-wide text-faint">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MockFigure({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-1">
|
||||
<span className="truncate text-muted">{label}</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums text-fg">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, Eye, History, RotateCcw, Tag, X } from "lucide-react";
|
||||
import { VersionMatrix } from "@/components/VersionMatrix";
|
||||
import { Button, useConfirm, useToast } from "@/components/ui";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
import type { RestoreImpact } from "@/lib/versioning";
|
||||
|
||||
export interface VersionRow {
|
||||
id: string;
|
||||
major: number;
|
||||
minor: number;
|
||||
comment: string | null;
|
||||
isMajor: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
interface VersionDetail {
|
||||
version: { id: string; major: number; minor: number; comment: string | null; isMajor: boolean };
|
||||
plan: PlanInput;
|
||||
computed: PlanComputed;
|
||||
impact: RestoreImpact;
|
||||
}
|
||||
|
||||
const dt = (iso: string) =>
|
||||
new Date(iso).toLocaleString("de-CH", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
export function VersionHistoryDialog({
|
||||
scenarioId,
|
||||
scenarioName,
|
||||
onClose,
|
||||
onRestored,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<VersionRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<VersionDetail | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [majorComment, setMajorComment] = useState("");
|
||||
const [showMajorForm, setShowMajorForm] = useState(false);
|
||||
const confirm = useConfirm();
|
||||
const toast = useToast();
|
||||
|
||||
// Nachladen nach einer Aktion (Wiederherstellen, Hauptversion) -- nicht beim Öffnen.
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
|
||||
setRows(data.versions);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
|
||||
}
|
||||
}, [scenarioId]);
|
||||
|
||||
// Erstes Laden. Das Cancel-Flag verhindert ein setState nach dem Schliessen des Dialogs.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{ versions: VersionRow[] }>(`/api/scenarios/${scenarioId}/versions`);
|
||||
if (!cancelled) setRows(data.versions);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Historie konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setDetail(await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${id}`));
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(row: VersionRow) {
|
||||
// Erst die Auswirkung holen: Kind-Szenarien können ihre Diff-Basis verlieren, und das
|
||||
// muss VOR dem Klick auf dem Tisch liegen, nicht danach.
|
||||
setBusy(true);
|
||||
let impact: RestoreImpact;
|
||||
try {
|
||||
const d = await api.get<VersionDetail>(`/api/scenarios/${scenarioId}/versions/${row.id}`);
|
||||
impact = d.impact;
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
toast("error", e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
return;
|
||||
}
|
||||
setBusy(false);
|
||||
|
||||
const warn =
|
||||
impact.affectedChildren.length > 0
|
||||
? `\n\nAchtung: ${impact.affectedChildren.length === 1 ? "Das Szenario" : "Die Szenarien"} ` +
|
||||
`«${impact.affectedChildren.join("», «")}» ${impact.affectedChildren.length === 1 ? "hängt" : "hängen"} ` +
|
||||
`an diesem Szenario. Dieser Stand kannte ${impact.lostElementIds.length} Element(e) und ` +
|
||||
`${impact.lostPhaseIds.length} Phase(n) noch nicht, auf die dort verwiesen wird – die Abweichungs-Markierung ` +
|
||||
`zeigt sie danach als «neu» statt als «geändert».`
|
||||
: "";
|
||||
|
||||
const ok = await confirm({
|
||||
title: `Auf Version ${row.major}.${row.minor} zurücksetzen?`,
|
||||
message:
|
||||
`Das Szenario «${scenarioName}» wird vollständig auf diesen Stand zurückgesetzt. ` +
|
||||
`Es geht nichts verloren: Der wiederhergestellte Stand wird als neue Version festgehalten, ` +
|
||||
`die bisherige Historie bleibt vollständig erhalten.${warn}`,
|
||||
confirmLabel: "Wiederherstellen",
|
||||
danger: impact.affectedChildren.length > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.post<{ version: { major: number; minor: number } }>(
|
||||
`/api/scenarios/${scenarioId}/versions/${row.id}`,
|
||||
{}
|
||||
);
|
||||
toast("success", `Wiederhergestellt aus ${row.major}.${row.minor} – neue Version ${res.version.major}.${res.version.minor}.`);
|
||||
await load();
|
||||
onRestored();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Wiederherstellen fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMajor() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api.post<{ version: { major: number; minor: number } }>(
|
||||
`/api/scenarios/${scenarioId}/versions`,
|
||||
{ comment: majorComment }
|
||||
);
|
||||
toast("success", `Hauptversion ${res.version.major}.${res.version.minor} festgelegt.`);
|
||||
setMajorComment("");
|
||||
setShowMajorForm(false);
|
||||
await load();
|
||||
onRestored();
|
||||
} catch (e) {
|
||||
toast("error", e instanceof Error ? e.message : "Hauptversion konnte nicht angelegt werden.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Detailansicht (nur lesen) ---
|
||||
if (detail) {
|
||||
const last = detail.computed.phases[detail.computed.phases.length - 1];
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={() => setDetail(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-5xl flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<Eye className="h-5 w-5 text-accent" />
|
||||
Version {detail.version.major}.{detail.version.minor}
|
||||
{detail.version.isMajor && (
|
||||
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[10px] font-semibold text-accent-soft-fg">
|
||||
Hauptversion
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Nur-Lese-Ansicht dieses Standes. {detail.version.comment && `«${detail.version.comment}»`}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setDetail(null)} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{last && (
|
||||
<div className="flex flex-wrap gap-4 rounded-xl border border-border bg-surface-2 p-3 text-xs">
|
||||
<span className="text-muted">
|
||||
Endvermögen nominal: <strong className="text-fg">{formatChf(last.endWealthNominal)}</strong>
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
real: <strong className="text-fg">{formatChf(last.endWealthReal)}</strong>
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
Kapital reicht:{" "}
|
||||
<strong className={detail.computed.ruinAge === null ? "text-success" : "text-danger"}>
|
||||
{detail.computed.ruinAge === null ? "bis Planende" : `bis Alter ${detail.computed.ruinAge}`}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VersionMatrix computed={detail.computed} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Liste ---
|
||||
return (
|
||||
<div className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8" onClick={onClose}>
|
||||
<div onClick={(e) => e.stopPropagation()} className="ui-pop flex w-full max-w-3xl flex-col gap-4 rounded-2xl border border-border bg-surface p-6 shadow-xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-fg">
|
||||
<History className="h-5 w-5 text-accent" /> Änderungshistorie
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-muted">
|
||||
Szenario «{scenarioName}». Eine Nebenversion entsteht je Bearbeitungssitzung, nicht je
|
||||
einzelner Änderung – sonst wäre die Liste ein Tastenprotokoll.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Schliessen" className="rounded-md p-1 text-faint hover:bg-surface-2">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hauptversion festlegen */}
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-3">
|
||||
{showMajorForm ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-xs font-medium text-fg">
|
||||
Wofür steht diese Hauptversion? <span className="text-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={majorComment}
|
||||
onChange={(e) => setMajorComment(e.target.value)}
|
||||
placeholder="z. B. «Stand nach Beratungsgespräch, vor dem Hauskauf»"
|
||||
className="w-full rounded-lg border border-border bg-surface px-2.5 py-1.5 text-sm text-fg"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" disabled={majorComment.trim().length < 3 || busy} onClick={createMajor}>
|
||||
<Tag className="h-3.5 w-3.5" /> Als Hauptversion festlegen
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted">
|
||||
Einen bewusst gesetzten Meilenstein festhalten – mit Begründung.
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowMajorForm(true)}>
|
||||
<Tag className="h-3.5 w-3.5" /> Aktuellen Stand als Hauptversion festlegen
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
{!rows && !error && <p className="text-xs text-muted">Historie wird geladen…</p>}
|
||||
|
||||
{rows && rows.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border bg-surface-2 p-4 text-xs text-muted">
|
||||
Für dieses Szenario ist noch keine Version festgehalten. Die erste entsteht mit der
|
||||
nächsten Änderung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows && rows.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 ${
|
||||
r.isMajor ? "border-accent bg-accent-soft/20" : "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<span className="flex w-16 shrink-0 items-center gap-1.5 font-semibold text-fg">
|
||||
{r.isMajor && <Tag className="h-3.5 w-3.5 text-accent" />}
|
||||
{r.major}.{r.minor}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs text-muted">
|
||||
{r.author} · {dt(r.updatedAt)}
|
||||
{i === 0 && (
|
||||
<span className="ml-1.5 rounded bg-surface px-1.5 py-0.5 text-[10px] text-faint">
|
||||
aktueller Stand
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.comment && <div className="mt-0.5 truncate text-xs text-fg">«{r.comment}»</div>}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => openDetail(r.id)}>
|
||||
<Eye className="h-3.5 w-3.5" /> Anzeigen
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={busy || i === 0} onClick={() => restore(r)}>
|
||||
<RotateCcw className="h-3.5 w-3.5" /> Wiederherstellen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="flex items-start gap-1.5 text-[11px] text-faint">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
Wiederherstellen löscht nichts: Der zurückgesetzte Stand wird selbst als neue Version
|
||||
festgehalten. Hängen Szenarien an diesem, wird vorher gewarnt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { CATEGORY_LABELS, CATEGORY_ORDER } from "@/lib/elements";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
|
||||
// Nur-Lese-Matrix eines festgehaltenen Standes. Bewusst NICHT die Bearbeitungs-Matrix aus
|
||||
// PlanView: Die trägt Inspector-Anbindung, Übergangs-Ampeln, Diff-Markierung und Dialoge --
|
||||
// alles ohne Bedeutung für einen alten Stand, den man nur ansehen kann. Diese Ansicht zeigt
|
||||
// stattdessen genau das, was eine Version ausmacht: Phasen, Elemente, Werte.
|
||||
export function VersionMatrix({ computed }: { computed: PlanComputed }) {
|
||||
const phases = computed.phases;
|
||||
if (phases.length === 0) {
|
||||
return <p className="text-sm text-muted">Dieser Stand enthielt noch keine Lebensphasen.</p>;
|
||||
}
|
||||
|
||||
// Elemente in der gewohnten Kategorie-Reihenfolge, über alle Phasen gesammelt.
|
||||
const elements = (() => {
|
||||
const seen = new Map<string, { name: string; category: ElementCategory; ownerRole: string | null }>();
|
||||
for (const p of phases) {
|
||||
for (const e of p.elements) {
|
||||
if (!seen.has(e.elementId)) {
|
||||
seen.set(e.elementId, { name: e.name, category: e.category, ownerRole: e.ownerRole });
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.map(([id, v]) => ({ id, ...v }))
|
||||
.sort((a, b) => {
|
||||
const ca = CATEGORY_ORDER.indexOf(a.category);
|
||||
const cb = CATEGORY_ORDER.indexOf(b.category);
|
||||
return ca !== cb ? ca - cb : a.name.localeCompare(b.name, "de-CH");
|
||||
});
|
||||
})();
|
||||
|
||||
// Kategoriewechsel als Zwischenüberschrift -- dieselbe Gliederung wie in der Planansicht.
|
||||
// Vorab bestimmt statt während des Renderns mitgezählt: Eine Variable, die der Render-Lauf
|
||||
// fortschreibt, verhält sich bei erneutem Rendern nicht mehr gleich.
|
||||
const headerAt = new Map<string, ElementCategory>();
|
||||
elements.forEach((el, i) => {
|
||||
if (i === 0 || el.category !== elements[i - 1].category) headerAt.set(el.id, el.category);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-xl border border-border">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-surface-2 text-xs text-faint">
|
||||
<th className="sticky left-0 z-20 bg-surface-2 px-3 py-2 text-left font-semibold">Element</th>
|
||||
{phases.map((p) => (
|
||||
<th key={p.id} className="whitespace-nowrap px-3 py-2 text-right font-semibold">
|
||||
<div className="text-fg">{p.name}</div>
|
||||
<div className="font-normal text-faint">{p.durationYears} J.</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{elements.map((el) => {
|
||||
const header = headerAt.get(el.id) ?? null;
|
||||
return (
|
||||
<Fragment key={el.id}>
|
||||
{header && (
|
||||
<tr className="border-t border-border bg-surface-2/60">
|
||||
<td
|
||||
colSpan={phases.length + 1}
|
||||
className="sticky left-0 px-3 py-1 text-[11px] font-semibold uppercase tracking-wide text-faint"
|
||||
>
|
||||
{CATEGORY_LABELS[header]}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr className="border-t border-border">
|
||||
<td className="sticky left-0 z-10 bg-surface px-3 py-2">
|
||||
<span className="font-medium text-fg">{el.name}</span>
|
||||
{el.ownerRole && el.ownerRole !== "HOUSEHOLD" && (
|
||||
<span className="ml-1.5 text-[10px] text-faint">
|
||||
{el.ownerRole === "PERSON_A" ? "A" : "B"}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{phases.map((p) => {
|
||||
const c = p.elements.find((x) => x.elementId === el.id);
|
||||
if (!c) return <td key={p.id} className="px-3 py-2 text-right text-faint">–</td>;
|
||||
return (
|
||||
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right">
|
||||
<div className={c.status === "ACTIVE" ? "text-fg" : "text-faint line-through"}>
|
||||
{formatChf(c.startValue)}
|
||||
</div>
|
||||
{c.endValue !== c.startValue && (
|
||||
<div className="text-[11px] text-muted">→ {formatChf(c.endValue)}</div>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Vermögen je Phase -- die Kennzahl, für die der ganze Stand steht. */}
|
||||
<tr className="border-t-2 border-border bg-surface-2">
|
||||
<td className="sticky left-0 z-10 bg-surface-2 px-3 py-2 text-xs font-semibold text-fg">
|
||||
Vermögen am Phasenende
|
||||
</td>
|
||||
{phases.map((p) => (
|
||||
<td key={p.id} className="whitespace-nowrap px-3 py-2 text-right text-xs font-semibold text-fg">
|
||||
{formatChf(p.endWealthNominal)}
|
||||
<div className="font-normal text-faint">({formatChf(p.endWealthReal)} real)</div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { History } from "lucide-react";
|
||||
import { InfoBubble } from "@/components/InfoBubble";
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Kennzeichnet den aktuellen (ungespeicherten) Arbeitsstand -- im Gegensatz zu einer
|
||||
// festgehaltenen Version.
|
||||
export const CURRENT = "current";
|
||||
|
||||
export interface VersionOption {
|
||||
id: string;
|
||||
label: string; // "1.4" bzw. "1.4 (Hauptversion)"
|
||||
isMajor: boolean;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
// Lädt die Versionsliste eines Szenarios. Bewusst ohne Snapshots -- die kommen erst beim
|
||||
// tatsächlichen Auswählen dazu.
|
||||
export function useVersionOptions(scenarioId: string | null): VersionOption[] {
|
||||
const [options, setOptions] = useState<VersionOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scenarioId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const data = await api.get<{
|
||||
versions: { id: string; major: number; minor: number; isMajor: boolean; comment: string | null }[];
|
||||
}>(`/api/scenarios/${scenarioId}/versions`);
|
||||
if (cancelled) return;
|
||||
setOptions(
|
||||
data.versions.map((v) => ({
|
||||
id: v.id,
|
||||
label: `${v.major}.${v.minor}${v.isMajor ? " (Hauptversion)" : ""}`,
|
||||
isMajor: v.isMajor,
|
||||
comment: v.comment,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setOptions([]);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId]);
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Holt den Plan-Stand einer Version. `CURRENT` liefert null -- dann gilt der Arbeitsstand.
|
||||
export async function loadVersionPlan(scenarioId: string, versionId: string): Promise<PlanInput | null> {
|
||||
if (versionId === CURRENT) return null;
|
||||
const data = await api.get<{ plan: PlanInput }>(`/api/scenarios/${scenarioId}/versions/${versionId}`);
|
||||
return data.plan;
|
||||
}
|
||||
|
||||
// Ein kompakter Wähler je Szenario. Wird in allen vier Analysewerkzeugen verwendet, damit
|
||||
// die Bedienung überall dieselbe ist.
|
||||
export function VersionSelect({
|
||||
scenarioId,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
compact,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
value: string;
|
||||
onChange: (versionId: string) => void;
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const options = useVersionOptions(scenarioId);
|
||||
|
||||
return (
|
||||
<label className={`flex items-center gap-1.5 ${compact ? "text-[11px]" : "text-xs"} text-muted`}>
|
||||
{label && (
|
||||
<span className="flex items-center font-medium">
|
||||
<History className="mr-1 h-3.5 w-3.5 text-faint" />
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`rounded-lg border border-border bg-surface px-2 py-1 text-fg ${compact ? "text-[11px]" : "text-xs"}`}
|
||||
>
|
||||
<option value={CURRENT}>Aktueller Stand</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
{o.comment ? ` – ${o.comment.slice(0, 40)}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Hält die Auswahl "welcher Stand" für ein einzelnes Szenario. Solange `CURRENT` gewählt
|
||||
// ist, wird der übergebene Arbeitsstand durchgereicht -- ohne Netzwerkzugriff.
|
||||
//
|
||||
// Der Snapshot ist bereits ein PlanInput, deshalb rechnen die Werkzeuge damit unverändert
|
||||
// weiter; das Gerechnete entsteht bei ihnen lokal (computePlan ist rein und kostet ~0.2 ms).
|
||||
export function useVersionedPlan(scenarioId: string, currentPlan: PlanInput) {
|
||||
const [versionId, setVersionId] = useState<string>(CURRENT);
|
||||
// Nur die geladenen Snapshots liegen im Zustand. Der Arbeitsstand ist bereits da und wird
|
||||
// abgeleitet -- ihn in den Zustand zu spiegeln, hiesse ihn doppelt zu führen.
|
||||
const [snapshots, setSnapshots] = useState<Record<string, PlanInput>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionId === CURRENT || snapshots[versionId]) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await loadVersionPlan(scenarioId, versionId);
|
||||
if (cancelled) return;
|
||||
if (loaded) setSnapshots((prev) => ({ ...prev, [versionId]: loaded }));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Version konnte nicht geladen werden.");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [scenarioId, versionId, snapshots]);
|
||||
|
||||
const plan = versionId === CURRENT ? currentPlan : (snapshots[versionId] ?? currentPlan);
|
||||
// Solange der Snapshot fehlt und kein Fehler vorliegt, laeuft der Abruf noch. Abgeleitet
|
||||
// statt als eigener Zustand -- ein Ladeflag, das nur den Abruf spiegelt, ist redundant.
|
||||
const loading = versionId !== CURRENT && !snapshots[versionId] && !error;
|
||||
|
||||
return { versionId, setVersionId, plan, loading, error };
|
||||
}
|
||||
|
||||
// Kopfzeile für die Analyse-Dialoge mit einem einzelnen Szenario.
|
||||
export function VersionBar({
|
||||
scenarioId,
|
||||
versionId,
|
||||
onChange,
|
||||
loading,
|
||||
}: {
|
||||
scenarioId: string;
|
||||
versionId: string;
|
||||
onChange: (id: string) => void;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2">
|
||||
<VersionSelect scenarioId={scenarioId} value={versionId} onChange={onChange} label="Berechnungsgrundlage" />
|
||||
<VersionHint />
|
||||
{loading && <span className="text-[11px] text-muted">wird geladen…</span>}
|
||||
{versionId !== CURRENT && !loading && (
|
||||
<span className="rounded bg-attention-soft px-1.5 py-0.5 text-[10px] font-semibold text-attention-soft-fg">
|
||||
Nicht der aktuelle Stand
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hinweiszeile für die Analyse-Dialoge.
|
||||
export function VersionHint() {
|
||||
return (
|
||||
<InfoBubble text="Standardmässig rechnen die Werkzeuge mit dem aktuellen Arbeitsstand. Du kannst stattdessen jede festgehaltene Version wählen – dann wird genau der damalige Stand gerechnet." />
|
||||
);
|
||||
}
|
||||
+134
-67
@@ -1,43 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { TOOLTIP_STYLE } from "@/components/AllocationChart";
|
||||
import { formatChf } from "@/lib/format";
|
||||
import type { PlanComputed } from "@/lib/calculations";
|
||||
|
||||
export interface TimelineSeries {
|
||||
// Eindeutiger Schlüssel der Serie. WICHTIG: Bis 0.30 diente der ANZEIGENAME als Datenschlüssel
|
||||
// -- zwei Szenarien mit gleichem Namen überschrieben sich dadurch gegenseitig (die Legende
|
||||
// zeigte beide, der Chart nur eine). Deshalb ein eigener, garantiert eindeutiger Schlüssel.
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
computed: PlanComputed;
|
||||
// Plandaten werden gestrichelt gezeichnet, effektive Daten durchgezogen. Das Stilbudget
|
||||
// geht bewusst an Plan/Ist statt an nominal/real (siehe SPEZIFIKATION 9.29).
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
// Datenpunkte je Serie: Start (Beginn Phase 1) + Endwert je Phase, verortet auf dem
|
||||
// Alter der Referenzperson (Person A). So laeuft die x-Achse ueber das Alter statt ueber
|
||||
// die Phasen.
|
||||
// Datenpunkte je Serie: JEDES Planjahr (nicht nur die Phasengrenzen), verortet auf dem Alter
|
||||
// der Referenzperson (Person A). Der erste Punkt ist das Startvermögen vor Jahr 1.
|
||||
function pointsFor(computed: PlanComputed) {
|
||||
const phases = computed.phases;
|
||||
if (phases.length === 0) return [] as { age: number; nominal: number; real: number }[];
|
||||
const refAge = (ph: PlanComputed["phases"][number]) => ph.persons[0]?.startAge ?? 0;
|
||||
const pts = [
|
||||
{ age: refAge(phases[0]), nominal: Math.round(phases[0].startWealthNominal), real: Math.round(phases[0].startWealthNominal) },
|
||||
];
|
||||
for (const p of phases) {
|
||||
pts.push({ age: refAge(p) + p.durationYears, nominal: Math.round(p.endWealthNominal), real: Math.round(p.endWealthReal) });
|
||||
const startAge = phases[0].persons[0]?.startAge ?? 0;
|
||||
const start = Math.round(phases[0].startWealthNominal);
|
||||
const pts = [{ age: startAge, nominal: start, real: start }];
|
||||
for (const y of computed.yearly) {
|
||||
pts.push({ age: y.age, nominal: y.wealthNominal, real: y.wealthReal });
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
// Liniendiagramm: Gesamtvermoegen (nominal + real) ueber das Alter. Unterstuetzt mehrere
|
||||
// ueberlagerte Plaene fuer den Szenario-Vergleich.
|
||||
export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
// `var(--chart-1)` in einen konkreten Hex-Wert auflösen -- das Farbwahl-Feld (input[type=color])
|
||||
// braucht einen echten Wert. Läuft nur im Browser und nur, wenn eine Variable übergeben wurde.
|
||||
function resolveColor(color: string): string {
|
||||
if (!color.startsWith("var(")) return color;
|
||||
if (typeof window === "undefined") return "#888888";
|
||||
const name = color.slice(4, -1).trim();
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || "#888888";
|
||||
}
|
||||
|
||||
// Liniendiagramm: Gesamtvermögen (nominal + real) über das Alter. Unterstützt mehrere
|
||||
// überlagerte Pläne für den Szenario-Vergleich.
|
||||
export function WealthChart({
|
||||
series,
|
||||
metric = "nominal",
|
||||
onColorChange,
|
||||
}: {
|
||||
series: TimelineSeries[];
|
||||
metric?: "nominal" | "real";
|
||||
// Optional: erlaubt das Umfärben einer Serie über die Legende.
|
||||
onColorChange?: (id: string, color: string) => void;
|
||||
}) {
|
||||
// Aufgelöste Farben für die Farbwahl-Felder; nach dem ersten Rendern (und bei Theme-Wechsel).
|
||||
const [resolved, setResolved] = useState<Record<string, string>>({});
|
||||
const colorKey = series.map((s) => `${s.id}:${s.color}`).join("|");
|
||||
useEffect(() => {
|
||||
const next: Record<string, string> = {};
|
||||
for (const s of series) next[s.id] = resolveColor(s.color);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Farbauflösung braucht das DOM
|
||||
setResolved(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [colorKey]);
|
||||
|
||||
if (series.length === 0 || series[0].computed.phases.length === 0) {
|
||||
return <p className="text-sm text-muted">Noch keine Phasen vorhanden.</p>;
|
||||
}
|
||||
@@ -49,58 +77,97 @@ export function WealthChart({ series }: { series: TimelineSeries[] }) {
|
||||
const row: Record<string, number | null> = { age };
|
||||
for (const s of withPoints) {
|
||||
const pt = s.points.find((p) => p.age === age);
|
||||
row[`${s.label} (nominal)`] = pt ? pt.nominal : null;
|
||||
row[`${s.label} (real)`] = pt ? pt.real : null;
|
||||
row[s.id] = pt ? (metric === "real" ? pt.real : pt.nominal) : null;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const nameById = new Map(series.map((s) => [s.id, s.label]));
|
||||
const hasDashed = series.some((s) => s.dashed);
|
||||
|
||||
return (
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis
|
||||
dataKey="age"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => `${v} J.`}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => (typeof v === "number" ? formatChf(v) : v)}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={`${s.label}-nominal`}
|
||||
type="monotone"
|
||||
dataKey={`${s.label} (nominal)`}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
<div className="w-full">
|
||||
<div className="h-80 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--chart-grid)" />
|
||||
<XAxis
|
||||
dataKey="age"
|
||||
type="number"
|
||||
domain={["dataMin", "dataMax"]}
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--faint)"
|
||||
tickFormatter={(v) => `${v} J.`}
|
||||
/>
|
||||
))}
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={`${s.label}-real`}
|
||||
type="monotone"
|
||||
dataKey={`${s.label} (real)`}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 3"
|
||||
dot={false}
|
||||
connectNulls
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--faint)"
|
||||
tickFormatter={(v) => Intl.NumberFormat("de-CH", { notation: "compact" }).format(v)}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<Tooltip
|
||||
contentStyle={TOOLTIP_STYLE}
|
||||
formatter={(v, key) => [typeof v === "number" ? formatChf(v) : v, nameById.get(String(key)) ?? key]}
|
||||
labelFormatter={(v) => `Alter ${v}`}
|
||||
/>
|
||||
{withPoints.map((s) => (
|
||||
<Line
|
||||
key={s.id}
|
||||
type="monotone"
|
||||
dataKey={s.id}
|
||||
name={s.label}
|
||||
stroke={s.color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={s.dashed ? "5 3" : undefined}
|
||||
dot={false}
|
||||
connectNulls
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Eigene Legende statt der von Recharts: Sie trägt die Farbwahl und erklärt den
|
||||
Linienstil -- ohne das wäre unklar, wofür die gestrichelte Linie steht. */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
||||
{series.map((s) => (
|
||||
<span key={s.id} className="flex items-center gap-1.5 text-xs text-muted">
|
||||
{onColorChange ? (
|
||||
<label className="relative flex cursor-pointer items-center" title="Farbe wählen">
|
||||
<span
|
||||
className="block h-3 w-6 rounded-sm"
|
||||
style={{
|
||||
background: s.dashed
|
||||
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
|
||||
: s.color,
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={resolved[s.id] ?? "#888888"}
|
||||
onChange={(e) => onColorChange(s.id, e.target.value)}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<span
|
||||
className="block h-3 w-6 rounded-sm"
|
||||
style={{
|
||||
background: s.dashed
|
||||
? `repeating-linear-gradient(90deg, ${s.color} 0 5px, transparent 5px 8px)`
|
||||
: s.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</span>
|
||||
))}
|
||||
{hasDashed && (
|
||||
<span className="text-[11px] text-faint">
|
||||
gestrichelt = reine Planwerte · durchgezogen = mit effektiven Werten
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
// UI-Primitiven des Tools. Ein einziger Ort für Buttons, Dialoge, Bestätigungen, Toasts
|
||||
// und Ladezustände -- damit Verhalten (ESC, Fokus-Falle, Animation) und Optik überall
|
||||
// identisch sind, statt in jeder Komponente von Hand nachgebaut zu werden.
|
||||
//
|
||||
// Ersetzt insbesondere window.confirm()/alert(): Browser-Systemdialoge folgen keinem der
|
||||
// drei Farbschemata und wirken wie ein Fremdkörper.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { AlertTriangle, CheckCircle2, X } from "lucide-react";
|
||||
|
||||
// --- ESC-Verwaltung ----------------------------------------------------------------------
|
||||
// Mehrere Schichten können gleichzeitig offen sein (Inspector + Dialog). ESC soll nur die
|
||||
// OBERSTE schliessen. Jede Schicht registriert sich beim Öffnen; ESC trifft die letzte.
|
||||
const escStack: (() => void)[] = [];
|
||||
let escListenerAttached = false;
|
||||
|
||||
function ensureEscListener() {
|
||||
if (escListenerAttached || typeof window === "undefined") return;
|
||||
escListenerAttached = true;
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape" || escStack.length === 0) return;
|
||||
e.stopPropagation();
|
||||
escStack[escStack.length - 1]();
|
||||
});
|
||||
}
|
||||
|
||||
function useEscClose(onClose: () => void) {
|
||||
const closeRef = useRef(onClose);
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
}, [onClose]);
|
||||
useEffect(() => {
|
||||
ensureEscListener();
|
||||
const handler = () => closeRef.current();
|
||||
escStack.push(handler);
|
||||
return () => {
|
||||
const i = escStack.indexOf(handler);
|
||||
if (i >= 0) escStack.splice(i, 1);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
// Einfache Fokus-Falle: beim Öffnen den ersten fokussierbaren Inhalt fokussieren,
|
||||
// Tab bleibt innerhalb der Schicht, beim Schliessen kehrt der Fokus zurück.
|
||||
function useFocusTrap(ref: React.RefObject<HTMLElement | null>) {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
[...el.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)].filter((x) => !x.hasAttribute("disabled"));
|
||||
(focusables()[0] ?? el).focus({ preventScroll: true });
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const f = focusables();
|
||||
if (f.length === 0) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
el.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
el.removeEventListener("keydown", onKey);
|
||||
previous?.focus?.({ preventScroll: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}
|
||||
|
||||
// --- Button ------------------------------------------------------------------------------
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
|
||||
const BUTTON_CLASSES: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
"bg-accent text-accent-fg shadow-sm hover:bg-accent-hover active:scale-[0.98]",
|
||||
secondary:
|
||||
"border border-border text-muted hover:bg-surface-2 hover:text-fg active:scale-[0.98]",
|
||||
danger:
|
||||
"border border-border text-muted hover:border-danger hover:bg-danger-soft hover:text-danger active:scale-[0.98]",
|
||||
ghost: "text-muted hover:bg-surface-2 hover:text-fg",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const sizeClass = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
className={`inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-all duration-150 disabled:pointer-events-none disabled:opacity-50 ${BUTTON_CLASSES[variant]} ${sizeClass} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Modal -------------------------------------------------------------------------------
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
wide,
|
||||
xwide,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
xwide?: boolean;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
useFocusTrap(panelRef);
|
||||
const maxWidth = xwide ? "max-w-4xl" : wide ? "max-w-2xl" : "max-w-md";
|
||||
return (
|
||||
<div
|
||||
className="ui-fade fixed inset-0 z-40 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`ui-pop flex w-full ${maxWidth} flex-col gap-3 rounded-2xl border border-border bg-surface p-6 shadow-xl`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inspector ---------------------------------------------------------------------------
|
||||
// Rechtes Seitenpanel für Einzel-Bearbeitungen (Zellen, Übergänge, Phase, Profil).
|
||||
// Bewusst OHNE Backdrop: Die Matrix bleibt sichtbar und klickbar -- ein Klick auf eine
|
||||
// andere Zelle wechselt einfach den Panel-Inhalt. Das hält den Kontext, den Modals nehmen.
|
||||
// Auf schmalen Screens deckt das Panel die volle Breite ab.
|
||||
|
||||
export function InspectorShell({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
useEscClose(onClose);
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
role="complementary"
|
||||
aria-label={title}
|
||||
className="ui-slide-in fixed inset-y-0 right-0 z-30 flex w-full flex-col border-l border-border bg-surface shadow-2xl sm:w-[26rem]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold text-fg">{title}</h2>
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Panel schliessen"
|
||||
className="rounded-md p-1 text-faint transition-colors hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Bestätigungs-Dialog (ersetzt window.confirm) ---------------------------------------
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<((opts: ConfirmOptions) => Promise<boolean>) | null>(null);
|
||||
|
||||
export function useConfirm() {
|
||||
const fn = useContext(ConfirmContext);
|
||||
if (!fn) throw new Error("useConfirm ausserhalb des ConfirmProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ConfirmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [pending, setPending] = useState<{
|
||||
opts: ConfirmOptions;
|
||||
resolve: (ok: boolean) => void;
|
||||
} | null>(null);
|
||||
|
||||
const confirm = useCallback(
|
||||
(opts: ConfirmOptions) =>
|
||||
new Promise<boolean>((resolve) => setPending({ opts, resolve })),
|
||||
[]
|
||||
);
|
||||
|
||||
function settle(ok: boolean) {
|
||||
pending?.resolve(ok);
|
||||
setPending(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={confirm}>
|
||||
{children}
|
||||
{pending && (
|
||||
<Modal title={pending.opts.title} onClose={() => settle(false)}>
|
||||
<p className="text-sm text-muted">{pending.opts.message}</p>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant={pending.opts.danger ? "danger" : "primary"}
|
||||
autoFocus
|
||||
onClick={() => settle(true)}
|
||||
className={pending.opts.danger ? "border-danger bg-danger-soft text-danger" : ""}
|
||||
>
|
||||
{pending.opts.confirmLabel ?? "Bestätigen"}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => settle(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Toasts (ersetzt alert und stumme Erfolge) -------------------------------------------
|
||||
|
||||
interface ToastItem {
|
||||
id: number;
|
||||
kind: "success" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<((kind: ToastItem["kind"], text: string) => void) | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const fn = useContext(ToastContext);
|
||||
if (!fn) throw new Error("useToast ausserhalb des ToastProviders");
|
||||
return fn;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const nextId = useRef(1);
|
||||
|
||||
const push = useCallback((kind: ToastItem["kind"], text: string) => {
|
||||
const id = nextId.current++;
|
||||
setToasts((prev) => [...prev, { id, kind, text }]);
|
||||
// Fehler bleiben länger stehen als Erfolge.
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), kind === "error" ? 6000 : 3200);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={push}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`ui-pop pointer-events-auto flex items-start gap-2 rounded-xl border px-3.5 py-2.5 text-sm shadow-lg ${
|
||||
t.kind === "error"
|
||||
? "border-danger bg-danger-soft text-danger"
|
||||
: "border-border bg-surface text-fg"
|
||||
}`}
|
||||
>
|
||||
{t.kind === "error" ? (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">{t.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Meldung schliessen"
|
||||
onClick={() => setToasts((prev) => prev.filter((x) => x.id !== t.id))}
|
||||
className="rounded p-0.5 text-faint hover:text-fg"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ladezustände und leere Zustände ---------------------------------------------------
|
||||
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`animate-pulse rounded-lg bg-surface-2 ${className}`} />;
|
||||
}
|
||||
|
||||
// Platzhalter während die Planansicht lädt -- deutet die Struktur an, statt nur
|
||||
// "Lädt…" zu schreiben (wahrgenommene Geschwindigkeit).
|
||||
export function PlanSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5" aria-busy="true" aria-label="Lädt">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
text: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed border-border bg-surface p-10 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-accent-soft text-accent-soft-fg">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-fg">{title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted">{text}</p>
|
||||
</div>
|
||||
{children && <div className="mt-1 flex flex-wrap justify-center gap-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import {
|
||||
actualKindOf,
|
||||
actualsForYear,
|
||||
latestPlanYear,
|
||||
rebaseFlow,
|
||||
resolveActuals,
|
||||
toPlanYear,
|
||||
type ActualsSetInput,
|
||||
} from "@/lib/actuals";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Plan ab 2020: ein Fonds mit 100'000 und 5 % Rendite, keine Zu-/Abflüsse. Das ist bewusst
|
||||
// das Beispiel aus der Anforderung, nur in ganzen Franken.
|
||||
function fundPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "fonds",
|
||||
category: "OTHER_ASSET",
|
||||
name: "Fonds",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { startValue: 100000, expectedReturn: 5 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
const valueAt = (computed: ReturnType<typeof computePlan>, elementId: string, year: number) =>
|
||||
computed.phases
|
||||
.flatMap((p) => p.elements.filter((e) => e.elementId === elementId).flatMap((e) => e.yearly))
|
||||
.find((y) => y.year === year)?.value ?? null;
|
||||
|
||||
const setAt = (year: number, values: Record<string, { value?: number; mortgage?: number }>, cash?: number): ActualsSetInput => ({
|
||||
id: `set-${year}`,
|
||||
recordedOn: `${year}-08-18`,
|
||||
year,
|
||||
cash: cash ?? null,
|
||||
values,
|
||||
});
|
||||
|
||||
describe("toPlanYear", () => {
|
||||
it("rechnet das Kalenderjahr auf das Planjahr um", () => {
|
||||
expect(toPlanYear(2020, 2020)).toBe(1);
|
||||
expect(toPlanYear(2026, 2020)).toBe(7);
|
||||
});
|
||||
|
||||
it("liefert nichts ohne Planstartjahr", () => {
|
||||
expect(toPlanYear(2026, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("actualKindOf", () => {
|
||||
it("trennt Bestände von Flüssen", () => {
|
||||
expect(actualKindOf("OTHER_ASSET")).toBe("STOCK");
|
||||
expect(actualKindOf("PENSION_FUND")).toBe("STOCK");
|
||||
expect(actualKindOf("REAL_ESTATE")).toBe("PROPERTY");
|
||||
expect(actualKindOf("INCOME")).toBe("FLOW");
|
||||
expect(actualKindOf("EXPENSE")).toBe("FLOW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveActuals", () => {
|
||||
const plan = fundPlan();
|
||||
|
||||
it("bildet Wurzel-IDs auf die Elemente des Szenarios ab", () => {
|
||||
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].planYear).toBe(3);
|
||||
expect(res[0].byElementId.fonds.value).toBe(120000);
|
||||
});
|
||||
|
||||
it("findet das Element auch über eine Kopie-Kette hinweg", () => {
|
||||
// Kind-Szenario: eigene Element-ID, zeigt über sourceElementId auf das Original.
|
||||
const child: PlanInput = {
|
||||
...plan,
|
||||
id: "child",
|
||||
elements: [{ ...plan.elements[0], id: "fonds-kopie", sourceElementId: "fonds" }],
|
||||
} as unknown as PlanInput;
|
||||
|
||||
// Der Ist-Satz ist auf die WURZEL-ID erfasst -- er muss trotzdem greifen.
|
||||
const res = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], child, [...plan.elements, ...child.elements]);
|
||||
expect(res[0].byElementId["fonds-kopie"].value).toBe(120000);
|
||||
});
|
||||
|
||||
it("verwirft Sätze ausserhalb des Planzeitraums", () => {
|
||||
const res = resolveActuals(
|
||||
[setAt(2019, { fonds: { value: 1 } }), setAt(2099, { fonds: { value: 1 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it("lässt Elemente ohne Ist-Wert weg -- sie laufen auf der Planlinie weiter", () => {
|
||||
const res = resolveActuals([setAt(2022, {})], plan, plan.elements);
|
||||
expect(res[0].byElementId).toEqual({});
|
||||
});
|
||||
|
||||
it("sortiert nach Planjahr", () => {
|
||||
const res = resolveActuals(
|
||||
[setAt(2024, { fonds: { value: 140000 } }), setAt(2022, { fonds: { value: 120000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
expect(res.map((r) => r.year)).toEqual([2022, 2024]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actualsForYear / latestPlanYear", () => {
|
||||
const plan = fundPlan();
|
||||
const res = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
|
||||
it("findet den Satz des Jahres", () => {
|
||||
expect(actualsForYear(res, 3)!.year).toBe(2022);
|
||||
expect(actualsForYear(res, 4)).toBeNull();
|
||||
});
|
||||
|
||||
it("kennt das jüngste erfasste Planjahr", () => {
|
||||
expect(latestPlanYear(res)).toBe(5);
|
||||
expect(latestPlanYear([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Berechnung mit Ist-Werten", () => {
|
||||
const plan = fundPlan();
|
||||
|
||||
it("lässt die Plan-Sicht unangetastet", () => {
|
||||
// Der wichtigste Test überhaupt: Ohne actuals-Option muss auf die Zahl dasselbe
|
||||
// herauskommen wie vorher.
|
||||
const a = computePlan(plan);
|
||||
const b = computePlan(plan, undefined, {});
|
||||
expect(a.phases[0].endWealthNominal).toBe(b.phases[0].endWealthNominal);
|
||||
});
|
||||
|
||||
it("springt im Ist-Jahr auf den erfassten Wert", () => {
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
// Planwert 2022 wäre 100'000 x 1.05^3 = 115'762. Erfasst sind 120'000.
|
||||
expect(valueAt(computePlan(plan), "fonds", 3)).toBe(115763);
|
||||
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||
});
|
||||
|
||||
it("rechnet ab dem Ist-Wert planmässig weiter", () => {
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
// Zwei Jahre nach dem Sprung: 120'000 x 1.05^2 = 132'300 -- genau die Erwartung aus der
|
||||
// Anforderung ("in 2024 wäre man dann bei ca. 132").
|
||||
expect(valueAt(computed, "fonds", 5)).toBe(132300);
|
||||
});
|
||||
|
||||
it("springt bei mehreren Sätzen an jeder erfassten Stelle", () => {
|
||||
const actuals = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 140000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
|
||||
expect(valueAt(computed, "fonds", 3)).toBe(120000);
|
||||
expect(valueAt(computed, "fonds", 5)).toBe(140000); // statt 132'300
|
||||
expect(valueAt(computed, "fonds", 6)).toBe(147000); // 140'000 x 1.05
|
||||
});
|
||||
|
||||
it("führt den Sprung als eigene Position, nicht als Rendite", () => {
|
||||
// Sonst erschiene eine Planabweichung als Anlageerfolg -- und die Brücke ginge nicht auf.
|
||||
const actuals = resolveActuals([setAt(2022, { fonds: { value: 120000 } })], plan, plan.elements);
|
||||
const withActuals = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||
const planOnly = computePlan(plan).phases[0].wealthBridge;
|
||||
|
||||
// Planwert 2022 exakt: 100'000 x 1.05^3 = 115'762.50 -> Korrektur 4'237.50, gerundet 4'238.
|
||||
expect(withActuals.actualsCorrection).toBe(4238);
|
||||
// Die Rendite selbst bleibt unberührt: Der Sprung ist KEIN Anlageerfolg. Sie ist nur
|
||||
// grösser, weil ab 2022 auf einem höheren Kapital verzinst wird.
|
||||
expect(withActuals.investmentReturn).toBeGreaterThan(planOnly.investmentReturn);
|
||||
expect(planOnly.actualsCorrection).toBe(0);
|
||||
});
|
||||
|
||||
it("hält die Vermögensbrücke auch bei mehreren Sprüngen geschlossen", () => {
|
||||
const actuals = resolveActuals(
|
||||
[setAt(2022, { fonds: { value: 120000 } }), setAt(2024, { fonds: { value: 90000 } })],
|
||||
plan,
|
||||
plan.elements
|
||||
);
|
||||
const bridge = computePlan(plan, undefined, { actuals }).phases[0].wealthBridge;
|
||||
// Der zweite Sprung geht nach UNTEN -- die Korrektur muss negativ sein können.
|
||||
expect(bridge.actualsCorrection).toBeLessThan(0);
|
||||
// Der Restposten bleibt reine Rundung. Die Toleranz entspricht der, ab der die
|
||||
// Detailansicht eine Fehlermeldung zeigt (ResidualNote: > 2 Franken).
|
||||
expect(Math.abs(bridge.residual)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("übernimmt den effektiven Cash-Bestand und hält die Cash-Brücke geschlossen", () => {
|
||||
const actuals = resolveActuals([setAt(2022, {}, 55000)], plan, plan.elements);
|
||||
const computed = computePlan(plan, undefined, { actuals });
|
||||
const cb = computed.phases[0].cashBridge;
|
||||
|
||||
expect(cb.actualsCorrection).not.toBe(0);
|
||||
expect(Math.abs(cb.residual)).toBeLessThanOrEqual(2);
|
||||
// Der Cash-Bestand am Phasenende trägt den Sprung wirklich mit.
|
||||
expect(computed.phases[0].cashBridge.cashEnd).toBe(55000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cash-Bestand je Jahr", () => {
|
||||
// Genau der gemeldete Fall: EINE lange Phase, in der sich das Cash-Konto kontinuierlich
|
||||
// füllt. Die Vorbelegung im Wizard muss den Stand des GEWÄHLTEN Jahres zeigen -- nicht den
|
||||
// Stand am Phasenende. Zuvor las der Dialog `cashBridge.cashEnd` und zeigte in der
|
||||
// Phasenmitte den Endwert an.
|
||||
function savingPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 100,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 70 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "inc",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
{
|
||||
id: "exp",
|
||||
category: "EXPENSE",
|
||||
name: "Leben",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 2,
|
||||
phaseValues: { p1: { amount: 90000 } },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
it("weist den Cash-Stand für JEDES Jahr aus, nicht nur je Phase", () => {
|
||||
const computed = computePlan(savingPlan());
|
||||
// 10'000 Überschuss pro Jahr auf 100 Startguthaben.
|
||||
expect(computed.yearly.find((y) => y.year === 1)!.cash).toBe(10100);
|
||||
expect(computed.yearly.find((y) => y.year === 5)!.cash).toBe(50100);
|
||||
expect(computed.yearly.find((y) => y.year === 10)!.cash).toBe(100100);
|
||||
});
|
||||
|
||||
it("liegt in der Phasenmitte deutlich unter dem Phasen-Endwert", () => {
|
||||
// Die eigentliche Regression: Mitte und Ende dürfen nicht dieselbe Zahl sein.
|
||||
const computed = computePlan(savingPlan());
|
||||
const mid = computed.yearly.find((y) => y.year === 5)!.cash;
|
||||
const phaseEnd = computed.phases[0].cashBridge.cashEnd;
|
||||
expect(phaseEnd).toBe(computed.yearly.find((y) => y.year === 10)!.cash);
|
||||
expect(mid).toBeLessThan(phaseEnd);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rebaseFlow", () => {
|
||||
it("trifft im Ist-Jahr genau den erfassten Betrag", () => {
|
||||
// Basis so, dass basis * (1+idx)^(t-1) === Ist-Wert.
|
||||
const basis = rebaseFlow(120000, 2, 5);
|
||||
expect(basis * Math.pow(1.02, 4)).toBeCloseTo(120000, 6);
|
||||
});
|
||||
|
||||
it("berücksichtigt bei Ausgaben zusätzlich die Teuerung", () => {
|
||||
const basis = rebaseFlow(50000, 1, 3, 1.1);
|
||||
expect(basis * Math.pow(1.01, 2) * 1.1).toBeCloseTo(50000, 6);
|
||||
});
|
||||
|
||||
it("weicht nicht auf NaN aus, wenn kein Wachstum vorliegt", () => {
|
||||
expect(rebaseFlow(1000, 0, 1)).toBe(1000);
|
||||
expect(rebaseFlow(1000, -100, 3)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Effektive Flusswerte wirken in die Folgephase (Roadmap Nr. 44)", () => {
|
||||
// Zwei Phasen a 5 Jahre, Lohn 100'000 ohne Teuerung. Im Planjahr 3 wird der Lohn
|
||||
// effektiv mit 200'000 erfasst -- das muss auch in Phase 2 ankommen, sonst faellt der
|
||||
// Wert am Phasenwechsel stillschweigend auf den geplanten zurueck.
|
||||
function lohnPlan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "E1", durationYears: 5, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "E2", durationYears: 5, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "lohn",
|
||||
category: "INCOME",
|
||||
name: "Lohn",
|
||||
ownerRole: "HOUSEHOLD",
|
||||
orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
|
||||
transitionValues: {},
|
||||
sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
it("traegt den effektiven Wert ueber die Phasengrenze", () => {
|
||||
const p = lohnPlan();
|
||||
const actuals = resolveActuals([setAt(2022, { lohn: { value: 200000 } })], p, p.elements);
|
||||
const computed = computePlan(p, undefined, { actuals });
|
||||
expect(valueAt(computed, "lohn", 3)).toBe(200000);
|
||||
// Phase 2 beginnt im Planjahr 6 -- ohne den Fix stuende hier wieder 100'000.
|
||||
expect(valueAt(computed, "lohn", 6)).toBe(200000);
|
||||
});
|
||||
|
||||
it("bleibt ohne Ist-Werte auf der Planlinie", () => {
|
||||
const computed = computePlan(lohnPlan());
|
||||
expect(valueAt(computed, "lohn", 6)).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Immobilie: Verkehrswert vs. Eigenkapital (Bugfix 0.31)", () => {
|
||||
// Immobilie 900'000 mit 600'000 Hypothek -> Eigenkapital 300'000.
|
||||
// Der Ist-Wizard belegt den Wert mit dem PLANWERT vor. Wuerde er dabei das Eigenkapital
|
||||
// nehmen (statt des Verkehrswerts), setzte der Rechenkern 300'000 als Verkehrswert ein,
|
||||
// waehrend die Hypothek bei 600'000 bliebe -- das Eigenkapital kippte auf -300'000.
|
||||
function immoPlan(): PlanInput {
|
||||
return {
|
||||
id: "s", name: "T", householdType: "SINGLE", inflationRateDefault: 0, initialCash: 0, startYear: 2020,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "E", durationYears: 10, cashTransition: {} }],
|
||||
elements: [
|
||||
{
|
||||
id: "immo", category: "REAL_ESTATE", name: "Haus", ownerRole: "HOUSEHOLD", orderIndex: 1,
|
||||
phaseValues: { p1: { purchasePrice: 900000, mortgage: 600000, amortization: 0, valueGrowth: 0 } },
|
||||
transitionValues: {}, sourceElementId: null,
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
it("fuehrt Verkehrswert und Eigenkapital getrennt in den Jahreswerten", () => {
|
||||
const c = computePlan(immoPlan());
|
||||
const y = c.phases[0].elements[0].yearly[0];
|
||||
expect(y.value).toBe(300000); // Eigenkapital -- das zeigt die Matrix
|
||||
expect(y.propertyValue).toBe(900000); // Verkehrswert -- den erfasst der Ist-Wizard
|
||||
expect(y.mortgage).toBe(600000);
|
||||
});
|
||||
|
||||
it("laesst das Eigenkapital unveraendert, wenn der Ist-Wert dem Plan entspricht", () => {
|
||||
const p = immoPlan();
|
||||
// So belegt der Wizard vor: Verkehrswert + Restschuld, beide auf Planniveau.
|
||||
const actuals = resolveActuals([setAt(2023, { immo: { value: 900000, mortgage: 600000 } })], p, p.elements);
|
||||
const computed = computePlan(p, undefined, { actuals });
|
||||
expect(valueAt(computed, "immo", 4)).toBe(300000);
|
||||
// Und der Sprung wird korrekt als "keine Abweichung" ausgewiesen.
|
||||
expect(computed.phases[0].wealthBridge.actualsCorrection).toBe(0);
|
||||
});
|
||||
|
||||
it("bildet eine echte Wertsteigerung sauber ab", () => {
|
||||
const p = immoPlan();
|
||||
const actuals = resolveActuals([setAt(2023, { immo: { value: 950000, mortgage: 580000 } })], p, p.elements);
|
||||
const computed = computePlan(p, undefined, { actuals });
|
||||
expect(valueAt(computed, "immo", 4)).toBe(370000); // 950'000 - 580'000
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
// Effektive (Ist-)Werte -- Roadmap Nr. 5, Plan-/Ist-Vergleich.
|
||||
//
|
||||
// Grundgedanke: Der Plan bleibt unangetastet. Parallel dazu läuft eine ZWEITE Berechnung mit
|
||||
// derselben Mechanik, aber korrigierter Ausgangsbasis: In jedem Jahr, für das ein Ist-Satz
|
||||
// erfasst wurde, schnappen die Werte auf die Realität und laufen von dort planmässig weiter.
|
||||
//
|
||||
// Beispiel: Fonds startet 2020 mit 100 bei 5 % Rendite. Ohne Ist-Daten steht 2022 rechnerisch
|
||||
// 110 da. Wird für 2022 ein Ist-Wert von 120 erfasst, rechnet die Ist-Sicht ab 2022 mit 120
|
||||
// weiter und steht 2024 bei ~132. Kommt für 2024 ein Ist-Wert von 140 dazu, springt sie dort
|
||||
// erneut.
|
||||
//
|
||||
// Ein Ist-Satz gehört zum PLAN, nicht zum Szenario: Die Realität ist dieselbe, egal gegen
|
||||
// welches Szenario man sie hält. Die Zuordnung auf die szenario-eigenen Element-IDs läuft
|
||||
// über dieselbe Herkunfts-Kette (`sourceElementId`), die auch der Diff und die
|
||||
// Monte-Carlo-Gruppierung benutzen.
|
||||
|
||||
import { resolveRootElementId } from "@/lib/montecarlo";
|
||||
import type { ElementCategory } from "@/lib/elements";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Was für ein Ist-Wert je Element erfasst werden kann. Bestände und Flüsse verhalten sich
|
||||
// verschieden: Ein Bestand ersetzt den laufenden Stand, ein Fluss die Basis für alle
|
||||
// Folgejahre.
|
||||
export type ActualKind = "STOCK" | "FLOW" | "PROPERTY" | "NONE";
|
||||
|
||||
export function actualKindOf(category: ElementCategory): ActualKind {
|
||||
switch (category) {
|
||||
case "PENSION_FUND":
|
||||
case "PILLAR_3A":
|
||||
case "OTHER_ASSET":
|
||||
return "STOCK";
|
||||
case "OTHER_DEBT":
|
||||
return "STOCK"; // Restschuld
|
||||
case "REAL_ESTATE":
|
||||
return "PROPERTY"; // Verkehrswert UND Resthypothek
|
||||
case "INCOME":
|
||||
case "EXPENSE":
|
||||
return "FLOW";
|
||||
case "AHV":
|
||||
// Die Rente folgt der amtlichen Formel aus der Beitragskarriere. Erfassbar ist sie nur,
|
||||
// wenn sie zum Stichtag bereits LÄUFT -- vorher gibt es keinen Stand. Das entscheidet
|
||||
// sich am Plan, nicht an der Kategorie (siehe `actualFieldsFor`).
|
||||
return "FLOW";
|
||||
}
|
||||
}
|
||||
|
||||
// Ein erfasster Wert je Element.
|
||||
export interface ActualElementValue {
|
||||
// Bestand bzw. Verkehrswert bei Immobilien; bei Flüssen der NOMINALE Jahresbetrag
|
||||
// (Einkommen: was aufs Konto kam; Ausgaben: was tatsächlich ausgegeben wurde).
|
||||
value?: number;
|
||||
// Nur Immobilie: tatsächliche Restschuld.
|
||||
mortgage?: number;
|
||||
}
|
||||
|
||||
// Ein Ist-Satz, wie er gespeichert wird. Schlüssel sind WURZEL-Element-IDs.
|
||||
export interface ActualsSetInput {
|
||||
id: string;
|
||||
recordedOn: string; // exaktes Datum (ISO) -- nur für Liste und Zeitachse
|
||||
year: number; // Kalenderjahr; nur dieses geht in die Rechnung ein
|
||||
comment?: string | null;
|
||||
cash?: number | null;
|
||||
values: Record<string, ActualElementValue>;
|
||||
}
|
||||
|
||||
// Auf ein konkretes Szenario aufgelöster Ist-Satz: Schlüssel sind die Element-IDs DIESES
|
||||
// Szenarios, und das Kalenderjahr ist in ein Planjahr (1-basiert) umgerechnet.
|
||||
export interface ResolvedActuals {
|
||||
planYear: number; // 1 = erstes Planjahr
|
||||
year: number; // Kalenderjahr (für Anzeige)
|
||||
cash?: number;
|
||||
byElementId: Record<string, ActualElementValue>;
|
||||
}
|
||||
|
||||
// Kalenderjahr -> Planjahr. `startYear` ist das Kalenderjahr des ersten Planjahres.
|
||||
export function toPlanYear(year: number, startYear: number | null | undefined): number | null {
|
||||
if (!startYear) return null;
|
||||
return year - startYear + 1;
|
||||
}
|
||||
|
||||
// Herkunft eines Elements: die lose Referenz auf sein Gegenstück im Eltern-Szenario.
|
||||
export interface ElementOrigin {
|
||||
id: string;
|
||||
sourceElementId?: string | null;
|
||||
}
|
||||
|
||||
// Bildet die gespeicherten Ist-Sätze auf ein Szenario ab.
|
||||
//
|
||||
// `origins` enthält die Elemente ALLER Szenarien des Plans -- nur so löst sich die
|
||||
// Herkunfts-Kette auch über ein übersprungenes Zwischen-Szenario hinweg auf.
|
||||
export function resolveActuals(
|
||||
sets: ActualsSetInput[],
|
||||
scenario: PlanInput,
|
||||
origins: ElementOrigin[]
|
||||
): ResolvedActuals[] {
|
||||
const sourceById = new Map<string, string | null>();
|
||||
for (const o of origins) sourceById.set(o.id, o.sourceElementId ?? null);
|
||||
// Sicherheitsnetz: Elemente des betrachteten Szenarios sind immer dabei.
|
||||
for (const e of scenario.elements) if (!sourceById.has(e.id)) sourceById.set(e.id, e.sourceElementId ?? null);
|
||||
|
||||
const totalYears = scenario.phases.reduce((s, p) => s + p.durationYears, 0);
|
||||
|
||||
return sets
|
||||
.flatMap<ResolvedActuals>((set) => {
|
||||
const planYear = toPlanYear(set.year, scenario.startYear);
|
||||
if (planYear === null) return [];
|
||||
const byElementId: Record<string, ActualElementValue> = {};
|
||||
for (const e of scenario.elements) {
|
||||
const root = resolveRootElementId(e.id, sourceById);
|
||||
const v = set.values[root];
|
||||
// Lücken fallen bewusst auf die Plandaten zurück: Ein Element ohne Ist-Wert läuft
|
||||
// unverändert auf seiner Planlinie weiter.
|
||||
if (v && (typeof v.value === "number" || typeof v.mortgage === "number")) {
|
||||
byElementId[e.id] = v;
|
||||
}
|
||||
}
|
||||
const out: ResolvedActuals = { planYear, year: set.year, byElementId };
|
||||
if (typeof set.cash === "number") out.cash = set.cash;
|
||||
return [out];
|
||||
})
|
||||
// Ausserhalb des Plans liegende Sätze werden ignoriert -- sie hätten keinen Angriffspunkt.
|
||||
.filter((r) => r.planYear >= 1 && r.planYear <= totalYears)
|
||||
// Bei zwei Sätzen im selben Planjahr gewinnt der zuletzt erfasste.
|
||||
.sort((a, b) => a.planYear - b.planYear);
|
||||
}
|
||||
|
||||
// Nachschlagen im Rechenkern: Gibt es für dieses Planjahr einen Ist-Satz?
|
||||
export function actualsForYear(list: ResolvedActuals[], planYear: number): ResolvedActuals | null {
|
||||
// Rückwärts, damit bei mehreren Sätzen im selben Jahr der letzte gewinnt.
|
||||
for (let i = list.length - 1; i >= 0; i--) if (list[i].planYear === planYear) return list[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
// Das jüngste erfasste Planjahr -- Startpunkt der Monte-Carlo-Simulation im Ist-Modus und
|
||||
// Grundlage der Hervorhebung auf der Zeitachse.
|
||||
export function latestPlanYear(list: ResolvedActuals[]): number | null {
|
||||
return list.length === 0 ? null : Math.max(...list.map((r) => r.planYear));
|
||||
}
|
||||
|
||||
// Rechnet einen Ist-Fluss auf die Basis zurück, mit der der Rechenkern arbeitet.
|
||||
//
|
||||
// Einkommen laufen als `basis * (1 + idx/100)^(t-1)`. Ist für Jahr t ein Ist-Wert erfasst,
|
||||
// muss die Basis so gesetzt werden, dass die Formel in genau diesem Jahr den Ist-Wert trifft
|
||||
// -- danach wächst sie planmässig weiter. Das ist gleichbedeutend damit, den Bezugspunkt der
|
||||
// Reihe auf das Ist-Jahr zu legen, kommt aber ohne Eingriff in die Struktur aus.
|
||||
export function rebaseFlow(actualValue: number, idxPercent: number, tInPhase: number, deflator = 1): number {
|
||||
const growth = Math.pow(1 + idxPercent / 100, tInPhase - 1);
|
||||
const divisor = growth * (deflator || 1);
|
||||
if (!Number.isFinite(divisor) || divisor === 0) return actualValue;
|
||||
return actualValue / divisor;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Gespeicherte Analysen -- Typen und Client-Helfer (SPEZIFIKATION 3.10).
|
||||
//
|
||||
// Festgehalten werden EINGABEN und ERGEBNIS als Zahlen, read-only. Beim Öffnen wird nichts
|
||||
// neu gerechnet. Das hält den Datensatz klein und macht ihn druckfähig (PDF-Bericht,
|
||||
// Roadmap Nr. 11): Aus Zahlen lässt sich vektoriell in jeder Auflösung zeichnen, aus einem
|
||||
// Screenshot nicht.
|
||||
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export type AnalysisType = "CHART" | "MONTE_CARLO" | "SENSITIVITY";
|
||||
|
||||
export const ANALYSIS_TYPE_LABEL: Record<AnalysisType, string> = {
|
||||
CHART: "Grafik",
|
||||
MONTE_CARLO: "Monte-Carlo",
|
||||
SENSITIVITY: "Einflussfaktoren",
|
||||
};
|
||||
|
||||
// Kopfdaten in der Liste (ohne die grossen JSON-Felder).
|
||||
export interface SavedAnalysisMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
type: AnalysisType;
|
||||
scenarioName: string | null;
|
||||
versionLabel: string | null;
|
||||
metric: string;
|
||||
source: string;
|
||||
summary: string | null;
|
||||
createdAt: string;
|
||||
author: string;
|
||||
}
|
||||
|
||||
// Vollständiger Datensatz beim Öffnen.
|
||||
export interface SavedAnalysisFull extends Omit<SavedAnalysisMeta, "author"> {
|
||||
inputs: unknown;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export interface SaveAnalysisPayload {
|
||||
name: string;
|
||||
type: AnalysisType;
|
||||
scenarioName?: string | null;
|
||||
versionLabel?: string | null;
|
||||
metric?: string;
|
||||
source?: string;
|
||||
summary?: string | null;
|
||||
inputs: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function saveAnalysis(planId: string, payload: SaveAnalysisPayload): Promise<void> {
|
||||
await api.post(`/api/plans/${planId}/analyses`, payload);
|
||||
}
|
||||
|
||||
// Automatischer Name: Typ · Szenario Version · Datum. Überschreibbar beim Speichern.
|
||||
export function defaultAnalysisName(
|
||||
type: AnalysisType,
|
||||
scenarioName: string | null,
|
||||
versionLabel: string | null
|
||||
): string {
|
||||
const parts = [ANALYSIS_TYPE_LABEL[type]];
|
||||
if (scenarioName) parts.push(scenarioName + (versionLabel ? ` ${versionLabel}` : ""));
|
||||
parts.push(new Date().toLocaleDateString("de-CH", { day: "2-digit", month: "2-digit", year: "numeric" }));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
return source === "ACTUAL" ? "Effektiv" : "Plan";
|
||||
}
|
||||
|
||||
// --- Generische, druckfähige Momentaufnahme -------------------------------------------
|
||||
//
|
||||
// Jedes Werkzeug schreibt sein Ergebnis in DIESE einheitliche Form. Dadurch braucht die
|
||||
// Nur-Lese-Ansicht nur einen Renderer, und der spätere PDF-Bericht (Roadmap Nr. 11) findet
|
||||
// überall dieselbe Struktur vor -- Zahlen, keine Pixel.
|
||||
|
||||
export interface SavedKeyValue {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "success" | "danger" | "muted";
|
||||
}
|
||||
|
||||
export interface SavedChartSeries {
|
||||
label: string;
|
||||
color?: string;
|
||||
dashed?: boolean;
|
||||
points: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
export interface SavedResult {
|
||||
// Eingabeparameter, mit denen gerechnet wurde.
|
||||
params: SavedKeyValue[];
|
||||
// Kernzahlen des Ergebnisses (gross dargestellt).
|
||||
metrics?: SavedKeyValue[];
|
||||
// Optionale Ergebnistabelle.
|
||||
table?: { columns: string[]; rows: (string | number)[][] };
|
||||
// Optionale Grafik.
|
||||
chart?: { kind: "line" | "bar"; xLabel?: string; yFormat?: "chf" | "pct"; series: SavedChartSeries[] };
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ export async function createSessionToken(userId: string): Promise<string> {
|
||||
.sign(getSecretKey());
|
||||
}
|
||||
|
||||
// Liefert die User-ID aus einem gueltigen Session-Token, sonst null.
|
||||
// Liefert die User-ID aus einem gültigen Session-Token, sonst null.
|
||||
export async function verifySessionToken(token: string): Promise<string | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getSecretKey());
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// «Ein Wert, ein Ort» -- durchgesetzt im Rechenkern, nicht nur in der Oberfläche
|
||||
// (SPEZIFIKATION 3.14.3).
|
||||
//
|
||||
// Der Bestand bei Planbeginn gehört in die Stammdaten. In der ersten Phase stand dafür bis
|
||||
// 0.39 zusätzlich ein Eingabefeld; eine dort gespeicherte Zahl überschattete die Stammdaten
|
||||
// still. Seit 0.40 ist das Feld read-only -- womit so ein Altwert unerreichbar WÄRE, wenn er
|
||||
// weiterhin gewönne. Deshalb entscheidet die Regel der Rechenkern.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan, firstPhaseValues } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function plan(elements: PlanInput["elements"]): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 40, retirementAge: 65 }],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "P1", durationYears: 10, cashTransition: { mode: "NONE" } }],
|
||||
elements,
|
||||
};
|
||||
}
|
||||
|
||||
function haus(phaseValues: Record<string, Record<string, unknown>>) {
|
||||
return [
|
||||
{
|
||||
id: "e1",
|
||||
category: "REAL_ESTATE" as never,
|
||||
name: "Haus",
|
||||
ownerRole: "HOUSEHOLD" as never,
|
||||
orderIndex: 1,
|
||||
baseData: { purchasePrice: 800000, mortgage: 500000 } as Record<string, number>,
|
||||
phaseValues: phaseValues as never,
|
||||
transitionValues: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
describe("Bestandsfelder in der ersten Phase", () => {
|
||||
it("nimmt den Bestand aus den Stammdaten", () => {
|
||||
const c = computePlan(plan(haus({})));
|
||||
expect(c.phases[0].elements[0].startValue).toBe(300000);
|
||||
});
|
||||
|
||||
it("lässt sich von einem Phasenwert NICHT überschatten", () => {
|
||||
// Genau der Fall aus der Testrunde: In 0.39 schrieb das Speichern der Zelle eine 0 --
|
||||
// seither stand das Haus mit Startwert 0 da, ohne dass man es noch korrigieren konnte.
|
||||
const c = computePlan(plan(haus({ p1: { confirmed: true, purchasePrice: 0, mortgage: 0 } })));
|
||||
expect(c.phases[0].elements[0].startValue).toBe(300000);
|
||||
});
|
||||
|
||||
it("fällt auf den Phasenwert zurück, solange die Stammdaten leer sind", () => {
|
||||
// Ohne diesen Rückfall wären Pläne aus der Zeit vor den Stammdaten schlagartig auf 0.
|
||||
const els = haus({ p1: { purchasePrice: 600000, mortgage: 400000 } });
|
||||
els[0].baseData = {};
|
||||
expect(computePlan(plan(els)).phases[0].elements[0].startValue).toBe(200000);
|
||||
});
|
||||
|
||||
it("lässt die ANNAHMEN in der ersten Phase überschreibbar", () => {
|
||||
// Eine Rendite gilt für einen Zeitraum, kein Anfangsbestand -- sie darf je Phase abweichen.
|
||||
const merged = firstPhaseValues(
|
||||
{ startValue: 100000, expectedReturn: 3 },
|
||||
{ expectedReturn: 7, startValue: 0 }
|
||||
);
|
||||
expect(merged.expectedReturn).toBe(7);
|
||||
expect(merged.startValue).toBe(100000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,528 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Die zentrale Invariante beider Wasserfall-Brücken: `residual` ist die Differenz zwischen
|
||||
// dem tatsächlichen Endwert und der Summe der gezeichneten Schritte. Ist die Zerlegung
|
||||
// vollständig und richtig, MUSS sie 0 sein -- ein Wasserfall, der nicht aufgeht, wäre ein
|
||||
// Fehler in der Berechnung und nicht bloss ein Darstellungsproblem. Deshalb über möglichst
|
||||
// verschiedene Konstellationen festgenagelt statt an einem einzigen Beispiel.
|
||||
|
||||
let idc = 0;
|
||||
const nid = () => `b${idc++}`;
|
||||
|
||||
function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {},
|
||||
// Pensionierungs-Entscheid: haengt seit 0.34 am Element, nicht an einer Phasengrenze.
|
||||
retirementDecision: RetirementDecision = {}
|
||||
) {
|
||||
return {
|
||||
id: nid(),
|
||||
category,
|
||||
name: category,
|
||||
ownerRole: ownerRole as never,
|
||||
orderIndex: idc,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
age: number;
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
}): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: opts.inflation ?? 1.5,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
sequenceNumber: i + 1,
|
||||
name: p.id,
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
const konstellationen: { name: string; build: () => PlanInput }[] = [
|
||||
{
|
||||
name: "Ansparen mit 3a, Sparbeitrag und Schuldentilgung",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
initialCash: 50000,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000, teuerungsausgleich: 1 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 } }),
|
||||
el("PILLAR_3A", "PERSON_A", { p1: { currentValue: 50000, annualContribution: 7000, expectedReturn: 3 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 200000, expectedReturn: 5, annualContribution: 10000 } }),
|
||||
el("OTHER_DEBT", "HOUSEHOLD", { p1: { startValue: 60000, annualRepayment: 8000 } }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Pensionierung mit PK-Verrentung und 3a-Bezug",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
initialCash: 20000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 20 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 130000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 75000 } }),
|
||||
el("AHV", "PERSON_A", { p1: { gapYears: 0 } }, { p1: { reviewed: true, avgIncomeBefore: 95000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 } },
|
||||
{},
|
||||
{ capitalSharePct: 0, conversionRate: 6 }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 120000, annualContribution: 7000, expectedReturn: 3 } },
|
||||
{},
|
||||
{ capitalTaxRate: 8 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 300000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, annualWithdrawal: 30000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "PK-Kapitalbezug statt Rente (Steuer am Übergang)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 110000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 70000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 500000, annualContribution: 18000, expectedReturn: 2 } },
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 8 }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie gehalten (Wertsteigerung und Amortisation)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el("REAL_ESTATE", "HOUSEHOLD", {
|
||||
p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
p2: { amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Immobilie verkauft (Verkaufsdifferenz und Grundstückgewinnsteuer)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 45,
|
||||
retirementAge: 70,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 140000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 700000, amortization: 12000, interestRate: 1.5, valueGrowth: 1.5 } },
|
||||
{ p1: { decision: "SELL", salePrice: 1400000, saleTaxRate: 20 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Einmalige Sonderein-/ausgaben am Übergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 30000,
|
||||
phases: [
|
||||
{
|
||||
id: "p1",
|
||||
durationYears: 5,
|
||||
cashTransition: { mode: "BOTH", inflowAmount: 250000, inflowTaxRate: 10, outflowAmount: 40000 },
|
||||
},
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 85000 }, p2: { amount: 85000 } }),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", {
|
||||
p1: { startValue: 100000, expectedReturn: 4 },
|
||||
p2: { expectedReturn: 4, additionalInvestment: 50000 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Sofort-Tilgung und Sonderamortisation am Übergang",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 50,
|
||||
retirementAge: 70,
|
||||
initialCash: 200000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
|
||||
el(
|
||||
"OTHER_DEBT",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { startValue: 80000, annualRepayment: 5000 }, p2: { annualRepayment: 5000 } },
|
||||
{ p1: { immediateRepayment: 30000 } }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{
|
||||
p1: { purchasePrice: 800000, mortgage: 500000, amortization: 10000, valueGrowth: 1 },
|
||||
p2: { amortization: 10000, valueGrowth: 1 },
|
||||
},
|
||||
{ p1: { decision: "HOLD", extraAmortization: 50000 } }
|
||||
),
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
// Roadmap Nr. 44, Punkt C: Das PK-Kapital wird nicht als Cash liegen gelassen, sondern
|
||||
// per Quote auf Hypothek und Anlagen verteilt. Beide Brücken müssen weiterhin aufgehen.
|
||||
name: "Kapitalverwendung am Pensions-Übergang (Quoten)",
|
||||
build: () =>
|
||||
plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
initialCash: 50000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 120000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 80000 }, p2: { amount: 80000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 600000, annualContribution: 20000, expectedReturn: 2 }, p2: {} },
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 5, capitalUseAmortizationPct: 20, capitalUseInvestPct: 70 }
|
||||
),
|
||||
el(
|
||||
"REAL_ESTATE",
|
||||
"HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 900000, mortgage: 600000, amortization: 5000, valueGrowth: 1 }, p2: {} },
|
||||
{ p1: { decision: "HOLD" } }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 4 }, p2: {} }),
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
describe("Wasserfall-Brücken", () => {
|
||||
for (const k of konstellationen) {
|
||||
it(`${k.name}: beide Brücken gehen ohne Restgrösse auf`, () => {
|
||||
const r = computePlan(k.build());
|
||||
expect(r.phases.length).toBeGreaterThan(0);
|
||||
for (const ph of r.phases) {
|
||||
expect(ph.wealthBridge.residual, `Vermögensbrücke ${ph.name}`).toBe(0);
|
||||
expect(ph.cashBridge.residual, `Cash-Brücke ${ph.name}`).toBe(0);
|
||||
// Die Kontrollpunkte müssen den offiziellen Kennzahlen entsprechen.
|
||||
expect(ph.wealthBridge.startWealth).toBe(ph.startWealthNominal);
|
||||
expect(ph.wealthBridge.endWealth).toBe(ph.endWealthNominal);
|
||||
expect(ph.cashBridge.cashStart).toBe(ph.cashStart);
|
||||
expect(ph.cashBridge.cashEnd).toBe(ph.cashEnd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("Umbuchungen erscheinen NICHT in der Vermögensbrücke", () => {
|
||||
// Sparraten und Amortisationen verschieben Geld vom Cash in einen Vermögenswert, ohne
|
||||
// das Vermögen zu ändern. Sie dürfen deshalb nur in der Cash-Brücke auftauchen; die
|
||||
// Vermögensänderung erklärt sich allein aus Quote, Erträgen und PK-Beiträgen.
|
||||
const r = computePlan(konstellationen[0].build());
|
||||
const ph = r.phases[0];
|
||||
expect(ph.cashBridge.savingRates).toBeGreaterThan(0);
|
||||
expect(ph.cashBridge.debtRates).toBeGreaterThan(0);
|
||||
const w = ph.wealthBridge;
|
||||
expect(w.endWealth - w.startWealth).toBe(
|
||||
w.quotaTotal + w.investmentReturn + w.propertyAppreciation + w.pensionFundContribution
|
||||
);
|
||||
});
|
||||
|
||||
it("PK-Verrentung erscheint als Vermögensabgang am Übergang", () => {
|
||||
// Das verrentete Kapital verlässt die Bilanz und wird zum Rentenstrom -- in der
|
||||
// Vermögensbrücke ein echter Abgang, kein Umbuchungsposten.
|
||||
const r = computePlan(konstellationen[1].build());
|
||||
const pension = r.phases[1];
|
||||
expect(pension.wealthBridge.pensionConversion).toBeGreaterThan(0);
|
||||
expect(pension.wealthBridge.startWealth).toBeLessThan(pension.wealthBridge.openingWealth);
|
||||
});
|
||||
|
||||
it("Verkaufsdifferenz und Steuer erscheinen nur beim Verkauf", () => {
|
||||
const gehalten = computePlan(konstellationen[3].build()).phases[1].wealthBridge;
|
||||
const verkauft = computePlan(konstellationen[4].build()).phases[1].wealthBridge;
|
||||
expect(gehalten.saleGainLoss).toBe(0);
|
||||
expect(gehalten.transitionTax).toBe(0);
|
||||
expect(verkauft.transitionTax).toBeGreaterThan(0);
|
||||
// Verkaufspreis über dem modellierten Verkehrswert -> positive Differenz.
|
||||
expect(verkauft.saleGainLoss).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kapitalverwendung am Pensions-Uebergang (Roadmap Nr. 44, Punkt C)", () => {
|
||||
const withQuoten = konstellationen.find((k) => k.name.startsWith("Kapitalverwendung"))!;
|
||||
|
||||
it("leitet den Zufluss gemaess Quote in Hypothek und Anlage statt ins Cash", () => {
|
||||
const mit = computePlan(withQuoten.build());
|
||||
// Vergleichsplan OHNE Verwendungs-Quoten: Die Quoten haengen seit 0.33 am PK-Element,
|
||||
// nicht mehr am Cash-Uebergang.
|
||||
const raw = withQuoten.build();
|
||||
const ohne = computePlan({
|
||||
...raw,
|
||||
elements: raw.elements.map((e) =>
|
||||
e.category === "PENSION_FUND"
|
||||
? { ...e, retirementDecision: { capitalSharePct: 100, capitalTaxRate: 5 } }
|
||||
: e
|
||||
),
|
||||
} as PlanInput);
|
||||
const p2Mit = mit.phases[1];
|
||||
const p2Ohne = ohne.phases[1];
|
||||
|
||||
// 90 % des Kapitals werden verwendet -> deutlich weniger Cash zu Beginn von Phase 2.
|
||||
expect(p2Mit.cashStart).toBeLessThan(p2Ohne.cashStart);
|
||||
// Die Hypothek ist tiefer, das Vermoegen dadurch hoeher.
|
||||
const immoMit = p2Mit.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
const immoOhne = p2Ohne.elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
expect(immoMit.mortgageStart).toBeLessThan(immoOhne.mortgageStart);
|
||||
// Das Sonstige Vermoegen startet hoeher.
|
||||
const assetMit = p2Mit.elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
const assetOhne = p2Ohne.elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect(assetMit.startValue).toBeGreaterThan(assetOhne.startValue);
|
||||
});
|
||||
|
||||
it("skaliert mit dem Pensionsalter, statt einen Frankenbetrag stehen zu lassen", () => {
|
||||
// Genau der Grund fuer Quoten statt Betraege: Ein Jahr laenger arbeiten heisst mehr
|
||||
// Kapital -- und damit automatisch mehr Amortisation und mehr Anlage.
|
||||
const base = withQuoten.build();
|
||||
const laenger: PlanInput = {
|
||||
...base,
|
||||
persons: base.persons.map((p) => ({ ...p, retirementAge: 66 })),
|
||||
phases: base.phases.map((p, i) =>
|
||||
i === 0 ? { ...p, durationYears: 6 } : { ...p, durationYears: 9 }
|
||||
),
|
||||
};
|
||||
const a = computePlan(base).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
const b = computePlan(laenger).phases[1].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
expect(b.mortgageStart).toBeLessThan(a.mortgageStart);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kapitalverwendung: Sichtbarkeit am Ziel-Element (0.33)", () => {
|
||||
// Genau der gemeldete Fehler: 100 % des PK-Kapitals auf den ETF -- danach zeigte das
|
||||
// Element als Zusatzinvestition eine 0, obwohl der Betrag eingeflossen war.
|
||||
function p(): PlanInput {
|
||||
return plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 400000, expectedReturn: 0 }, p2: {} },
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 50000, expectedReturn: 0 }, p2: {} }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("weist den umgeleiteten Betrag am Ziel-Element aus", () => {
|
||||
const c = computePlan(p());
|
||||
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
// 400'000 brutto, 0 % Steuer -> 400'000 netto, davon 100 % in den ETF.
|
||||
expect(etf.capitalFromTransfer).toBe(400000);
|
||||
expect(etf.startValue).toBe(450000); // 50'000 fortgeschrieben + 400'000
|
||||
});
|
||||
|
||||
it("zaehlt ihn als Investition in der Cash-Bruecke (nicht als Tilgung)", () => {
|
||||
const c = computePlan(p());
|
||||
expect(c.phases[1].cashBridge.investments).toBe(400000);
|
||||
expect(c.phases[1].cashBridge.residual).toBe(0);
|
||||
});
|
||||
|
||||
it("fliesst NUR einmal -- nicht in jeder Folgephase erneut", () => {
|
||||
const base = p();
|
||||
const drei: PlanInput = {
|
||||
...base,
|
||||
phases: [...base.phases, { id: "p3", sequenceNumber: 3, name: "p3", durationYears: 5, cashTransition: {} }],
|
||||
} as PlanInput;
|
||||
const c = computePlan(drei);
|
||||
const etfP3 = c.phases[2].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect(etfP3.capitalFromTransfer ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("verteilt zwei Guthaben getrennt (PK und 3a je eigene Quote)", () => {
|
||||
const base = p();
|
||||
const mit3a: PlanInput = {
|
||||
...base,
|
||||
elements: [
|
||||
...base.elements,
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 100000, expectedReturn: 0 }, p2: {} },
|
||||
{},
|
||||
{ capitalTaxRate: 0, capitalUseInvestPct: 50 }
|
||||
),
|
||||
],
|
||||
} as PlanInput;
|
||||
const c = computePlan(mit3a);
|
||||
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
// PK: 100 % von 400'000 = 400'000; 3a: 50 % von 100'000 = 50'000.
|
||||
expect(etf.capitalFromTransfer).toBe(450000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kapitalverwendung: Herkunft und ruhende Guthaben (0.34)", () => {
|
||||
// Der gemeldete Fall: PK und 3a fliessen beide in denselben ETF. Ohne Aufschluesselung
|
||||
// sieht man dort nur eine Summe und kann nicht pruefen, ob wirklich beide angekommen sind.
|
||||
function zweiQuellen(): PlanInput {
|
||||
return plan({
|
||||
age: 62,
|
||||
retirementAge: 65,
|
||||
initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 3 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000 } }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 90000 }, p2: { amount: 90000 } }),
|
||||
el(
|
||||
"PENSION_FUND",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 300000, expectedReturn: 0 }, p2: {} },
|
||||
{},
|
||||
{ capitalSharePct: 100, capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el(
|
||||
"PILLAR_3A",
|
||||
"PERSON_A",
|
||||
{ p1: { currentValue: 200000, expectedReturn: 0 }, p2: {} },
|
||||
{},
|
||||
{ capitalTaxRate: 0, capitalUseInvestPct: 100 }
|
||||
),
|
||||
el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 0, expectedReturn: 0 }, p2: {} }),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("weist beide Quellen einzeln aus", () => {
|
||||
const c = computePlan(zweiQuellen());
|
||||
const etf = c.phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect(etf.capitalFromTransfer).toBe(500000);
|
||||
const src = etf.capitalFromTransferSources ?? [];
|
||||
expect(src).toHaveLength(2);
|
||||
expect(src.reduce((a, x) => a + x.amount, 0)).toBe(500000);
|
||||
// Der Name traegt die Person -- sonst sind zwei gleichnamige Guthaben nicht trennbar.
|
||||
expect(src.every((x) => x.name.includes("Person A"))).toBe(true);
|
||||
expect(src.map((x) => x.amount).sort((a, b) => a - b)).toEqual([200000, 300000]);
|
||||
});
|
||||
|
||||
it("nennt die Person beim Namen, wenn eine erfasst ist", () => {
|
||||
const base = zweiQuellen();
|
||||
const benannt: PlanInput = {
|
||||
...base,
|
||||
persons: base.persons.map((x) => ({ ...x, name: "Jeanine" })),
|
||||
};
|
||||
const etf = computePlan(benannt).phases[1].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
expect((etf.capitalFromTransferSources ?? []).every((x) => x.name.includes("Jeanine"))).toBe(true);
|
||||
});
|
||||
|
||||
it("markiert bezogene PK und 3a als nicht mehr aufnahmefaehig", () => {
|
||||
// Grundlage der Verteil-Dialoge: Eine bezogene 3a darf dort keine Einzahlungszeile mehr
|
||||
// anbieten -- die Rechnung ignoriert solche Betraege, der Nutzer haelt sie fuer wirksam.
|
||||
const c = computePlan(zweiQuellen());
|
||||
const p2 = c.phases[1];
|
||||
expect(p2.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBe(false);
|
||||
expect(p2.elements.find((e) => e.category === "PENSION_FUND")!.acceptsCapital).toBe(false);
|
||||
// Vor der Pensionierung dagegen unbeschraenkt.
|
||||
const p1 = c.phases[0];
|
||||
expect(p1.elements.find((e) => e.category === "PILLAR_3A")!.acceptsCapital).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignoriert eine Einzahlung in die bezogene 3a auch rechnerisch", () => {
|
||||
// Die Zusicherung hinter dem Dialog-Filter: Selbst wenn der Wert in den Daten steht,
|
||||
// aendert er nichts -- Filter und Rechnung sagen dasselbe.
|
||||
const base = zweiQuellen();
|
||||
const mitRate: PlanInput = {
|
||||
...base,
|
||||
elements: base.elements.map((e) =>
|
||||
e.category === "PILLAR_3A"
|
||||
? { ...e, phaseValues: { ...e.phaseValues, p2: { annualContribution: 7000, additionalInvestment: 50000 } } }
|
||||
: e
|
||||
),
|
||||
};
|
||||
expect(computePlan(mitRate).phases[1].endWealthNominal).toBe(computePlan(base).phases[1].endWealthNominal);
|
||||
});
|
||||
});
|
||||
+643
-12
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import { ahvMonthlyFullPension, computePlan } from "@/lib/calculations";
|
||||
import type { CashTransitionData, ElementCategory, PhaseData, TransitionData } from "@/lib/elements";
|
||||
import type { RetirementDecision } from "@/lib/retirement-decision";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// --- kleine Bau-Helfer ---
|
||||
@@ -11,9 +12,20 @@ function el(
|
||||
category: ElementCategory,
|
||||
ownerRole: string | null,
|
||||
phaseValues: Record<string, PhaseData>,
|
||||
transitionValues: Record<string, TransitionData> = {}
|
||||
transitionValues: Record<string, TransitionData> = {},
|
||||
// Pensionierungs-Entscheid: hängt seit 0.34 am Element, nicht an einer Phasengrenze.
|
||||
retirementDecision: RetirementDecision = {}
|
||||
) {
|
||||
return { id: nid(), category, name: category, ownerRole: ownerRole as never, orderIndex: idc, phaseValues, transitionValues };
|
||||
return {
|
||||
id: nid(),
|
||||
category,
|
||||
name: category,
|
||||
ownerRole: ownerRole as never,
|
||||
orderIndex: idc,
|
||||
phaseValues,
|
||||
transitionValues,
|
||||
retirementDecision,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(opts: {
|
||||
@@ -21,7 +33,7 @@ function plan(opts: {
|
||||
retirementAge: number;
|
||||
inflation?: number;
|
||||
initialCash?: number;
|
||||
phases: { id: string; durationYears: number }[];
|
||||
phases: { id: string; durationYears: number; cashTransition?: CashTransitionData }[];
|
||||
elements: ReturnType<typeof el>[];
|
||||
household?: "SINGLE" | "COUPLE";
|
||||
}): PlanInput {
|
||||
@@ -32,15 +44,360 @@ function plan(opts: {
|
||||
inflationRateDefault: opts.inflation ?? 2,
|
||||
initialCash: opts.initialCash ?? 0,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: opts.age, retirementAge: opts.retirementAge }],
|
||||
phases: opts.phases.map((p, i) => ({ id: p.id, sequenceNumber: i + 1, name: p.id, durationYears: p.durationYears })),
|
||||
phases: opts.phases.map((p, i) => ({
|
||||
id: p.id,
|
||||
sequenceNumber: i + 1,
|
||||
name: p.id,
|
||||
durationYears: p.durationYears,
|
||||
cashTransition: p.cashTransition ?? {},
|
||||
})),
|
||||
elements: opts.elements,
|
||||
};
|
||||
}
|
||||
|
||||
const within = (actual: number, expected: number, pct: number) => Math.abs(actual - expected) <= Math.abs(expected) * pct;
|
||||
|
||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhoehung; Ausgaben = REALE Basis +
|
||||
// Stützstellen aus der AMTLICHEN Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44"
|
||||
// (BSV, gültig ab 1.1.2025/2026): mdJE -> monatliche Vollrente. Deckt Mindestrente,
|
||||
// Formel 1, den Wendepunkt (45'360), Formel 2 und die Maximalrente ab.
|
||||
const AHV_AMTLICHE_TABELLE: [number, number][] = [
|
||||
[15120, 1260], // Mindestrente (12 x R0)
|
||||
[16632, 1293],
|
||||
[22680, 1424],
|
||||
[30240, 1588],
|
||||
[43848, 1882],
|
||||
[45360, 1915], // Wendepunkt (36 x R0)
|
||||
[46872, 1935],
|
||||
[60480, 2117],
|
||||
[75600, 2318],
|
||||
[89208, 2500],
|
||||
[90720, 2520], // Maximalrente (72 x R0)
|
||||
];
|
||||
|
||||
describe("AHV-Rentenformel (Skala 44)", () => {
|
||||
it("reproduziert die amtliche Tabelle 318.117.1 exakt", () => {
|
||||
for (const [mdJE, erwartet] of AHV_AMTLICHE_TABELLE) {
|
||||
expect(Math.round(ahvMonthlyFullPension(mdJE)), `mdJE ${mdJE}`).toBe(erwartet);
|
||||
}
|
||||
});
|
||||
|
||||
it("kappt ausserhalb der Schwellen", () => {
|
||||
expect(ahvMonthlyFullPension(0)).toBe(1260); // unter 12 x R0 -> Mindestrente
|
||||
expect(ahvMonthlyFullPension(10000)).toBe(1260);
|
||||
expect(ahvMonthlyFullPension(150000)).toBe(2520); // über 72 x R0 -> Maximalrente
|
||||
});
|
||||
|
||||
it("ist am Wendepunkt stetig (kein Sprung zwischen Formel 1 und 2)", () => {
|
||||
const links = ahvMonthlyFullPension(45360 - 0.01);
|
||||
const rechts = ahvMonthlyFullPension(45360 + 0.01);
|
||||
expect(Math.abs(links - rechts)).toBeLessThan(0.01);
|
||||
});
|
||||
});
|
||||
|
||||
// V5-Modell: Einkommen = nominale Basis + nominale Lohnerhöhung; Ausgaben = REALE Basis +
|
||||
// reale Mehrausgaben, nominal = real x (plan-weite Inflation).
|
||||
// Netto -> Brutto für die AHV (AHV_GROSS_FROM_NET_FACTOR).
|
||||
const BRUTTO = 1.12;
|
||||
|
||||
describe("Teilverkauf Sonstiges Vermögen (Roadmap 42)", () => {
|
||||
function p42(decision: "HOLD" | "SELL" | "PARTIAL", amount?: number) {
|
||||
return plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1 },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [
|
||||
el(
|
||||
"OTHER_ASSET", "HOUSEHOLD",
|
||||
{ p1: { startValue: 100000, expectedReturn: 0, annualContribution: 0 }, p2: {} },
|
||||
{ p1: { decision, partialSaleAmount: amount } }
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
const asset = (pl: PlanInput, i: number) =>
|
||||
computePlan(pl).phases[i].elements.find((e) => e.category === "OTHER_ASSET")!;
|
||||
|
||||
it("Teilverkauf: Betrag fliesst ins Cash, Rest bleibt investiert", () => {
|
||||
const r = computePlan(p42("PARTIAL", 30000));
|
||||
expect(r.phases[1].capitalInflow).toBe(30000); // "zu investierendes Kapital" im Phasenkopf
|
||||
expect(r.phases[1].cashStart).toBe(30000);
|
||||
expect(asset(p42("PARTIAL", 30000), 1).startValue).toBe(70000); // Rest läuft weiter
|
||||
});
|
||||
|
||||
it("Teilverkauf am Endwert gekappt; Vollverkauf und Halten unverändert", () => {
|
||||
expect(computePlan(p42("PARTIAL", 999999)).phases[1].capitalInflow).toBe(100000);
|
||||
expect(computePlan(p42("SELL")).phases[1].capitalInflow).toBe(100000);
|
||||
expect(computePlan(p42("HOLD")).phases[1].capitalInflow).toBe(0);
|
||||
expect(asset(p42("HOLD"), 1).startValue).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sonderamortisation Hypothek (Roadmap 15)", () => {
|
||||
function p15(extra?: number) {
|
||||
return plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 500000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el(
|
||||
"REAL_ESTATE", "HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 800000, amortization: 0 }, p2: {} },
|
||||
{ p1: { decision: "HOLD", extraAmortization: extra } }
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
const immo = (pl: PlanInput, i: number) =>
|
||||
computePlan(pl).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
|
||||
it("Einmaltilgung senkt Restschuld und belastet das Cash", () => {
|
||||
const r = computePlan(p15(300000));
|
||||
expect(r.phases[1].capitalInvest).toBe(300000); // erscheint als Kapitalinvestition
|
||||
expect(r.phases[1].cashStart).toBe(200000); // 500'000 - 300'000
|
||||
// Netto-Immobilienwert steigt um die Tilgung: 1 Mio - (800k - 300k) = 500'000.
|
||||
expect(immo(p15(300000), 1).startValue).toBe(500000);
|
||||
});
|
||||
|
||||
it("Einmaltilgung an der Restschuld gekappt", () => {
|
||||
const r = computePlan(p15(999999));
|
||||
expect(r.phases[1].capitalInvest).toBe(800000); // nie mehr als die Resthypothek
|
||||
expect(immo(p15(999999), 1).startValue).toBe(1000000); // schuldenfrei
|
||||
});
|
||||
|
||||
it("ohne Sonderamortisation bleibt alles beim Alten", () => {
|
||||
expect(computePlan(p15()).phases[1].cashStart).toBe(500000);
|
||||
expect(immo(p15(), 1).startValue).toBe(200000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Immobilie: Zins und Wertsteigerung", () => {
|
||||
function immoPlan(pd: PhaseData, jahre = 10) {
|
||||
return plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 500000,
|
||||
phases: [{ id: "p1", durationYears: jahre }],
|
||||
elements: [el("REAL_ESTATE", "HOUSEHOLD", { p1: pd })],
|
||||
});
|
||||
}
|
||||
const immo = (p: PlanInput, i = 0) =>
|
||||
computePlan(p).phases[i].elements.find((e) => e.category === "REAL_ESTATE")!;
|
||||
|
||||
it("Wertsteigerung wirkt auf die LIEGENSCHAFT, nicht auf das Eigenkapital (Hebel)", () => {
|
||||
// Kaufpreis 1 Mio, Hypothek 900k -> EK 100k. 1%/J. auf die Liegenschaft, Amort. 10k/J.
|
||||
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 });
|
||||
const wertEnde = 1000000 * Math.pow(1.01, 10);
|
||||
const hypEnde = 900000 - 10000 * 10;
|
||||
expect(immo(p).startValue).toBe(100000);
|
||||
expect(immo(p).endValue).toBe(Math.round(wertEnde - hypEnde)); // ~304'622
|
||||
// Der Hebel: 1% auf 1 Mio sind rund 10% des Eigenkapitals -- nicht 1%.
|
||||
expect(immo(p).endValue).toBeGreaterThan(Math.round(100000 * Math.pow(1.01, 10) + 100000));
|
||||
});
|
||||
|
||||
it("ohne Wertsteigerung bleibt es beim bisherigen Verhalten", () => {
|
||||
const p = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 10000 });
|
||||
expect(immo(p).endValue).toBe(200000); // 1 Mio - 800k Resthypothek
|
||||
});
|
||||
|
||||
it("Hypothekarzins belastet das Cash nur bei 'ADD'", () => {
|
||||
const ohne = immoPlan({ purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1 });
|
||||
expect(computePlan(ohne).phases[0].cashEnd).toBe(500000); // Default INCLUDED -> kein Abzug
|
||||
|
||||
const mit = immoPlan({
|
||||
purchasePrice: 1000000, mortgage: 900000, amortization: 0, interestRate: 1, interestHandling: "ADD",
|
||||
});
|
||||
// 10 Jahre x 1% von 900'000 = 90'000
|
||||
expect(computePlan(mit).phases[0].cashEnd).toBe(500000 - 90000);
|
||||
});
|
||||
|
||||
it("Zinsbetrag sinkt mit der Amortisation und zählt in die Quote", () => {
|
||||
const p = immoPlan({
|
||||
purchasePrice: 1000000, mortgage: 1000000, amortization: 10000, interestRate: 1, interestHandling: "ADD",
|
||||
});
|
||||
const ph = computePlan(p).phases[0];
|
||||
// Jahr 1: 1% von 1'000'000 = 10'000. Jahr 10: 1% von (1'000'000 - 9 x 10'000) = 9'100.
|
||||
expect(ph.expenseStart).toBe(10000);
|
||||
expect(ph.expenseEnd).toBe(9100);
|
||||
expect(ph.quotaStart).toBe(-10000); // Zins schlägt auf die Quote durch
|
||||
});
|
||||
|
||||
it("Verkauf: Grundstückgewinnsteuer bemisst sich am ursprünglichen Kaufpreis", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [
|
||||
el(
|
||||
"REAL_ESTATE", "HOUSEHOLD",
|
||||
{ p1: { purchasePrice: 1000000, mortgage: 900000, amortization: 10000, valueGrowth: 1 }, p2: {} },
|
||||
{ p1: { decision: "SELL", salePrice: 1200000, saleTaxRate: 20 } }
|
||||
),
|
||||
],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
// Gewinn = 1'200'000 - 1'000'000 (Kaufpreis!) = 200'000 -> Steuer 40'000.
|
||||
// Erlös = 1'200'000 - 800'000 Resthypothek - 40'000 = 360'000.
|
||||
expect(r.phases[1].capitalInflow).toBe(360000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AHV einkommensabhängig", () => {
|
||||
// 60-jährig, Pension mit 65: 39 Beitragsjahre vor Planbeginn (ab 21), 5 im Plan.
|
||||
function ahvPlan(opts: {
|
||||
age?: number;
|
||||
income: number;
|
||||
avgIncomeBefore?: number;
|
||||
gapYearsBefore?: number;
|
||||
gapYearsInPlan?: number;
|
||||
reviewed?: boolean;
|
||||
}) {
|
||||
const age = opts.age ?? 60;
|
||||
return plan({
|
||||
age,
|
||||
retirementAge: 65,
|
||||
inflation: 0, // real = nominal, damit die Erwartungswerte von Hand prüfbar bleiben
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 65 - age },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: opts.income, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el(
|
||||
"AHV",
|
||||
"PERSON_A",
|
||||
{ p1: { gapYears: opts.gapYearsInPlan ?? 0 }, p2: {} },
|
||||
{},
|
||||
{
|
||||
confirmed: opts.reviewed ?? true,
|
||||
avgIncomeBefore: opts.avgIncomeBefore ?? 0,
|
||||
gapYearsBefore: opts.gapYearsBefore ?? 0,
|
||||
}
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const renteIn = (p: PlanInput, phaseIdx: number) =>
|
||||
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
|
||||
it("volle Karriere auf Maximalniveau -> Maximalrente 32'760", () => {
|
||||
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||
expect(renteIn(p, 1)).toBe(32760); // 2'520 x 13
|
||||
});
|
||||
|
||||
it("mdJE unter der Schwelle -> abgestufte Rente (Formel 2)", () => {
|
||||
// Plan-Einkommen ist NETTO -> für die AHV auf brutto hochgerechnet; das Feld
|
||||
// "vor Planbeginn" ist bereits brutto. 39 Jahre davor, 5 im Plan.
|
||||
const p = ahvPlan({ income: 60000, avgIncomeBefore: 60000 });
|
||||
const mdJE = (60000 * 39 + 60000 * BRUTTO * 5) / 44;
|
||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||
expect(renteIn(p, 1)).toBeLessThan(32760);
|
||||
});
|
||||
|
||||
it("Nettolohn wird für die AHV auf den Bruttolohn hochgerechnet", () => {
|
||||
// Ohne Vorgeschichte (Alter 21 bei Planbeginn) hängt das mdJE nur am Plan-Einkommen.
|
||||
const p = plan({
|
||||
age: 21,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 44 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 70000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true } }),
|
||||
],
|
||||
});
|
||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
// mdJE = 70'000 x 1.12 = 78'400 (brutto), NICHT 70'000.
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(70000 * BRUTTO) * 13));
|
||||
expect(rente).toBeGreaterThan(Math.round(ahvMonthlyFullPension(70000) * 13));
|
||||
});
|
||||
|
||||
it("tiefes Einkommen -> Mindestrente 1'260 x 13", () => {
|
||||
const p = ahvPlan({ income: 10000, avgIncomeBefore: 10000 });
|
||||
expect(renteIn(p, 1)).toBe(1260 * 13);
|
||||
});
|
||||
|
||||
it("Einkommen vor Planbeginn dominiert bei kurzer Restlaufzeit", () => {
|
||||
// 39 Jahre vor Planbeginn zu 40'000 brutto, nur 5 Jahre im Plan zu 200'000 netto.
|
||||
const p = ahvPlan({ income: 200000, avgIncomeBefore: 40000 });
|
||||
const mdJE = (40000 * 39 + 200000 * BRUTTO * 5) / 44;
|
||||
expect(renteIn(p, 1)).toBe(Math.round(ahvMonthlyFullPension(mdJE) * 13));
|
||||
});
|
||||
|
||||
it("Ausfalljahre kürzen die Rente über die Skala 44", () => {
|
||||
const ohne = ahvPlan({ income: 100000, avgIncomeBefore: 100000 });
|
||||
const mit = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsBefore: 4 });
|
||||
expect(renteIn(mit, 1)).toBe(Math.round(32760 * (40 / 44))); // 4 Ausfalljahre = 4/44 weniger
|
||||
expect(renteIn(mit, 1)).toBeLessThan(renteIn(ohne, 1));
|
||||
});
|
||||
|
||||
it("Ausfalljahre im Plan senken die Beitragsjahre, nicht das Durchschnittseinkommen", () => {
|
||||
// Gleiches Einkommen, aber 2 Ausfalljahre im Plan -> nur die Skala sinkt.
|
||||
const p = ahvPlan({ income: 100000, avgIncomeBefore: 100000, gapYearsInPlan: 2 });
|
||||
expect(renteIn(p, 1)).toBe(Math.round(32760 * (42 / 44)));
|
||||
});
|
||||
|
||||
it("ohne Prüfung gilt der geplante Durchschnitt auch für die Jahre vor Planbeginn", () => {
|
||||
// Kein avgIncomeBefore erfasst -> darf NICHT als 0 gerechnet werden (sonst mdJE ~45%).
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 80000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }), // keine Übergangsdaten
|
||||
],
|
||||
});
|
||||
const rente = computePlan(p).phases[1].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
// mdJE = geplantes Brutto (80'000 x 1.12), nicht ~9'091 (das wäre der Fallback auf 0).
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(80000 * BRUTTO) * 13));
|
||||
});
|
||||
|
||||
it("bereits bei Planbeginn pensioniert: Karriere kommt aus dem Pensionierungs-Entscheid", () => {
|
||||
const p = plan({
|
||||
age: 66,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [{ id: "p1", durationYears: 10 }],
|
||||
elements: [el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 60000, gapYearsBefore: 0 })],
|
||||
});
|
||||
const rente = computePlan(p).phases[0].elements.find((e) => e.category === "AHV")!.startValue;
|
||||
expect(rente).toBe(Math.round(ahvMonthlyFullPension(60000) * 13));
|
||||
});
|
||||
|
||||
it("Ehepaar-Plafonierung greift weiterhin (150% der Maximalrente)", () => {
|
||||
const p: PlanInput = {
|
||||
id: "plan", name: "T", householdType: "COUPLE", inflationRateDefault: 0, initialCash: 0,
|
||||
persons: [
|
||||
{ id: "A", role: "PERSON_A", name: null, age: 66, retirementAge: 65 },
|
||||
{ id: "B", role: "PERSON_B", name: null, age: 66, retirementAge: 65 },
|
||||
],
|
||||
phases: [{ id: "p1", sequenceNumber: 1, name: "p1", durationYears: 5, cashTransition: {} }],
|
||||
elements: [
|
||||
el("AHV", "PERSON_A", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
|
||||
el("AHV", "PERSON_B", { p1: {} }, {}, { avgIncomeBefore: 100000 }),
|
||||
],
|
||||
};
|
||||
const ph = computePlan(p).phases[0];
|
||||
const summe = ph.elements.filter((e) => e.category === "AHV").reduce((s, e) => s + e.startValue, 0);
|
||||
expect(summe).toBe(Math.round(32760 * 1.5)); // plafoniert, nicht 2 x 32'760
|
||||
});
|
||||
});
|
||||
|
||||
describe("V5 Golden Tests", () => {
|
||||
it("Test 1 – Ansparen (Einkommen +2% nominal, Ausgaben real flach -> nominal +2%)", () => {
|
||||
const p = plan({
|
||||
@@ -117,7 +474,7 @@ describe("V5 Golden Tests", () => {
|
||||
expect(ph.expenseEnd).toBe(Math.round(100000 * Math.pow(1.02, 4))); // 108'243
|
||||
});
|
||||
|
||||
it("Einkommen nominal flach bei Lohnerhoehung 0%", () => {
|
||||
it("Einkommen nominal flach bei Lohnerhöhung 0%", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 60,
|
||||
@@ -129,7 +486,7 @@ describe("V5 Golden Tests", () => {
|
||||
expect(ph.incomeEnd).toBe(80000);
|
||||
});
|
||||
|
||||
it("Bezugsrate aus Sonstigem Vermoegen fliesst ins Cash (Verzehrrate)", () => {
|
||||
it("Bezugsrate aus Sonstigem Vermögen fliesst ins Cash (Verzehrrate)", () => {
|
||||
const p = plan({
|
||||
age: 65,
|
||||
retirementAge: 65,
|
||||
@@ -160,11 +517,11 @@ describe("V5 Golden Tests", () => {
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const p2 = r.phases[1];
|
||||
expect(p2.capitalInflow).toBe(10000); // Verkaufserloes aus dem Uebergang
|
||||
expect(p2.capitalInflow).toBe(10000); // Verkaufserlös aus dem Übergang
|
||||
expect(p2.capitalInvest).toBe(10000); // Zusatzinvestition
|
||||
expect(p2.cashStart).toBe(0); // Startwert bereits nach Abzug der Investition
|
||||
expect(p2.cashEnd).toBe(0);
|
||||
// Startvermoegen Phase 2: Cash(0) + Vermoegen B(10'000) = 10'000 (kein Doppelzaehlen).
|
||||
// Startvermögen Phase 2: Cash(0) + Vermögen B(10'000) = 10'000 (kein Doppelzählen).
|
||||
expect(p2.startWealthNominal).toBe(10000);
|
||||
});
|
||||
|
||||
@@ -251,7 +608,123 @@ describe("V5 Golden Tests", () => {
|
||||
expect(pk.startValue).toBe(200000); // brutto 100'000 dem Kapital entnommen
|
||||
});
|
||||
|
||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash laeuft fort", () => {
|
||||
it("Einmaliger Zufluss am Übergang: nominal erfasst, Steuer abgezogen, direkt ins Cash", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
initialCash: 1000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 2, cashTransition: { mode: "INFLOW", inflowLabel: "Erbschaft", inflowAmount: 100000, inflowTaxRate: 10 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.phases[1].oneOffInflow).toBe(90000); // 100'000 abzüglich 10% Steuer
|
||||
expect(r.phases[1].oneOffInflowLabel).toBe("Erbschaft");
|
||||
expect(r.phases[1].cashStart).toBe(91000); // 1'000 + 90'000
|
||||
expect(r.phases[0].oneOffInflow).toBe(0); // Phase 1 hat keinen eingehenden Übergang
|
||||
});
|
||||
|
||||
it("Einmalige Kosten am Übergang: real erfasst, mit Inflation aufgewertet", () => {
|
||||
// Kosten 20'000 real, 2% Inflation, Grenze nach 10 Jahren -> 20'000 x 1.02^10 = 24'380.
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
inflation: 2,
|
||||
initialCash: 100000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10, cashTransition: { mode: "OUTFLOW", outflowLabel: "Poolbau", outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
const erwartet = Math.round(20000 * Math.pow(1.02, 10));
|
||||
expect(r.phases[1].oneOffOutflow).toBe(erwartet);
|
||||
expect(r.phases[1].oneOffOutflowLabel).toBe("Poolbau");
|
||||
expect(r.phases[1].cashStart).toBe(100000 - erwartet);
|
||||
});
|
||||
|
||||
it("Zufluss und Kosten zusammen (BOTH); Modus NONE bleibt wirkungslos", () => {
|
||||
const beide = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "BOTH", inflowAmount: 50000, outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const r1 = computePlan(beide);
|
||||
expect(r1.phases[1].cashStart).toBe(30000); // +50'000 -20'000
|
||||
|
||||
// Beträge sind erfasst, aber der Entscheid lautet "1:1 übernehmen" -> keine Wirkung.
|
||||
const keine = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "NONE", inflowAmount: 50000, outflowAmount: 20000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
expect(computePlan(keine).phases[1].cashStart).toBe(0);
|
||||
});
|
||||
|
||||
it("Einmalige Kosten können eine Liquiditätslücke auslösen", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 10000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 1, cashTransition: { mode: "OUTFLOW", outflowAmount: 25000 } },
|
||||
{ id: "p2", durationYears: 1 },
|
||||
],
|
||||
elements: [],
|
||||
});
|
||||
const p2 = computePlan(p).phases[1];
|
||||
expect(p2.cashStart).toBe(-15000);
|
||||
expect(p2.cashNegative).toBe(true);
|
||||
expect(p2.incomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("Cash-Entscheid der LETZTEN Phase bleibt wirkungslos (kein Übergang mehr)", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 5000,
|
||||
phases: [{ id: "p1", durationYears: 1, cashTransition: { mode: "INFLOW", inflowAmount: 999999 } }],
|
||||
elements: [],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.phases[0].cashEnd).toBe(5000);
|
||||
expect(r.nachlass).toBe(5000);
|
||||
});
|
||||
|
||||
it("Vermögen wird pro JAHR mitgeführt (Grundlage des Verlaufs über alle Jahre)", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 0, initialCash: 0,
|
||||
phases: [{ id: "p1", durationYears: 3 }],
|
||||
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 10, annualContribution: 0 } })],
|
||||
});
|
||||
const r = computePlan(p);
|
||||
expect(r.yearly).toHaveLength(3); // ein Punkt je Jahr, nicht nur je Phase
|
||||
expect(r.yearly[0].wealthNominal).toBe(110000);
|
||||
expect(r.yearly[1].wealthNominal).toBe(121000);
|
||||
expect(r.yearly[2].wealthNominal).toBe(133100);
|
||||
// Ohne Inflation sind real und nominal identisch; Endjahr = Phasen-Endvermögen.
|
||||
expect(r.yearly[2].wealthReal).toBe(133100);
|
||||
expect(r.yearly[2].wealthNominal).toBe(r.phases[0].endWealthNominal);
|
||||
});
|
||||
|
||||
it("Vermögen real ist bei Inflation kaufkraftbereinigt", () => {
|
||||
const p = plan({
|
||||
age: 40, retirementAge: 70, inflation: 10, initialCash: 100000,
|
||||
phases: [{ id: "p1", durationYears: 1 }],
|
||||
elements: [],
|
||||
});
|
||||
const y = computePlan(p).yearly[0];
|
||||
expect(y.wealthNominal).toBe(100000);
|
||||
expect(y.wealthReal).toBe(Math.round(100000 / 1.1)); // 90'909
|
||||
});
|
||||
|
||||
it("Fortschreibung: nominaler Einkommens-Basiswert Phase 1 -> Startwert Phase 2; Cash läuft fort", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 70,
|
||||
@@ -271,3 +744,161 @@ describe("V5 Golden Tests", () => {
|
||||
expect(r.phases[1].cashStart).toBe(r.phases[0].cashEnd);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Roadmap Nr. 44: Referenzalter, Beitragspflicht, Einkommen bei Pensionierung ---
|
||||
|
||||
describe("AHV-Referenzalter (Kap. 4.4.6)", () => {
|
||||
// Person wird mit 60 pensioniert, die AHV-Rente beginnt trotzdem erst mit 65.
|
||||
function fruehPlan(beitrag: number) {
|
||||
return plan({
|
||||
age: 55,
|
||||
retirementAge: 60,
|
||||
inflation: 0,
|
||||
initialCash: 500000,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 }, // Alter 55-60, erwerbstaetig
|
||||
{ id: "p2", durationYears: 15 }, // Alter 60-75, pensioniert
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("EXPENSE", "HOUSEHOLD", { p1: { amount: 60000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el(
|
||||
"AHV",
|
||||
"PERSON_A",
|
||||
{ p1: {}, p2: { ahvContribution: beitrag } },
|
||||
{ p1: { reviewed: true, avgIncomeBefore: 100000, gapYearsBefore: 0 } }
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const ahvYearly = (p: PlanInput, phaseIdx: number) =>
|
||||
computePlan(p).phases[phaseIdx].elements.find((e) => e.category === "AHV")!.yearly.map((y) => y.value);
|
||||
|
||||
it("zahlt vor 65 den Beitrag und ab 65 die Rente", () => {
|
||||
const werte = ahvYearly(fruehPlan(2000), 1);
|
||||
// Phase 2 laeuft von Alter 60 bis 75: 5 Jahre Beitrag (60-64), dann 10 Jahre Rente.
|
||||
expect(werte.slice(0, 5)).toEqual([-2000, -2000, -2000, -2000, -2000]);
|
||||
expect(new Set(werte.slice(5))).toEqual(new Set([32760]));
|
||||
});
|
||||
|
||||
it("belastet den Beitrag als Kosten -- ohne Beitrag bleibt mehr Vermoegen", () => {
|
||||
const mit = computePlan(fruehPlan(20000));
|
||||
const ohne = computePlan(fruehPlan(0));
|
||||
const endeMit = mit.phases[1].endWealthNominal;
|
||||
const endeOhne = ohne.phases[1].endWealthNominal;
|
||||
expect(endeOhne).toBeGreaterThan(endeMit);
|
||||
// 5 Beitragsjahre x 20'000 = 100'000 Unterschied (zzgl. entgangener Rendite darauf).
|
||||
expect(endeOhne - endeMit).toBeGreaterThanOrEqual(100000);
|
||||
});
|
||||
|
||||
it("laesst die Rente auch waehrend der Erwerbstaetigkeit ab 65 fliessen", () => {
|
||||
// Pension erst mit 70 -- die Rente muss trotzdem ab 65 kommen.
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 70,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 }, // Alter 60-70, erwerbstaetig (deckt 65 ab)
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
|
||||
],
|
||||
});
|
||||
const werte = ahvYearly(p, 0);
|
||||
// Jahre 1-5 (Alter 60-64): nichts, danach die Rente.
|
||||
expect(werte.slice(0, 5)).toEqual([0, 0, 0, 0, 0]);
|
||||
expect(werte[5]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("Pensionierung mit genau 65 bleibt unveraendert", () => {
|
||||
const p = plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [
|
||||
el("INCOME", "PERSON_A", { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} }),
|
||||
el("AHV", "PERSON_A", { p1: {}, p2: {} }, { p1: { reviewed: true, avgIncomeBefore: 100000 } }),
|
||||
],
|
||||
});
|
||||
const werte = ahvYearly(p, 1);
|
||||
expect(werte).toHaveLength(10);
|
||||
expect(new Set(werte)).toEqual(new Set([32760]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Einkommen bei Pensionierung (Roadmap Nr. 44, Punkt B)", () => {
|
||||
function lohnPlan(p2: PhaseData, owner: "PERSON_A" | "HOUSEHOLD") {
|
||||
return plan({
|
||||
age: 60,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
household: "COUPLE", // sonst zaehlt "Gemeinsam" zur Person A
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 5 },
|
||||
{ id: "p2", durationYears: 5 },
|
||||
],
|
||||
elements: [el("INCOME", owner, { p1: { amount: 100000, teuerungsausgleich: 0 }, p2 })],
|
||||
});
|
||||
}
|
||||
const lohn = (p: PlanInput) => computePlan(p).phases[1].elements[0].startValue;
|
||||
|
||||
it("faellt ein personenbezogener Lohn ohne eigenen Wert auf 0", () => {
|
||||
expect(lohn(lohnPlan({}, "PERSON_A"))).toBe(0);
|
||||
});
|
||||
|
||||
it("bleibt bestehen, wenn ausdruecklich ein Betrag erfasst ist (Teilzeit)", () => {
|
||||
expect(lohn(lohnPlan({ amount: 30000, teuerungsausgleich: 0 }, "PERSON_A"))).toBe(30000);
|
||||
});
|
||||
|
||||
it("laesst gemeinsame Einkommen unberuehrt", () => {
|
||||
expect(lohn(lohnPlan({}, "HOUSEHOLD"))).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Wiederkehr-Parameter aus der Vorphase (Roadmap Nr. 44, Punkt A)", () => {
|
||||
function assetPlan(p2: PhaseData) {
|
||||
return plan({
|
||||
age: 40,
|
||||
retirementAge: 65,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
],
|
||||
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2 })],
|
||||
});
|
||||
}
|
||||
const endwert = (p: PlanInput) => computePlan(p).phases[1].elements[0].endValue;
|
||||
|
||||
it("uebernimmt eine nicht erfasste Rendite aus der Vorphase", () => {
|
||||
// Ohne Vererbung waere Phase 2 zinslos -- der Endwert bliebe auf dem Stand von Phase 1.
|
||||
// Rundung je Phase -> auf den Franken genau vergleichen waere zu streng.
|
||||
expect(endwert(assetPlan({}))).toBeCloseTo(100000 * Math.pow(1.05, 20), -1);
|
||||
});
|
||||
|
||||
it("laesst einen ausdruecklich erfassten Wert gewinnen", () => {
|
||||
expect(endwert(assetPlan({ expectedReturn: 0 }))).toBe(Math.round(100000 * Math.pow(1.05, 10)));
|
||||
});
|
||||
|
||||
it("traegt die Vererbung ueber mehrere Phasen", () => {
|
||||
const p = plan({
|
||||
age: 40,
|
||||
retirementAge: 75,
|
||||
inflation: 0,
|
||||
phases: [
|
||||
{ id: "p1", durationYears: 10 },
|
||||
{ id: "p2", durationYears: 10 },
|
||||
{ id: "p3", durationYears: 10 },
|
||||
],
|
||||
elements: [el("OTHER_ASSET", "HOUSEHOLD", { p1: { startValue: 100000, expectedReturn: 5 }, p2: {}, p3: {} })],
|
||||
});
|
||||
expect(computePlan(p).phases[2].elements[0].endValue).toBeCloseTo(100000 * Math.pow(1.05, 30), -1);
|
||||
});
|
||||
});
|
||||
|
||||
+1629
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
// Verwendung des bezogenen Alterskapitals: das Tilgungs-Ziel ist wählbar (0.41).
|
||||
//
|
||||
// Bis 0.40 floss die Tilgungs-Quote stur in die erstbeste Immobilie. Wer neben einer Hypothek
|
||||
// zu 1,5 % einen Konsumkredit zu 6 % trägt, will aber zuerst den Kredit los -- und hatte keine
|
||||
// Möglichkeit, das zu sagen.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
function plan(debtTarget?: string): PlanInput {
|
||||
return {
|
||||
id: "plan",
|
||||
name: "T",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 0,
|
||||
initialCash: 0,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: null, age: 64, retirementAge: 65 }],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 1, cashTransition: { mode: "NONE" } },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 5, cashTransition: { mode: "NONE" } },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "pk",
|
||||
category: "PENSION_FUND" as never,
|
||||
name: "PK",
|
||||
ownerRole: "PERSON_A" as never,
|
||||
orderIndex: 1,
|
||||
baseData: { currentValue: 400000, expectedReturn: 0 },
|
||||
phaseValues: { p1: {}, p2: {} },
|
||||
transitionValues: {},
|
||||
// Volles Kapital, keine Steuer, alles in die Tilgung: 400'000 stehen zur Verfügung.
|
||||
retirementDecision: {
|
||||
capitalSharePct: 100,
|
||||
capitalTaxRate: 0,
|
||||
conversionRate: 6,
|
||||
capitalUseAmortizationPct: 100,
|
||||
...(debtTarget ? { capitalUseDebtTargetElementId: debtTarget } : {}),
|
||||
} as never,
|
||||
},
|
||||
{
|
||||
id: "haus",
|
||||
category: "REAL_ESTATE" as never,
|
||||
name: "Haus",
|
||||
ownerRole: "HOUSEHOLD" as never,
|
||||
orderIndex: 2,
|
||||
baseData: { purchasePrice: 900000, mortgage: 300000, interestRate: 1.5, valueGrowth: 0 },
|
||||
phaseValues: { p1: {}, p2: {} },
|
||||
transitionValues: { p1: { decision: "HOLD" as never } },
|
||||
},
|
||||
{
|
||||
id: "kredit",
|
||||
category: "OTHER_DEBT" as never,
|
||||
name: "Konsumkredit",
|
||||
ownerRole: "HOUSEHOLD" as never,
|
||||
orderIndex: 3,
|
||||
baseData: { startValue: 100000 },
|
||||
phaseValues: { p1: {}, p2: {} },
|
||||
transitionValues: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const restOf = (p: PlanInput, id: string) => {
|
||||
const c = computePlan(p);
|
||||
const ph = c.phases[1];
|
||||
const ce = ph.elements.find((e) => e.elementId === id)!;
|
||||
return id === "haus" ? ce.mortgageStart : Math.abs(ce.startValue);
|
||||
};
|
||||
|
||||
describe("Schuldentilgung aus dem Alterskapital", () => {
|
||||
it("tilgt ohne Wahl weiterhin die Hypothek", () => {
|
||||
// Bestandsschutz: Pläne aus der Zeit vor der Zielwahl müssen unverändert rechnen.
|
||||
const p = plan();
|
||||
expect(restOf(p, "haus")).toBe(0);
|
||||
expect(restOf(p, "kredit")).toBe(100000);
|
||||
});
|
||||
|
||||
it("tilgt den gewählten Kredit zuerst", () => {
|
||||
const p = plan("kredit");
|
||||
expect(restOf(p, "kredit")).toBe(0);
|
||||
});
|
||||
|
||||
it("lässt eine nicht gewählte Schuld unberührt, wenn die Hypothek gewählt ist", () => {
|
||||
const p = plan("haus");
|
||||
expect(restOf(p, "haus")).toBe(0);
|
||||
expect(restOf(p, "kredit")).toBe(100000);
|
||||
});
|
||||
});
|
||||
+283
-13
@@ -1,24 +1,294 @@
|
||||
// Konfigurierbare Systemparameter (Stand 2026). Aendern sich periodisch durch
|
||||
// Anpassungen des Bundes -- deshalb hier zentral gefuehrt, nicht im Code verteilt.
|
||||
// Konfigurierbare Systemparameter (Stand 2026). Ändern sich periodisch durch
|
||||
// Anpassungen des Bundes -- deshalb hier zentral geführt, nicht im Code verteilt.
|
||||
|
||||
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente (2'520/Monat × 13 = 32'760).
|
||||
// Quelle: BSV / AHV-IV 2026.
|
||||
export const AHV_MAX_ANNUAL_SINGLE = 32760;
|
||||
// R0 = Mindestbetrag der vollen monatlichen Altersrente (Skala 44). Alle Schwellen der
|
||||
// Rentenformel sind Vielfache von R0 -- ändert sich R0, verschiebt sich die ganze Skala.
|
||||
// Quelle: BSV/MAS "Berechnungsvorschriften der AHV/IV-Renten", gültig ab 1.1.2026;
|
||||
// amtliche Tabelle 318.117.1 "Monatliche Vollrenten, Skala 44".
|
||||
export const AHV_MIN_MONTHLY_FULL = 1260;
|
||||
|
||||
// Anzahl Rentenzahlungen pro Jahr. Ab 1.1.2026 gibt es die 13. Altersrente (Art. 34bis AHVG),
|
||||
// deshalb 13 statt 12.
|
||||
export const AHV_PENSION_MONTHS = 13;
|
||||
|
||||
// Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag -- faktisch ab Alter 21.
|
||||
export const AHV_CONTRIBUTION_START_AGE = 21;
|
||||
|
||||
// Referenzalter: Ab hier fliesst die AHV-Rente -- UNABHAENGIG vom gewaehlten Pensionsalter.
|
||||
// Wer frueher aufhoert, bleibt bis dahin beitragspflichtig (als Nichterwerbstaetige(r)); wer
|
||||
// laenger arbeitet, erhaelt die Rente trotzdem ab diesem Alter (siehe SPEZIFIKATION 4.4.6).
|
||||
export const AHV_REFERENCE_AGE = 65;
|
||||
|
||||
// Maximale einfache AHV-Altersrente pro Jahr, inkl. 13. Rente: 2 x R0 x 13 = 32'760.
|
||||
// Abgeleitet, damit eine Anpassung von R0 nicht an zwei Stellen nachgezogen werden muss.
|
||||
export const AHV_MAX_ANNUAL_SINGLE = 2 * AHV_MIN_MONTHLY_FULL * AHV_PENSION_MONTHS;
|
||||
|
||||
// --- Flexibler Rentenbezug (AHV 21) --------------------------------------------------------
|
||||
//
|
||||
// Vorbezug: fruehestens 3 Jahre vor dem Referenzalter, seit AHV 21 in MONATSschritten.
|
||||
// Die Kuerzung ist lebenslang und betraegt 6.8 % pro vorbezogenem Jahr; Monate anteilig.
|
||||
// Die seit 1.1.2025 tieferen, einkommensabhaengigen Saetze fuer Frauen der
|
||||
// Uebergangsgeneration (Jahrgaenge 1961-1969) sind BEWUSST nicht abgebildet -- sie laufen
|
||||
// aus, und ihre Nachbildung erforderte eine zweite, jahrgangsabhaengige Rentenformel.
|
||||
// Quelle: Merkblatt 3.04 "Flexibler Rentenbezug", Stand 1.1.2026.
|
||||
export const AHV_EARLY_MAX_MONTHS = 36;
|
||||
export const AHV_EARLY_REDUCTION_PER_YEAR = 6.8;
|
||||
|
||||
// Aufschub: 1 bis 5 Jahre. Der Zuschlag ist NICHT linear -- er steigt ueberproportional,
|
||||
// weil sich die Bezugsdauer verkuerzt. Amtliche Stuetzwerte je volles Jahr; fuer Monate
|
||||
// dazwischen wird linear interpoliert (die amtliche Tabelle ist monatsgenau, die Abweichung
|
||||
// liegt im Zehntelprozent-Bereich und damit weit unter der Unschaerfe der Rentenschaetzung).
|
||||
export const AHV_DEFER_MIN_MONTHS = 12;
|
||||
export const AHV_DEFER_MAX_MONTHS = 60;
|
||||
export const AHV_DEFER_BONUS_BY_YEAR = [0, 5.2, 10.8, 17.1, 24.0, 31.5];
|
||||
|
||||
// Teilbezug/Teilaufschub: zwischen 20 und 80 Prozent der Rente.
|
||||
export const AHV_PARTIAL_MIN_PCT = 20;
|
||||
export const AHV_PARTIAL_MAX_PCT = 80;
|
||||
|
||||
// --- Saeule 3a: Bezugsfenster ---------------------------------------------------------------
|
||||
//
|
||||
// Fruehestens 5 Jahre vor dem Referenzalter (Art. 3 BVV3). Spaetestens beim Referenzalter --
|
||||
// aufschiebbar bis 70, solange eine Erwerbstaetigkeit besteht. Ein Konto laesst sich bei der
|
||||
// Pensionierung nur GANZ aufloesen; gestaffelt wird ueber mehrere Konten.
|
||||
export const PILLAR_3A_MIN_WITHDRAWAL_AGE = AHV_REFERENCE_AGE - 5;
|
||||
export const PILLAR_3A_MAX_WITHDRAWAL_AGE = AHV_REFERENCE_AGE + 5;
|
||||
|
||||
// --- Pensionskasse: Bezugsfenster -----------------------------------------------------------
|
||||
//
|
||||
// Gesetzliches Mindestalter fuer die vorzeitige Pensionierung (Art. 1i BVV2). Viele Reglemente
|
||||
// setzen erst bei 60 an -- deshalb Hinweis statt Sperre.
|
||||
export const PK_MIN_RETIREMENT_AGE = 58;
|
||||
export const PK_MAX_RETIREMENT_AGE = AHV_REFERENCE_AGE + 5;
|
||||
|
||||
// Sperrfrist nach einem Einkauf, innerhalb derer ein Kapitalbezug den Steuerabzug
|
||||
// nachtraeglich entfallen laesst (Art. 79b Abs. 3 BVG). Reines Hinweis-Mass: Das Tool kennt
|
||||
// keine Einkaeufe und kann die Frist deshalb nicht selbst pruefen.
|
||||
export const PK_BUYIN_BLOCKING_YEARS = 3;
|
||||
|
||||
// --- Planungshorizont -----------------------------------------------------------------------
|
||||
//
|
||||
// Bis zu welchem Alter gerechnet wird. Bisher ergab sich das Planende stillschweigend aus der
|
||||
// Summe der Phasendauern -- zwei Szenarien konnten dadurch unbemerkt verschieden weit rechnen
|
||||
// und waren nicht vergleichbar. Der Default liegt bewusst ueber der Lebenserwartung: Eine zu
|
||||
// kurze Planung sieht tragfaehig aus, obwohl das Geld nur nicht lange genug reichen muss.
|
||||
// Gefuehrt wird die Laufzeit in JAHREN und nicht als Endalter je Person: eine Zahl statt
|
||||
// zweier, die bei einem Paar auseinanderlaufen koennten. Die Endalter sind die Ableitung.
|
||||
export const DEFAULT_PLANNING_HORIZON_YEARS = 35;
|
||||
export const MIN_PLANNING_HORIZON_YEARS = 1;
|
||||
export const MAX_PLANNING_HORIZON_YEARS = 70;
|
||||
|
||||
// Umrechnung Netto- -> Bruttolohn für die AHV. Das Tool erfasst das Einkommen NETTO (so
|
||||
// denkt der Nutzer, und so stimmt der Cash-Fluss), die AHV bemisst sich aber am BRUTTOlohn.
|
||||
//
|
||||
// Herleitung (Arbeitnehmer-Abzüge in % des Bruttolohns):
|
||||
// AHV/IV/EO 5.30 % (10.6 % total, hälftig geteilt)
|
||||
// ALV 1.10 % ( 2.2 % total, hälftig geteilt, bis 148'200)
|
||||
// NBU ~1.00 % (variiert 0.4-1.6 %, teils vom Arbeitgeber getragen)
|
||||
// PK ~2-5 % (BVG-Altersgutschrift 7/10/15/18 % auf den KOORDINIERTEN Lohn
|
||||
// [Brutto - 26'460, max. 90'720], Arbeitnehmer >= die Hälfte)
|
||||
// -> total ~9-13 % je nach Alter und Lohn; Faktor 1/(1-q) = 1.10 bis 1.16, Mittel ~1.12.
|
||||
//
|
||||
// Ein fixer Faktor ist hier vertretbar, weil das mdJE selbst ein Durchschnitt über die ganze
|
||||
// Beitragskarriere (44 Jahre) ist: der altersabhängige PK-Satz mittelt sich dabei heraus.
|
||||
// Die Restunschärfe (~3 %) ist deutlich kleiner als der Fehler, den die Umrechnung behebt (~11 %).
|
||||
//
|
||||
// Quellen: BSV "Beträge gültig ab 1.1.2026" (Koordinationsabzug 26'460, obere Limite 90'720,
|
||||
// keine Änderung gegenüber 2025); AHV-Merkblätter 2.01 (AHV/IV/EO) und 2.08 (ALV).
|
||||
export const AHV_GROSS_FROM_NET_FACTOR = 1.12;
|
||||
|
||||
// Ehepaar-Plafonierung: die Summe beider Einzelrenten ist auf 150% der Einzel-
|
||||
// Maximalrente begrenzt. Bei Ueberschreitung werden beide Renten proportional gekuerzt.
|
||||
// Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.
|
||||
export const AHV_COUPLE_CAP_FACTOR = 1.5;
|
||||
|
||||
// Volle Beitragsdauer fuer eine ungekuerzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
||||
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekuerzt.
|
||||
// Volle Beitragsdauer für eine ungekürzte AHV-Rente (Rentenskala 44). Pro fehlendes
|
||||
// Beitragsjahr (Ausfalljahr) wird die Rente um 1/44 gekürzt.
|
||||
export const AHV_FULL_CONTRIBUTION_YEARS = 44;
|
||||
|
||||
// Maximaler jaehrlicher Saeule-3a-Beitrag fuer PK-Versicherte (2026).
|
||||
// Maximaler jährlicher Säule-3a-Beitrag für PK-Versicherte (2026).
|
||||
export const PILLAR_3A_MAX_ANNUAL = 7258;
|
||||
|
||||
// Default-Annahmen fuer Uebergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
|
||||
// Maximaler jährlicher Säule-3a-Beitrag für Selbstständigerwerbende OHNE Pensionskasse
|
||||
// («grosse Säule 3a»): 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag.
|
||||
// TODO(Verifikation): Wert gegen die aktuellen BSV-Angaben für 2026 prüfen -- 2025 galt
|
||||
// CHF 36'288. Das Tool klammert das Eingabefeld hart auf diesen Deckel; die 20-%-Regel
|
||||
// selbst wird (noch) nicht geprüft.
|
||||
export const PILLAR_3A_MAX_SELF_EMPLOYED = 36288;
|
||||
|
||||
// Default-Annahmen für Übergangs-Ereignisse (pro Ereignis editierbar). Werden SOWOHL als
|
||||
// UI-Vorschlag als auch in der Berechnung als Fallback verwendet -- damit ein nicht
|
||||
// angetupfter Wert nicht faelschlich als 0 gerechnet wird.
|
||||
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jaehrliche Rente = Kapital x Satz)
|
||||
// angetupfter Wert nicht fälschlich als 0 gerechnet wird.
|
||||
export const DEFAULT_PK_CONVERSION_RATE = 6; // % (jährliche Rente = Kapital x Satz)
|
||||
export const DEFAULT_CAPITAL_TAX_RATE = 8; // % Kapitalbezugssteuer (PK-/3a-Kapitalbezug)
|
||||
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstueckgewinnsteuer
|
||||
export const DEFAULT_PROPERTY_GAINS_TAX_RATE = 20; // % Grundstückgewinnsteuer
|
||||
|
||||
// --- Strukturierte Fassung für die Systemparameter-Ansicht (Roadmap Nr. 41) -------------
|
||||
//
|
||||
// Dieselben Werte wie oben, nur maschinenlesbar beschrieben: Damit kann die Oberfläche
|
||||
// Wert, Bedeutung, Herleitung und Quelle anzeigen, OHNE dass die Zahlen ein zweites Mal
|
||||
// gepflegt werden müssen -- die Einträge referenzieren die Konstanten direkt und können
|
||||
// deshalb nicht von ihnen abdriften.
|
||||
|
||||
export type ParameterGroup = "AHV" | "Vorsorge" | "Steuern";
|
||||
|
||||
export interface SystemParameter {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: "CHF" | "%" | "Jahre" | "Anzahl";
|
||||
meaning: string;
|
||||
derivation?: string; // nur bei abgeleiteten Werten
|
||||
source: string;
|
||||
validFrom: string;
|
||||
group: ParameterGroup;
|
||||
editablePerEvent?: boolean; // Default-Satz, pro Ereignis überschreibbar
|
||||
}
|
||||
|
||||
export const SYSTEM_PARAMETERS: SystemParameter[] = [
|
||||
{
|
||||
key: "AHV_MIN_MONTHLY_FULL",
|
||||
label: "Mindestbetrag der vollen Monatsrente (R0)",
|
||||
value: AHV_MIN_MONTHLY_FULL,
|
||||
unit: "CHF",
|
||||
meaning:
|
||||
"Ankerwert der ganzen Rentenskala 44. Sämtliche Schwellen der Rentenformel sind Vielfache von R0 – ändert sich R0, verschiebt sich die gesamte Skala.",
|
||||
source: 'BSV/MAS «Berechnungsvorschriften der AHV/IV-Renten»; amtliche Tabelle 318.117.1 «Monatliche Vollrenten, Skala 44»',
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_PENSION_MONTHS",
|
||||
label: "Rentenzahlungen pro Jahr",
|
||||
value: AHV_PENSION_MONTHS,
|
||||
unit: "Anzahl",
|
||||
meaning:
|
||||
"13 statt 12, weil seit 1.1.2026 die 13. Altersrente ausgerichtet wird. Die Rentenformel liefert Monatsbeträge; der Jahresbetrag ist deshalb × 13.",
|
||||
source: "Art. 34bis AHVG (13. Altersrente)",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_MAX_ANNUAL_SINGLE",
|
||||
label: "Maximale einfache Altersrente pro Jahr",
|
||||
value: AHV_MAX_ANNUAL_SINGLE,
|
||||
unit: "CHF",
|
||||
meaning: "Obergrenze der Einzelrente. Wird ab einem massgebenden Durchschnittseinkommen von 90'720 erreicht.",
|
||||
derivation: `2 × R0 × ${AHV_PENSION_MONTHS} = 2 × ${AHV_MIN_MONTHLY_FULL} × ${AHV_PENSION_MONTHS} = ${AHV_MAX_ANNUAL_SINGLE}`,
|
||||
source: "abgeleitet aus R0 und der Anzahl Rentenzahlungen",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_FULL_CONTRIBUTION_YEARS",
|
||||
label: "Volle Beitragsdauer (Rentenskala)",
|
||||
value: AHV_FULL_CONTRIBUTION_YEARS,
|
||||
unit: "Jahre",
|
||||
meaning: "Für eine ungekürzte Rente nötige Beitragsjahre. Jedes fehlende Jahr kürzt die Rente um 1/44.",
|
||||
source: "AHVG, Rentenskala 44",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_CONTRIBUTION_START_AGE",
|
||||
label: "Beginn der Beitragspflicht",
|
||||
value: AHV_CONTRIBUTION_START_AGE,
|
||||
unit: "Jahre",
|
||||
meaning:
|
||||
"Beitragspflicht ab dem 1. Januar nach dem 20. Geburtstag, faktisch ab Alter 21. Bestimmt, wie viele Beitragsjahre vor dem Planbeginn liegen.",
|
||||
source: "AHVG",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_REFERENCE_AGE",
|
||||
label: "Referenzalter (Rentenbeginn)",
|
||||
value: AHV_REFERENCE_AGE,
|
||||
unit: "Jahre",
|
||||
meaning:
|
||||
"Ab diesem Alter fliesst die AHV-Rente – unabhängig vom gewählten Pensionsalter. Wer früher aufhört, bleibt bis dahin beitragspflichtig; wer länger arbeitet, erhält die Rente trotzdem ab hier. Ein Aufschub der Rente ist nicht modelliert.",
|
||||
source: "AHVG (Referenzalter 65 nach AHV 21)",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_COUPLE_CAP_FACTOR",
|
||||
label: "Ehepaar-Plafonierung",
|
||||
value: AHV_COUPLE_CAP_FACTOR * 100,
|
||||
unit: "%",
|
||||
meaning:
|
||||
"Die Summe beider Einzelrenten ist auf 150 % der Einzel-Maximalrente begrenzt. Bei Überschreitung werden beide Renten proportional gekürzt.",
|
||||
derivation: `${AHV_MAX_ANNUAL_SINGLE} × ${AHV_COUPLE_CAP_FACTOR} = ${AHV_MAX_ANNUAL_SINGLE * AHV_COUPLE_CAP_FACTOR} CHF pro Jahr für das Paar`,
|
||||
source: "AHVG",
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "AHV_GROSS_FROM_NET_FACTOR",
|
||||
label: "Umrechnung Netto- auf Bruttolohn (für die AHV)",
|
||||
value: AHV_GROSS_FROM_NET_FACTOR,
|
||||
unit: "Anzahl",
|
||||
meaning:
|
||||
"Das Tool erfasst das Einkommen netto (so stimmt der Cash-Fluss), die AHV bemisst sich am Bruttolohn. Dieser Faktor rechnet für die Rentenberechnung zurück.",
|
||||
derivation:
|
||||
"Arbeitnehmer-Abzüge: AHV/IV/EO 5.30 % + ALV 1.10 % + NBU ~1.00 % + PK ~2–5 % = ~9–13 % → 1/(1−q) = 1.10 bis 1.16, Mittel 1.12",
|
||||
source: 'BSV «Beträge gültig ab 1.1.2026»; AHV-Merkblätter 2.01 und 2.08',
|
||||
validFrom: "1.1.2026",
|
||||
group: "AHV",
|
||||
},
|
||||
{
|
||||
key: "PILLAR_3A_MAX_ANNUAL",
|
||||
label: "Maximaler Säule-3a-Beitrag pro Jahr (mit PK)",
|
||||
value: PILLAR_3A_MAX_ANNUAL,
|
||||
unit: "CHF",
|
||||
meaning: "Höchstbetrag für Personen mit Pensionskasse. Im Eingabefeld hart geklammert.",
|
||||
source: 'BSV «Beträge gültig ab 1.1.2026»',
|
||||
validFrom: "1.1.2026",
|
||||
group: "Vorsorge",
|
||||
},
|
||||
{
|
||||
key: "PILLAR_3A_MAX_SELF_EMPLOYED",
|
||||
label: "Maximaler Säule-3a-Beitrag pro Jahr (Selbstständige ohne PK)",
|
||||
value: PILLAR_3A_MAX_SELF_EMPLOYED,
|
||||
unit: "CHF",
|
||||
meaning:
|
||||
"«Grosse Säule 3a»: 20 % des Nettoerwerbseinkommens, gedeckelt auf diesen Betrag. Gilt für Selbstständige ohne Pensionskasse. Wert für 2026 noch zu verifizieren (2025: 36'288).",
|
||||
source: 'BSV «Beträge gültig ab 1.1.2026» (zu verifizieren)',
|
||||
validFrom: "1.1.2026",
|
||||
group: "Vorsorge",
|
||||
},
|
||||
{
|
||||
key: "DEFAULT_PK_CONVERSION_RATE",
|
||||
label: "Umwandlungssatz Pensionskasse (Vorschlag)",
|
||||
value: DEFAULT_PK_CONVERSION_RATE,
|
||||
unit: "%",
|
||||
meaning: "Jährliche Rente = verrentetes Kapital × Umwandlungssatz. Der tatsächliche Satz steht im PK-Reglement.",
|
||||
source: "Vorschlagswert; überobligatorische Sätze liegen oft tiefer",
|
||||
validFrom: "—",
|
||||
group: "Vorsorge",
|
||||
editablePerEvent: true,
|
||||
},
|
||||
{
|
||||
key: "DEFAULT_CAPITAL_TAX_RATE",
|
||||
label: "Kapitalbezugssteuer (Vorschlag)",
|
||||
value: DEFAULT_CAPITAL_TAX_RATE,
|
||||
unit: "%",
|
||||
meaning:
|
||||
"Steuer auf dem Kapitalbezug aus PK und Säule 3a. Kantonal sehr unterschiedlich und progressiv – dieser Pauschalsatz ersetzt keine Steuerberechnung.",
|
||||
source: "Vorschlagswert; kantonal stark abweichend",
|
||||
validFrom: "—",
|
||||
group: "Steuern",
|
||||
editablePerEvent: true,
|
||||
},
|
||||
{
|
||||
key: "DEFAULT_PROPERTY_GAINS_TAX_RATE",
|
||||
label: "Grundstückgewinnsteuer (Vorschlag)",
|
||||
value: DEFAULT_PROPERTY_GAINS_TAX_RATE,
|
||||
unit: "%",
|
||||
meaning:
|
||||
"Steuer auf dem Gewinn beim Immobilienverkauf (Verkaufspreis − ursprünglicher Kaufpreis). Die Haltedauer-Abstufung ist nicht modelliert.",
|
||||
source: "Vorschlagswert; kantonal stark abweichend, teils degressiv nach Haltedauer",
|
||||
validFrom: "—",
|
||||
group: "Steuern",
|
||||
editablePerEvent: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computePlan } from "@/lib/calculations";
|
||||
import { planToCsv } from "@/lib/csv";
|
||||
import type { PlanInput } from "@/lib/types";
|
||||
|
||||
// Plan mit zwei Phasen, einem Uebergangs-Entscheid und mehreren Elementen -- deckt alle vier
|
||||
// Bloecke des Exports ab.
|
||||
function plan(): PlanInput {
|
||||
return {
|
||||
id: "s",
|
||||
name: "Test-Szenario",
|
||||
householdType: "SINGLE",
|
||||
inflationRateDefault: 1.5,
|
||||
initialCash: 20000,
|
||||
startYear: 2026,
|
||||
persons: [{ id: "A", role: "PERSON_A", name: "Anna", age: 60, retirementAge: 65 }],
|
||||
phases: [
|
||||
{ id: "p1", sequenceNumber: 1, name: "Erwerb", durationYears: 5, cashTransition: {} },
|
||||
{ id: "p2", sequenceNumber: 2, name: "Pension", durationYears: 10, cashTransition: {} },
|
||||
],
|
||||
elements: [
|
||||
{
|
||||
id: "inc", category: "INCOME", name: "Lohn", ownerRole: "PERSON_A", orderIndex: 1,
|
||||
phaseValues: { p1: { amount: 100000, teuerungsausgleich: 0 }, p2: {} },
|
||||
transitionValues: {},
|
||||
},
|
||||
{
|
||||
id: "exp", category: "EXPENSE", name: "Lebenshaltung", ownerRole: "HOUSEHOLD", orderIndex: 2,
|
||||
phaseValues: { p1: { amount: 70000 }, p2: { amount: 70000 } },
|
||||
transitionValues: {},
|
||||
},
|
||||
{
|
||||
id: "pk", category: "PENSION_FUND", name: "Pensionskasse", ownerRole: "PERSON_A", orderIndex: 3,
|
||||
phaseValues: { p1: { currentValue: 500000, annualContribution: 15000, expectedReturn: 2 }, p2: {} },
|
||||
transitionValues: { p1: { payoutMode: "CAPITAL", capitalTaxRate: 8 } },
|
||||
},
|
||||
],
|
||||
} as unknown as PlanInput;
|
||||
}
|
||||
|
||||
const build = () => planToCsv(plan(), computePlan(plan()));
|
||||
|
||||
describe("planToCsv", () => {
|
||||
it("beginnt mit dem BOM, damit Excel die Umlaute erkennt", () => {
|
||||
expect(build().charCodeAt(0)).toBe(0xfeff);
|
||||
});
|
||||
|
||||
it("enthaelt alle vier Bloecke", () => {
|
||||
const csv = build();
|
||||
expect(csv).toContain("LEBENSPHASEN");
|
||||
expect(csv).toContain("MATRIX");
|
||||
expect(csv).toContain("JAHRESWERTE");
|
||||
expect(csv).toContain("Test-Szenario");
|
||||
});
|
||||
|
||||
it("fuehrt JEDES finanzielle Element als eigene Zeile", () => {
|
||||
const csv = build();
|
||||
for (const name of ["Lohn", "Lebenshaltung", "Pensionskasse", "Cash"]) {
|
||||
expect(csv).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("hat je Phase eine Beginn- und eine Ende-Spalte plus die Uebergangsspalte", () => {
|
||||
const lines = build().split("\r\n");
|
||||
const header = lines.find((l) => l.startsWith("Element;"))!;
|
||||
expect(header).toContain("Erwerb – Beginn");
|
||||
expect(header).toContain("Erwerb – Ende");
|
||||
expect(header).toContain("Übergang → Pension");
|
||||
expect(header).toContain("Pension – Ende");
|
||||
});
|
||||
|
||||
it("schreibt den Uebergangs-Entscheid im Klartext", () => {
|
||||
expect(build()).toContain("Kapitalbezug (Steuer 8 %)");
|
||||
});
|
||||
|
||||
it("listet jedes Planjahr einmal", () => {
|
||||
const csv = build();
|
||||
const start = csv.indexOf("JAHRESWERTE");
|
||||
const yearRows = csv
|
||||
.slice(start)
|
||||
.split("\r\n")
|
||||
.filter((l) => /^\d+;/.test(l));
|
||||
expect(yearRows).toHaveLength(15); // 5 + 10 Jahre
|
||||
expect(yearRows[0]).toContain(";2026;"); // Kalenderjahr aus dem Planstart
|
||||
});
|
||||
|
||||
it("maskiert Semikolon und Anfuehrungszeichen im Text", () => {
|
||||
const p = plan();
|
||||
p.elements[0].name = 'Lohn; "Haupt"';
|
||||
const csv = planToCsv(p, computePlan(p));
|
||||
expect(csv).toContain('"Lohn; ""Haupt"""');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user