diff --git a/package-lock.json b/package-lock.json
index a9613ad..00efe00 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,8 @@
"@hookform/resolvers": "^5.0.1",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.5.0",
+ "@radix-ui/react-alert-dialog": "^1.1.7",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.7",
@@ -18,6 +20,7 @@
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
+ "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.72.0",
"@trpc/client": "^11.0.2",
"@trpc/next": "^11.0.2",
@@ -1474,6 +1477,264 @@
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
+ "node_modules/@radix-ui/react-alert-dialog": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.7.tgz",
+ "integrity": "sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dialog": "1.1.7",
+ "@radix-ui/react-primitive": "2.0.3",
+ "@radix-ui/react-slot": "1.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.7.tgz",
+ "integrity": "sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.6",
+ "@radix-ui/react-focus-guards": "1.1.2",
+ "@radix-ui/react-focus-scope": "1.1.3",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.5",
+ "@radix-ui/react-presence": "1.1.3",
+ "@radix-ui/react-primitive": "2.0.3",
+ "@radix-ui/react-slot": "1.2.0",
+ "@radix-ui/react-use-controllable-state": "1.1.1",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz",
+ "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
+ "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
+ "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz",
+ "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz",
@@ -1594,6 +1855,319 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.11",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
+ "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
+ "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz",
+ "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
+ "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -3018,6 +3592,257 @@
}
}
},
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.0.tgz",
+ "integrity": "sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.6",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-popper": "1.2.3",
+ "@radix-ui/react-portal": "1.1.5",
+ "@radix-ui/react-presence": "1.1.3",
+ "@radix-ui/react-primitive": "2.0.3",
+ "@radix-ui/react-slot": "1.2.0",
+ "@radix-ui/react-use-controllable-state": "1.1.1",
+ "@radix-ui/react-visually-hidden": "1.1.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz",
+ "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-escape-keydown": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz",
+ "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz",
+ "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz",
+ "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
+ "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-callback-ref": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz",
+ "integrity": "sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
@@ -3051,6 +3876,39 @@
}
}
},
+ "node_modules/@radix-ui/react-use-effect-event": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+ "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
diff --git a/package.json b/package.json
index 2ceb6db..8ff2065 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
"@hookform/resolvers": "^5.0.1",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.5.0",
+ "@radix-ui/react-alert-dialog": "^1.1.7",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-popover": "^1.1.7",
@@ -19,6 +21,7 @@
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.4",
+ "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.72.0",
"@trpc/client": "^11.0.2",
"@trpc/next": "^11.0.2",
diff --git a/src/app/dashboard/child/[childId]/page.tsx b/src/app/dashboard/child/[childId]/page.tsx
index 620914c..9d390c1 100644
--- a/src/app/dashboard/child/[childId]/page.tsx
+++ b/src/app/dashboard/child/[childId]/page.tsx
@@ -8,18 +8,18 @@ import { notFound } from "next/navigation"
export default async function ChildDetailPage({
params,
}: {
- params: { childId: string }
+ params: Promise<{ childId: string }>
}) {
const session = await getAuthSession()
if (!session?.user) redirect("/login")
- const { childId } = params;
+ const { childId } = await params
if (!childId) {
notFound();
}
-
+
return (
<>
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 7071759..854666f 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -9,13 +9,51 @@ import Link from "next/link"
import { useState } from "react"
import { Header } from "@/components/layout/header"
import { Footer } from "@/components/layout/footer"
+import { useRouter } from "next/navigation"
+import { signIn } from "next-auth/react"
+import { toast } from "sonner"
export default function RegisterPage() {
const [email, setEmail] = useState("")
const [name, setName] = useState("")
const [password, setPassword] = useState("")
+ const [isLoading, setIsLoading] = useState(false)
+ const router = useRouter()
const registerMutation = trpc.auth.register.useMutation()
+ const handleRegister = async () => {
+ if (!email || !name || !password) {
+ toast.error("Bitte fĂĽllen Sie alle Felder aus")
+ return
+ }
+
+ setIsLoading(true)
+
+ try {
+ await registerMutation.mutateAsync({ name, email, password })
+
+ // Automatically sign in the user after successful registration
+ const result = await signIn("credentials", {
+ email,
+ password,
+ redirect: false,
+ })
+
+ if (result?.error) {
+ toast.error("Anmeldung fehlgeschlagen: " + result.error)
+ setIsLoading(false)
+ return
+ }
+
+ // Redirect to the app
+ toast.success("Registrierung erfolgreich! 🎉")
+ router.push("/app")
+ } catch (error) {
+ toast.error("Fehler bei der Registrierung: " + (error instanceof Error ? error.message : "Unbekannter Fehler"))
+ setIsLoading(false)
+ }
+ }
+
return (
<>
@@ -31,6 +69,7 @@ export default function RegisterPage() {
placeholder="Max Mustermann"
value={name}
onChange={(e) => setName(e.target.value)}
+ disabled={isLoading}
/>
@@ -42,6 +81,7 @@ export default function RegisterPage() {
placeholder="max@bambino.at"
value={email}
onChange={(e) => setEmail(e.target.value)}
+ disabled={isLoading}
/>
@@ -53,24 +93,16 @@ export default function RegisterPage() {
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
+ disabled={isLoading}
/>
diff --git a/src/components/child/child-detail-content.tsx b/src/components/child/child-detail-content.tsx
index 749faf9..bcb0033 100644
--- a/src/components/child/child-detail-content.tsx
+++ b/src/components/child/child-detail-content.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useRouter } from "next/navigation";
import { format, differenceInMonths } from "date-fns";
import { de } from "date-fns/locale";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -25,14 +26,39 @@ import type { AppRouter } from "@/server/api/root";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
-import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp } from "lucide-react";
+import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp, Trash2, List, InfoIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { DentureVisualization } from "@/components/child/denture-visualization";
+import { VaccineForm } from "@/components/child/vaccine-form";
+import { VaccineList } from "@/components/child/vaccine-list";
+import { ChildEditDialog } from "@/components/child/child-edit-dialog";
+import { calculatePercentile } from "@/lib/percentiles";
+import {
+ Tooltip as UITooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
const measurementFormSchema = z.object({
date: z.date({
required_error: "Bitte wähle ein Datum",
- }),
+ }).refine(
+ (date) => date <= new Date(),
+ "Messungen können nicht für zukünftige Daten hinzugefügt werden"
+ ),
weightKg: z.string().refine((val) => !isNaN(Number(val)) && Number(val) > 0, {
message: "Bitte gib ein gĂĽltiges Gewicht ein",
}),
@@ -48,6 +74,7 @@ interface ChildDetailContentProps {
}
export function ChildDetailContent({ childId }: ChildDetailContentProps) {
+ const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [date, setDate] = useState(new Date());
const [expandedSections, setExpandedSections] = useState({
@@ -56,6 +83,35 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
vaccinations: false,
toothing: false,
});
+ const [showMeasurementsList, setShowMeasurementsList] = useState(false);
+ const [showVaccineForm, setShowVaccineForm] = useState(false);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+
+ const handleDeleteChild = async () => {
+ setIsLoading(true);
+ try {
+ const response = await fetch('/api/trpc/child.delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ json: { id: childId } }),
+ });
+
+ if (!response.ok) throw new Error('Failed to delete');
+
+ toast.success("Kind gelöscht", {
+ description: "Das Kind wurde erfolgreich gelöscht."
+ });
+
+ router.push('/app');
+ } catch {
+ toast.error("Fehler", {
+ description: "Das Kind konnte nicht gelöscht werden."
+ });
+ } finally {
+ setIsLoading(false);
+ setShowDeleteDialog(false);
+ }
+ };
// Define error handler within component scope
const handleAddMeasurement = (err: TRPCClientErrorLike) => {
@@ -74,6 +130,18 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{ enabled: !!childId }
);
+ // Fetch teeth status
+ const { data: teethStatus, refetch: refetchTeeth } = trpc.tooth.getByChildId.useQuery(
+ { childId },
+ { enabled: !!childId }
+ );
+
+ // Fetch vaccines
+ const { data: vaccines, refetch: refetchVaccines } = trpc.vaccine.getByChildId.useQuery(
+ { childId },
+ { enabled: !!childId }
+ );
+
// Add measurement mutation
const addMeasurement = trpc.measurement.add.useMutation({
onSuccess: () => {
@@ -88,6 +156,21 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
onError: handleAddMeasurement,
});
+ // Add delete measurement mutation
+ const deleteMeasurement = trpc.measurement.delete.useMutation({
+ onSuccess: () => {
+ toast.success("Messung gelöscht", {
+ description: "Die Messung wurde erfolgreich gelöscht."
+ });
+ refetchMeasurements();
+ },
+ onError: (err) => {
+ toast.error("Fehler", {
+ description: err.message
+ });
+ },
+ });
+
// Form setup
const form = useForm({
resolver: zodResolver(measurementFormSchema),
@@ -103,6 +186,16 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
// Create a new date at the start of the selected day in local timezone
const selectedDate = date || new Date();
+
+ // Check if the selected date is in the future
+ if (selectedDate > new Date()) {
+ toast.error("Fehler", {
+ description: "Messungen können nicht für zukünftige Daten hinzugefügt werden"
+ });
+ setIsLoading(false);
+ return;
+ }
+
const localDate = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
@@ -150,6 +243,27 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
const birthDate = new Date(child.birthDate);
const ageInMonths = differenceInMonths(new Date(), birthDate);
+ const gender = (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown";
+
+ const latestMeasurement = measurements?.[0];
+ const weightPercentile = latestMeasurement?.weightKg
+ ? calculatePercentile(latestMeasurement.weightKg, ageInMonths, gender, "weight")
+ : null;
+ const heightPercentile = latestMeasurement?.heightCm
+ ? calculatePercentile(latestMeasurement.heightCm, ageInMonths, gender, "height")
+ : null;
+
+ const getDevelopmentStatus = () => {
+ if (!weightPercentile && !heightPercentile) return "Keine Daten";
+ if ((weightPercentile && weightPercentile.percentile < 3) || (heightPercentile && heightPercentile.percentile < 3)) {
+ return "Achtung";
+ }
+ if ((weightPercentile && weightPercentile.percentile > 97) || (heightPercentile && heightPercentile.percentile > 97)) {
+ return "Achtung";
+ }
+ return "Normal";
+ };
+
// Prepare data for charts
const measurementData = measurements
?.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
@@ -163,15 +277,48 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
-
-
-
+
+
+
+
+
+
+
{child.name}
+
+ {ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })}
+
+
-
-
{child.name}
-
- {ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })}
-
+
+
{
+ trpc.useUtils().child.getById.invalidate({ id: childId });
+ }} />
+
+
+
+
+
+
+ Kind löschen
+
+ Möchten Sie {child.name} wirklich löschen? Alle zugehörigen Daten (Messungen, Impfungen, Zahnstatus) werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
+
+
+
+ Abbrechen
+
+ {isLoading ? "Wird gelöscht..." : "Löschen"}
+
+
+
+
@@ -196,7 +343,15 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
Entwicklung
-
Normal
+
+ {getDevelopmentStatus()}
+
+ {weightPercentile && (
+
Gewicht: {weightPercentile.percentile}. Perzentil
+ )}
+ {heightPercentile && (
+
Größe: {heightPercentile.percentile}. Perzentil
+ )}
@@ -391,6 +546,59 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
+
+
+
+
Liste der Messungen
+
+
+ {showMeasurementsList && (
+
+ {measurements.map((measurement) => (
+
+
+
{format(new Date(measurement.date), "PPP", { locale: de })}
+
+ Gewicht: {measurement.weightKg} kg • Größe: {measurement.heightCm} cm
+
+
+
+
+
+
+
+
+ Messung löschen
+
+ Möchten Sie diese Messung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
+
+
+
+ Abbrechen
+ deleteMeasurement.mutate({ id: measurement.id })}
+ className="bg-red-600 text-white hover:bg-red-700"
+ >
+ Löschen
+
+
+
+
+
+ ))}
+
+ )}
+
) : (
Noch keine Messungen aufgezeichnet.
@@ -414,32 +622,49 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.vaccinations && (
-
-
-
-
-
-
Noch keine Impfungen aufgezeichnet.
-
-
Empfohlene Impfungen
-
- -
- 6-fach Impfung
- 2 Monate
-
- -
- Pneumokokken
- 2 Monate
-
- -
- Rotaviren
- 6 Wochen
-
-
+ {!showVaccineForm ? (
+
+
+ ) : (
+
{
+ setShowVaccineForm(false);
+ refetchVaccines();
+ }}
+ onCancel={() => setShowVaccineForm(false)}
+ />
+ )}
+
+ refetchVaccines()}
+ />
+
+
+
Empfohlene Impfungen
+
+ -
+ 6-fach Impfung
+ 2 Monate
+
+ -
+ Pneumokokken
+ 2 Monate
+
+ -
+ Rotaviren
+ 6 Wochen
+
+
)}
@@ -460,15 +685,7 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.toothing && (
-
-
-
-
-
Noch keine Zähne aufgezeichnet.
Zahnungsplan
@@ -508,6 +725,31 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
+
+
+
+
Zahnstatus
+
+
+
+
+
+
+ Klicken Sie auf einen Zahn in der Visualisierung oder wählen Sie einen Zahn aus dem Dropdown-Menü, um den Durchbruch zu markieren.
+ Rote Zähne sind bereits durchgebrochen.
+
+
+
+
+
refetchTeeth()}
+ showTitle={false}
+ />
+
)}
diff --git a/src/components/child/child-edit-dialog.tsx b/src/components/child/child-edit-dialog.tsx
new file mode 100644
index 0000000..1e7f53e
--- /dev/null
+++ b/src/components/child/child-edit-dialog.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useState } from "react";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { CalendarIcon, Pencil } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+const childFormSchema = z.object({
+ name: z.string().min(1, "Name ist erforderlich"),
+ birthDate: z.date({
+ required_error: "Bitte wähle ein Geburtsdatum",
+ }),
+ gender: z.enum(["male", "female", "diverse", "unknown"]),
+});
+
+type ChildFormValues = z.infer
;
+
+interface Child {
+ id: string;
+ name: string;
+ birthDate: Date | string;
+ gender: string | null;
+}
+
+interface ChildEditDialogProps {
+ child: Child;
+ onSuccess: () => void;
+}
+
+export function ChildEditDialog({ child, onSuccess }: ChildEditDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [date, setDate] = useState(new Date(child.birthDate));
+ const [isLoading, setIsLoading] = useState(false);
+ const router = useRouter();
+
+ const form = useForm({
+ resolver: zodResolver(childFormSchema),
+ defaultValues: {
+ name: child.name,
+ birthDate: new Date(child.birthDate),
+ gender: (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown",
+ },
+ });
+
+ const onSubmit = form.handleSubmit(async (values: ChildFormValues) => {
+ setIsLoading(true);
+
+ try {
+ const response = await fetch('/api/trpc/child.update', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ json: {
+ id: child.id,
+ name: values.name,
+ birthDate: date.toISOString(),
+ gender: values.gender,
+ }
+ }),
+ });
+
+ if (!response.ok) throw new Error('Failed to save');
+
+ toast.success("Kind aktualisiert", {
+ description: "Die Daten wurden erfolgreich gespeichert."
+ });
+
+ setOpen(false);
+ onSuccess();
+ router.refresh();
+ } catch {
+ toast.error("Fehler", {
+ description: "Die Daten konnten nicht gespeichert werden."
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ });
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/src/components/child/denture-visualization.tsx b/src/components/child/denture-visualization.tsx
new file mode 100644
index 0000000..0bc8ee0
--- /dev/null
+++ b/src/components/child/denture-visualization.tsx
@@ -0,0 +1,416 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Calendar } from "@/components/ui/calendar";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import { CalendarIcon, InfoIcon, Plus } from "lucide-react";
+import { toast } from "sonner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { trpc } from "@/utils/trpc";
+
+const UPPER_TEETH = [
+ { id: "U8", name: "Oben rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
+ { id: "U7", name: "Oben rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
+ { id: "U6", name: "Oben rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
+ { id: "U5", name: "Oben rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
+ { id: "U4", name: "Oben rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
+ { id: "U3", name: "Oben rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
+ { id: "U2", name: "Oben rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
+ { id: "U1", name: "Oben rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
+ { id: "U1L", name: "Oben links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
+ { id: "U2L", name: "Oben links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
+ { id: "U3L", name: "Oben links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
+ { id: "U4L", name: "Oben links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
+ { id: "U5L", name: "Oben links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
+ { id: "U6L", name: "Oben links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
+ { id: "U7L", name: "Oben links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
+ { id: "U8L", name: "Oben links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
+];
+
+const LOWER_TEETH = [
+ { id: "L8", name: "Unten rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
+ { id: "L7", name: "Unten rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
+ { id: "L6", name: "Unten rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
+ { id: "L5", name: "Unten rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
+ { id: "L4", name: "Unten rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
+ { id: "L3", name: "Unten rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
+ { id: "L2", name: "Unten rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
+ { id: "L1", name: "Unten rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
+ { id: "L1L", name: "Unten links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
+ { id: "L2L", name: "Unten links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
+ { id: "L3L", name: "Unten links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
+ { id: "L4L", name: "Unten links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
+ { id: "L5L", name: "Unten links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
+ { id: "L6L", name: "Unten links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
+ { id: "L7L", name: "Unten links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
+ { id: "L8L", name: "Unten links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
+];
+
+interface Tooth {
+ id: string;
+ name: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ curve: number;
+ erupted?: boolean;
+ eruptionDate?: Date;
+ dbId?: string;
+}
+
+interface ToothStatusFromDB {
+ id: string;
+ toothLabel: string;
+ date: Date | string;
+ status: string;
+}
+
+interface DentureVisualizationProps {
+ childId: string;
+ initialTeeth?: ToothStatusFromDB[];
+ onToothSaved?: () => void;
+ showTitle?: boolean;
+}
+
+export function DentureVisualization({
+ childId,
+ initialTeeth = [],
+ onToothSaved,
+ showTitle = false
+}: DentureVisualizationProps) {
+ const [upperTeeth, setUpperTeeth] = useState([]);
+ const [lowerTeeth, setLowerTeeth] = useState([]);
+ const [selectedTooth, setSelectedTooth] = useState(null);
+ const [eruptionDate, setEruptionDate] = useState(new Date());
+ const [hoveredTooth, setHoveredTooth] = useState(null);
+
+ const addTooth = trpc.tooth.add.useMutation({
+ onSuccess: () => {
+ toast.success("Zahn hinzugefĂĽgt", {
+ description: selectedTooth ? `${selectedTooth.name} wurde am ${format(eruptionDate!, "PPP", { locale: de })} als durchgebrochen markiert.` : ""
+ });
+ setSelectedTooth(null);
+ onToothSaved?.();
+ },
+ onError: (error) => {
+ toast.error("Fehler", {
+ description: error.message || "Der Zahn konnte nicht gespeichert werden."
+ });
+ },
+ });
+
+ useEffect(() => {
+ const teethMap = new Map();
+ initialTeeth.forEach(t => teethMap.set(t.toothLabel, t));
+
+ const mapTeeth = (teeth: typeof UPPER_TEETH): Tooth[] =>
+ teeth.map(tooth => {
+ const dbTooth = teethMap.get(tooth.id);
+ return {
+ ...tooth,
+ erupted: !!dbTooth,
+ eruptionDate: dbTooth ? new Date(dbTooth.date) : undefined,
+ dbId: dbTooth?.id,
+ };
+ });
+
+ setUpperTeeth(mapTeeth(UPPER_TEETH));
+ setLowerTeeth(mapTeeth(LOWER_TEETH));
+ }, [initialTeeth]);
+
+ const handleToothClick = (tooth: Tooth) => {
+ setSelectedTooth(tooth);
+ if (tooth.eruptionDate) {
+ setEruptionDate(tooth.eruptionDate);
+ } else {
+ setEruptionDate(new Date());
+ }
+ };
+
+ const handleEruptionDateSelect = (date: Date | undefined) => {
+ setEruptionDate(date);
+ };
+
+ const handleSaveEruption = () => {
+ if (!selectedTooth || !eruptionDate) return;
+
+ const localDate = new Date(
+ eruptionDate.getFullYear(),
+ eruptionDate.getMonth(),
+ eruptionDate.getDate(),
+ 12, 0, 0, 0
+ );
+
+ addTooth.mutate({
+ childId,
+ toothLabel: selectedTooth.id,
+ date: localDate.toISOString(),
+ status: "durchgebrochen",
+ });
+
+ const updatedUpperTeeth = upperTeeth.map(t =>
+ t.id === selectedTooth.id
+ ? { ...t, erupted: true, eruptionDate: eruptionDate }
+ : t
+ );
+
+ const updatedLowerTeeth = lowerTeeth.map(t =>
+ t.id === selectedTooth.id
+ ? { ...t, erupted: true, eruptionDate: eruptionDate }
+ : t
+ );
+
+ setUpperTeeth(updatedUpperTeeth);
+ setLowerTeeth(updatedLowerTeeth);
+ };
+
+ const getToothPath = (tooth: Tooth, isUpper: boolean) => {
+ const { x, y, width, height, curve } = tooth;
+ const curveHeight = height * curve;
+ const curveDirection = isUpper ? -1 : 1;
+
+ const cp1x = x + width * 0.25;
+ const cp1y = y + (isUpper ? height : 0);
+ const cp2x = x + width * 0.75;
+ const cp2y = y + (isUpper ? height : 0);
+
+ const startX = x;
+ const startY = y + (isUpper ? 0 : height);
+ const endX = x + width;
+ const endY = y + (isUpper ? 0 : height);
+
+ return `M ${startX} ${startY}
+ C ${cp1x} ${cp1y + curveHeight * curveDirection * 0.5},
+ ${cp2x} ${cp2y + curveHeight * curveDirection * 0.5},
+ ${endX} ${endY}`;
+ };
+
+ const handleToothSelect = (toothId: string) => {
+ const tooth = [...upperTeeth, ...lowerTeeth].find(t => t.id === toothId);
+ if (tooth) {
+ handleToothClick(tooth);
+ }
+ };
+
+ return (
+
+
+ {showTitle && (
+
+
Zahnstatus
+
+
+
+
+
+ Klicken Sie auf einen Zahn, um den Durchbruch zu markieren.
+ Rote Zähne sind bereits durchgebrochen.
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
Oberkiefer
+
+
+
+
+
+
+
Unterkiefer
+
+
+
+
+
+
+ {selectedTooth && (
+
+
{selectedTooth.name}
+ {selectedTooth.erupted && selectedTooth.eruptionDate && (
+
+ Durchbruch: {format(selectedTooth.eruptionDate, "PPP", { locale: de })}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/child/vaccine-form.tsx b/src/components/child/vaccine-form.tsx
new file mode 100644
index 0000000..d38e33b
--- /dev/null
+++ b/src/components/child/vaccine-form.tsx
@@ -0,0 +1,176 @@
+"use client";
+
+import { useState } from "react";
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { CalendarIcon, Check, X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { trpc } from "@/utils/trpc";
+import { toast } from "sonner";
+
+const vaccineFormSchema = z.object({
+ name: z.string().min(1, "Name ist erforderlich"),
+ date: z.date({
+ required_error: "Bitte wähle ein Datum",
+ }),
+ done: z.boolean(),
+ notes: z.string().optional(),
+});
+
+type VaccineFormValues = z.infer;
+
+interface VaccineFormProps {
+ childId: string;
+ onSuccess: () => void;
+ onCancel: () => void;
+}
+
+export function VaccineForm({ childId, onSuccess, onCancel }: VaccineFormProps) {
+ const [date, setDate] = useState(new Date());
+
+ const form = useForm({
+ resolver: zodResolver(vaccineFormSchema),
+ defaultValues: {
+ name: "",
+ date: new Date(),
+ done: false,
+ notes: "",
+ },
+ });
+
+ const addVaccine = trpc.vaccine.add.useMutation({
+ onSuccess: () => {
+ toast.success("Impfung hinzugefĂĽgt", {
+ description: "Die Impfung wurde erfolgreich gespeichert."
+ });
+ form.reset();
+ setDate(new Date());
+ onSuccess();
+ },
+ onError: (error) => {
+ toast.error("Fehler", {
+ description: error.message || "Die Impfung konnte nicht gespeichert werden."
+ });
+ },
+ });
+
+ const onSubmit = form.handleSubmit((values: VaccineFormValues) => {
+ const localDate = new Date(
+ (date || values.date).getFullYear(),
+ (date || values.date).getMonth(),
+ (date || values.date).getDate(),
+ 12, 0, 0, 0
+ );
+
+ addVaccine.mutate({
+ childId,
+ name: values.name,
+ date: localDate.toISOString(),
+ done: values.done,
+ notes: values.notes || undefined,
+ });
+ });
+
+ return (
+
+ );
+}
diff --git a/src/components/child/vaccine-list.tsx b/src/components/child/vaccine-list.tsx
new file mode 100644
index 0000000..428e186
--- /dev/null
+++ b/src/components/child/vaccine-list.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import { format } from "date-fns";
+import { de } from "date-fns/locale";
+import { Button } from "@/components/ui/button";
+import { Trash2, Check, X, Pencil } from "lucide-react";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { useState } from "react";
+import { Input } from "@/components/ui/input";
+import { Calendar } from "@/components/ui/calendar";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { CalendarIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { trpc } from "@/utils/trpc";
+import { toast } from "sonner";
+
+interface Vaccine {
+ id: string;
+ name: string;
+ date: Date | string;
+ done: boolean;
+ notes: string | null;
+}
+
+interface VaccineListProps {
+ vaccines: Vaccine[];
+ onRefetch: () => void;
+}
+
+export function VaccineList({ vaccines, onRefetch }: VaccineListProps) {
+ const [editingId, setEditingId] = useState(null);
+ const [editName, setEditName] = useState("");
+ const [editDate, setEditDate] = useState();
+ const [editNotes, setEditNotes] = useState("");
+ const [editDone, setEditDone] = useState(false);
+
+ const updateVaccine = trpc.vaccine.update.useMutation({
+ onSuccess: () => {
+ onRefetch();
+ },
+ onError: (error) => {
+ toast.error("Fehler", { description: error.message });
+ },
+ });
+
+ const deleteVaccine = trpc.vaccine.delete.useMutation({
+ onSuccess: () => {
+ toast.success("Impfung gelöscht");
+ onRefetch();
+ },
+ onError: (error) => {
+ toast.error("Fehler", { description: error.message });
+ },
+ });
+
+ const startEdit = (vaccine: Vaccine) => {
+ setEditingId(vaccine.id);
+ setEditName(vaccine.name);
+ setEditDate(new Date(vaccine.date));
+ setEditNotes(vaccine.notes || "");
+ setEditDone(vaccine.done);
+ };
+
+ const cancelEdit = () => {
+ setEditingId(null);
+ setEditName("");
+ setEditDate(undefined);
+ setEditNotes("");
+ setEditDone(false);
+ };
+
+ const saveEdit = () => {
+ if (!editingId || !editDate) return;
+
+ const localDate = new Date(
+ editDate.getFullYear(),
+ editDate.getMonth(),
+ editDate.getDate(),
+ 12, 0, 0, 0
+ );
+
+ updateVaccine.mutate({
+ id: editingId,
+ name: editName,
+ date: localDate.toISOString(),
+ done: editDone,
+ notes: editNotes || undefined,
+ });
+ cancelEdit();
+ };
+
+ const toggleDone = (vaccine: Vaccine) => {
+ const localDate = new Date(
+ new Date(vaccine.date).getFullYear(),
+ new Date(vaccine.date).getMonth(),
+ new Date(vaccine.date).getDate(),
+ 12, 0, 0, 0
+ );
+
+ updateVaccine.mutate({
+ id: vaccine.id,
+ done: !vaccine.done,
+ date: localDate.toISOString(),
+ });
+ };
+
+ if (vaccines.length === 0) {
+ return Noch keine Impfungen aufgezeichnet.
;
+ }
+
+ return (
+
+ {vaccines.map((vaccine) => (
+
+ {editingId === vaccine.id ? (
+
+ ) : (
+ <>
+
+
+
{vaccine.name}
+ {vaccine.done && (
+
+ Erledigt
+
+ )}
+
+
+ {format(new Date(vaccine.date), "PPP", { locale: de })}
+
+ {vaccine.notes && (
+
{vaccine.notes}
+ )}
+
+
+
+
+
+
+
+
+
+
+ Impfung löschen
+
+ Möchten Sie diese Impfung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
+
+
+
+ Abbrechen
+ deleteVaccine.mutate({ id: vaccine.id })}
+ className="bg-red-600 text-white hover:bg-red-700"
+ >
+ Löschen
+
+
+
+
+
+ >
+ )}
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/dashboard/dashboard-content.tsx b/src/components/dashboard/dashboard-content.tsx
index 8fecd72..65650b9 100644
--- a/src/components/dashboard/dashboard-content.tsx
+++ b/src/components/dashboard/dashboard-content.tsx
@@ -4,7 +4,7 @@ import { format, differenceInMonths } from "date-fns";
import { de } from "date-fns/locale";
import Link from "next/link";
import { Button } from "@/components/ui/button";
-import { Plus, Baby, Activity, Calendar } from "lucide-react";
+import { Plus, Baby, Activity, Calendar, Syringe } from "lucide-react";
import { trpc } from "@/utils/trpc";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { useState } from "react";
@@ -14,24 +14,29 @@ export function DashboardContent() {
const { data: children, isLoading } = trpc.child.getAllByUser.useQuery();
const [selectedChildId, setSelectedChildId] = useState(null);
- // Get measurements for the selected child
const { data: measurements } = trpc.measurement.getByChildId.useQuery(
{ childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
- // Get vaccinations for the selected child - using child router as a fallback
- const { data: vaccinations } = trpc.child.getById.useQuery(
- { id: selectedChildId || "" },
+ const { data: vaccines } = trpc.vaccine.getByChildId.useQuery(
+ { childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
- // Get toothing data for the selected child - using child router as a fallback
- const { data: toothing } = trpc.child.getById.useQuery(
- { id: selectedChildId || "" },
+ const { data: teeth } = trpc.tooth.getByChildId.useQuery(
+ { childId: selectedChildId || "" },
{ enabled: !!selectedChildId }
);
+ const upcomingVaccines = vaccines?.filter(v => !v.done) || [];
+ const nextVaccine = upcomingVaccines[0];
+
+ const eruptedTeeth = teeth?.filter(t => t.status === "durchgebrochen").length || 0;
+
+ const selectedChild = children?.find(c => c.id === selectedChildId);
+ const ageInMonths = selectedChild ? differenceInMonths(new Date(), new Date(selectedChild.birthDate)) : 0;
+
return (
@@ -106,9 +111,28 @@ export function DashboardContent() {
Wichtige Termine und Erinnerungen
-
-
Keine anstehenden Termine.
-
+ {nextVaccine ? (
+
+
+
+
+
{nextVaccine.name}
+
+ Geplant: {format(new Date(nextVaccine.date), "PPP", { locale: de })}
+
+
+
+ {upcomingVaccines.length > 1 && (
+
+ + {upcomingVaccines.length - 1} weitere Impfungen geplant
+
+ )}
+
+ ) : (
+
+
Keine anstehenden Impfungen.
+
+ )}
@@ -149,7 +173,6 @@ export function DashboardContent() {
{selectedChildId ? (
- {/* Measurements Summary */}
Messungen
{measurements && measurements.length > 0 ? (
@@ -160,11 +183,11 @@ export function DashboardContent() {
Gewicht
-
{measurements[0].weightKg} kg
+
{measurements[0].weightKg?.toFixed(1)} kg
Größe
-
{measurements[0].heightCm} cm
+
{measurements[0].heightCm?.toFixed(1)} cm
@@ -173,31 +196,39 @@ export function DashboardContent() {
)}
- {/* Vaccinations Summary */}
Impfungen
- {vaccinations ? (
+ {vaccines && vaccines.length > 0 ? (
- Impfungen werden bald verfĂĽgbar sein
+ {vaccines.filter(v => v.done).length} von {vaccines.length} erledigt
+ {upcomingVaccines.length > 0 && (
+
+ {upcomingVaccines.length} ausstehend
+
+ )}
) : (
Keine Impfungen vorhanden
)}
- {/* Toothing Summary */}
Zahnung
- {toothing ? (
+ {ageInMonths >= 5 ? (
- Zahnungsdaten werden bald verfĂĽgbar sein
+ {eruptedTeeth} Zähne durchgebrochen
+
+
+ von 20 Milchzähnen
) : (
-
Keine Zahnungsdaten vorhanden
+
+ Zahnung beginnt ca. ab 5 Monaten
+
)}
@@ -214,4 +245,4 @@ export function DashboardContent() {
);
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..f414294
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -0,0 +1,141 @@
+"use client"
+
+import * as React from "react"
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogHeader.displayName = "AlertDialogHeader"
+
+const AlertDialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+AlertDialogFooter.displayName = "AlertDialogFooter"
+
+const AlertDialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogDescription.displayName =
+ AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
\ No newline at end of file
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..f38593b
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,122 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+))
+DialogContent.displayName = DialogPrimitive.Content.displayName
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogHeader.displayName = "DialogHeader"
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+)
+DialogFooter.displayName = "DialogFooter"
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogTitle.displayName = DialogPrimitive.Title.displayName
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogDescription.displayName = DialogPrimitive.Description.displayName
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000..7398d69
--- /dev/null
+++ b/src/components/ui/tooltip.tsx
@@ -0,0 +1,30 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+const TooltipProvider = TooltipPrimitive.Provider
+
+const Tooltip = TooltipPrimitive.Root
+
+const TooltipTrigger = TooltipPrimitive.Trigger
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, sideOffset = 4, ...props }, ref) => (
+
+))
+TooltipContent.displayName = TooltipPrimitive.Content.displayName
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
\ No newline at end of file
diff --git a/src/lib/percentiles.ts b/src/lib/percentiles.ts
new file mode 100644
index 0000000..1bae4c0
--- /dev/null
+++ b/src/lib/percentiles.ts
@@ -0,0 +1,188 @@
+export interface PercentileData {
+ ageMonths: number;
+ p3: number;
+ p10: number;
+ p25: number;
+ p50: number;
+ p75: number;
+ p90: number;
+ p97: number;
+}
+
+export const WHO_WEIGHT_BOYS: PercentileData[] = [
+ { ageMonths: 0, p3: 2.5, p10: 2.9, p25: 3.2, p50: 3.5, p75: 3.9, p90: 4.2, p97: 4.6 },
+ { ageMonths: 1, p3: 3.4, p10: 3.9, p25: 4.3, p50: 4.8, p75: 5.3, p90: 5.8, p97: 6.3 },
+ { ageMonths: 2, p3: 4.3, p10: 4.9, p25: 5.4, p50: 6.0, p75: 6.6, p90: 7.2, p97: 7.8 },
+ { ageMonths: 3, p3: 5.0, p10: 5.7, p25: 6.3, p50: 6.9, p75: 7.6, p90: 8.3, p97: 9.0 },
+ { ageMonths: 4, p3: 5.6, p10: 6.3, p25: 7.0, p50: 7.7, p75: 8.5, p90: 9.2, p97: 10.0 },
+ { ageMonths: 5, p3: 6.0, p10: 6.8, p25: 7.5, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.8 },
+ { ageMonths: 6, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.5 },
+ { ageMonths: 7, p3: 6.7, p10: 7.6, p25: 8.4, p50: 9.3, p75: 10.2, p90: 11.2, p97: 12.1 },
+ { ageMonths: 8, p3: 7.0, p10: 7.9, p25: 8.7, p50: 9.7, p75: 10.7, p90: 11.7, p97: 12.7 },
+ { ageMonths: 9, p3: 7.2, p10: 8.2, p25: 9.1, p50: 10.0, p75: 11.1, p90: 12.1, p97: 13.2 },
+ { ageMonths: 10, p3: 7.5, p10: 8.4, p25: 9.4, p50: 10.4, p75: 11.5, p90: 12.6, p97: 13.7 },
+ { ageMonths: 11, p3: 7.7, p10: 8.7, p25: 9.6, p50: 10.7, p75: 11.8, p90: 13.0, p97: 14.1 },
+ { ageMonths: 12, p3: 7.9, p10: 8.9, p25: 9.9, p50: 10.9, p75: 12.1, p90: 13.3, p97: 14.5 },
+ { ageMonths: 15, p3: 8.4, p10: 9.5, p25: 10.5, p50: 11.7, p75: 13.0, p90: 14.3, p97: 15.6 },
+ { ageMonths: 18, p3: 8.8, p10: 10.0, p25: 11.1, p50: 12.4, p75: 13.8, p90: 15.2, p97: 16.6 },
+ { ageMonths: 21, p3: 9.2, p10: 10.5, p25: 11.7, p50: 13.0, p75: 14.5, p90: 16.0, p97: 17.5 },
+ { ageMonths: 24, p3: 9.7, p10: 11.0, p25: 12.2, p50: 13.6, p75: 15.2, p90: 16.8, p97: 18.4 },
+ { ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 14.8, p75: 16.6, p90: 18.3, p97: 20.1 },
+ { ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.3, p50: 16.0, p75: 17.9, p90: 19.8, p97: 21.8 },
+ { ageMonths: 48, p3: 12.6, p10: 14.4, p25: 16.1, p50: 18.1, p75: 20.3, p90: 22.5, p97: 24.8 },
+ { ageMonths: 60, p3: 14.0, p10: 16.0, p25: 18.0, p50: 20.3, p75: 22.8, p90: 25.4, p97: 28.1 },
+];
+
+export const WHO_WEIGHT_GIRLS: PercentileData[] = [
+ { ageMonths: 0, p3: 2.4, p10: 2.8, p25: 3.1, p50: 3.4, p75: 3.7, p90: 4.1, p97: 4.4 },
+ { ageMonths: 1, p3: 3.2, p10: 3.7, p25: 4.1, p50: 4.5, p75: 5.0, p90: 5.4, p97: 5.9 },
+ { ageMonths: 2, p3: 4.0, p10: 4.6, p25: 5.1, p50: 5.6, p75: 6.2, p90: 6.7, p97: 7.3 },
+ { ageMonths: 3, p3: 4.7, p10: 5.3, p25: 5.9, p50: 6.5, p75: 7.1, p90: 7.8, p97: 8.5 },
+ { ageMonths: 4, p3: 5.2, p10: 5.9, p25: 6.5, p50: 7.2, p75: 7.9, p90: 8.7, p97: 9.4 },
+ { ageMonths: 5, p3: 5.7, p10: 6.4, p25: 7.1, p50: 7.8, p75: 8.6, p90: 9.4, p97: 10.2 },
+ { ageMonths: 6, p3: 6.1, p10: 6.8, p25: 7.6, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.9 },
+ { ageMonths: 7, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.6 },
+ { ageMonths: 8, p3: 6.7, p10: 7.5, p25: 8.4, p50: 9.2, p75: 10.2, p90: 11.2, p97: 12.2 },
+ { ageMonths: 9, p3: 6.9, p10: 7.8, p25: 8.7, p50: 9.6, p75: 10.6, p90: 11.7, p97: 12.8 },
+ { ageMonths: 10, p3: 7.2, p10: 8.1, p25: 9.0, p50: 10.0, p75: 11.0, p90: 12.1, p97: 13.3 },
+ { ageMonths: 11, p3: 7.4, p10: 8.3, p25: 9.3, p50: 10.3, p75: 11.4, p90: 12.6, p97: 13.8 },
+ { ageMonths: 12, p3: 7.6, p10: 8.6, p25: 9.6, p50: 10.6, p75: 11.8, p90: 13.0, p97: 14.3 },
+ { ageMonths: 15, p3: 8.1, p10: 9.2, p25: 10.3, p50: 11.5, p75: 12.8, p90: 14.1, p97: 15.5 },
+ { ageMonths: 18, p3: 8.6, p10: 9.8, p25: 10.9, p50: 12.2, p75: 13.7, p90: 15.1, p97: 16.6 },
+ { ageMonths: 21, p3: 9.1, p10: 10.3, p25: 11.5, p50: 12.9, p75: 14.5, p90: 16.0, p97: 17.6 },
+ { ageMonths: 24, p3: 9.6, p10: 10.9, p25: 12.2, p50: 13.6, p75: 15.3, p90: 17.0, p97: 18.7 },
+ { ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 15.0, p75: 16.8, p90: 18.7, p97: 20.6 },
+ { ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.4, p50: 16.2, p75: 18.2, p90: 20.3, p97: 22.5 },
+ { ageMonths: 48, p3: 12.8, p10: 14.6, p25: 16.4, p50: 18.5, p75: 20.9, p90: 23.4, p97: 26.0 },
+ { ageMonths: 60, p3: 14.3, p10: 16.4, p25: 18.5, p50: 21.0, p75: 23.8, p90: 26.8, p97: 30.0 },
+];
+
+export const WHO_HEIGHT_BOYS: PercentileData[] = [
+ { ageMonths: 0, p3: 46.1, p10: 47.4, p25: 48.6, p50: 49.9, p75: 51.2, p90: 52.4, p97: 53.7 },
+ { ageMonths: 1, p3: 50.8, p10: 52.2, p25: 53.6, p50: 55.0, p75: 56.5, p90: 57.9, p97: 59.4 },
+ { ageMonths: 2, p3: 54.4, p10: 55.9, p25: 57.4, p50: 59.0, p75: 60.6, p90: 62.1, p97: 63.7 },
+ { ageMonths: 3, p3: 57.3, p10: 58.9, p25: 60.5, p50: 62.2, p75: 63.9, p90: 65.6, p97: 67.3 },
+ { ageMonths: 4, p3: 59.7, p10: 61.4, p25: 63.1, p50: 64.9, p75: 66.7, p90: 68.5, p97: 70.3 },
+ { ageMonths: 5, p3: 61.7, p10: 63.5, p25: 65.3, p50: 67.2, p75: 69.1, p90: 71.0, p97: 72.9 },
+ { ageMonths: 6, p3: 63.4, p10: 65.3, p25: 67.2, p50: 69.2, p75: 71.2, p90: 73.2, p97: 75.2 },
+ { ageMonths: 7, p3: 64.9, p10: 66.9, p25: 68.9, p50: 71.0, p75: 73.1, p90: 75.2, p97: 77.3 },
+ { ageMonths: 8, p3: 66.3, p10: 68.4, p25: 70.5, p50: 72.6, p75: 74.8, p90: 77.0, p97: 79.2 },
+ { ageMonths: 9, p3: 67.5, p10: 69.7, p25: 71.9, p50: 74.1, p75: 76.4, p90: 78.7, p97: 81.0 },
+ { ageMonths: 10, p3: 68.7, p10: 71.0, p25: 73.3, p50: 75.6, p75: 77.9, p90: 80.3, p97: 82.7 },
+ { ageMonths: 11, p3: 69.9, p10: 72.2, p25: 74.6, p50: 77.0, p75: 79.4, p90: 81.8, p97: 84.3 },
+ { ageMonths: 12, p3: 71.0, p10: 73.4, p25: 75.8, p50: 78.3, p75: 80.8, p90: 83.3, p97: 85.8 },
+ { ageMonths: 15, p3: 73.9, p10: 76.5, p25: 79.1, p50: 81.8, p75: 84.5, p90: 87.2, p97: 89.9 },
+ { ageMonths: 18, p3: 76.5, p10: 79.3, p25: 82.1, p50: 85.0, p75: 87.9, p90: 90.8, p97: 93.7 },
+ { ageMonths: 21, p3: 78.9, p10: 81.8, p25: 84.8, p50: 87.8, p75: 90.9, p90: 93.9, p97: 97.0 },
+ { ageMonths: 24, p3: 81.1, p10: 84.2, p25: 87.3, p50: 90.5, p75: 93.7, p90: 97.0, p97: 100.2 },
+ { ageMonths: 30, p3: 85.1, p10: 88.5, p25: 91.9, p50: 95.4, p75: 98.9, p90: 102.4, p97: 106.0 },
+ { ageMonths: 36, p3: 88.7, p10: 92.4, p25: 96.1, p50: 99.9, p75: 103.7, p90: 107.5, p97: 111.3 },
+ { ageMonths: 48, p3: 95.1, p10: 99.3, p25: 103.5, p50: 107.8, p75: 112.2, p90: 116.6, p97: 121.0 },
+ { ageMonths: 60, p3: 100.8, p10: 105.5, p25: 110.2, p50: 115.0, p75: 119.9, p90: 124.8, p97: 129.7 },
+];
+
+export const WHO_HEIGHT_GIRLS: PercentileData[] = [
+ { ageMonths: 0, p3: 45.4, p10: 46.7, p25: 47.9, p50: 49.2, p75: 50.4, p90: 51.7, p97: 52.9 },
+ { ageMonths: 1, p3: 49.8, p10: 51.2, p25: 52.5, p50: 53.9, p75: 55.3, p90: 56.7, p97: 58.1 },
+ { ageMonths: 2, p3: 53.1, p10: 54.6, p25: 56.1, p50: 57.6, p75: 59.2, p90: 60.7, p97: 62.3 },
+ { ageMonths: 3, p3: 55.7, p10: 57.3, p25: 58.9, p50: 60.6, p75: 62.3, p90: 63.9, p97: 65.6 },
+ { ageMonths: 4, p3: 57.8, p10: 59.5, p25: 61.2, p50: 63.0, p75: 64.8, p90: 66.6, p97: 68.4 },
+ { ageMonths: 5, p3: 59.6, p10: 61.4, p25: 63.2, p50: 65.1, p75: 67.0, p90: 68.9, p97: 70.8 },
+ { ageMonths: 6, p3: 61.2, p10: 63.1, p25: 65.0, p50: 66.9, p75: 68.9, p90: 70.9, p97: 72.9 },
+ { ageMonths: 7, p3: 62.6, p10: 64.6, p25: 66.6, p50: 68.7, p75: 70.8, p90: 72.9, p97: 75.0 },
+ { ageMonths: 8, p3: 64.0, p10: 66.1, p25: 68.2, p50: 70.4, p75: 72.6, p90: 74.8, p97: 77.0 },
+ { ageMonths: 9, p3: 65.2, p10: 67.4, p25: 69.6, p50: 71.9, p75: 74.2, p90: 76.5, p97: 78.8 },
+ { ageMonths: 10, p3: 66.4, p10: 68.7, p25: 71.0, p50: 73.4, p75: 75.8, p90: 78.2, p97: 80.6 },
+ { ageMonths: 11, p3: 67.6, p10: 70.0, p25: 72.4, p50: 74.8, p75: 77.3, p90: 79.8, p97: 82.3 },
+ { ageMonths: 12, p3: 68.7, p10: 71.2, p25: 73.7, p50: 76.2, p75: 78.8, p90: 81.4, p97: 84.0 },
+ { ageMonths: 15, p3: 71.6, p10: 74.3, p25: 77.0, p50: 79.8, p75: 82.6, p90: 85.4, p97: 88.2 },
+ { ageMonths: 18, p3: 74.2, p10: 77.1, p25: 80.0, p50: 83.0, p75: 86.0, p90: 89.1, p97: 92.1 },
+ { ageMonths: 21, p3: 76.6, p10: 79.7, p25: 82.8, p50: 86.0, p75: 89.2, p90: 92.5, p97: 95.7 },
+ { ageMonths: 24, p3: 78.8, p10: 82.1, p25: 85.4, p50: 88.8, p75: 92.2, p90: 95.6, p97: 99.1 },
+ { ageMonths: 30, p3: 82.8, p10: 86.4, p25: 90.0, p50: 93.7, p75: 97.4, p90: 101.2, p97: 105.0 },
+ { ageMonths: 36, p3: 86.4, p10: 90.2, p25: 94.1, p50: 98.1, p75: 102.1, p90: 106.1, p97: 110.2 },
+ { ageMonths: 48, p3: 92.8, p10: 97.1, p25: 101.4, p50: 105.9, p75: 110.4, p90: 114.9, p97: 119.5 },
+ { ageMonths: 60, p3: 98.5, p10: 103.3, p25: 108.1, p50: 113.0, p75: 118.0, p90: 123.0, p97: 128.1 },
+];
+
+function interpolate(data: PercentileData[], ageMonths: number): PercentileData | null {
+ if (data.length === 0) return null;
+
+ const sorted = [...data].sort((a, b) => a.ageMonths - b.ageMonths);
+
+ if (ageMonths <= sorted[0].ageMonths) return sorted[0];
+ if (ageMonths >= sorted[sorted.length - 1].ageMonths) return sorted[sorted.length - 1];
+
+ let lower = sorted[0];
+ let upper = sorted[sorted.length - 1];
+
+ for (let i = 0; i < sorted.length - 1; i++) {
+ if (sorted[i].ageMonths <= ageMonths && sorted[i + 1].ageMonths >= ageMonths) {
+ lower = sorted[i];
+ upper = sorted[i + 1];
+ break;
+ }
+ }
+
+ const range = upper.ageMonths - lower.ageMonths;
+ const factor = range === 0 ? 0 : (ageMonths - lower.ageMonths) / range;
+
+ return {
+ ageMonths,
+ p3: lower.p3 + (upper.p3 - lower.p3) * factor,
+ p10: lower.p10 + (upper.p10 - lower.p10) * factor,
+ p25: lower.p25 + (upper.p25 - lower.p25) * factor,
+ p50: lower.p50 + (upper.p50 - lower.p50) * factor,
+ p75: lower.p75 + (upper.p75 - lower.p75) * factor,
+ p90: lower.p90 + (upper.p90 - lower.p90) * factor,
+ p97: lower.p97 + (upper.p97 - lower.p97) * factor,
+ };
+}
+
+export function calculatePercentile(
+ value: number,
+ ageMonths: number,
+ gender: "male" | "female" | "diverse" | "unknown",
+ type: "weight" | "height"
+): { percentile: number; label: string } | null {
+ const isMale = gender === "male";
+
+ const dataTable = type === "weight"
+ ? (isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS)
+ : (isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS);
+
+ const ref = interpolate(dataTable, Math.round(ageMonths));
+ if (!ref) return null;
+
+ let percentile = 50;
+
+ if (value <= ref.p3) percentile = 3;
+ else if (value <= ref.p10) percentile = 3 + ((value - ref.p3) / (ref.p10 - ref.p3)) * 7;
+ else if (value <= ref.p25) percentile = 10 + ((value - ref.p10) / (ref.p25 - ref.p10)) * 15;
+ else if (value <= ref.p50) percentile = 25 + ((value - ref.p25) / (ref.p50 - ref.p25)) * 25;
+ else if (value <= ref.p75) percentile = 50 + ((value - ref.p50) / (ref.p75 - ref.p50)) * 25;
+ else if (value <= ref.p90) percentile = 75 + ((value - ref.p75) / (ref.p90 - ref.p75)) * 15;
+ else if (value <= ref.p97) percentile = 90 + ((value - ref.p90) / (ref.p97 - ref.p90)) * 7;
+ else percentile = 97;
+
+ let label = "Normal";
+ if (percentile < 3) label = "Untergewicht / Klein";
+ else if (percentile < 10) label = "Unterdurchschnittlich";
+ else if (percentile < 90) label = "Normal";
+ else if (percentile < 97) label = "Ăśberdurchschnittlich";
+ else label = "Ăśbergewicht / GroĂź";
+
+ return { percentile: Math.round(percentile), label };
+}
+
+export function getPercentileDataForAge(
+ gender: "male" | "female" | "diverse" | "unknown",
+ type: "weight" | "height"
+): PercentileData[] {
+ const isMale = gender === "male";
+
+ if (type === "weight") {
+ return isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS;
+ }
+ return isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS;
+}
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 2706bf9..f5f6643 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -2,11 +2,15 @@ import { router } from "@/server/trpc"
import { childRouter } from "./routers/child"
import { authRouter } from "./routers/auth"
import { measurementRouter } from "./routers/measurement"
+import { toothRouter } from "./routers/tooth"
+import { vaccineRouter } from "./routers/vaccine"
export const appRouter = router({
child: childRouter,
auth: authRouter,
measurement: measurementRouter,
+ tooth: toothRouter,
+ vaccine: vaccineRouter,
})
// Export type helper
diff --git a/src/server/api/routers/child.ts b/src/server/api/routers/child.ts
index 8af8b2c..c1aa7ac 100644
--- a/src/server/api/routers/child.ts
+++ b/src/server/api/routers/child.ts
@@ -57,4 +57,68 @@ export const childRouter = router({
})
return child
}),
+
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1, "Name is required").optional(),
+ birthDate: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(),
+ gender: z.enum(["male", "female", "diverse", "unknown"]).optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { id, ...data } = input;
+
+ const child = await ctx.prisma.child.findFirst({
+ where: {
+ id,
+ userId: ctx.session.user.id,
+ },
+ });
+
+ if (!child) {
+ throw new Error("Child not found or unauthorized");
+ }
+
+ return ctx.prisma.child.update({
+ where: { id },
+ data: {
+ ...(data.name && { name: data.name }),
+ ...(data.birthDate && { birthDate: new Date(data.birthDate) }),
+ ...(data.gender && { gender: data.gender }),
+ },
+ });
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const child = await ctx.prisma.child.findFirst({
+ where: {
+ id: input.id,
+ userId: ctx.session.user.id,
+ },
+ });
+
+ if (!child) {
+ throw new Error("Child not found or unauthorized");
+ }
+
+ await ctx.prisma.toothStatus.deleteMany({
+ where: { childId: input.id },
+ });
+
+ await ctx.prisma.vaccine.deleteMany({
+ where: { childId: input.id },
+ });
+
+ await ctx.prisma.measurement.deleteMany({
+ where: { childId: input.id },
+ });
+
+ return ctx.prisma.child.delete({
+ where: { id: input.id },
+ });
+ }),
});
\ No newline at end of file
diff --git a/src/server/api/routers/measurement.ts b/src/server/api/routers/measurement.ts
index 58a0170..e04353b 100644
--- a/src/server/api/routers/measurement.ts
+++ b/src/server/api/routers/measurement.ts
@@ -34,4 +34,30 @@ export const measurementRouter = router({
},
});
}),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ // First verify the measurement belongs to a child owned by the user
+ const measurement = await ctx.prisma.measurement.findFirst({
+ where: {
+ id: input.id,
+ child: {
+ userId: ctx.session.user.id,
+ },
+ },
+ include: {
+ child: true,
+ },
+ });
+
+ if (!measurement) {
+ throw new Error("Measurement not found or unauthorized");
+ }
+
+ // Delete the measurement
+ return ctx.prisma.measurement.delete({
+ where: { id: input.id },
+ });
+ }),
});
\ No newline at end of file
diff --git a/src/server/api/routers/tooth.ts b/src/server/api/routers/tooth.ts
new file mode 100644
index 0000000..3dbbb83
--- /dev/null
+++ b/src/server/api/routers/tooth.ts
@@ -0,0 +1,117 @@
+import { z } from "zod";
+import { protectedProcedure, router } from "@/server/trpc";
+
+export const toothRouter = router({
+ getByChildId: protectedProcedure
+ .input(z.object({ childId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.toothStatus.findMany({
+ where: {
+ childId: input.childId,
+ },
+ orderBy: {
+ date: "desc",
+ },
+ });
+ }),
+
+ add: protectedProcedure
+ .input(
+ z.object({
+ childId: z.string(),
+ toothLabel: z.string(),
+ date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"),
+ status: z.enum(["durchgebrochen", "locker", "fehlend"]).default("durchgebrochen"),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const child = await ctx.prisma.child.findFirst({
+ where: {
+ id: input.childId,
+ userId: ctx.session.user.id,
+ },
+ });
+
+ if (!child) {
+ throw new Error("Child not found or unauthorized");
+ }
+
+ const existing = await ctx.prisma.toothStatus.findFirst({
+ where: {
+ childId: input.childId,
+ toothLabel: input.toothLabel,
+ },
+ });
+
+ if (existing) {
+ return ctx.prisma.toothStatus.update({
+ where: { id: existing.id },
+ data: {
+ date: new Date(input.date),
+ status: input.status,
+ },
+ });
+ }
+
+ return ctx.prisma.toothStatus.create({
+ data: {
+ childId: input.childId,
+ toothLabel: input.toothLabel,
+ date: new Date(input.date),
+ status: input.status,
+ },
+ });
+ }),
+
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(),
+ status: z.enum(["durchgebrochen", "locker", "fehlend"]).optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const toothStatus = await ctx.prisma.toothStatus.findFirst({
+ where: {
+ id: input.id,
+ child: {
+ userId: ctx.session.user.id,
+ },
+ },
+ });
+
+ if (!toothStatus) {
+ throw new Error("Tooth status not found or unauthorized");
+ }
+
+ return ctx.prisma.toothStatus.update({
+ where: { id: input.id },
+ data: {
+ ...(input.date && { date: new Date(input.date) }),
+ ...(input.status && { status: input.status }),
+ },
+ });
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const toothStatus = await ctx.prisma.toothStatus.findFirst({
+ where: {
+ id: input.id,
+ child: {
+ userId: ctx.session.user.id,
+ },
+ },
+ });
+
+ if (!toothStatus) {
+ throw new Error("Tooth status not found or unauthorized");
+ }
+
+ return ctx.prisma.toothStatus.delete({
+ where: { id: input.id },
+ });
+ }),
+});
diff --git a/src/server/api/routers/vaccine.ts b/src/server/api/routers/vaccine.ts
new file mode 100644
index 0000000..6168c03
--- /dev/null
+++ b/src/server/api/routers/vaccine.ts
@@ -0,0 +1,106 @@
+import { z } from "zod";
+import { protectedProcedure, router } from "@/server/trpc";
+
+export const vaccineRouter = router({
+ getByChildId: protectedProcedure
+ .input(z.object({ childId: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return ctx.prisma.vaccine.findMany({
+ where: {
+ childId: input.childId,
+ },
+ orderBy: {
+ date: "asc",
+ },
+ });
+ }),
+
+ add: protectedProcedure
+ .input(
+ z.object({
+ childId: z.string(),
+ name: z.string().min(1, "Name is required"),
+ date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"),
+ done: z.boolean().default(false),
+ notes: z.string().optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const child = await ctx.prisma.child.findFirst({
+ where: {
+ id: input.childId,
+ userId: ctx.session.user.id,
+ },
+ });
+
+ if (!child) {
+ throw new Error("Child not found or unauthorized");
+ }
+
+ return ctx.prisma.vaccine.create({
+ data: {
+ childId: input.childId,
+ name: input.name,
+ date: new Date(input.date),
+ done: input.done,
+ notes: input.notes,
+ },
+ });
+ }),
+
+ update: protectedProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ name: z.string().min(1, "Name is required").optional(),
+ date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(),
+ done: z.boolean().optional(),
+ notes: z.string().optional(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const vaccine = await ctx.prisma.vaccine.findFirst({
+ where: {
+ id: input.id,
+ child: {
+ userId: ctx.session.user.id,
+ },
+ },
+ });
+
+ if (!vaccine) {
+ throw new Error("Vaccine not found or unauthorized");
+ }
+
+ return ctx.prisma.vaccine.update({
+ where: { id: input.id },
+ data: {
+ ...(input.name && { name: input.name }),
+ ...(input.date && { date: new Date(input.date) }),
+ ...(input.done !== undefined && { done: input.done }),
+ ...(input.notes !== undefined && { notes: input.notes }),
+ },
+ });
+ }),
+
+ delete: protectedProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ const vaccine = await ctx.prisma.vaccine.findFirst({
+ where: {
+ id: input.id,
+ child: {
+ userId: ctx.session.user.id,
+ },
+ },
+ });
+
+ if (!vaccine) {
+ throw new Error("Vaccine not found or unauthorized");
+ }
+
+ return ctx.prisma.vaccine.delete({
+ where: { id: input.id },
+ });
+ }),
+});