From e6ad08c4a9aa317b533086e6932a9fcfbf95319b Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 16 Feb 2026 21:16:56 +0100 Subject: [PATCH] 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 --- package-lock.json | 858 ++++++++++++++++++ package.json | 3 + src/app/dashboard/child/[childId]/page.tsx | 6 +- src/app/register/page.tsx | 56 +- src/components/child/child-detail-content.tsx | 330 ++++++- src/components/child/child-edit-dialog.tsx | 206 +++++ .../child/denture-visualization.tsx | 416 +++++++++ src/components/child/vaccine-form.tsx | 176 ++++ src/components/child/vaccine-list.tsx | 252 +++++ .../dashboard/dashboard-content.tsx | 75 +- src/components/ui/alert-dialog.tsx | 141 +++ src/components/ui/dialog.tsx | 122 +++ src/components/ui/tooltip.tsx | 30 + src/lib/percentiles.ts | 188 ++++ src/server/api/root.ts | 4 + src/server/api/routers/child.ts | 64 ++ src/server/api/routers/measurement.ts | 26 + src/server/api/routers/tooth.ts | 117 +++ src/server/api/routers/vaccine.ts | 106 +++ 19 files changed, 3095 insertions(+), 81 deletions(-) create mode 100644 src/components/child/child-edit-dialog.tsx create mode 100644 src/components/child/denture-visualization.tsx create mode 100644 src/components/child/vaccine-form.tsx create mode 100644 src/components/child/vaccine-list.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/percentiles.ts create mode 100644 src/server/api/routers/tooth.ts create mode 100644 src/server/api/routers/vaccine.ts diff --git a/package-lock.json b/package-lock.json index a9613ad..00efe00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@hookform/resolvers": "^5.0.1", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.5.0", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.7", @@ -18,6 +20,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.72.0", "@trpc/client": "^11.0.2", "@trpc/next": "^11.0.2", @@ -1474,6 +1477,264 @@ "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", "license": "MIT" }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.7.tgz", + "integrity": "sha512-7Gx1gcoltd0VxKoR8mc+TAVbzvChJyZryZsTam0UhoL92z0L+W8ovxvcgvd+nkz24y7Qc51JQKBAGe4+825tYw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.7", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dialog": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.7.tgz", + "integrity": "sha512-EIdma8C0C/I6kL6sO02avaCRqi3fmWJpxH6mqbVScorW6nNktzKJT/le7VPho3o/7wCsyRg3z0+Q+Obr0Gy/VQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.3", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.5", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz", + "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", + "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz", + "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz", + "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz", @@ -1594,6 +1855,319 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -3018,6 +3592,257 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.0.tgz", + "integrity": "sha512-b1Sdc75s7zN9B8ONQTGBSHL3XS8+IcjcOIY51fhM4R1Hx8s0YbgqgyNZiri4qcYMVZK8hfCZVBiyCm7N9rs0rw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.6", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.3", + "@radix-ui/react-portal": "1.1.5", + "@radix-ui/react-presence": "1.1.3", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-slot": "1.2.0", + "@radix-ui/react-use-controllable-state": "1.1.1", + "@radix-ui/react-visually-hidden": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz", + "integrity": "sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.0.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-presence": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.3.tgz", + "integrity": "sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-primitive": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz", + "integrity": "sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz", + "integrity": "sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz", + "integrity": "sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -3051,6 +3876,39 @@ } } }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", diff --git a/package.json b/package.json index 2ceb6db..8ff2065 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "@hookform/resolvers": "^5.0.1", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.5.0", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-navigation-menu": "^1.2.5", "@radix-ui/react-popover": "^1.1.7", @@ -19,6 +21,7 @@ "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.72.0", "@trpc/client": "^11.0.2", "@trpc/next": "^11.0.2", diff --git a/src/app/dashboard/child/[childId]/page.tsx b/src/app/dashboard/child/[childId]/page.tsx index 620914c..9d390c1 100644 --- a/src/app/dashboard/child/[childId]/page.tsx +++ b/src/app/dashboard/child/[childId]/page.tsx @@ -8,18 +8,18 @@ import { notFound } from "next/navigation" export default async function ChildDetailPage({ params, }: { - params: { childId: string } + params: Promise<{ childId: string }> }) { const session = await getAuthSession() if (!session?.user) redirect("/login") - const { childId } = params; + const { childId } = await params if (!childId) { notFound(); } - + return ( <>
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 7071759..854666f 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -9,13 +9,51 @@ import Link from "next/link" import { useState } from "react" import { Header } from "@/components/layout/header" import { Footer } from "@/components/layout/footer" +import { useRouter } from "next/navigation" +import { signIn } from "next-auth/react" +import { toast } from "sonner" export default function RegisterPage() { const [email, setEmail] = useState("") const [name, setName] = useState("") const [password, setPassword] = useState("") + const [isLoading, setIsLoading] = useState(false) + const router = useRouter() const registerMutation = trpc.auth.register.useMutation() + const handleRegister = async () => { + if (!email || !name || !password) { + toast.error("Bitte füllen Sie alle Felder aus") + return + } + + setIsLoading(true) + + try { + await registerMutation.mutateAsync({ name, email, password }) + + // Automatically sign in the user after successful registration + const result = await signIn("credentials", { + email, + password, + redirect: false, + }) + + if (result?.error) { + toast.error("Anmeldung fehlgeschlagen: " + result.error) + setIsLoading(false) + return + } + + // Redirect to the app + toast.success("Registrierung erfolgreich! 🎉") + router.push("/app") + } catch (error) { + toast.error("Fehler bei der Registrierung: " + (error instanceof Error ? error.message : "Unbekannter Fehler")) + setIsLoading(false) + } + } + return ( <>
@@ -31,6 +69,7 @@ export default function RegisterPage() { placeholder="Max Mustermann" value={name} onChange={(e) => setName(e.target.value)} + disabled={isLoading} /> @@ -42,6 +81,7 @@ export default function RegisterPage() { placeholder="max@bambino.at" value={email} onChange={(e) => setEmail(e.target.value)} + disabled={isLoading} /> @@ -53,24 +93,16 @@ export default function RegisterPage() { placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} + disabled={isLoading} />

diff --git a/src/components/child/child-detail-content.tsx b/src/components/child/child-detail-content.tsx index 749faf9..bcb0033 100644 --- a/src/components/child/child-detail-content.tsx +++ b/src/components/child/child-detail-content.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { format, differenceInMonths } from "date-fns"; import { de } from "date-fns/locale"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -25,14 +26,39 @@ import type { AppRouter } from "@/server/api/root"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; -import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp } from "lucide-react"; +import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp, Trash2, List, InfoIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { DentureVisualization } from "@/components/child/denture-visualization"; +import { VaccineForm } from "@/components/child/vaccine-form"; +import { VaccineList } from "@/components/child/vaccine-list"; +import { ChildEditDialog } from "@/components/child/child-edit-dialog"; +import { calculatePercentile } from "@/lib/percentiles"; +import { + Tooltip as UITooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; const measurementFormSchema = z.object({ date: z.date({ required_error: "Bitte wähle ein Datum", - }), + }).refine( + (date) => date <= new Date(), + "Messungen können nicht für zukünftige Daten hinzugefügt werden" + ), weightKg: z.string().refine((val) => !isNaN(Number(val)) && Number(val) > 0, { message: "Bitte gib ein gültiges Gewicht ein", }), @@ -48,6 +74,7 @@ interface ChildDetailContentProps { } export function ChildDetailContent({ childId }: ChildDetailContentProps) { + const router = useRouter(); const [isLoading, setIsLoading] = useState(false); const [date, setDate] = useState(new Date()); const [expandedSections, setExpandedSections] = useState({ @@ -56,6 +83,35 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { vaccinations: false, toothing: false, }); + const [showMeasurementsList, setShowMeasurementsList] = useState(false); + const [showVaccineForm, setShowVaccineForm] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDeleteChild = async () => { + setIsLoading(true); + try { + const response = await fetch('/api/trpc/child.delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ json: { id: childId } }), + }); + + if (!response.ok) throw new Error('Failed to delete'); + + toast.success("Kind gelöscht", { + description: "Das Kind wurde erfolgreich gelöscht." + }); + + router.push('/app'); + } catch { + toast.error("Fehler", { + description: "Das Kind konnte nicht gelöscht werden." + }); + } finally { + setIsLoading(false); + setShowDeleteDialog(false); + } + }; // Define error handler within component scope const handleAddMeasurement = (err: TRPCClientErrorLike) => { @@ -74,6 +130,18 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { { enabled: !!childId } ); + // Fetch teeth status + const { data: teethStatus, refetch: refetchTeeth } = trpc.tooth.getByChildId.useQuery( + { childId }, + { enabled: !!childId } + ); + + // Fetch vaccines + const { data: vaccines, refetch: refetchVaccines } = trpc.vaccine.getByChildId.useQuery( + { childId }, + { enabled: !!childId } + ); + // Add measurement mutation const addMeasurement = trpc.measurement.add.useMutation({ onSuccess: () => { @@ -88,6 +156,21 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { onError: handleAddMeasurement, }); + // Add delete measurement mutation + const deleteMeasurement = trpc.measurement.delete.useMutation({ + onSuccess: () => { + toast.success("Messung gelöscht", { + description: "Die Messung wurde erfolgreich gelöscht." + }); + refetchMeasurements(); + }, + onError: (err) => { + toast.error("Fehler", { + description: err.message + }); + }, + }); + // Form setup const form = useForm({ resolver: zodResolver(measurementFormSchema), @@ -103,6 +186,16 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { // Create a new date at the start of the selected day in local timezone const selectedDate = date || new Date(); + + // Check if the selected date is in the future + if (selectedDate > new Date()) { + toast.error("Fehler", { + description: "Messungen können nicht für zukünftige Daten hinzugefügt werden" + }); + setIsLoading(false); + return; + } + const localDate = new Date( selectedDate.getFullYear(), selectedDate.getMonth(), @@ -150,6 +243,27 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { const birthDate = new Date(child.birthDate); const ageInMonths = differenceInMonths(new Date(), birthDate); + const gender = (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown"; + + const latestMeasurement = measurements?.[0]; + const weightPercentile = latestMeasurement?.weightKg + ? calculatePercentile(latestMeasurement.weightKg, ageInMonths, gender, "weight") + : null; + const heightPercentile = latestMeasurement?.heightCm + ? calculatePercentile(latestMeasurement.heightCm, ageInMonths, gender, "height") + : null; + + const getDevelopmentStatus = () => { + if (!weightPercentile && !heightPercentile) return "Keine Daten"; + if ((weightPercentile && weightPercentile.percentile < 3) || (heightPercentile && heightPercentile.percentile < 3)) { + return "Achtung"; + } + if ((weightPercentile && weightPercentile.percentile > 97) || (heightPercentile && heightPercentile.percentile > 97)) { + return "Achtung"; + } + return "Normal"; + }; + // Prepare data for charts const measurementData = measurements ?.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) @@ -163,15 +277,48 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {

-
-
- +
+
+
+ +
+
+

{child.name}

+

+ {ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })} +

+
-
-

{child.name}

-

- {ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })} -

+
+ { + trpc.useUtils().child.getById.invalidate({ id: childId }); + }} /> + + + + + + + Kind löschen + + Möchten Sie {child.name} wirklich löschen? Alle zugehörigen Daten (Messungen, Impfungen, Zahnstatus) werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. + + + + Abbrechen + + {isLoading ? "Wird gelöscht..." : "Löschen"} + + + +
@@ -196,7 +343,15 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {

Entwicklung

-

Normal

+

+ {getDevelopmentStatus()} +

+ {weightPercentile && ( +

Gewicht: {weightPercentile.percentile}. Perzentil

+ )} + {heightPercentile && ( +

Größe: {heightPercentile.percentile}. Perzentil

+ )}
@@ -391,6 +546,59 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
+ +
+
+

Liste der Messungen

+ +
+ {showMeasurementsList && ( +
+ {measurements.map((measurement) => ( +
+
+

{format(new Date(measurement.date), "PPP", { locale: de })}

+

+ Gewicht: {measurement.weightKg} kg • Größe: {measurement.heightCm} cm +

+
+ + + + + + + Messung löschen + + Möchten Sie diese Messung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + + + + Abbrechen + deleteMeasurement.mutate({ id: measurement.id })} + className="bg-red-600 text-white hover:bg-red-700" + > + Löschen + + + + +
+ ))} +
+ )} +
) : (

Noch keine Messungen aufgezeichnet.

@@ -414,32 +622,49 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { {expandedSections.vaccinations && (
-
- -
- -
-

Noch keine Impfungen aufgezeichnet.

-
-

Empfohlene Impfungen

-
    -
  • - 6-fach Impfung - 2 Monate -
  • -
  • - Pneumokokken - 2 Monate -
  • -
  • - Rotaviren - 6 Wochen -
  • -
+ {!showVaccineForm ? ( +
+
+ ) : ( + { + setShowVaccineForm(false); + refetchVaccines(); + }} + onCancel={() => setShowVaccineForm(false)} + /> + )} + + refetchVaccines()} + /> + +
+

Empfohlene Impfungen

+
    +
  • + 6-fach Impfung + 2 Monate +
  • +
  • + Pneumokokken + 2 Monate +
  • +
  • + Rotaviren + 6 Wochen +
  • +
)} @@ -460,15 +685,7 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) { {expandedSections.toothing && (
-
- -
-
-

Noch keine Zähne aufgezeichnet.

Zahnungsplan

@@ -508,6 +725,31 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
+ +
+
+

Zahnstatus

+ + + + + + +

Klicken Sie auf einen Zahn in der Visualisierung oder wählen Sie einen Zahn aus dem Dropdown-Menü, um den Durchbruch zu markieren.

+

Rote Zähne sind bereits durchgebrochen.

+
+
+
+
+ refetchTeeth()} + showTitle={false} + /> +
)} diff --git a/src/components/child/child-edit-dialog.tsx b/src/components/child/child-edit-dialog.tsx new file mode 100644 index 0000000..1e7f53e --- /dev/null +++ b/src/components/child/child-edit-dialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { CalendarIcon, Pencil } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +const childFormSchema = z.object({ + name: z.string().min(1, "Name ist erforderlich"), + birthDate: z.date({ + required_error: "Bitte wähle ein Geburtsdatum", + }), + gender: z.enum(["male", "female", "diverse", "unknown"]), +}); + +type ChildFormValues = z.infer; + +interface Child { + id: string; + name: string; + birthDate: Date | string; + gender: string | null; +} + +interface ChildEditDialogProps { + child: Child; + onSuccess: () => void; +} + +export function ChildEditDialog({ child, onSuccess }: ChildEditDialogProps) { + const [open, setOpen] = useState(false); + const [date, setDate] = useState(new Date(child.birthDate)); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(childFormSchema), + defaultValues: { + name: child.name, + birthDate: new Date(child.birthDate), + gender: (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown", + }, + }); + + const onSubmit = form.handleSubmit(async (values: ChildFormValues) => { + setIsLoading(true); + + try { + const response = await fetch('/api/trpc/child.update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + json: { + id: child.id, + name: values.name, + birthDate: date.toISOString(), + gender: values.gender, + } + }), + }); + + if (!response.ok) throw new Error('Failed to save'); + + toast.success("Kind aktualisiert", { + description: "Die Daten wurden erfolgreich gespeichert." + }); + + setOpen(false); + onSuccess(); + router.refresh(); + } catch { + toast.error("Fehler", { + description: "Die Daten konnten nicht gespeichert werden." + }); + } finally { + setIsLoading(false); + } + }); + + return ( + <> + + + + + + Kind bearbeiten + + Bearbeiten Sie die Daten von {child.name} + + + +
+
+ + + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ +
+ + + + + + + d && setDate(d)} + initialFocus + locale={de} + /> + + +
+ +
+ + +
+ + + + + +
+
+
+ + ); +} diff --git a/src/components/child/denture-visualization.tsx b/src/components/child/denture-visualization.tsx new file mode 100644 index 0000000..0bc8ee0 --- /dev/null +++ b/src/components/child/denture-visualization.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { CalendarIcon, InfoIcon, Plus } from "lucide-react"; +import { toast } from "sonner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { trpc } from "@/utils/trpc"; + +const UPPER_TEETH = [ + { id: "U8", name: "Oben rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 }, + { id: "U7", name: "Oben rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 }, + { id: "U6", name: "Oben rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 }, + { id: "U5", name: "Oben rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 }, + { id: "U4", name: "Oben rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 }, + { id: "U3", name: "Oben rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 }, + { id: "U2", name: "Oben rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 }, + { id: "U1", name: "Oben rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 }, + { id: "U1L", name: "Oben links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 }, + { id: "U2L", name: "Oben links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 }, + { id: "U3L", name: "Oben links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 }, + { id: "U4L", name: "Oben links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 }, + { id: "U5L", name: "Oben links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 }, + { id: "U6L", name: "Oben links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 }, + { id: "U7L", name: "Oben links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 }, + { id: "U8L", name: "Oben links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 }, +]; + +const LOWER_TEETH = [ + { id: "L8", name: "Unten rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 }, + { id: "L7", name: "Unten rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 }, + { id: "L6", name: "Unten rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 }, + { id: "L5", name: "Unten rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 }, + { id: "L4", name: "Unten rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 }, + { id: "L3", name: "Unten rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 }, + { id: "L2", name: "Unten rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 }, + { id: "L1", name: "Unten rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 }, + { id: "L1L", name: "Unten links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 }, + { id: "L2L", name: "Unten links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 }, + { id: "L3L", name: "Unten links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 }, + { id: "L4L", name: "Unten links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 }, + { id: "L5L", name: "Unten links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 }, + { id: "L6L", name: "Unten links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 }, + { id: "L7L", name: "Unten links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 }, + { id: "L8L", name: "Unten links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 }, +]; + +interface Tooth { + id: string; + name: string; + x: number; + y: number; + width: number; + height: number; + curve: number; + erupted?: boolean; + eruptionDate?: Date; + dbId?: string; +} + +interface ToothStatusFromDB { + id: string; + toothLabel: string; + date: Date | string; + status: string; +} + +interface DentureVisualizationProps { + childId: string; + initialTeeth?: ToothStatusFromDB[]; + onToothSaved?: () => void; + showTitle?: boolean; +} + +export function DentureVisualization({ + childId, + initialTeeth = [], + onToothSaved, + showTitle = false +}: DentureVisualizationProps) { + const [upperTeeth, setUpperTeeth] = useState([]); + const [lowerTeeth, setLowerTeeth] = useState([]); + const [selectedTooth, setSelectedTooth] = useState(null); + const [eruptionDate, setEruptionDate] = useState(new Date()); + const [hoveredTooth, setHoveredTooth] = useState(null); + + const addTooth = trpc.tooth.add.useMutation({ + onSuccess: () => { + toast.success("Zahn hinzugefĂĽgt", { + description: selectedTooth ? `${selectedTooth.name} wurde am ${format(eruptionDate!, "PPP", { locale: de })} als durchgebrochen markiert.` : "" + }); + setSelectedTooth(null); + onToothSaved?.(); + }, + onError: (error) => { + toast.error("Fehler", { + description: error.message || "Der Zahn konnte nicht gespeichert werden." + }); + }, + }); + + useEffect(() => { + const teethMap = new Map(); + initialTeeth.forEach(t => teethMap.set(t.toothLabel, t)); + + const mapTeeth = (teeth: typeof UPPER_TEETH): Tooth[] => + teeth.map(tooth => { + const dbTooth = teethMap.get(tooth.id); + return { + ...tooth, + erupted: !!dbTooth, + eruptionDate: dbTooth ? new Date(dbTooth.date) : undefined, + dbId: dbTooth?.id, + }; + }); + + setUpperTeeth(mapTeeth(UPPER_TEETH)); + setLowerTeeth(mapTeeth(LOWER_TEETH)); + }, [initialTeeth]); + + const handleToothClick = (tooth: Tooth) => { + setSelectedTooth(tooth); + if (tooth.eruptionDate) { + setEruptionDate(tooth.eruptionDate); + } else { + setEruptionDate(new Date()); + } + }; + + const handleEruptionDateSelect = (date: Date | undefined) => { + setEruptionDate(date); + }; + + const handleSaveEruption = () => { + if (!selectedTooth || !eruptionDate) return; + + const localDate = new Date( + eruptionDate.getFullYear(), + eruptionDate.getMonth(), + eruptionDate.getDate(), + 12, 0, 0, 0 + ); + + addTooth.mutate({ + childId, + toothLabel: selectedTooth.id, + date: localDate.toISOString(), + status: "durchgebrochen", + }); + + const updatedUpperTeeth = upperTeeth.map(t => + t.id === selectedTooth.id + ? { ...t, erupted: true, eruptionDate: eruptionDate } + : t + ); + + const updatedLowerTeeth = lowerTeeth.map(t => + t.id === selectedTooth.id + ? { ...t, erupted: true, eruptionDate: eruptionDate } + : t + ); + + setUpperTeeth(updatedUpperTeeth); + setLowerTeeth(updatedLowerTeeth); + }; + + const getToothPath = (tooth: Tooth, isUpper: boolean) => { + const { x, y, width, height, curve } = tooth; + const curveHeight = height * curve; + const curveDirection = isUpper ? -1 : 1; + + const cp1x = x + width * 0.25; + const cp1y = y + (isUpper ? height : 0); + const cp2x = x + width * 0.75; + const cp2y = y + (isUpper ? height : 0); + + const startX = x; + const startY = y + (isUpper ? 0 : height); + const endX = x + width; + const endY = y + (isUpper ? 0 : height); + + return `M ${startX} ${startY} + C ${cp1x} ${cp1y + curveHeight * curveDirection * 0.5}, + ${cp2x} ${cp2y + curveHeight * curveDirection * 0.5}, + ${endX} ${endY}`; + }; + + const handleToothSelect = (toothId: string) => { + const tooth = [...upperTeeth, ...lowerTeeth].find(t => t.id === toothId); + if (tooth) { + handleToothClick(tooth); + } + }; + + return ( + +
+ {showTitle && ( +
+

Zahnstatus

+ + + + + +

Klicken Sie auf einen Zahn, um den Durchbruch zu markieren.

+

Rote Zähne sind bereits durchgebrochen.

+
+
+
+ )} + +
+ + +
+ +
+
+

Oberkiefer

+
+ + {upperTeeth.map((tooth) => ( + + handleToothClick(tooth)} + onMouseEnter={() => setHoveredTooth(tooth)} + onMouseLeave={() => setHoveredTooth(null)} + /> + handleToothClick(tooth)} + onMouseEnter={() => setHoveredTooth(tooth)} + onMouseLeave={() => setHoveredTooth(null)} + /> + {tooth.erupted && ( + + âś“ + + )} + + ))} + +
+
+ +
+

Unterkiefer

+
+ + {lowerTeeth.map((tooth) => ( + + handleToothClick(tooth)} + onMouseEnter={() => setHoveredTooth(tooth)} + onMouseLeave={() => setHoveredTooth(null)} + /> + handleToothClick(tooth)} + onMouseEnter={() => setHoveredTooth(tooth)} + onMouseLeave={() => setHoveredTooth(null)} + /> + {tooth.erupted && ( + + âś“ + + )} + + ))} + +
+
+
+ + {selectedTooth && ( +
+

{selectedTooth.name}

+ {selectedTooth.erupted && selectedTooth.eruptionDate && ( +

+ Durchbruch: {format(selectedTooth.eruptionDate, "PPP", { locale: de })} +

+ )} + +
+
+ + + + + + + + + +
+ +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/child/vaccine-form.tsx b/src/components/child/vaccine-form.tsx new file mode 100644 index 0000000..d38e33b --- /dev/null +++ b/src/components/child/vaccine-form.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, Check, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { trpc } from "@/utils/trpc"; +import { toast } from "sonner"; + +const vaccineFormSchema = z.object({ + name: z.string().min(1, "Name ist erforderlich"), + date: z.date({ + required_error: "Bitte wähle ein Datum", + }), + done: z.boolean(), + notes: z.string().optional(), +}); + +type VaccineFormValues = z.infer; + +interface VaccineFormProps { + childId: string; + onSuccess: () => void; + onCancel: () => void; +} + +export function VaccineForm({ childId, onSuccess, onCancel }: VaccineFormProps) { + const [date, setDate] = useState(new Date()); + + const form = useForm({ + resolver: zodResolver(vaccineFormSchema), + defaultValues: { + name: "", + date: new Date(), + done: false, + notes: "", + }, + }); + + const addVaccine = trpc.vaccine.add.useMutation({ + onSuccess: () => { + toast.success("Impfung hinzugefügt", { + description: "Die Impfung wurde erfolgreich gespeichert." + }); + form.reset(); + setDate(new Date()); + onSuccess(); + }, + onError: (error) => { + toast.error("Fehler", { + description: error.message || "Die Impfung konnte nicht gespeichert werden." + }); + }, + }); + + const onSubmit = form.handleSubmit((values: VaccineFormValues) => { + const localDate = new Date( + (date || values.date).getFullYear(), + (date || values.date).getMonth(), + (date || values.date).getDate(), + 12, 0, 0, 0 + ); + + addVaccine.mutate({ + childId, + name: values.name, + date: localDate.toISOString(), + done: values.done, + notes: values.notes || undefined, + }); + }); + + return ( +
+
+ + + {form.formState.errors.name && ( +

{form.formState.errors.name.message}

+ )} +
+ +
+ + + + + + + + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ ); +} diff --git a/src/components/child/vaccine-list.tsx b/src/components/child/vaccine-list.tsx new file mode 100644 index 0000000..428e186 --- /dev/null +++ b/src/components/child/vaccine-list.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { Button } from "@/components/ui/button"; +import { Trash2, Check, X, Pencil } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { trpc } from "@/utils/trpc"; +import { toast } from "sonner"; + +interface Vaccine { + id: string; + name: string; + date: Date | string; + done: boolean; + notes: string | null; +} + +interface VaccineListProps { + vaccines: Vaccine[]; + onRefetch: () => void; +} + +export function VaccineList({ vaccines, onRefetch }: VaccineListProps) { + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editDate, setEditDate] = useState(); + const [editNotes, setEditNotes] = useState(""); + const [editDone, setEditDone] = useState(false); + + const updateVaccine = trpc.vaccine.update.useMutation({ + onSuccess: () => { + onRefetch(); + }, + onError: (error) => { + toast.error("Fehler", { description: error.message }); + }, + }); + + const deleteVaccine = trpc.vaccine.delete.useMutation({ + onSuccess: () => { + toast.success("Impfung gelöscht"); + onRefetch(); + }, + onError: (error) => { + toast.error("Fehler", { description: error.message }); + }, + }); + + const startEdit = (vaccine: Vaccine) => { + setEditingId(vaccine.id); + setEditName(vaccine.name); + setEditDate(new Date(vaccine.date)); + setEditNotes(vaccine.notes || ""); + setEditDone(vaccine.done); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditName(""); + setEditDate(undefined); + setEditNotes(""); + setEditDone(false); + }; + + const saveEdit = () => { + if (!editingId || !editDate) return; + + const localDate = new Date( + editDate.getFullYear(), + editDate.getMonth(), + editDate.getDate(), + 12, 0, 0, 0 + ); + + updateVaccine.mutate({ + id: editingId, + name: editName, + date: localDate.toISOString(), + done: editDone, + notes: editNotes || undefined, + }); + cancelEdit(); + }; + + const toggleDone = (vaccine: Vaccine) => { + const localDate = new Date( + new Date(vaccine.date).getFullYear(), + new Date(vaccine.date).getMonth(), + new Date(vaccine.date).getDate(), + 12, 0, 0, 0 + ); + + updateVaccine.mutate({ + id: vaccine.id, + done: !vaccine.done, + date: localDate.toISOString(), + }); + }; + + if (vaccines.length === 0) { + return

Noch keine Impfungen aufgezeichnet.

; + } + + return ( +
+ {vaccines.map((vaccine) => ( +
+ {editingId === vaccine.id ? ( +
+ setEditName(e.target.value)} + placeholder="Impfname" + disabled={updateVaccine.isPending} + /> + + + + + + + + + setEditNotes(e.target.value)} + placeholder="Notizen" + disabled={updateVaccine.isPending} + /> +
+ setEditDone(e.target.checked)} + className="h-4 w-4 rounded" + /> + +
+
+ + +
+
+ ) : ( + <> +
+
+

{vaccine.name}

+ {vaccine.done && ( + + Erledigt + + )} +
+

+ {format(new Date(vaccine.date), "PPP", { locale: de })} +

+ {vaccine.notes && ( +

{vaccine.notes}

+ )} +
+
+ + + + + + + + + Impfung löschen + + Möchten Sie diese Impfung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden. + + + + Abbrechen + deleteVaccine.mutate({ id: vaccine.id })} + className="bg-red-600 text-white hover:bg-red-700" + > + Löschen + + + + +
+ + )} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/dashboard/dashboard-content.tsx b/src/components/dashboard/dashboard-content.tsx index 8fecd72..65650b9 100644 --- a/src/components/dashboard/dashboard-content.tsx +++ b/src/components/dashboard/dashboard-content.tsx @@ -4,7 +4,7 @@ import { format, differenceInMonths } from "date-fns"; import { de } from "date-fns/locale"; import Link from "next/link"; import { Button } from "@/components/ui/button"; -import { Plus, Baby, Activity, Calendar } from "lucide-react"; +import { Plus, Baby, Activity, Calendar, Syringe } from "lucide-react"; import { trpc } from "@/utils/trpc"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { useState } from "react"; @@ -14,24 +14,29 @@ export function DashboardContent() { const { data: children, isLoading } = trpc.child.getAllByUser.useQuery(); const [selectedChildId, setSelectedChildId] = useState(null); - // Get measurements for the selected child const { data: measurements } = trpc.measurement.getByChildId.useQuery( { childId: selectedChildId || "" }, { enabled: !!selectedChildId } ); - // Get vaccinations for the selected child - using child router as a fallback - const { data: vaccinations } = trpc.child.getById.useQuery( - { id: selectedChildId || "" }, + const { data: vaccines } = trpc.vaccine.getByChildId.useQuery( + { childId: selectedChildId || "" }, { enabled: !!selectedChildId } ); - // Get toothing data for the selected child - using child router as a fallback - const { data: toothing } = trpc.child.getById.useQuery( - { id: selectedChildId || "" }, + const { data: teeth } = trpc.tooth.getByChildId.useQuery( + { childId: selectedChildId || "" }, { enabled: !!selectedChildId } ); + const upcomingVaccines = vaccines?.filter(v => !v.done) || []; + const nextVaccine = upcomingVaccines[0]; + + const eruptedTeeth = teeth?.filter(t => t.status === "durchgebrochen").length || 0; + + const selectedChild = children?.find(c => c.id === selectedChildId); + const ageInMonths = selectedChild ? differenceInMonths(new Date(), new Date(selectedChild.birthDate)) : 0; + return (
@@ -106,9 +111,28 @@ export function DashboardContent() { Wichtige Termine und Erinnerungen -
-

Keine anstehenden Termine.

-
+ {nextVaccine ? ( +
+
+ +
+

{nextVaccine.name}

+

+ Geplant: {format(new Date(nextVaccine.date), "PPP", { locale: de })} +

+
+
+ {upcomingVaccines.length > 1 && ( +

+ + {upcomingVaccines.length - 1} weitere Impfungen geplant +

+ )} +
+ ) : ( +
+

Keine anstehenden Impfungen.

+
+ )}
@@ -149,7 +173,6 @@ export function DashboardContent() { {selectedChildId ? (
- {/* Measurements Summary */}

Messungen

{measurements && measurements.length > 0 ? ( @@ -160,11 +183,11 @@ export function DashboardContent() {

Gewicht

-

{measurements[0].weightKg} kg

+

{measurements[0].weightKg?.toFixed(1)} kg

Größe

-

{measurements[0].heightCm} cm

+

{measurements[0].heightCm?.toFixed(1)} cm

@@ -173,31 +196,39 @@ export function DashboardContent() { )}
- {/* Vaccinations Summary */}

Impfungen

- {vaccinations ? ( + {vaccines && vaccines.length > 0 ? (

- Impfungen werden bald verfĂĽgbar sein + {vaccines.filter(v => v.done).length} von {vaccines.length} erledigt

+ {upcomingVaccines.length > 0 && ( +

+ {upcomingVaccines.length} ausstehend +

+ )}
) : (

Keine Impfungen vorhanden

)}
- {/* Toothing Summary */}

Zahnung

- {toothing ? ( + {ageInMonths >= 5 ? (

- Zahnungsdaten werden bald verfügbar sein + {eruptedTeeth} Zähne durchgebrochen +

+

+ von 20 Milchzähnen

) : ( -

Keine Zahnungsdaten vorhanden

+

+ Zahnung beginnt ca. ab 5 Monaten +

)}
@@ -214,4 +245,4 @@ export function DashboardContent() {
); -} \ No newline at end of file +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..f414294 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} \ No newline at end of file diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..f38593b --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..7398d69 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } \ No newline at end of file diff --git a/src/lib/percentiles.ts b/src/lib/percentiles.ts new file mode 100644 index 0000000..1bae4c0 --- /dev/null +++ b/src/lib/percentiles.ts @@ -0,0 +1,188 @@ +export interface PercentileData { + ageMonths: number; + p3: number; + p10: number; + p25: number; + p50: number; + p75: number; + p90: number; + p97: number; +} + +export const WHO_WEIGHT_BOYS: PercentileData[] = [ + { ageMonths: 0, p3: 2.5, p10: 2.9, p25: 3.2, p50: 3.5, p75: 3.9, p90: 4.2, p97: 4.6 }, + { ageMonths: 1, p3: 3.4, p10: 3.9, p25: 4.3, p50: 4.8, p75: 5.3, p90: 5.8, p97: 6.3 }, + { ageMonths: 2, p3: 4.3, p10: 4.9, p25: 5.4, p50: 6.0, p75: 6.6, p90: 7.2, p97: 7.8 }, + { ageMonths: 3, p3: 5.0, p10: 5.7, p25: 6.3, p50: 6.9, p75: 7.6, p90: 8.3, p97: 9.0 }, + { ageMonths: 4, p3: 5.6, p10: 6.3, p25: 7.0, p50: 7.7, p75: 8.5, p90: 9.2, p97: 10.0 }, + { ageMonths: 5, p3: 6.0, p10: 6.8, p25: 7.5, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.8 }, + { ageMonths: 6, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.5 }, + { ageMonths: 7, p3: 6.7, p10: 7.6, p25: 8.4, p50: 9.3, p75: 10.2, p90: 11.2, p97: 12.1 }, + { ageMonths: 8, p3: 7.0, p10: 7.9, p25: 8.7, p50: 9.7, p75: 10.7, p90: 11.7, p97: 12.7 }, + { ageMonths: 9, p3: 7.2, p10: 8.2, p25: 9.1, p50: 10.0, p75: 11.1, p90: 12.1, p97: 13.2 }, + { ageMonths: 10, p3: 7.5, p10: 8.4, p25: 9.4, p50: 10.4, p75: 11.5, p90: 12.6, p97: 13.7 }, + { ageMonths: 11, p3: 7.7, p10: 8.7, p25: 9.6, p50: 10.7, p75: 11.8, p90: 13.0, p97: 14.1 }, + { ageMonths: 12, p3: 7.9, p10: 8.9, p25: 9.9, p50: 10.9, p75: 12.1, p90: 13.3, p97: 14.5 }, + { ageMonths: 15, p3: 8.4, p10: 9.5, p25: 10.5, p50: 11.7, p75: 13.0, p90: 14.3, p97: 15.6 }, + { ageMonths: 18, p3: 8.8, p10: 10.0, p25: 11.1, p50: 12.4, p75: 13.8, p90: 15.2, p97: 16.6 }, + { ageMonths: 21, p3: 9.2, p10: 10.5, p25: 11.7, p50: 13.0, p75: 14.5, p90: 16.0, p97: 17.5 }, + { ageMonths: 24, p3: 9.7, p10: 11.0, p25: 12.2, p50: 13.6, p75: 15.2, p90: 16.8, p97: 18.4 }, + { ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 14.8, p75: 16.6, p90: 18.3, p97: 20.1 }, + { ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.3, p50: 16.0, p75: 17.9, p90: 19.8, p97: 21.8 }, + { ageMonths: 48, p3: 12.6, p10: 14.4, p25: 16.1, p50: 18.1, p75: 20.3, p90: 22.5, p97: 24.8 }, + { ageMonths: 60, p3: 14.0, p10: 16.0, p25: 18.0, p50: 20.3, p75: 22.8, p90: 25.4, p97: 28.1 }, +]; + +export const WHO_WEIGHT_GIRLS: PercentileData[] = [ + { ageMonths: 0, p3: 2.4, p10: 2.8, p25: 3.1, p50: 3.4, p75: 3.7, p90: 4.1, p97: 4.4 }, + { ageMonths: 1, p3: 3.2, p10: 3.7, p25: 4.1, p50: 4.5, p75: 5.0, p90: 5.4, p97: 5.9 }, + { ageMonths: 2, p3: 4.0, p10: 4.6, p25: 5.1, p50: 5.6, p75: 6.2, p90: 6.7, p97: 7.3 }, + { ageMonths: 3, p3: 4.7, p10: 5.3, p25: 5.9, p50: 6.5, p75: 7.1, p90: 7.8, p97: 8.5 }, + { ageMonths: 4, p3: 5.2, p10: 5.9, p25: 6.5, p50: 7.2, p75: 7.9, p90: 8.7, p97: 9.4 }, + { ageMonths: 5, p3: 5.7, p10: 6.4, p25: 7.1, p50: 7.8, p75: 8.6, p90: 9.4, p97: 10.2 }, + { ageMonths: 6, p3: 6.1, p10: 6.8, p25: 7.6, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.9 }, + { ageMonths: 7, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.6 }, + { ageMonths: 8, p3: 6.7, p10: 7.5, p25: 8.4, p50: 9.2, p75: 10.2, p90: 11.2, p97: 12.2 }, + { ageMonths: 9, p3: 6.9, p10: 7.8, p25: 8.7, p50: 9.6, p75: 10.6, p90: 11.7, p97: 12.8 }, + { ageMonths: 10, p3: 7.2, p10: 8.1, p25: 9.0, p50: 10.0, p75: 11.0, p90: 12.1, p97: 13.3 }, + { ageMonths: 11, p3: 7.4, p10: 8.3, p25: 9.3, p50: 10.3, p75: 11.4, p90: 12.6, p97: 13.8 }, + { ageMonths: 12, p3: 7.6, p10: 8.6, p25: 9.6, p50: 10.6, p75: 11.8, p90: 13.0, p97: 14.3 }, + { ageMonths: 15, p3: 8.1, p10: 9.2, p25: 10.3, p50: 11.5, p75: 12.8, p90: 14.1, p97: 15.5 }, + { ageMonths: 18, p3: 8.6, p10: 9.8, p25: 10.9, p50: 12.2, p75: 13.7, p90: 15.1, p97: 16.6 }, + { ageMonths: 21, p3: 9.1, p10: 10.3, p25: 11.5, p50: 12.9, p75: 14.5, p90: 16.0, p97: 17.6 }, + { ageMonths: 24, p3: 9.6, p10: 10.9, p25: 12.2, p50: 13.6, p75: 15.3, p90: 17.0, p97: 18.7 }, + { ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 15.0, p75: 16.8, p90: 18.7, p97: 20.6 }, + { ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.4, p50: 16.2, p75: 18.2, p90: 20.3, p97: 22.5 }, + { ageMonths: 48, p3: 12.8, p10: 14.6, p25: 16.4, p50: 18.5, p75: 20.9, p90: 23.4, p97: 26.0 }, + { ageMonths: 60, p3: 14.3, p10: 16.4, p25: 18.5, p50: 21.0, p75: 23.8, p90: 26.8, p97: 30.0 }, +]; + +export const WHO_HEIGHT_BOYS: PercentileData[] = [ + { ageMonths: 0, p3: 46.1, p10: 47.4, p25: 48.6, p50: 49.9, p75: 51.2, p90: 52.4, p97: 53.7 }, + { ageMonths: 1, p3: 50.8, p10: 52.2, p25: 53.6, p50: 55.0, p75: 56.5, p90: 57.9, p97: 59.4 }, + { ageMonths: 2, p3: 54.4, p10: 55.9, p25: 57.4, p50: 59.0, p75: 60.6, p90: 62.1, p97: 63.7 }, + { ageMonths: 3, p3: 57.3, p10: 58.9, p25: 60.5, p50: 62.2, p75: 63.9, p90: 65.6, p97: 67.3 }, + { ageMonths: 4, p3: 59.7, p10: 61.4, p25: 63.1, p50: 64.9, p75: 66.7, p90: 68.5, p97: 70.3 }, + { ageMonths: 5, p3: 61.7, p10: 63.5, p25: 65.3, p50: 67.2, p75: 69.1, p90: 71.0, p97: 72.9 }, + { ageMonths: 6, p3: 63.4, p10: 65.3, p25: 67.2, p50: 69.2, p75: 71.2, p90: 73.2, p97: 75.2 }, + { ageMonths: 7, p3: 64.9, p10: 66.9, p25: 68.9, p50: 71.0, p75: 73.1, p90: 75.2, p97: 77.3 }, + { ageMonths: 8, p3: 66.3, p10: 68.4, p25: 70.5, p50: 72.6, p75: 74.8, p90: 77.0, p97: 79.2 }, + { ageMonths: 9, p3: 67.5, p10: 69.7, p25: 71.9, p50: 74.1, p75: 76.4, p90: 78.7, p97: 81.0 }, + { ageMonths: 10, p3: 68.7, p10: 71.0, p25: 73.3, p50: 75.6, p75: 77.9, p90: 80.3, p97: 82.7 }, + { ageMonths: 11, p3: 69.9, p10: 72.2, p25: 74.6, p50: 77.0, p75: 79.4, p90: 81.8, p97: 84.3 }, + { ageMonths: 12, p3: 71.0, p10: 73.4, p25: 75.8, p50: 78.3, p75: 80.8, p90: 83.3, p97: 85.8 }, + { ageMonths: 15, p3: 73.9, p10: 76.5, p25: 79.1, p50: 81.8, p75: 84.5, p90: 87.2, p97: 89.9 }, + { ageMonths: 18, p3: 76.5, p10: 79.3, p25: 82.1, p50: 85.0, p75: 87.9, p90: 90.8, p97: 93.7 }, + { ageMonths: 21, p3: 78.9, p10: 81.8, p25: 84.8, p50: 87.8, p75: 90.9, p90: 93.9, p97: 97.0 }, + { ageMonths: 24, p3: 81.1, p10: 84.2, p25: 87.3, p50: 90.5, p75: 93.7, p90: 97.0, p97: 100.2 }, + { ageMonths: 30, p3: 85.1, p10: 88.5, p25: 91.9, p50: 95.4, p75: 98.9, p90: 102.4, p97: 106.0 }, + { ageMonths: 36, p3: 88.7, p10: 92.4, p25: 96.1, p50: 99.9, p75: 103.7, p90: 107.5, p97: 111.3 }, + { ageMonths: 48, p3: 95.1, p10: 99.3, p25: 103.5, p50: 107.8, p75: 112.2, p90: 116.6, p97: 121.0 }, + { ageMonths: 60, p3: 100.8, p10: 105.5, p25: 110.2, p50: 115.0, p75: 119.9, p90: 124.8, p97: 129.7 }, +]; + +export const WHO_HEIGHT_GIRLS: PercentileData[] = [ + { ageMonths: 0, p3: 45.4, p10: 46.7, p25: 47.9, p50: 49.2, p75: 50.4, p90: 51.7, p97: 52.9 }, + { ageMonths: 1, p3: 49.8, p10: 51.2, p25: 52.5, p50: 53.9, p75: 55.3, p90: 56.7, p97: 58.1 }, + { ageMonths: 2, p3: 53.1, p10: 54.6, p25: 56.1, p50: 57.6, p75: 59.2, p90: 60.7, p97: 62.3 }, + { ageMonths: 3, p3: 55.7, p10: 57.3, p25: 58.9, p50: 60.6, p75: 62.3, p90: 63.9, p97: 65.6 }, + { ageMonths: 4, p3: 57.8, p10: 59.5, p25: 61.2, p50: 63.0, p75: 64.8, p90: 66.6, p97: 68.4 }, + { ageMonths: 5, p3: 59.6, p10: 61.4, p25: 63.2, p50: 65.1, p75: 67.0, p90: 68.9, p97: 70.8 }, + { ageMonths: 6, p3: 61.2, p10: 63.1, p25: 65.0, p50: 66.9, p75: 68.9, p90: 70.9, p97: 72.9 }, + { ageMonths: 7, p3: 62.6, p10: 64.6, p25: 66.6, p50: 68.7, p75: 70.8, p90: 72.9, p97: 75.0 }, + { ageMonths: 8, p3: 64.0, p10: 66.1, p25: 68.2, p50: 70.4, p75: 72.6, p90: 74.8, p97: 77.0 }, + { ageMonths: 9, p3: 65.2, p10: 67.4, p25: 69.6, p50: 71.9, p75: 74.2, p90: 76.5, p97: 78.8 }, + { ageMonths: 10, p3: 66.4, p10: 68.7, p25: 71.0, p50: 73.4, p75: 75.8, p90: 78.2, p97: 80.6 }, + { ageMonths: 11, p3: 67.6, p10: 70.0, p25: 72.4, p50: 74.8, p75: 77.3, p90: 79.8, p97: 82.3 }, + { ageMonths: 12, p3: 68.7, p10: 71.2, p25: 73.7, p50: 76.2, p75: 78.8, p90: 81.4, p97: 84.0 }, + { ageMonths: 15, p3: 71.6, p10: 74.3, p25: 77.0, p50: 79.8, p75: 82.6, p90: 85.4, p97: 88.2 }, + { ageMonths: 18, p3: 74.2, p10: 77.1, p25: 80.0, p50: 83.0, p75: 86.0, p90: 89.1, p97: 92.1 }, + { ageMonths: 21, p3: 76.6, p10: 79.7, p25: 82.8, p50: 86.0, p75: 89.2, p90: 92.5, p97: 95.7 }, + { ageMonths: 24, p3: 78.8, p10: 82.1, p25: 85.4, p50: 88.8, p75: 92.2, p90: 95.6, p97: 99.1 }, + { ageMonths: 30, p3: 82.8, p10: 86.4, p25: 90.0, p50: 93.7, p75: 97.4, p90: 101.2, p97: 105.0 }, + { ageMonths: 36, p3: 86.4, p10: 90.2, p25: 94.1, p50: 98.1, p75: 102.1, p90: 106.1, p97: 110.2 }, + { ageMonths: 48, p3: 92.8, p10: 97.1, p25: 101.4, p50: 105.9, p75: 110.4, p90: 114.9, p97: 119.5 }, + { ageMonths: 60, p3: 98.5, p10: 103.3, p25: 108.1, p50: 113.0, p75: 118.0, p90: 123.0, p97: 128.1 }, +]; + +function interpolate(data: PercentileData[], ageMonths: number): PercentileData | null { + if (data.length === 0) return null; + + const sorted = [...data].sort((a, b) => a.ageMonths - b.ageMonths); + + if (ageMonths <= sorted[0].ageMonths) return sorted[0]; + if (ageMonths >= sorted[sorted.length - 1].ageMonths) return sorted[sorted.length - 1]; + + let lower = sorted[0]; + let upper = sorted[sorted.length - 1]; + + for (let i = 0; i < sorted.length - 1; i++) { + if (sorted[i].ageMonths <= ageMonths && sorted[i + 1].ageMonths >= ageMonths) { + lower = sorted[i]; + upper = sorted[i + 1]; + break; + } + } + + const range = upper.ageMonths - lower.ageMonths; + const factor = range === 0 ? 0 : (ageMonths - lower.ageMonths) / range; + + return { + ageMonths, + p3: lower.p3 + (upper.p3 - lower.p3) * factor, + p10: lower.p10 + (upper.p10 - lower.p10) * factor, + p25: lower.p25 + (upper.p25 - lower.p25) * factor, + p50: lower.p50 + (upper.p50 - lower.p50) * factor, + p75: lower.p75 + (upper.p75 - lower.p75) * factor, + p90: lower.p90 + (upper.p90 - lower.p90) * factor, + p97: lower.p97 + (upper.p97 - lower.p97) * factor, + }; +} + +export function calculatePercentile( + value: number, + ageMonths: number, + gender: "male" | "female" | "diverse" | "unknown", + type: "weight" | "height" +): { percentile: number; label: string } | null { + const isMale = gender === "male"; + + const dataTable = type === "weight" + ? (isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS) + : (isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS); + + const ref = interpolate(dataTable, Math.round(ageMonths)); + if (!ref) return null; + + let percentile = 50; + + if (value <= ref.p3) percentile = 3; + else if (value <= ref.p10) percentile = 3 + ((value - ref.p3) / (ref.p10 - ref.p3)) * 7; + else if (value <= ref.p25) percentile = 10 + ((value - ref.p10) / (ref.p25 - ref.p10)) * 15; + else if (value <= ref.p50) percentile = 25 + ((value - ref.p25) / (ref.p50 - ref.p25)) * 25; + else if (value <= ref.p75) percentile = 50 + ((value - ref.p50) / (ref.p75 - ref.p50)) * 25; + else if (value <= ref.p90) percentile = 75 + ((value - ref.p75) / (ref.p90 - ref.p75)) * 15; + else if (value <= ref.p97) percentile = 90 + ((value - ref.p90) / (ref.p97 - ref.p90)) * 7; + else percentile = 97; + + let label = "Normal"; + if (percentile < 3) label = "Untergewicht / Klein"; + else if (percentile < 10) label = "Unterdurchschnittlich"; + else if (percentile < 90) label = "Normal"; + else if (percentile < 97) label = "Ăśberdurchschnittlich"; + else label = "Ăśbergewicht / GroĂź"; + + return { percentile: Math.round(percentile), label }; +} + +export function getPercentileDataForAge( + gender: "male" | "female" | "diverse" | "unknown", + type: "weight" | "height" +): PercentileData[] { + const isMale = gender === "male"; + + if (type === "weight") { + return isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS; + } + return isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2706bf9..f5f6643 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -2,11 +2,15 @@ import { router } from "@/server/trpc" import { childRouter } from "./routers/child" import { authRouter } from "./routers/auth" import { measurementRouter } from "./routers/measurement" +import { toothRouter } from "./routers/tooth" +import { vaccineRouter } from "./routers/vaccine" export const appRouter = router({ child: childRouter, auth: authRouter, measurement: measurementRouter, + tooth: toothRouter, + vaccine: vaccineRouter, }) // Export type helper diff --git a/src/server/api/routers/child.ts b/src/server/api/routers/child.ts index 8af8b2c..c1aa7ac 100644 --- a/src/server/api/routers/child.ts +++ b/src/server/api/routers/child.ts @@ -57,4 +57,68 @@ export const childRouter = router({ }) return child }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1, "Name is required").optional(), + birthDate: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(), + gender: z.enum(["male", "female", "diverse", "unknown"]).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + + const child = await ctx.prisma.child.findFirst({ + where: { + id, + userId: ctx.session.user.id, + }, + }); + + if (!child) { + throw new Error("Child not found or unauthorized"); + } + + return ctx.prisma.child.update({ + where: { id }, + data: { + ...(data.name && { name: data.name }), + ...(data.birthDate && { birthDate: new Date(data.birthDate) }), + ...(data.gender && { gender: data.gender }), + }, + }); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const child = await ctx.prisma.child.findFirst({ + where: { + id: input.id, + userId: ctx.session.user.id, + }, + }); + + if (!child) { + throw new Error("Child not found or unauthorized"); + } + + await ctx.prisma.toothStatus.deleteMany({ + where: { childId: input.id }, + }); + + await ctx.prisma.vaccine.deleteMany({ + where: { childId: input.id }, + }); + + await ctx.prisma.measurement.deleteMany({ + where: { childId: input.id }, + }); + + return ctx.prisma.child.delete({ + where: { id: input.id }, + }); + }), }); \ No newline at end of file diff --git a/src/server/api/routers/measurement.ts b/src/server/api/routers/measurement.ts index 58a0170..e04353b 100644 --- a/src/server/api/routers/measurement.ts +++ b/src/server/api/routers/measurement.ts @@ -34,4 +34,30 @@ export const measurementRouter = router({ }, }); }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + // First verify the measurement belongs to a child owned by the user + const measurement = await ctx.prisma.measurement.findFirst({ + where: { + id: input.id, + child: { + userId: ctx.session.user.id, + }, + }, + include: { + child: true, + }, + }); + + if (!measurement) { + throw new Error("Measurement not found or unauthorized"); + } + + // Delete the measurement + return ctx.prisma.measurement.delete({ + where: { id: input.id }, + }); + }), }); \ No newline at end of file diff --git a/src/server/api/routers/tooth.ts b/src/server/api/routers/tooth.ts new file mode 100644 index 0000000..3dbbb83 --- /dev/null +++ b/src/server/api/routers/tooth.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; +import { protectedProcedure, router } from "@/server/trpc"; + +export const toothRouter = router({ + getByChildId: protectedProcedure + .input(z.object({ childId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.toothStatus.findMany({ + where: { + childId: input.childId, + }, + orderBy: { + date: "desc", + }, + }); + }), + + add: protectedProcedure + .input( + z.object({ + childId: z.string(), + toothLabel: z.string(), + date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"), + status: z.enum(["durchgebrochen", "locker", "fehlend"]).default("durchgebrochen"), + }) + ) + .mutation(async ({ ctx, input }) => { + const child = await ctx.prisma.child.findFirst({ + where: { + id: input.childId, + userId: ctx.session.user.id, + }, + }); + + if (!child) { + throw new Error("Child not found or unauthorized"); + } + + const existing = await ctx.prisma.toothStatus.findFirst({ + where: { + childId: input.childId, + toothLabel: input.toothLabel, + }, + }); + + if (existing) { + return ctx.prisma.toothStatus.update({ + where: { id: existing.id }, + data: { + date: new Date(input.date), + status: input.status, + }, + }); + } + + return ctx.prisma.toothStatus.create({ + data: { + childId: input.childId, + toothLabel: input.toothLabel, + date: new Date(input.date), + status: input.status, + }, + }); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(), + status: z.enum(["durchgebrochen", "locker", "fehlend"]).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const toothStatus = await ctx.prisma.toothStatus.findFirst({ + where: { + id: input.id, + child: { + userId: ctx.session.user.id, + }, + }, + }); + + if (!toothStatus) { + throw new Error("Tooth status not found or unauthorized"); + } + + return ctx.prisma.toothStatus.update({ + where: { id: input.id }, + data: { + ...(input.date && { date: new Date(input.date) }), + ...(input.status && { status: input.status }), + }, + }); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const toothStatus = await ctx.prisma.toothStatus.findFirst({ + where: { + id: input.id, + child: { + userId: ctx.session.user.id, + }, + }, + }); + + if (!toothStatus) { + throw new Error("Tooth status not found or unauthorized"); + } + + return ctx.prisma.toothStatus.delete({ + where: { id: input.id }, + }); + }), +}); diff --git a/src/server/api/routers/vaccine.ts b/src/server/api/routers/vaccine.ts new file mode 100644 index 0000000..6168c03 --- /dev/null +++ b/src/server/api/routers/vaccine.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { protectedProcedure, router } from "@/server/trpc"; + +export const vaccineRouter = router({ + getByChildId: protectedProcedure + .input(z.object({ childId: z.string() })) + .query(async ({ ctx, input }) => { + return ctx.prisma.vaccine.findMany({ + where: { + childId: input.childId, + }, + orderBy: { + date: "asc", + }, + }); + }), + + add: protectedProcedure + .input( + z.object({ + childId: z.string(), + name: z.string().min(1, "Name is required"), + date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"), + done: z.boolean().default(false), + notes: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const child = await ctx.prisma.child.findFirst({ + where: { + id: input.childId, + userId: ctx.session.user.id, + }, + }); + + if (!child) { + throw new Error("Child not found or unauthorized"); + } + + return ctx.prisma.vaccine.create({ + data: { + childId: input.childId, + name: input.name, + date: new Date(input.date), + done: input.done, + notes: input.notes, + }, + }); + }), + + update: protectedProcedure + .input( + z.object({ + id: z.string(), + name: z.string().min(1, "Name is required").optional(), + date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(), + done: z.boolean().optional(), + notes: z.string().optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + const vaccine = await ctx.prisma.vaccine.findFirst({ + where: { + id: input.id, + child: { + userId: ctx.session.user.id, + }, + }, + }); + + if (!vaccine) { + throw new Error("Vaccine not found or unauthorized"); + } + + return ctx.prisma.vaccine.update({ + where: { id: input.id }, + data: { + ...(input.name && { name: input.name }), + ...(input.date && { date: new Date(input.date) }), + ...(input.done !== undefined && { done: input.done }), + ...(input.notes !== undefined && { notes: input.notes }), + }, + }); + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const vaccine = await ctx.prisma.vaccine.findFirst({ + where: { + id: input.id, + child: { + userId: ctx.session.user.id, + }, + }, + }); + + if (!vaccine) { + throw new Error("Vaccine not found or unauthorized"); + } + + return ctx.prisma.vaccine.delete({ + where: { id: input.id }, + }); + }), +});