Add vaccines, teeth tracking, child management, and WHO growth percentiles

- Add tooth router and vaccine router with full CRUD operations
- Implement vaccine form and list components with edit/delete functionality
- Connect denture visualization to database for persistent tooth tracking
- Add child edit dialog and delete functionality with cascade deletion
- Implement WHO growth percentile calculations for weight and height
- Update dashboard to display real data for measurements, vaccines, and teeth
- Add dialog, alert-dialog, and tooltip UI components
- Install @radix-ui/react-dialog dependency
This commit is contained in:
Philip
2026-02-16 21:16:56 +01:00
parent 84a2b3bf0d
commit e6ad08c4a9
19 changed files with 3095 additions and 81 deletions

858
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<>
<Header />

View File

@@ -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 (
<>
<Header />
@@ -31,6 +69,7 @@ export default function RegisterPage() {
placeholder="Max Mustermann"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
</div>
@@ -42,6 +81,7 @@ export default function RegisterPage() {
placeholder="max@bambino.at"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
@@ -53,24 +93,16 @@ export default function RegisterPage() {
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<Button
className="w-full"
onClick={() => {
registerMutation.mutate({ name, email, password }, {
onSuccess: () => {
alert("Registrierung erfolgreich! 🎉")
// TODO: Weiterleitung zu /login oder direkt einloggen
},
onError: (err) => {
alert("Fehler: " + err.message)
}
})
}}
onClick={handleRegister}
disabled={isLoading}
>
Registrieren
{isLoading ? "Wird registriert..." : "Registrieren"}
</Button>
<p className="text-center text-sm text-zinc-500">

View File

@@ -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<Date | undefined>(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<AppRouter>) => {
@@ -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<MeasurementFormValues>({
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) {
<div className="space-y-6">
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-rose-100 to-rose-200 p-6">
<div className="flex items-center gap-4">
<div className="bg-white/80 p-3 rounded-full shadow-sm">
<Baby className="h-8 w-8 text-rose-600" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-white/80 p-3 rounded-full shadow-sm">
<Baby className="h-8 w-8 text-rose-600" />
</div>
<div>
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
<p className="text-rose-700">
{ageInMonths} Monate {format(new Date(child.birthDate), "PPP", { locale: de })}
</p>
</div>
</div>
<div>
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
<p className="text-rose-700">
{ageInMonths} Monate {format(new Date(child.birthDate), "PPP", { locale: de })}
</p>
<div className="flex items-center gap-2">
<ChildEditDialog child={child} onSuccess={() => {
trpc.useUtils().child.getById.invalidate({ id: childId });
}} />
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4 mr-1" />
Löschen
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Kind löschen</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteChild}
className="bg-red-600 text-white hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? "Wird gelöscht..." : "Löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
@@ -196,7 +343,15 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
<Stethoscope className="h-5 w-5 text-rose-500" />
<h3 className="font-medium text-gray-700">Entwicklung</h3>
</div>
<p className="text-lg">Normal</p>
<p className={`text-lg ${getDevelopmentStatus() === 'Achtung' ? 'text-amber-600' : 'text-gray-900'}`}>
{getDevelopmentStatus()}
</p>
{weightPercentile && (
<p className="text-sm text-gray-500">Gewicht: {weightPercentile.percentile}. Perzentil</p>
)}
{heightPercentile && (
<p className="text-sm text-gray-500">Größe: {heightPercentile.percentile}. Perzentil</p>
)}
</div>
</div>
</CardContent>
@@ -391,6 +546,59 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
</ResponsiveContainer>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Liste der Messungen</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowMeasurementsList(!showMeasurementsList)}
className="flex items-center gap-2"
>
<List className="h-4 w-4" />
<span>{showMeasurementsList ? "Ausblenden" : "Anzeigen"}</span>
</Button>
</div>
{showMeasurementsList && (
<div className="space-y-2">
{measurements.map((measurement) => (
<div key={measurement.id} className="flex items-center justify-between p-4 bg-white rounded-lg border">
<div>
<p className="font-medium">{format(new Date(measurement.date), "PPP", { locale: de })}</p>
<p className="text-sm text-gray-500">
Gewicht: {measurement.weightKg} kg Größe: {measurement.heightCm} cm
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Messung löschen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie diese Messung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMeasurement.mutate({ id: measurement.id })}
className="bg-red-600 text-white hover:bg-red-700"
>
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
)}
</div>
</div>
) : (
<p className="text-muted-foreground">Noch keine Messungen aufgezeichnet.</p>
@@ -414,32 +622,49 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.vaccinations && (
<div className="p-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
<Plus className="h-4 w-4 mr-2" />
Impfung hinzufügen
</Button>
</div>
<div className="space-y-4">
<p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>
<div className="bg-white rounded-md p-4 border">
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
<ul className="space-y-2">
<li className="flex items-center justify-between">
<span>6-fach Impfung</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Pneumokokken</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Rotaviren</span>
<span className="text-sm text-gray-500">6 Wochen</span>
</li>
</ul>
{!showVaccineForm ? (
<div className="flex justify-end">
<Button
size="sm"
className="bg-rose-600 text-white hover:bg-rose-700"
onClick={() => setShowVaccineForm(true)}
>
<Plus className="h-4 w-4 mr-2" />
Impfung hinzufügen
</Button>
</div>
) : (
<VaccineForm
childId={childId}
onSuccess={() => {
setShowVaccineForm(false);
refetchVaccines();
}}
onCancel={() => setShowVaccineForm(false)}
/>
)}
<VaccineList
vaccines={vaccines || []}
onRefetch={() => refetchVaccines()}
/>
<div className="bg-white rounded-md p-4 border mt-4">
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
<ul className="space-y-2">
<li className="flex items-center justify-between">
<span>6-fach Impfung</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Pneumokokken</span>
<span className="text-sm text-gray-500">2 Monate</span>
</li>
<li className="flex items-center justify-between">
<span>Rotaviren</span>
<span className="text-sm text-gray-500">6 Wochen</span>
</li>
</ul>
</div>
</div>
)}
@@ -460,15 +685,7 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
{expandedSections.toothing && (
<div className="p-4 space-y-4">
<div className="flex justify-end">
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
<Plus className="h-4 w-4 mr-2" />
Zahn hinzufügen
</Button>
</div>
<div className="space-y-4">
<p className="text-muted-foreground">Noch keine Zähne aufgezeichnet.</p>
<div className="bg-white rounded-md p-4 border">
<h3 className="text-lg font-medium mb-2">Zahnungsplan</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -508,6 +725,31 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
</div>
</div>
</div>
<div className="mt-4">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-medium">Zahnstatus</h3>
<TooltipProvider>
<UITooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">about:blank#blocked
<InfoIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Klicken Sie auf einen Zahn in der Visualisierung oder wählen Sie einen Zahn aus dem Dropdown-Menü, um den Durchbruch zu markieren.</p>
<p>Rote Zähne sind bereits durchgebrochen.</p>
</TooltipContent>
</UITooltip>
</TooltipProvider>
</div>
<DentureVisualization
childId={childId}
initialTeeth={teethStatus || []}
onToothSaved={() => refetchTeeth()}
showTitle={false}
/>
</div>
</div>
</div>
)}

View File

@@ -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<typeof childFormSchema>;
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<Date>(new Date(child.birthDate));
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const form = useForm<ChildFormValues>({
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 (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setOpen(true)}
className="text-gray-600 hover:text-gray-800"
>
<Pencil className="h-4 w-4 mr-1" />
Bearbeiten
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Kind bearbeiten</DialogTitle>
<DialogDescription>
Bearbeiten Sie die Daten von {child.name}
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-gray-700">
Name *
</label>
<Input
id="name"
{...form.register("name")}
disabled={isLoading}
/>
{form.formState.errors.name && (
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Geburtsdatum *
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={(d) => d && setDate(d)}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Geschlecht
</label>
<Select
value={form.watch("gender")}
onValueChange={(value) => form.setValue("gender", value as "male" | "female" | "diverse" | "unknown")}
>
<SelectTrigger>
<SelectValue placeholder="Geschlecht auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Männlich</SelectItem>
<SelectItem value="female">Weiblich</SelectItem>
<SelectItem value="diverse">Divers</SelectItem>
<SelectItem value="unknown">Unbekannt</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Abbrechen
</Button>
<Button
type="submit"
disabled={isLoading}
className="bg-rose-600 text-white hover:bg-rose-700"
>
{isLoading ? "Wird gespeichert..." : "Speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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<Tooth[]>([]);
const [lowerTeeth, setLowerTeeth] = useState<Tooth[]>([]);
const [selectedTooth, setSelectedTooth] = useState<Tooth | null>(null);
const [eruptionDate, setEruptionDate] = useState<Date | undefined>(new Date());
const [hoveredTooth, setHoveredTooth] = useState<Tooth | null>(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<string, ToothStatusFromDB>();
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 (
<TooltipProvider>
<div className="space-y-4">
{showTitle && (
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Zahnstatus</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<InfoIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Klicken Sie auf einen Zahn, um den Durchbruch zu markieren.</p>
<p>Rote Zähne sind bereits durchgebrochen.</p>
</TooltipContent>
</Tooltip>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<label className="text-sm font-medium">Zahn auswählen:</label>
<Select onValueChange={handleToothSelect} value={selectedTooth?.id || "placeholder"}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Zahn auswählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="placeholder" disabled>Zahn auswählen</SelectItem>
<SelectItem value="group-upper" disabled className="font-semibold">Oberkiefer</SelectItem>
{upperTeeth.map((tooth) => (
<SelectItem key={tooth.id} value={tooth.id}>
{tooth.name} {tooth.erupted && "✓"}
</SelectItem>
))}
<SelectItem value="group-lower" disabled className="font-semibold">Unterkiefer</SelectItem>
{lowerTeeth.map((tooth) => (
<SelectItem key={tooth.id} value={tooth.id}>
{tooth.name} {tooth.erupted && "✓"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col md:flex-row md:items-start md:justify-center gap-4">
<div className="flex flex-col items-center">
<h3 className="text-lg font-medium mb-2">Oberkiefer</h3>
<div className="relative w-[320px] h-[180px]">
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
{upperTeeth.map((tooth) => (
<g key={tooth.id}>
<rect
x={tooth.x - 2}
y={tooth.y - 2}
width={tooth.width + 4}
height={tooth.height + 4}
fill="transparent"
className="cursor-pointer"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
<path
d={getToothPath(tooth, true)}
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
className="cursor-pointer transition-all duration-200"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
{tooth.erupted && (
<text
x={tooth.x + tooth.width / 2}
y={tooth.y + tooth.height / 2}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-bold fill-rose-600"
>
</text>
)}
</g>
))}
</svg>
</div>
</div>
<div className="flex flex-col items-center">
<h3 className="text-lg font-medium mb-2">Unterkiefer</h3>
<div className="relative w-[320px] h-[180px]">
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
{lowerTeeth.map((tooth) => (
<g key={tooth.id}>
<rect
x={tooth.x - 2}
y={tooth.y - 2}
width={tooth.width + 4}
height={tooth.height + 4}
fill="transparent"
className="cursor-pointer"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
<path
d={getToothPath(tooth, false)}
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
className="cursor-pointer transition-all duration-200"
onClick={() => handleToothClick(tooth)}
onMouseEnter={() => setHoveredTooth(tooth)}
onMouseLeave={() => setHoveredTooth(null)}
/>
{tooth.erupted && (
<text
x={tooth.x + tooth.width / 2}
y={tooth.y + tooth.height / 2}
textAnchor="middle"
dominantBaseline="middle"
className="text-sm font-bold fill-rose-600"
>
</text>
)}
</g>
))}
</svg>
</div>
</div>
</div>
{selectedTooth && (
<div className="p-3 border rounded-lg bg-white shadow-sm">
<h4 className="font-medium mb-2">{selectedTooth.name}</h4>
{selectedTooth.erupted && selectedTooth.eruptionDate && (
<p className="text-sm text-gray-500 mb-2">
Durchbruch: {format(selectedTooth.eruptionDate, "PPP", { locale: de })}
</p>
)}
<div className="space-y-3">
<div className="space-y-1">
<label className="text-sm font-medium">
{selectedTooth.erupted ? "Datum ändern" : "Durchbruchdatum"}
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!eruptionDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{eruptionDate ? (
format(eruptionDate, "PPP", { locale: de })
) : (
<span>Datum auswählen</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={eruptionDate}
onSelect={handleEruptionDateSelect}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="flex gap-2">
<Button
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
onClick={handleSaveEruption}
disabled={addTooth.isPending || !eruptionDate}
>
{addTooth.isPending ? "Wird gespeichert..." : (
<>
<Plus className="h-4 w-4 mr-2" />
Speichern
</>
)}
</Button>
<Button
variant="outline"
onClick={() => setSelectedTooth(null)}
disabled={addTooth.isPending}
>
Abbrechen
</Button>
</div>
</div>
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -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<typeof vaccineFormSchema>;
interface VaccineFormProps {
childId: string;
onSuccess: () => void;
onCancel: () => void;
}
export function VaccineForm({ childId, onSuccess, onCancel }: VaccineFormProps) {
const [date, setDate] = useState<Date | undefined>(new Date());
const form = useForm<VaccineFormValues>({
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 (
<form onSubmit={onSubmit} className="space-y-4 p-4 border rounded-lg bg-gray-50">
<div className="space-y-2">
<label htmlFor="name" className="text-sm font-medium text-gray-700">
Impfname *
</label>
<Input
id="name"
placeholder="z.B. 6-fach Impfung"
{...form.register("name")}
disabled={addVaccine.isPending}
/>
{form.formState.errors.name && (
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
Datum *
</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={setDate}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium text-gray-700">
Notizen
</label>
<Input
id="notes"
placeholder="Optionale Notizen"
{...form.register("notes")}
disabled={addVaccine.isPending}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="done"
{...form.register("done")}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="done" className="text-sm text-gray-700">
Bereits durchgeführt
</label>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
disabled={addVaccine.isPending}
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
>
{addVaccine.isPending ? "Wird gespeichert..." : (
<>
<Check className="h-4 w-4 mr-2" />
Speichern
</>
)}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={addVaccine.isPending}
>
<X className="h-4 w-4 mr-2" />
Abbrechen
</Button>
</div>
</form>
);
}

View File

@@ -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<string | null>(null);
const [editName, setEditName] = useState("");
const [editDate, setEditDate] = useState<Date | undefined>();
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 <p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>;
}
return (
<div className="space-y-2">
{vaccines.map((vaccine) => (
<div
key={vaccine.id}
className={`flex items-center justify-between p-4 rounded-lg border ${
vaccine.done ? "bg-green-50 border-green-200" : "bg-white"
}`}
>
{editingId === vaccine.id ? (
<div className="flex-1 space-y-2">
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
placeholder="Impfname"
disabled={updateVaccine.isPending}
/>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!editDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{editDate ? format(editDate, "PPP", { locale: de }) : <span>Datum</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={editDate}
onSelect={setEditDate}
initialFocus
locale={de}
/>
</PopoverContent>
</Popover>
<Input
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
placeholder="Notizen"
disabled={updateVaccine.isPending}
/>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={editDone}
onChange={(e) => setEditDone(e.target.checked)}
className="h-4 w-4 rounded"
/>
<label className="text-sm">Durchgeführt</label>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={saveEdit} disabled={updateVaccine.isPending}>
<Check className="h-4 w-4 mr-1" /> Speichern
</Button>
<Button size="sm" variant="outline" onClick={cancelEdit} disabled={updateVaccine.isPending}>
<X className="h-4 w-4 mr-1" /> Abbrechen
</Button>
</div>
</div>
) : (
<>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">{vaccine.name}</p>
{vaccine.done && (
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
Erledigt
</span>
)}
</div>
<p className="text-sm text-gray-500">
{format(new Date(vaccine.date), "PPP", { locale: de })}
</p>
{vaccine.notes && (
<p className="text-sm text-gray-400 mt-1">{vaccine.notes}</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleDone(vaccine)}
disabled={updateVaccine.isPending}
className={vaccine.done ? "text-green-600" : "text-gray-400"}
>
<Check className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => startEdit(vaccine)}
disabled={updateVaccine.isPending}
>
<Pencil className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Impfung löschen</AlertDialogTitle>
<AlertDialogDescription>
Möchten Sie diese Impfung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteVaccine.mutate({ id: vaccine.id })}
className="bg-red-600 text-white hover:bg-red-700"
>
Löschen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
)}
</div>
))}
</div>
);
}

View File

@@ -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<string | null>(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 (
<div className="space-y-6">
<div className="flex justify-between items-center">
@@ -106,9 +111,28 @@ export function DashboardContent() {
<CardDescription className="text-zinc-500">Wichtige Termine und Erinnerungen</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-zinc-500 mb-2">Keine anstehenden Termine.</p>
</div>
{nextVaccine ? (
<div className="space-y-3">
<div className="flex items-center p-3 rounded-lg bg-blue-50 border border-blue-100">
<Syringe className="h-5 w-5 text-blue-500 mr-3" />
<div>
<p className="font-medium text-blue-900">{nextVaccine.name}</p>
<p className="text-sm text-blue-600">
Geplant: {format(new Date(nextVaccine.date), "PPP", { locale: de })}
</p>
</div>
</div>
{upcomingVaccines.length > 1 && (
<p className="text-sm text-zinc-500">
+ {upcomingVaccines.length - 1} weitere Impfungen geplant
</p>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-zinc-500 mb-2">Keine anstehenden Impfungen.</p>
</div>
)}
</CardContent>
</Card>
</div>
@@ -149,7 +173,6 @@ export function DashboardContent() {
{selectedChildId ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Measurements Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Messungen</h3>
{measurements && measurements.length > 0 ? (
@@ -160,11 +183,11 @@ export function DashboardContent() {
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<p className="text-xs text-zinc-500">Gewicht</p>
<p className="font-medium">{measurements[0].weightKg} kg</p>
<p className="font-medium">{measurements[0].weightKg?.toFixed(1)} kg</p>
</div>
<div>
<p className="text-xs text-zinc-500">Größe</p>
<p className="font-medium">{measurements[0].heightCm} cm</p>
<p className="font-medium">{measurements[0].heightCm?.toFixed(1)} cm</p>
</div>
</div>
</div>
@@ -173,31 +196,39 @@ export function DashboardContent() {
)}
</div>
{/* Vaccinations Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Impfungen</h3>
{vaccinations ? (
{vaccines && vaccines.length > 0 ? (
<div>
<p className="text-sm text-zinc-600">
Impfungen werden bald verfügbar sein
{vaccines.filter(v => v.done).length} von {vaccines.length} erledigt
</p>
{upcomingVaccines.length > 0 && (
<p className="text-xs text-amber-600 mt-1">
{upcomingVaccines.length} ausstehend
</p>
)}
</div>
) : (
<p className="text-sm text-zinc-500">Keine Impfungen vorhanden</p>
)}
</div>
{/* Toothing Summary */}
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
<h3 className="font-medium text-zinc-800 mb-2">Zahnung</h3>
{toothing ? (
{ageInMonths >= 5 ? (
<div>
<p className="text-sm text-zinc-600">
Zahnungsdaten werden bald verfügbar sein
{eruptedTeeth} Zähne durchgebrochen
</p>
<p className="text-xs text-zinc-500 mt-1">
von 20 Milchzähnen
</p>
</div>
) : (
<p className="text-sm text-zinc-500">Keine Zahnungsdaten vorhanden</p>
<p className="text-sm text-zinc-500">
Zahnung beginnt ca. ab 5 Monaten
</p>
)}
</div>
</div>
@@ -214,4 +245,4 @@ export function DashboardContent() {
</Card>
</div>
);
}
}

View File

@@ -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<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -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<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

188
src/lib/percentiles.ts Normal file
View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 },
});
}),
});

View File

@@ -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 },
});
}),
});

View File

@@ -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 },
});
}),
});

View File

@@ -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 },
});
}),
});