Add vaccines, teeth tracking, child management, and WHO growth percentiles
- Add tooth router and vaccine router with full CRUD operations - Implement vaccine form and list components with edit/delete functionality - Connect denture visualization to database for persistent tooth tracking - Add child edit dialog and delete functionality with cascade deletion - Implement WHO growth percentile calculations for weight and height - Update dashboard to display real data for measurements, vaccines, and teeth - Add dialog, alert-dialog, and tooltip UI components - Install @radix-ui/react-dialog dependency
This commit is contained in:
858
package-lock.json
generated
858
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,13 +8,13 @@ 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();
|
||||
|
||||
@@ -9,13 +9,51 @@ import Link from "next/link"
|
||||
import { useState } from "react"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Footer } from "@/components/layout/footer"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const registerMutation = trpc.auth.register.useMutation()
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!email || !name || !password) {
|
||||
toast.error("Bitte füllen Sie alle Felder aus")
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await registerMutation.mutateAsync({ name, email, password })
|
||||
|
||||
// Automatically sign in the user after successful registration
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
toast.error("Anmeldung fehlgeschlagen: " + result.error)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to the app
|
||||
toast.success("Registrierung erfolgreich! 🎉")
|
||||
router.push("/app")
|
||||
} catch (error) {
|
||||
toast.error("Fehler bei der Registrierung: " + (error instanceof Error ? error.message : "Unbekannter Fehler"))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
@@ -31,6 +69,7 @@ export default function RegisterPage() {
|
||||
placeholder="Max Mustermann"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +81,7 @@ export default function RegisterPage() {
|
||||
placeholder="max@bambino.at"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -53,24 +93,16 @@ export default function RegisterPage() {
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
registerMutation.mutate({ name, email, password }, {
|
||||
onSuccess: () => {
|
||||
alert("Registrierung erfolgreich! 🎉")
|
||||
// TODO: Weiterleitung zu /login oder direkt einloggen
|
||||
},
|
||||
onError: (err) => {
|
||||
alert("Fehler: " + err.message)
|
||||
}
|
||||
})
|
||||
}}
|
||||
onClick={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Registrieren
|
||||
{isLoading ? "Wird registriert..." : "Registrieren"}
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-zinc-500">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format, differenceInMonths } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -25,14 +26,39 @@ import type { AppRouter } from "@/server/api/root";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { CalendarIcon, Baby, Syringe, Activity, Plus, Stethoscope, ChevronDown, ChevronUp, Trash2, List, InfoIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { DentureVisualization } from "@/components/child/denture-visualization";
|
||||
import { VaccineForm } from "@/components/child/vaccine-form";
|
||||
import { VaccineList } from "@/components/child/vaccine-list";
|
||||
import { ChildEditDialog } from "@/components/child/child-edit-dialog";
|
||||
import { calculatePercentile } from "@/lib/percentiles";
|
||||
import {
|
||||
Tooltip as UITooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const measurementFormSchema = z.object({
|
||||
date: z.date({
|
||||
required_error: "Bitte wähle ein Datum",
|
||||
}),
|
||||
}).refine(
|
||||
(date) => date <= new Date(),
|
||||
"Messungen können nicht für zukünftige Daten hinzugefügt werden"
|
||||
),
|
||||
weightKg: z.string().refine((val) => !isNaN(Number(val)) && Number(val) > 0, {
|
||||
message: "Bitte gib ein gültiges Gewicht ein",
|
||||
}),
|
||||
@@ -48,6 +74,7 @@ interface ChildDetailContentProps {
|
||||
}
|
||||
|
||||
export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
@@ -56,6 +83,35 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
vaccinations: false,
|
||||
toothing: false,
|
||||
});
|
||||
const [showMeasurementsList, setShowMeasurementsList] = useState(false);
|
||||
const [showVaccineForm, setShowVaccineForm] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const handleDeleteChild = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/trpc/child.delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ json: { id: childId } }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
|
||||
toast.success("Kind gelöscht", {
|
||||
description: "Das Kind wurde erfolgreich gelöscht."
|
||||
});
|
||||
|
||||
router.push('/app');
|
||||
} catch {
|
||||
toast.error("Fehler", {
|
||||
description: "Das Kind konnte nicht gelöscht werden."
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define error handler within component scope
|
||||
const handleAddMeasurement = (err: TRPCClientErrorLike<AppRouter>) => {
|
||||
@@ -74,6 +130,18 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
{ enabled: !!childId }
|
||||
);
|
||||
|
||||
// Fetch teeth status
|
||||
const { data: teethStatus, refetch: refetchTeeth } = trpc.tooth.getByChildId.useQuery(
|
||||
{ childId },
|
||||
{ enabled: !!childId }
|
||||
);
|
||||
|
||||
// Fetch vaccines
|
||||
const { data: vaccines, refetch: refetchVaccines } = trpc.vaccine.getByChildId.useQuery(
|
||||
{ childId },
|
||||
{ enabled: !!childId }
|
||||
);
|
||||
|
||||
// Add measurement mutation
|
||||
const addMeasurement = trpc.measurement.add.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -88,6 +156,21 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
onError: handleAddMeasurement,
|
||||
});
|
||||
|
||||
// Add delete measurement mutation
|
||||
const deleteMeasurement = trpc.measurement.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Messung gelöscht", {
|
||||
description: "Die Messung wurde erfolgreich gelöscht."
|
||||
});
|
||||
refetchMeasurements();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error("Fehler", {
|
||||
description: err.message
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Form setup
|
||||
const form = useForm<MeasurementFormValues>({
|
||||
resolver: zodResolver(measurementFormSchema),
|
||||
@@ -103,6 +186,16 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
|
||||
// Create a new date at the start of the selected day in local timezone
|
||||
const selectedDate = date || new Date();
|
||||
|
||||
// Check if the selected date is in the future
|
||||
if (selectedDate > new Date()) {
|
||||
toast.error("Fehler", {
|
||||
description: "Messungen können nicht für zukünftige Daten hinzugefügt werden"
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const localDate = new Date(
|
||||
selectedDate.getFullYear(),
|
||||
selectedDate.getMonth(),
|
||||
@@ -150,6 +243,27 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
const birthDate = new Date(child.birthDate);
|
||||
const ageInMonths = differenceInMonths(new Date(), birthDate);
|
||||
|
||||
const gender = (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown";
|
||||
|
||||
const latestMeasurement = measurements?.[0];
|
||||
const weightPercentile = latestMeasurement?.weightKg
|
||||
? calculatePercentile(latestMeasurement.weightKg, ageInMonths, gender, "weight")
|
||||
: null;
|
||||
const heightPercentile = latestMeasurement?.heightCm
|
||||
? calculatePercentile(latestMeasurement.heightCm, ageInMonths, gender, "height")
|
||||
: null;
|
||||
|
||||
const getDevelopmentStatus = () => {
|
||||
if (!weightPercentile && !heightPercentile) return "Keine Daten";
|
||||
if ((weightPercentile && weightPercentile.percentile < 3) || (heightPercentile && heightPercentile.percentile < 3)) {
|
||||
return "Achtung";
|
||||
}
|
||||
if ((weightPercentile && weightPercentile.percentile > 97) || (heightPercentile && heightPercentile.percentile > 97)) {
|
||||
return "Achtung";
|
||||
}
|
||||
return "Normal";
|
||||
};
|
||||
|
||||
// Prepare data for charts
|
||||
const measurementData = measurements
|
||||
?.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
@@ -163,15 +277,48 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
<div className="space-y-6">
|
||||
<Card className="overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-rose-100 to-rose-200 p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white/80 p-3 rounded-full shadow-sm">
|
||||
<Baby className="h-8 w-8 text-rose-600" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white/80 p-3 rounded-full shadow-sm">
|
||||
<Baby className="h-8 w-8 text-rose-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
|
||||
<p className="text-rose-700">
|
||||
{ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-rose-900">{child.name}</h2>
|
||||
<p className="text-rose-700">
|
||||
{ageInMonths} Monate • {format(new Date(child.birthDate), "PPP", { locale: de })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChildEditDialog child={child} onSuccess={() => {
|
||||
trpc.useUtils().child.getById.invalidate({ id: childId });
|
||||
}} />
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Löschen
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Kind löschen</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchten Sie {child.name} wirklich löschen? Alle zugehörigen Daten (Messungen, Impfungen, Zahnstatus) werden ebenfalls gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteChild}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Wird gelöscht..." : "Löschen"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,7 +343,15 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
<Stethoscope className="h-5 w-5 text-rose-500" />
|
||||
<h3 className="font-medium text-gray-700">Entwicklung</h3>
|
||||
</div>
|
||||
<p className="text-lg">Normal</p>
|
||||
<p className={`text-lg ${getDevelopmentStatus() === 'Achtung' ? 'text-amber-600' : 'text-gray-900'}`}>
|
||||
{getDevelopmentStatus()}
|
||||
</p>
|
||||
{weightPercentile && (
|
||||
<p className="text-sm text-gray-500">Gewicht: {weightPercentile.percentile}. Perzentil</p>
|
||||
)}
|
||||
{heightPercentile && (
|
||||
<p className="text-sm text-gray-500">Größe: {heightPercentile.percentile}. Perzentil</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -391,6 +546,59 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Liste der Messungen</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMeasurementsList(!showMeasurementsList)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
<span>{showMeasurementsList ? "Ausblenden" : "Anzeigen"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
{showMeasurementsList && (
|
||||
<div className="space-y-2">
|
||||
{measurements.map((measurement) => (
|
||||
<div key={measurement.id} className="flex items-center justify-between p-4 bg-white rounded-lg border">
|
||||
<div>
|
||||
<p className="font-medium">{format(new Date(measurement.date), "PPP", { locale: de })}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Gewicht: {measurement.weightKg} kg • Größe: {measurement.heightCm} cm
|
||||
</p>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Messung löschen</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchten Sie diese Messung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMeasurement.mutate({ id: measurement.id })}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Noch keine Messungen aufgezeichnet.</p>
|
||||
@@ -414,32 +622,49 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
|
||||
{expandedSections.vaccinations && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Impfung hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>
|
||||
<div className="bg-white rounded-md p-4 border">
|
||||
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>6-fach Impfung</span>
|
||||
<span className="text-sm text-gray-500">2 Monate</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Pneumokokken</span>
|
||||
<span className="text-sm text-gray-500">2 Monate</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Rotaviren</span>
|
||||
<span className="text-sm text-gray-500">6 Wochen</span>
|
||||
</li>
|
||||
</ul>
|
||||
{!showVaccineForm ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||
onClick={() => setShowVaccineForm(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Impfung hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<VaccineForm
|
||||
childId={childId}
|
||||
onSuccess={() => {
|
||||
setShowVaccineForm(false);
|
||||
refetchVaccines();
|
||||
}}
|
||||
onCancel={() => setShowVaccineForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VaccineList
|
||||
vaccines={vaccines || []}
|
||||
onRefetch={() => refetchVaccines()}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-md p-4 border mt-4">
|
||||
<h3 className="text-lg font-medium mb-2">Empfohlene Impfungen</h3>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex items-center justify-between">
|
||||
<span>6-fach Impfung</span>
|
||||
<span className="text-sm text-gray-500">2 Monate</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Pneumokokken</span>
|
||||
<span className="text-sm text-gray-500">2 Monate</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span>Rotaviren</span>
|
||||
<span className="text-sm text-gray-500">6 Wochen</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,15 +685,7 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
|
||||
{expandedSections.toothing && (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" className="bg-rose-600 text-white hover:bg-rose-700">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Zahn hinzufügen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">Noch keine Zähne aufgezeichnet.</p>
|
||||
<div className="bg-white rounded-md p-4 border">
|
||||
<h3 className="text-lg font-medium mb-2">Zahnungsplan</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -508,6 +725,31 @@ export function ChildDetailContent({ childId }: ChildDetailContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-medium">Zahnstatus</h3>
|
||||
<TooltipProvider>
|
||||
<UITooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">about:blank#blocked
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Klicken Sie auf einen Zahn in der Visualisierung oder wählen Sie einen Zahn aus dem Dropdown-Menü, um den Durchbruch zu markieren.</p>
|
||||
<p>Rote Zähne sind bereits durchgebrochen.</p>
|
||||
</TooltipContent>
|
||||
</UITooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<DentureVisualization
|
||||
childId={childId}
|
||||
initialTeeth={teethStatus || []}
|
||||
onToothSaved={() => refetchTeeth()}
|
||||
showTitle={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
206
src/components/child/child-edit-dialog.tsx
Normal file
206
src/components/child/child-edit-dialog.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CalendarIcon, Pencil } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const childFormSchema = z.object({
|
||||
name: z.string().min(1, "Name ist erforderlich"),
|
||||
birthDate: z.date({
|
||||
required_error: "Bitte wähle ein Geburtsdatum",
|
||||
}),
|
||||
gender: z.enum(["male", "female", "diverse", "unknown"]),
|
||||
});
|
||||
|
||||
type ChildFormValues = z.infer<typeof childFormSchema>;
|
||||
|
||||
interface Child {
|
||||
id: string;
|
||||
name: string;
|
||||
birthDate: Date | string;
|
||||
gender: string | null;
|
||||
}
|
||||
|
||||
interface ChildEditDialogProps {
|
||||
child: Child;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ChildEditDialog({ child, onSuccess }: ChildEditDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [date, setDate] = useState<Date>(new Date(child.birthDate));
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<ChildFormValues>({
|
||||
resolver: zodResolver(childFormSchema),
|
||||
defaultValues: {
|
||||
name: child.name,
|
||||
birthDate: new Date(child.birthDate),
|
||||
gender: (child.gender as "male" | "female" | "diverse" | "unknown") || "unknown",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values: ChildFormValues) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/trpc/child.update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
json: {
|
||||
id: child.id,
|
||||
name: values.name,
|
||||
birthDate: date.toISOString(),
|
||||
gender: values.gender,
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save');
|
||||
|
||||
toast.success("Kind aktualisiert", {
|
||||
description: "Die Daten wurden erfolgreich gespeichert."
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error("Fehler", {
|
||||
description: "Die Daten konnten nicht gespeichert werden."
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
Bearbeiten
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Kind bearbeiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
Bearbeiten Sie die Daten von {child.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium text-gray-700">
|
||||
Name *
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
{...form.register("name")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Geburtsdatum *
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d) => d && setDate(d)}
|
||||
initialFocus
|
||||
locale={de}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Geschlecht
|
||||
</label>
|
||||
<Select
|
||||
value={form.watch("gender")}
|
||||
onValueChange={(value) => form.setValue("gender", value as "male" | "female" | "diverse" | "unknown")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Geschlecht auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Männlich</SelectItem>
|
||||
<SelectItem value="female">Weiblich</SelectItem>
|
||||
<SelectItem value="diverse">Divers</SelectItem>
|
||||
<SelectItem value="unknown">Unbekannt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="bg-rose-600 text-white hover:bg-rose-700"
|
||||
>
|
||||
{isLoading ? "Wird gespeichert..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
416
src/components/child/denture-visualization.tsx
Normal file
416
src/components/child/denture-visualization.tsx
Normal file
@@ -0,0 +1,416 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { CalendarIcon, InfoIcon, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
|
||||
const UPPER_TEETH = [
|
||||
{ id: "U8", name: "Oben rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
|
||||
{ id: "U7", name: "Oben rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
|
||||
{ id: "U6", name: "Oben rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
|
||||
{ id: "U5", name: "Oben rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
|
||||
{ id: "U4", name: "Oben rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
|
||||
{ id: "U3", name: "Oben rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
|
||||
{ id: "U2", name: "Oben rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
|
||||
{ id: "U1", name: "Oben rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
|
||||
{ id: "U1L", name: "Oben links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
|
||||
{ id: "U2L", name: "Oben links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
|
||||
{ id: "U3L", name: "Oben links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
|
||||
{ id: "U4L", name: "Oben links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
|
||||
{ id: "U5L", name: "Oben links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
|
||||
{ id: "U6L", name: "Oben links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
|
||||
{ id: "U7L", name: "Oben links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
|
||||
{ id: "U8L", name: "Oben links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
|
||||
];
|
||||
|
||||
const LOWER_TEETH = [
|
||||
{ id: "L8", name: "Unten rechts 8", x: 20, y: 0, width: 18, height: 30, curve: 0.1 },
|
||||
{ id: "L7", name: "Unten rechts 7", x: 38, y: 0, width: 18, height: 30, curve: 0.15 },
|
||||
{ id: "L6", name: "Unten rechts 6", x: 56, y: 0, width: 18, height: 30, curve: 0.2 },
|
||||
{ id: "L5", name: "Unten rechts 5", x: 74, y: 0, width: 18, height: 30, curve: 0.25 },
|
||||
{ id: "L4", name: "Unten rechts 4", x: 92, y: 0, width: 18, height: 30, curve: 0.3 },
|
||||
{ id: "L3", name: "Unten rechts 3", x: 110, y: 0, width: 18, height: 30, curve: 0.35 },
|
||||
{ id: "L2", name: "Unten rechts 2", x: 128, y: 0, width: 18, height: 30, curve: 0.4 },
|
||||
{ id: "L1", name: "Unten rechts 1", x: 146, y: 0, width: 18, height: 30, curve: 0.45 },
|
||||
{ id: "L1L", name: "Unten links 1", x: 164, y: 0, width: 18, height: 30, curve: 0.45 },
|
||||
{ id: "L2L", name: "Unten links 2", x: 182, y: 0, width: 18, height: 30, curve: 0.4 },
|
||||
{ id: "L3L", name: "Unten links 3", x: 200, y: 0, width: 18, height: 30, curve: 0.35 },
|
||||
{ id: "L4L", name: "Unten links 4", x: 218, y: 0, width: 18, height: 30, curve: 0.3 },
|
||||
{ id: "L5L", name: "Unten links 5", x: 236, y: 0, width: 18, height: 30, curve: 0.25 },
|
||||
{ id: "L6L", name: "Unten links 6", x: 254, y: 0, width: 18, height: 30, curve: 0.2 },
|
||||
{ id: "L7L", name: "Unten links 7", x: 272, y: 0, width: 18, height: 30, curve: 0.15 },
|
||||
{ id: "L8L", name: "Unten links 8", x: 290, y: 0, width: 18, height: 30, curve: 0.1 },
|
||||
];
|
||||
|
||||
interface Tooth {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
curve: number;
|
||||
erupted?: boolean;
|
||||
eruptionDate?: Date;
|
||||
dbId?: string;
|
||||
}
|
||||
|
||||
interface ToothStatusFromDB {
|
||||
id: string;
|
||||
toothLabel: string;
|
||||
date: Date | string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface DentureVisualizationProps {
|
||||
childId: string;
|
||||
initialTeeth?: ToothStatusFromDB[];
|
||||
onToothSaved?: () => void;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
export function DentureVisualization({
|
||||
childId,
|
||||
initialTeeth = [],
|
||||
onToothSaved,
|
||||
showTitle = false
|
||||
}: DentureVisualizationProps) {
|
||||
const [upperTeeth, setUpperTeeth] = useState<Tooth[]>([]);
|
||||
const [lowerTeeth, setLowerTeeth] = useState<Tooth[]>([]);
|
||||
const [selectedTooth, setSelectedTooth] = useState<Tooth | null>(null);
|
||||
const [eruptionDate, setEruptionDate] = useState<Date | undefined>(new Date());
|
||||
const [hoveredTooth, setHoveredTooth] = useState<Tooth | null>(null);
|
||||
|
||||
const addTooth = trpc.tooth.add.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Zahn hinzugefügt", {
|
||||
description: selectedTooth ? `${selectedTooth.name} wurde am ${format(eruptionDate!, "PPP", { locale: de })} als durchgebrochen markiert.` : ""
|
||||
});
|
||||
setSelectedTooth(null);
|
||||
onToothSaved?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Fehler", {
|
||||
description: error.message || "Der Zahn konnte nicht gespeichert werden."
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const teethMap = new Map<string, ToothStatusFromDB>();
|
||||
initialTeeth.forEach(t => teethMap.set(t.toothLabel, t));
|
||||
|
||||
const mapTeeth = (teeth: typeof UPPER_TEETH): Tooth[] =>
|
||||
teeth.map(tooth => {
|
||||
const dbTooth = teethMap.get(tooth.id);
|
||||
return {
|
||||
...tooth,
|
||||
erupted: !!dbTooth,
|
||||
eruptionDate: dbTooth ? new Date(dbTooth.date) : undefined,
|
||||
dbId: dbTooth?.id,
|
||||
};
|
||||
});
|
||||
|
||||
setUpperTeeth(mapTeeth(UPPER_TEETH));
|
||||
setLowerTeeth(mapTeeth(LOWER_TEETH));
|
||||
}, [initialTeeth]);
|
||||
|
||||
const handleToothClick = (tooth: Tooth) => {
|
||||
setSelectedTooth(tooth);
|
||||
if (tooth.eruptionDate) {
|
||||
setEruptionDate(tooth.eruptionDate);
|
||||
} else {
|
||||
setEruptionDate(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
const handleEruptionDateSelect = (date: Date | undefined) => {
|
||||
setEruptionDate(date);
|
||||
};
|
||||
|
||||
const handleSaveEruption = () => {
|
||||
if (!selectedTooth || !eruptionDate) return;
|
||||
|
||||
const localDate = new Date(
|
||||
eruptionDate.getFullYear(),
|
||||
eruptionDate.getMonth(),
|
||||
eruptionDate.getDate(),
|
||||
12, 0, 0, 0
|
||||
);
|
||||
|
||||
addTooth.mutate({
|
||||
childId,
|
||||
toothLabel: selectedTooth.id,
|
||||
date: localDate.toISOString(),
|
||||
status: "durchgebrochen",
|
||||
});
|
||||
|
||||
const updatedUpperTeeth = upperTeeth.map(t =>
|
||||
t.id === selectedTooth.id
|
||||
? { ...t, erupted: true, eruptionDate: eruptionDate }
|
||||
: t
|
||||
);
|
||||
|
||||
const updatedLowerTeeth = lowerTeeth.map(t =>
|
||||
t.id === selectedTooth.id
|
||||
? { ...t, erupted: true, eruptionDate: eruptionDate }
|
||||
: t
|
||||
);
|
||||
|
||||
setUpperTeeth(updatedUpperTeeth);
|
||||
setLowerTeeth(updatedLowerTeeth);
|
||||
};
|
||||
|
||||
const getToothPath = (tooth: Tooth, isUpper: boolean) => {
|
||||
const { x, y, width, height, curve } = tooth;
|
||||
const curveHeight = height * curve;
|
||||
const curveDirection = isUpper ? -1 : 1;
|
||||
|
||||
const cp1x = x + width * 0.25;
|
||||
const cp1y = y + (isUpper ? height : 0);
|
||||
const cp2x = x + width * 0.75;
|
||||
const cp2y = y + (isUpper ? height : 0);
|
||||
|
||||
const startX = x;
|
||||
const startY = y + (isUpper ? 0 : height);
|
||||
const endX = x + width;
|
||||
const endY = y + (isUpper ? 0 : height);
|
||||
|
||||
return `M ${startX} ${startY}
|
||||
C ${cp1x} ${cp1y + curveHeight * curveDirection * 0.5},
|
||||
${cp2x} ${cp2y + curveHeight * curveDirection * 0.5},
|
||||
${endX} ${endY}`;
|
||||
};
|
||||
|
||||
const handleToothSelect = (toothId: string) => {
|
||||
const tooth = [...upperTeeth, ...lowerTeeth].find(t => t.id === toothId);
|
||||
if (tooth) {
|
||||
handleToothClick(tooth);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-4">
|
||||
{showTitle && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Zahnstatus</h3>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Klicken Sie auf einen Zahn, um den Durchbruch zu markieren.</p>
|
||||
<p>Rote Zähne sind bereits durchgebrochen.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="text-sm font-medium">Zahn auswählen:</label>
|
||||
<Select onValueChange={handleToothSelect} value={selectedTooth?.id || "placeholder"}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Zahn auswählen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="placeholder" disabled>Zahn auswählen</SelectItem>
|
||||
<SelectItem value="group-upper" disabled className="font-semibold">Oberkiefer</SelectItem>
|
||||
{upperTeeth.map((tooth) => (
|
||||
<SelectItem key={tooth.id} value={tooth.id}>
|
||||
{tooth.name} {tooth.erupted && "✓"}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="group-lower" disabled className="font-semibold">Unterkiefer</SelectItem>
|
||||
{lowerTeeth.map((tooth) => (
|
||||
<SelectItem key={tooth.id} value={tooth.id}>
|
||||
{tooth.name} {tooth.erupted && "✓"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-center gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-lg font-medium mb-2">Oberkiefer</h3>
|
||||
<div className="relative w-[320px] h-[180px]">
|
||||
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
|
||||
{upperTeeth.map((tooth) => (
|
||||
<g key={tooth.id}>
|
||||
<rect
|
||||
x={tooth.x - 2}
|
||||
y={tooth.y - 2}
|
||||
width={tooth.width + 4}
|
||||
height={tooth.height + 4}
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleToothClick(tooth)}
|
||||
onMouseEnter={() => setHoveredTooth(tooth)}
|
||||
onMouseLeave={() => setHoveredTooth(null)}
|
||||
/>
|
||||
<path
|
||||
d={getToothPath(tooth, true)}
|
||||
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
|
||||
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
|
||||
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
|
||||
className="cursor-pointer transition-all duration-200"
|
||||
onClick={() => handleToothClick(tooth)}
|
||||
onMouseEnter={() => setHoveredTooth(tooth)}
|
||||
onMouseLeave={() => setHoveredTooth(null)}
|
||||
/>
|
||||
{tooth.erupted && (
|
||||
<text
|
||||
x={tooth.x + tooth.width / 2}
|
||||
y={tooth.y + tooth.height / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-sm font-bold fill-rose-600"
|
||||
>
|
||||
✓
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<h3 className="text-lg font-medium mb-2">Unterkiefer</h3>
|
||||
<div className="relative w-[320px] h-[180px]">
|
||||
<svg width="320" height="180" viewBox="0 0 320 180" className="absolute inset-0">
|
||||
{lowerTeeth.map((tooth) => (
|
||||
<g key={tooth.id}>
|
||||
<rect
|
||||
x={tooth.x - 2}
|
||||
y={tooth.y - 2}
|
||||
width={tooth.width + 4}
|
||||
height={tooth.height + 4}
|
||||
fill="transparent"
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleToothClick(tooth)}
|
||||
onMouseEnter={() => setHoveredTooth(tooth)}
|
||||
onMouseLeave={() => setHoveredTooth(null)}
|
||||
/>
|
||||
<path
|
||||
d={getToothPath(tooth, false)}
|
||||
fill={tooth.erupted ? "#fecaca" : "#ffffff"}
|
||||
stroke={selectedTooth?.id === tooth.id ? "#ef4444" : hoveredTooth?.id === tooth.id ? "#ef4444" : "#d1d5db"}
|
||||
strokeWidth={selectedTooth?.id === tooth.id ? "3" : hoveredTooth?.id === tooth.id ? "2" : "1"}
|
||||
className="cursor-pointer transition-all duration-200"
|
||||
onClick={() => handleToothClick(tooth)}
|
||||
onMouseEnter={() => setHoveredTooth(tooth)}
|
||||
onMouseLeave={() => setHoveredTooth(null)}
|
||||
/>
|
||||
{tooth.erupted && (
|
||||
<text
|
||||
x={tooth.x + tooth.width / 2}
|
||||
y={tooth.y + tooth.height / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-sm font-bold fill-rose-600"
|
||||
>
|
||||
✓
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTooth && (
|
||||
<div className="p-3 border rounded-lg bg-white shadow-sm">
|
||||
<h4 className="font-medium mb-2">{selectedTooth.name}</h4>
|
||||
{selectedTooth.erupted && selectedTooth.eruptionDate && (
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Durchbruch: {format(selectedTooth.eruptionDate, "PPP", { locale: de })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{selectedTooth.erupted ? "Datum ändern" : "Durchbruchdatum"}
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!eruptionDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{eruptionDate ? (
|
||||
format(eruptionDate, "PPP", { locale: de })
|
||||
) : (
|
||||
<span>Datum auswählen</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={eruptionDate}
|
||||
onSelect={handleEruptionDateSelect}
|
||||
initialFocus
|
||||
locale={de}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
|
||||
onClick={handleSaveEruption}
|
||||
disabled={addTooth.isPending || !eruptionDate}
|
||||
>
|
||||
{addTooth.isPending ? "Wird gespeichert..." : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSelectedTooth(null)}
|
||||
disabled={addTooth.isPending}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
176
src/components/child/vaccine-form.tsx
Normal file
176
src/components/child/vaccine-form.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, Check, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const vaccineFormSchema = z.object({
|
||||
name: z.string().min(1, "Name ist erforderlich"),
|
||||
date: z.date({
|
||||
required_error: "Bitte wähle ein Datum",
|
||||
}),
|
||||
done: z.boolean(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type VaccineFormValues = z.infer<typeof vaccineFormSchema>;
|
||||
|
||||
interface VaccineFormProps {
|
||||
childId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function VaccineForm({ childId, onSuccess, onCancel }: VaccineFormProps) {
|
||||
const [date, setDate] = useState<Date | undefined>(new Date());
|
||||
|
||||
const form = useForm<VaccineFormValues>({
|
||||
resolver: zodResolver(vaccineFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
date: new Date(),
|
||||
done: false,
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
|
||||
const addVaccine = trpc.vaccine.add.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Impfung hinzugefügt", {
|
||||
description: "Die Impfung wurde erfolgreich gespeichert."
|
||||
});
|
||||
form.reset();
|
||||
setDate(new Date());
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Fehler", {
|
||||
description: error.message || "Die Impfung konnte nicht gespeichert werden."
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit((values: VaccineFormValues) => {
|
||||
const localDate = new Date(
|
||||
(date || values.date).getFullYear(),
|
||||
(date || values.date).getMonth(),
|
||||
(date || values.date).getDate(),
|
||||
12, 0, 0, 0
|
||||
);
|
||||
|
||||
addVaccine.mutate({
|
||||
childId,
|
||||
name: values.name,
|
||||
date: localDate.toISOString(),
|
||||
done: values.done,
|
||||
notes: values.notes || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4 p-4 border rounded-lg bg-gray-50">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="text-sm font-medium text-gray-700">
|
||||
Impfname *
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="z.B. 6-fach Impfung"
|
||||
{...form.register("name")}
|
||||
disabled={addVaccine.isPending}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">{form.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Datum *
|
||||
</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, "PPP", { locale: de }) : <span>Datum auswählen</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={setDate}
|
||||
initialFocus
|
||||
locale={de}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="notes" className="text-sm font-medium text-gray-700">
|
||||
Notizen
|
||||
</label>
|
||||
<Input
|
||||
id="notes"
|
||||
placeholder="Optionale Notizen"
|
||||
{...form.register("notes")}
|
||||
disabled={addVaccine.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="done"
|
||||
{...form.register("done")}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="done" className="text-sm text-gray-700">
|
||||
Bereits durchgeführt
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={addVaccine.isPending}
|
||||
className="flex-1 bg-rose-600 text-white hover:bg-rose-700"
|
||||
>
|
||||
{addVaccine.isPending ? "Wird gespeichert..." : (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={addVaccine.isPending}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
252
src/components/child/vaccine-list.tsx
Normal file
252
src/components/child/vaccine-list.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Check, X, Pencil } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Vaccine {
|
||||
id: string;
|
||||
name: string;
|
||||
date: Date | string;
|
||||
done: boolean;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface VaccineListProps {
|
||||
vaccines: Vaccine[];
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function VaccineList({ vaccines, onRefetch }: VaccineListProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDate, setEditDate] = useState<Date | undefined>();
|
||||
const [editNotes, setEditNotes] = useState("");
|
||||
const [editDone, setEditDone] = useState(false);
|
||||
|
||||
const updateVaccine = trpc.vaccine.update.useMutation({
|
||||
onSuccess: () => {
|
||||
onRefetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Fehler", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteVaccine = trpc.vaccine.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Impfung gelöscht");
|
||||
onRefetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Fehler", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const startEdit = (vaccine: Vaccine) => {
|
||||
setEditingId(vaccine.id);
|
||||
setEditName(vaccine.name);
|
||||
setEditDate(new Date(vaccine.date));
|
||||
setEditNotes(vaccine.notes || "");
|
||||
setEditDone(vaccine.done);
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingId(null);
|
||||
setEditName("");
|
||||
setEditDate(undefined);
|
||||
setEditNotes("");
|
||||
setEditDone(false);
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!editingId || !editDate) return;
|
||||
|
||||
const localDate = new Date(
|
||||
editDate.getFullYear(),
|
||||
editDate.getMonth(),
|
||||
editDate.getDate(),
|
||||
12, 0, 0, 0
|
||||
);
|
||||
|
||||
updateVaccine.mutate({
|
||||
id: editingId,
|
||||
name: editName,
|
||||
date: localDate.toISOString(),
|
||||
done: editDone,
|
||||
notes: editNotes || undefined,
|
||||
});
|
||||
cancelEdit();
|
||||
};
|
||||
|
||||
const toggleDone = (vaccine: Vaccine) => {
|
||||
const localDate = new Date(
|
||||
new Date(vaccine.date).getFullYear(),
|
||||
new Date(vaccine.date).getMonth(),
|
||||
new Date(vaccine.date).getDate(),
|
||||
12, 0, 0, 0
|
||||
);
|
||||
|
||||
updateVaccine.mutate({
|
||||
id: vaccine.id,
|
||||
done: !vaccine.done,
|
||||
date: localDate.toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
if (vaccines.length === 0) {
|
||||
return <p className="text-muted-foreground">Noch keine Impfungen aufgezeichnet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{vaccines.map((vaccine) => (
|
||||
<div
|
||||
key={vaccine.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${
|
||||
vaccine.done ? "bg-green-50 border-green-200" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
{editingId === vaccine.id ? (
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Impfname"
|
||||
disabled={updateVaccine.isPending}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!editDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{editDate ? format(editDate, "PPP", { locale: de }) : <span>Datum</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={editDate}
|
||||
onSelect={setEditDate}
|
||||
initialFocus
|
||||
locale={de}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Input
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
placeholder="Notizen"
|
||||
disabled={updateVaccine.isPending}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editDone}
|
||||
onChange={(e) => setEditDone(e.target.checked)}
|
||||
className="h-4 w-4 rounded"
|
||||
/>
|
||||
<label className="text-sm">Durchgeführt</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={saveEdit} disabled={updateVaccine.isPending}>
|
||||
<Check className="h-4 w-4 mr-1" /> Speichern
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={cancelEdit} disabled={updateVaccine.isPending}>
|
||||
<X className="h-4 w-4 mr-1" /> Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{vaccine.name}</p>
|
||||
{vaccine.done && (
|
||||
<span className="text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
|
||||
Erledigt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(new Date(vaccine.date), "PPP", { locale: de })}
|
||||
</p>
|
||||
{vaccine.notes && (
|
||||
<p className="text-sm text-gray-400 mt-1">{vaccine.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleDone(vaccine)}
|
||||
disabled={updateVaccine.isPending}
|
||||
className={vaccine.done ? "text-green-600" : "text-gray-400"}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startEdit(vaccine)}
|
||||
disabled={updateVaccine.isPending}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Impfung löschen</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchten Sie diese Impfung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteVaccine.mutate({ id: vaccine.id })}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
Löschen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { format, differenceInMonths } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Baby, Activity, Calendar } from "lucide-react";
|
||||
import { Plus, Baby, Activity, Calendar, Syringe } from "lucide-react";
|
||||
import { trpc } from "@/utils/trpc";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
@@ -14,24 +14,29 @@ export function DashboardContent() {
|
||||
const { data: children, isLoading } = trpc.child.getAllByUser.useQuery();
|
||||
const [selectedChildId, setSelectedChildId] = useState<string | null>(null);
|
||||
|
||||
// Get measurements for the selected child
|
||||
const { data: measurements } = trpc.measurement.getByChildId.useQuery(
|
||||
{ childId: selectedChildId || "" },
|
||||
{ enabled: !!selectedChildId }
|
||||
);
|
||||
|
||||
// Get vaccinations for the selected child - using child router as a fallback
|
||||
const { data: vaccinations } = trpc.child.getById.useQuery(
|
||||
{ id: selectedChildId || "" },
|
||||
const { data: vaccines } = trpc.vaccine.getByChildId.useQuery(
|
||||
{ childId: selectedChildId || "" },
|
||||
{ enabled: !!selectedChildId }
|
||||
);
|
||||
|
||||
// Get toothing data for the selected child - using child router as a fallback
|
||||
const { data: toothing } = trpc.child.getById.useQuery(
|
||||
{ id: selectedChildId || "" },
|
||||
const { data: teeth } = trpc.tooth.getByChildId.useQuery(
|
||||
{ childId: selectedChildId || "" },
|
||||
{ enabled: !!selectedChildId }
|
||||
);
|
||||
|
||||
const upcomingVaccines = vaccines?.filter(v => !v.done) || [];
|
||||
const nextVaccine = upcomingVaccines[0];
|
||||
|
||||
const eruptedTeeth = teeth?.filter(t => t.status === "durchgebrochen").length || 0;
|
||||
|
||||
const selectedChild = children?.find(c => c.id === selectedChildId);
|
||||
const ageInMonths = selectedChild ? differenceInMonths(new Date(), new Date(selectedChild.birthDate)) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -106,9 +111,28 @@ export function DashboardContent() {
|
||||
<CardDescription className="text-zinc-500">Wichtige Termine und Erinnerungen</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-zinc-500 mb-2">Keine anstehenden Termine.</p>
|
||||
</div>
|
||||
{nextVaccine ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center p-3 rounded-lg bg-blue-50 border border-blue-100">
|
||||
<Syringe className="h-5 w-5 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">{nextVaccine.name}</p>
|
||||
<p className="text-sm text-blue-600">
|
||||
Geplant: {format(new Date(nextVaccine.date), "PPP", { locale: de })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{upcomingVaccines.length > 1 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
+ {upcomingVaccines.length - 1} weitere Impfungen geplant
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<p className="text-zinc-500 mb-2">Keine anstehenden Impfungen.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -149,7 +173,6 @@ export function DashboardContent() {
|
||||
|
||||
{selectedChildId ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Measurements Summary */}
|
||||
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
|
||||
<h3 className="font-medium text-zinc-800 mb-2">Messungen</h3>
|
||||
{measurements && measurements.length > 0 ? (
|
||||
@@ -160,11 +183,11 @@ export function DashboardContent() {
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">Gewicht</p>
|
||||
<p className="font-medium">{measurements[0].weightKg} kg</p>
|
||||
<p className="font-medium">{measurements[0].weightKg?.toFixed(1)} kg</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500">Größe</p>
|
||||
<p className="font-medium">{measurements[0].heightCm} cm</p>
|
||||
<p className="font-medium">{measurements[0].heightCm?.toFixed(1)} cm</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,31 +196,39 @@ export function DashboardContent() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vaccinations Summary */}
|
||||
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
|
||||
<h3 className="font-medium text-zinc-800 mb-2">Impfungen</h3>
|
||||
{vaccinations ? (
|
||||
{vaccines && vaccines.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-600">
|
||||
Impfungen werden bald verfügbar sein
|
||||
{vaccines.filter(v => v.done).length} von {vaccines.length} erledigt
|
||||
</p>
|
||||
{upcomingVaccines.length > 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
{upcomingVaccines.length} ausstehend
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Keine Impfungen vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toothing Summary */}
|
||||
<div className="bg-zinc-50 p-4 rounded-lg border border-zinc-200">
|
||||
<h3 className="font-medium text-zinc-800 mb-2">Zahnung</h3>
|
||||
{toothing ? (
|
||||
{ageInMonths >= 5 ? (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-600">
|
||||
Zahnungsdaten werden bald verfügbar sein
|
||||
{eruptedTeeth} Zähne durchgebrochen
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
von 20 Milchzähnen
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Keine Zahnungsdaten vorhanden</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Zahnung beginnt ca. ab 5 Monaten
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
122
src/components/ui/dialog.tsx
Normal file
122
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
30
src/components/ui/tooltip.tsx
Normal file
30
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
188
src/lib/percentiles.ts
Normal file
188
src/lib/percentiles.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
export interface PercentileData {
|
||||
ageMonths: number;
|
||||
p3: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p50: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
p97: number;
|
||||
}
|
||||
|
||||
export const WHO_WEIGHT_BOYS: PercentileData[] = [
|
||||
{ ageMonths: 0, p3: 2.5, p10: 2.9, p25: 3.2, p50: 3.5, p75: 3.9, p90: 4.2, p97: 4.6 },
|
||||
{ ageMonths: 1, p3: 3.4, p10: 3.9, p25: 4.3, p50: 4.8, p75: 5.3, p90: 5.8, p97: 6.3 },
|
||||
{ ageMonths: 2, p3: 4.3, p10: 4.9, p25: 5.4, p50: 6.0, p75: 6.6, p90: 7.2, p97: 7.8 },
|
||||
{ ageMonths: 3, p3: 5.0, p10: 5.7, p25: 6.3, p50: 6.9, p75: 7.6, p90: 8.3, p97: 9.0 },
|
||||
{ ageMonths: 4, p3: 5.6, p10: 6.3, p25: 7.0, p50: 7.7, p75: 8.5, p90: 9.2, p97: 10.0 },
|
||||
{ ageMonths: 5, p3: 6.0, p10: 6.8, p25: 7.5, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.8 },
|
||||
{ ageMonths: 6, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.5 },
|
||||
{ ageMonths: 7, p3: 6.7, p10: 7.6, p25: 8.4, p50: 9.3, p75: 10.2, p90: 11.2, p97: 12.1 },
|
||||
{ ageMonths: 8, p3: 7.0, p10: 7.9, p25: 8.7, p50: 9.7, p75: 10.7, p90: 11.7, p97: 12.7 },
|
||||
{ ageMonths: 9, p3: 7.2, p10: 8.2, p25: 9.1, p50: 10.0, p75: 11.1, p90: 12.1, p97: 13.2 },
|
||||
{ ageMonths: 10, p3: 7.5, p10: 8.4, p25: 9.4, p50: 10.4, p75: 11.5, p90: 12.6, p97: 13.7 },
|
||||
{ ageMonths: 11, p3: 7.7, p10: 8.7, p25: 9.6, p50: 10.7, p75: 11.8, p90: 13.0, p97: 14.1 },
|
||||
{ ageMonths: 12, p3: 7.9, p10: 8.9, p25: 9.9, p50: 10.9, p75: 12.1, p90: 13.3, p97: 14.5 },
|
||||
{ ageMonths: 15, p3: 8.4, p10: 9.5, p25: 10.5, p50: 11.7, p75: 13.0, p90: 14.3, p97: 15.6 },
|
||||
{ ageMonths: 18, p3: 8.8, p10: 10.0, p25: 11.1, p50: 12.4, p75: 13.8, p90: 15.2, p97: 16.6 },
|
||||
{ ageMonths: 21, p3: 9.2, p10: 10.5, p25: 11.7, p50: 13.0, p75: 14.5, p90: 16.0, p97: 17.5 },
|
||||
{ ageMonths: 24, p3: 9.7, p10: 11.0, p25: 12.2, p50: 13.6, p75: 15.2, p90: 16.8, p97: 18.4 },
|
||||
{ ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 14.8, p75: 16.6, p90: 18.3, p97: 20.1 },
|
||||
{ ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.3, p50: 16.0, p75: 17.9, p90: 19.8, p97: 21.8 },
|
||||
{ ageMonths: 48, p3: 12.6, p10: 14.4, p25: 16.1, p50: 18.1, p75: 20.3, p90: 22.5, p97: 24.8 },
|
||||
{ ageMonths: 60, p3: 14.0, p10: 16.0, p25: 18.0, p50: 20.3, p75: 22.8, p90: 25.4, p97: 28.1 },
|
||||
];
|
||||
|
||||
export const WHO_WEIGHT_GIRLS: PercentileData[] = [
|
||||
{ ageMonths: 0, p3: 2.4, p10: 2.8, p25: 3.1, p50: 3.4, p75: 3.7, p90: 4.1, p97: 4.4 },
|
||||
{ ageMonths: 1, p3: 3.2, p10: 3.7, p25: 4.1, p50: 4.5, p75: 5.0, p90: 5.4, p97: 5.9 },
|
||||
{ ageMonths: 2, p3: 4.0, p10: 4.6, p25: 5.1, p50: 5.6, p75: 6.2, p90: 6.7, p97: 7.3 },
|
||||
{ ageMonths: 3, p3: 4.7, p10: 5.3, p25: 5.9, p50: 6.5, p75: 7.1, p90: 7.8, p97: 8.5 },
|
||||
{ ageMonths: 4, p3: 5.2, p10: 5.9, p25: 6.5, p50: 7.2, p75: 7.9, p90: 8.7, p97: 9.4 },
|
||||
{ ageMonths: 5, p3: 5.7, p10: 6.4, p25: 7.1, p50: 7.8, p75: 8.6, p90: 9.4, p97: 10.2 },
|
||||
{ ageMonths: 6, p3: 6.1, p10: 6.8, p25: 7.6, p50: 8.3, p75: 9.2, p90: 10.0, p97: 10.9 },
|
||||
{ ageMonths: 7, p3: 6.4, p10: 7.2, p25: 8.0, p50: 8.8, p75: 9.7, p90: 10.6, p97: 11.6 },
|
||||
{ ageMonths: 8, p3: 6.7, p10: 7.5, p25: 8.4, p50: 9.2, p75: 10.2, p90: 11.2, p97: 12.2 },
|
||||
{ ageMonths: 9, p3: 6.9, p10: 7.8, p25: 8.7, p50: 9.6, p75: 10.6, p90: 11.7, p97: 12.8 },
|
||||
{ ageMonths: 10, p3: 7.2, p10: 8.1, p25: 9.0, p50: 10.0, p75: 11.0, p90: 12.1, p97: 13.3 },
|
||||
{ ageMonths: 11, p3: 7.4, p10: 8.3, p25: 9.3, p50: 10.3, p75: 11.4, p90: 12.6, p97: 13.8 },
|
||||
{ ageMonths: 12, p3: 7.6, p10: 8.6, p25: 9.6, p50: 10.6, p75: 11.8, p90: 13.0, p97: 14.3 },
|
||||
{ ageMonths: 15, p3: 8.1, p10: 9.2, p25: 10.3, p50: 11.5, p75: 12.8, p90: 14.1, p97: 15.5 },
|
||||
{ ageMonths: 18, p3: 8.6, p10: 9.8, p25: 10.9, p50: 12.2, p75: 13.7, p90: 15.1, p97: 16.6 },
|
||||
{ ageMonths: 21, p3: 9.1, p10: 10.3, p25: 11.5, p50: 12.9, p75: 14.5, p90: 16.0, p97: 17.6 },
|
||||
{ ageMonths: 24, p3: 9.6, p10: 10.9, p25: 12.2, p50: 13.6, p75: 15.3, p90: 17.0, p97: 18.7 },
|
||||
{ ageMonths: 30, p3: 10.5, p10: 11.9, p25: 13.3, p50: 15.0, p75: 16.8, p90: 18.7, p97: 20.6 },
|
||||
{ ageMonths: 36, p3: 11.3, p10: 12.8, p25: 14.4, p50: 16.2, p75: 18.2, p90: 20.3, p97: 22.5 },
|
||||
{ ageMonths: 48, p3: 12.8, p10: 14.6, p25: 16.4, p50: 18.5, p75: 20.9, p90: 23.4, p97: 26.0 },
|
||||
{ ageMonths: 60, p3: 14.3, p10: 16.4, p25: 18.5, p50: 21.0, p75: 23.8, p90: 26.8, p97: 30.0 },
|
||||
];
|
||||
|
||||
export const WHO_HEIGHT_BOYS: PercentileData[] = [
|
||||
{ ageMonths: 0, p3: 46.1, p10: 47.4, p25: 48.6, p50: 49.9, p75: 51.2, p90: 52.4, p97: 53.7 },
|
||||
{ ageMonths: 1, p3: 50.8, p10: 52.2, p25: 53.6, p50: 55.0, p75: 56.5, p90: 57.9, p97: 59.4 },
|
||||
{ ageMonths: 2, p3: 54.4, p10: 55.9, p25: 57.4, p50: 59.0, p75: 60.6, p90: 62.1, p97: 63.7 },
|
||||
{ ageMonths: 3, p3: 57.3, p10: 58.9, p25: 60.5, p50: 62.2, p75: 63.9, p90: 65.6, p97: 67.3 },
|
||||
{ ageMonths: 4, p3: 59.7, p10: 61.4, p25: 63.1, p50: 64.9, p75: 66.7, p90: 68.5, p97: 70.3 },
|
||||
{ ageMonths: 5, p3: 61.7, p10: 63.5, p25: 65.3, p50: 67.2, p75: 69.1, p90: 71.0, p97: 72.9 },
|
||||
{ ageMonths: 6, p3: 63.4, p10: 65.3, p25: 67.2, p50: 69.2, p75: 71.2, p90: 73.2, p97: 75.2 },
|
||||
{ ageMonths: 7, p3: 64.9, p10: 66.9, p25: 68.9, p50: 71.0, p75: 73.1, p90: 75.2, p97: 77.3 },
|
||||
{ ageMonths: 8, p3: 66.3, p10: 68.4, p25: 70.5, p50: 72.6, p75: 74.8, p90: 77.0, p97: 79.2 },
|
||||
{ ageMonths: 9, p3: 67.5, p10: 69.7, p25: 71.9, p50: 74.1, p75: 76.4, p90: 78.7, p97: 81.0 },
|
||||
{ ageMonths: 10, p3: 68.7, p10: 71.0, p25: 73.3, p50: 75.6, p75: 77.9, p90: 80.3, p97: 82.7 },
|
||||
{ ageMonths: 11, p3: 69.9, p10: 72.2, p25: 74.6, p50: 77.0, p75: 79.4, p90: 81.8, p97: 84.3 },
|
||||
{ ageMonths: 12, p3: 71.0, p10: 73.4, p25: 75.8, p50: 78.3, p75: 80.8, p90: 83.3, p97: 85.8 },
|
||||
{ ageMonths: 15, p3: 73.9, p10: 76.5, p25: 79.1, p50: 81.8, p75: 84.5, p90: 87.2, p97: 89.9 },
|
||||
{ ageMonths: 18, p3: 76.5, p10: 79.3, p25: 82.1, p50: 85.0, p75: 87.9, p90: 90.8, p97: 93.7 },
|
||||
{ ageMonths: 21, p3: 78.9, p10: 81.8, p25: 84.8, p50: 87.8, p75: 90.9, p90: 93.9, p97: 97.0 },
|
||||
{ ageMonths: 24, p3: 81.1, p10: 84.2, p25: 87.3, p50: 90.5, p75: 93.7, p90: 97.0, p97: 100.2 },
|
||||
{ ageMonths: 30, p3: 85.1, p10: 88.5, p25: 91.9, p50: 95.4, p75: 98.9, p90: 102.4, p97: 106.0 },
|
||||
{ ageMonths: 36, p3: 88.7, p10: 92.4, p25: 96.1, p50: 99.9, p75: 103.7, p90: 107.5, p97: 111.3 },
|
||||
{ ageMonths: 48, p3: 95.1, p10: 99.3, p25: 103.5, p50: 107.8, p75: 112.2, p90: 116.6, p97: 121.0 },
|
||||
{ ageMonths: 60, p3: 100.8, p10: 105.5, p25: 110.2, p50: 115.0, p75: 119.9, p90: 124.8, p97: 129.7 },
|
||||
];
|
||||
|
||||
export const WHO_HEIGHT_GIRLS: PercentileData[] = [
|
||||
{ ageMonths: 0, p3: 45.4, p10: 46.7, p25: 47.9, p50: 49.2, p75: 50.4, p90: 51.7, p97: 52.9 },
|
||||
{ ageMonths: 1, p3: 49.8, p10: 51.2, p25: 52.5, p50: 53.9, p75: 55.3, p90: 56.7, p97: 58.1 },
|
||||
{ ageMonths: 2, p3: 53.1, p10: 54.6, p25: 56.1, p50: 57.6, p75: 59.2, p90: 60.7, p97: 62.3 },
|
||||
{ ageMonths: 3, p3: 55.7, p10: 57.3, p25: 58.9, p50: 60.6, p75: 62.3, p90: 63.9, p97: 65.6 },
|
||||
{ ageMonths: 4, p3: 57.8, p10: 59.5, p25: 61.2, p50: 63.0, p75: 64.8, p90: 66.6, p97: 68.4 },
|
||||
{ ageMonths: 5, p3: 59.6, p10: 61.4, p25: 63.2, p50: 65.1, p75: 67.0, p90: 68.9, p97: 70.8 },
|
||||
{ ageMonths: 6, p3: 61.2, p10: 63.1, p25: 65.0, p50: 66.9, p75: 68.9, p90: 70.9, p97: 72.9 },
|
||||
{ ageMonths: 7, p3: 62.6, p10: 64.6, p25: 66.6, p50: 68.7, p75: 70.8, p90: 72.9, p97: 75.0 },
|
||||
{ ageMonths: 8, p3: 64.0, p10: 66.1, p25: 68.2, p50: 70.4, p75: 72.6, p90: 74.8, p97: 77.0 },
|
||||
{ ageMonths: 9, p3: 65.2, p10: 67.4, p25: 69.6, p50: 71.9, p75: 74.2, p90: 76.5, p97: 78.8 },
|
||||
{ ageMonths: 10, p3: 66.4, p10: 68.7, p25: 71.0, p50: 73.4, p75: 75.8, p90: 78.2, p97: 80.6 },
|
||||
{ ageMonths: 11, p3: 67.6, p10: 70.0, p25: 72.4, p50: 74.8, p75: 77.3, p90: 79.8, p97: 82.3 },
|
||||
{ ageMonths: 12, p3: 68.7, p10: 71.2, p25: 73.7, p50: 76.2, p75: 78.8, p90: 81.4, p97: 84.0 },
|
||||
{ ageMonths: 15, p3: 71.6, p10: 74.3, p25: 77.0, p50: 79.8, p75: 82.6, p90: 85.4, p97: 88.2 },
|
||||
{ ageMonths: 18, p3: 74.2, p10: 77.1, p25: 80.0, p50: 83.0, p75: 86.0, p90: 89.1, p97: 92.1 },
|
||||
{ ageMonths: 21, p3: 76.6, p10: 79.7, p25: 82.8, p50: 86.0, p75: 89.2, p90: 92.5, p97: 95.7 },
|
||||
{ ageMonths: 24, p3: 78.8, p10: 82.1, p25: 85.4, p50: 88.8, p75: 92.2, p90: 95.6, p97: 99.1 },
|
||||
{ ageMonths: 30, p3: 82.8, p10: 86.4, p25: 90.0, p50: 93.7, p75: 97.4, p90: 101.2, p97: 105.0 },
|
||||
{ ageMonths: 36, p3: 86.4, p10: 90.2, p25: 94.1, p50: 98.1, p75: 102.1, p90: 106.1, p97: 110.2 },
|
||||
{ ageMonths: 48, p3: 92.8, p10: 97.1, p25: 101.4, p50: 105.9, p75: 110.4, p90: 114.9, p97: 119.5 },
|
||||
{ ageMonths: 60, p3: 98.5, p10: 103.3, p25: 108.1, p50: 113.0, p75: 118.0, p90: 123.0, p97: 128.1 },
|
||||
];
|
||||
|
||||
function interpolate(data: PercentileData[], ageMonths: number): PercentileData | null {
|
||||
if (data.length === 0) return null;
|
||||
|
||||
const sorted = [...data].sort((a, b) => a.ageMonths - b.ageMonths);
|
||||
|
||||
if (ageMonths <= sorted[0].ageMonths) return sorted[0];
|
||||
if (ageMonths >= sorted[sorted.length - 1].ageMonths) return sorted[sorted.length - 1];
|
||||
|
||||
let lower = sorted[0];
|
||||
let upper = sorted[sorted.length - 1];
|
||||
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
if (sorted[i].ageMonths <= ageMonths && sorted[i + 1].ageMonths >= ageMonths) {
|
||||
lower = sorted[i];
|
||||
upper = sorted[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const range = upper.ageMonths - lower.ageMonths;
|
||||
const factor = range === 0 ? 0 : (ageMonths - lower.ageMonths) / range;
|
||||
|
||||
return {
|
||||
ageMonths,
|
||||
p3: lower.p3 + (upper.p3 - lower.p3) * factor,
|
||||
p10: lower.p10 + (upper.p10 - lower.p10) * factor,
|
||||
p25: lower.p25 + (upper.p25 - lower.p25) * factor,
|
||||
p50: lower.p50 + (upper.p50 - lower.p50) * factor,
|
||||
p75: lower.p75 + (upper.p75 - lower.p75) * factor,
|
||||
p90: lower.p90 + (upper.p90 - lower.p90) * factor,
|
||||
p97: lower.p97 + (upper.p97 - lower.p97) * factor,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePercentile(
|
||||
value: number,
|
||||
ageMonths: number,
|
||||
gender: "male" | "female" | "diverse" | "unknown",
|
||||
type: "weight" | "height"
|
||||
): { percentile: number; label: string } | null {
|
||||
const isMale = gender === "male";
|
||||
|
||||
const dataTable = type === "weight"
|
||||
? (isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS)
|
||||
: (isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS);
|
||||
|
||||
const ref = interpolate(dataTable, Math.round(ageMonths));
|
||||
if (!ref) return null;
|
||||
|
||||
let percentile = 50;
|
||||
|
||||
if (value <= ref.p3) percentile = 3;
|
||||
else if (value <= ref.p10) percentile = 3 + ((value - ref.p3) / (ref.p10 - ref.p3)) * 7;
|
||||
else if (value <= ref.p25) percentile = 10 + ((value - ref.p10) / (ref.p25 - ref.p10)) * 15;
|
||||
else if (value <= ref.p50) percentile = 25 + ((value - ref.p25) / (ref.p50 - ref.p25)) * 25;
|
||||
else if (value <= ref.p75) percentile = 50 + ((value - ref.p50) / (ref.p75 - ref.p50)) * 25;
|
||||
else if (value <= ref.p90) percentile = 75 + ((value - ref.p75) / (ref.p90 - ref.p75)) * 15;
|
||||
else if (value <= ref.p97) percentile = 90 + ((value - ref.p90) / (ref.p97 - ref.p90)) * 7;
|
||||
else percentile = 97;
|
||||
|
||||
let label = "Normal";
|
||||
if (percentile < 3) label = "Untergewicht / Klein";
|
||||
else if (percentile < 10) label = "Unterdurchschnittlich";
|
||||
else if (percentile < 90) label = "Normal";
|
||||
else if (percentile < 97) label = "Überdurchschnittlich";
|
||||
else label = "Übergewicht / Groß";
|
||||
|
||||
return { percentile: Math.round(percentile), label };
|
||||
}
|
||||
|
||||
export function getPercentileDataForAge(
|
||||
gender: "male" | "female" | "diverse" | "unknown",
|
||||
type: "weight" | "height"
|
||||
): PercentileData[] {
|
||||
const isMale = gender === "male";
|
||||
|
||||
if (type === "weight") {
|
||||
return isMale ? WHO_WEIGHT_BOYS : WHO_WEIGHT_GIRLS;
|
||||
}
|
||||
return isMale ? WHO_HEIGHT_BOYS : WHO_HEIGHT_GIRLS;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}),
|
||||
});
|
||||
117
src/server/api/routers/tooth.ts
Normal file
117
src/server/api/routers/tooth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "@/server/trpc";
|
||||
|
||||
export const toothRouter = router({
|
||||
getByChildId: protectedProcedure
|
||||
.input(z.object({ childId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.toothStatus.findMany({
|
||||
where: {
|
||||
childId: input.childId,
|
||||
},
|
||||
orderBy: {
|
||||
date: "desc",
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
add: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
childId: z.string(),
|
||||
toothLabel: z.string(),
|
||||
date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"),
|
||||
status: z.enum(["durchgebrochen", "locker", "fehlend"]).default("durchgebrochen"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const child = await ctx.prisma.child.findFirst({
|
||||
where: {
|
||||
id: input.childId,
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
throw new Error("Child not found or unauthorized");
|
||||
}
|
||||
|
||||
const existing = await ctx.prisma.toothStatus.findFirst({
|
||||
where: {
|
||||
childId: input.childId,
|
||||
toothLabel: input.toothLabel,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return ctx.prisma.toothStatus.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
date: new Date(input.date),
|
||||
status: input.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.prisma.toothStatus.create({
|
||||
data: {
|
||||
childId: input.childId,
|
||||
toothLabel: input.toothLabel,
|
||||
date: new Date(input.date),
|
||||
status: input.status,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(),
|
||||
status: z.enum(["durchgebrochen", "locker", "fehlend"]).optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const toothStatus = await ctx.prisma.toothStatus.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
child: {
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toothStatus) {
|
||||
throw new Error("Tooth status not found or unauthorized");
|
||||
}
|
||||
|
||||
return ctx.prisma.toothStatus.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.date && { date: new Date(input.date) }),
|
||||
...(input.status && { status: input.status }),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const toothStatus = await ctx.prisma.toothStatus.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
child: {
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!toothStatus) {
|
||||
throw new Error("Tooth status not found or unauthorized");
|
||||
}
|
||||
|
||||
return ctx.prisma.toothStatus.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
}),
|
||||
});
|
||||
106
src/server/api/routers/vaccine.ts
Normal file
106
src/server/api/routers/vaccine.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { z } from "zod";
|
||||
import { protectedProcedure, router } from "@/server/trpc";
|
||||
|
||||
export const vaccineRouter = router({
|
||||
getByChildId: protectedProcedure
|
||||
.input(z.object({ childId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.prisma.vaccine.findMany({
|
||||
where: {
|
||||
childId: input.childId,
|
||||
},
|
||||
orderBy: {
|
||||
date: "asc",
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
add: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
childId: z.string(),
|
||||
name: z.string().min(1, "Name is required"),
|
||||
date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date"),
|
||||
done: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const child = await ctx.prisma.child.findFirst({
|
||||
where: {
|
||||
id: input.childId,
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!child) {
|
||||
throw new Error("Child not found or unauthorized");
|
||||
}
|
||||
|
||||
return ctx.prisma.vaccine.create({
|
||||
data: {
|
||||
childId: input.childId,
|
||||
name: input.name,
|
||||
date: new Date(input.date),
|
||||
done: input.done,
|
||||
notes: input.notes,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1, "Name is required").optional(),
|
||||
date: z.string().refine((date) => !isNaN(Date.parse(date)), "Invalid date").optional(),
|
||||
done: z.boolean().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const vaccine = await ctx.prisma.vaccine.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
child: {
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!vaccine) {
|
||||
throw new Error("Vaccine not found or unauthorized");
|
||||
}
|
||||
|
||||
return ctx.prisma.vaccine.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.name && { name: input.name }),
|
||||
...(input.date && { date: new Date(input.date) }),
|
||||
...(input.done !== undefined && { done: input.done }),
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const vaccine = await ctx.prisma.vaccine.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
child: {
|
||||
userId: ctx.session.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!vaccine) {
|
||||
throw new Error("Vaccine not found or unauthorized");
|
||||
}
|
||||
|
||||
return ctx.prisma.vaccine.delete({
|
||||
where: { id: input.id },
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user