feat(records): surface allergies + family history in Settings

Add a new "Medical history" (Anamnese) Settings section that hosts the two
structured-record managers — allergies and family history — each a
list + add/edit sheet + delete-with-confirm built on the shared query-key
factory and the ResponsiveSheet pattern. It sits between the illness journal
and the preventive-care reminders and is always shown (not module-gated, like
Vorsorge), since these are foundational health-profile records.

The section carries a plain non-diagnostic note ("your own records, not a
medical diagnosis"). Full i18n across all six locales (de/en/es/fr/it/pl).
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:03:25 +02:00
1 parent 7ec2555d9a
commit a37883e3f8
16 files changed
+1582

No files matched your search

+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "MCP-Connector",
"subtitle": "Verbinde einen externen Assistenten mit deinen eigenen Gesundheitsdaten."
},
"anamnesis": {
"title": "Anamnese",
"subtitle": "Verwalte deine Allergien und deine Familienanamnese.",
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
}
},
"profile": "Profil",
@@ -7426,5 +7431,113 @@
"reload": "Neu laden",
"dismiss": "Schließen"
}
},
"records": {
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
"allergies": {
"cardTitle": "Allergien & Unverträglichkeiten",
"cardDescription": "Stoffe, auf die du reagierst, mit Reaktion und Schweregrad.",
"description": "Eine Allergie oder Unverträglichkeit erfassen.",
"add": "Allergie hinzufügen",
"addFirst": "Erste Allergie hinzufügen",
"addTitle": "Allergie hinzufügen",
"editTitle": "Allergie bearbeiten",
"formDescription": "Der Stoff wird im Klartext gespeichert; Reaktion und Notiz werden verschlüsselt.",
"emptyTitle": "Keine Allergien erfasst",
"emptyDescription": "Erfasse Stoffe, auf die du reagierst, und die Reaktionen.",
"loadError": "Deine Allergien konnten nicht geladen werden.",
"deletedToast": "Allergie gelöscht",
"deleteError": "Die Allergie konnte nicht gelöscht werden.",
"deleteConfirmTitle": "Diese Allergie löschen?",
"deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.",
"category": {
"FOOD": "Nahrungsmittel",
"MEDICATION": "Medikament",
"ENVIRONMENT": "Umwelt",
"BIOLOGIC": "Biologikum",
"OTHER": "Sonstiges"
},
"type": {
"ALLERGY": "Allergie",
"INTOLERANCE": "Unverträglichkeit"
},
"severity": {
"NONE": "Nicht bewertet",
"MILD": "Leicht",
"MODERATE": "Mittel",
"SEVERE": "Schwer"
},
"status": {
"ACTIVE": "Aktiv",
"INACTIVE": "Inaktiv",
"RESOLVED": "Abgeklungen"
},
"form": {
"substance": "Stoff",
"substancePlaceholder": "z. B. Penicillin, Erdnüsse",
"category": "Kategorie",
"type": "Art",
"severity": "Schweregrad",
"status": "Status",
"onset": "Erstmals bemerkt",
"reaction": "Reaktion",
"reactionPlaceholder": "z. B. Nesselsucht, Schwellung, Anaphylaxie",
"note": "Notiz",
"notePlaceholder": "Was du dir sonst merken möchtest",
"requiredError": "Bitte den Stoff angeben.",
"savedToast": "Allergie gespeichert",
"updatedToast": "Allergie aktualisiert",
"saveError": "Die Allergie konnte nicht gespeichert werden."
}
},
"family": {
"cardTitle": "Familienanamnese",
"cardDescription": "Erkrankungen bei Verwandten, ein Eintrag je Erkrankung.",
"description": "Eine Erkrankung bei einem Verwandten erfassen.",
"add": "Eintrag hinzufügen",
"addFirst": "Ersten Eintrag hinzufügen",
"addTitle": "Familienanamnese hinzufügen",
"editTitle": "Familienanamnese bearbeiten",
"formDescription": "Verwandtschaft und Erkrankung werden im Klartext gespeichert; die Notiz wird verschlüsselt.",
"emptyTitle": "Keine Familienanamnese erfasst",
"emptyDescription": "Erfasse Erkrankungen, die in deiner Familie vorkommen.",
"loadError": "Deine Familienanamnese konnte nicht geladen werden.",
"deletedToast": "Eintrag gelöscht",
"deleteError": "Der Eintrag konnte nicht gelöscht werden.",
"deleteConfirmTitle": "Diesen Eintrag löschen?",
"deleteConfirmDescription": "Damit wird der Eintrag entfernt. Das lässt sich nicht rückgängig machen.",
"ageAtOnsetShort": "Alter {age}",
"relationship": {
"MOTHER": "Mutter",
"FATHER": "Vater",
"SISTER": "Schwester",
"BROTHER": "Bruder",
"DAUGHTER": "Tochter",
"SON": "Sohn",
"GRANDMOTHER_MATERNAL": "Großmutter (mütterlich)",
"GRANDFATHER_MATERNAL": "Großvater (mütterlich)",
"GRANDMOTHER_PATERNAL": "Großmutter (väterlich)",
"GRANDFATHER_PATERNAL": "Großvater (väterlich)",
"AUNT": "Tante",
"UNCLE": "Onkel",
"COUSIN": "Cousin/Cousine",
"HALF_SIBLING": "Halbgeschwister",
"OTHER": "Andere/r Verwandte/r"
},
"form": {
"relationship": "Verwandte/r",
"condition": "Erkrankung",
"conditionPlaceholder": "z. B. Typ-2-Diabetes, Brustkrebs",
"ageAtOnset": "Alter bei Beginn",
"ageAtOnsetPlaceholder": "z. B. 55",
"note": "Notiz",
"notePlaceholder": "Was du dir sonst merken möchtest",
"requiredError": "Bitte die Erkrankung angeben.",
"ageError": "Bitte ein Alter zwischen 0 und 120 angeben.",
"savedToast": "Eintrag gespeichert",
"updatedToast": "Eintrag aktualisiert",
"saveError": "Der Eintrag konnte nicht gespeichert werden."
}
}
}
}
+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "MCP Connector",
"subtitle": "Connect an external assistant to your own health data."
},
"anamnesis": {
"title": "Medical history",
"subtitle": "Manage your allergies and family history.",
"description": "Structured allergies and family history records."
}
},
"profile": "Profile",
@@ -7426,5 +7431,113 @@
"reload": "Reload",
"dismiss": "Dismiss"
}
},
"records": {
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
"allergies": {
"cardTitle": "Allergies & intolerances",
"cardDescription": "Substances you react to, with the reaction and how severe it is.",
"description": "Record an allergy or intolerance.",
"add": "Add allergy",
"addFirst": "Add your first allergy",
"addTitle": "Add allergy",
"editTitle": "Edit allergy",
"formDescription": "The substance is stored as plain text; the reaction and note are encrypted.",
"emptyTitle": "No allergies recorded",
"emptyDescription": "Add substances you react to and their reactions.",
"loadError": "Couldn't load your allergies.",
"deletedToast": "Allergy deleted",
"deleteError": "Couldn't delete the allergy.",
"deleteConfirmTitle": "Delete this allergy?",
"deleteConfirmDescription": "This removes the record. It can't be undone.",
"category": {
"FOOD": "Food",
"MEDICATION": "Medication",
"ENVIRONMENT": "Environment",
"BIOLOGIC": "Biologic",
"OTHER": "Other"
},
"type": {
"ALLERGY": "Allergy",
"INTOLERANCE": "Intolerance"
},
"severity": {
"NONE": "Not assessed",
"MILD": "Mild",
"MODERATE": "Moderate",
"SEVERE": "Severe"
},
"status": {
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"RESOLVED": "Resolved"
},
"form": {
"substance": "Substance",
"substancePlaceholder": "e.g. Penicillin, Peanuts",
"category": "Category",
"type": "Type",
"severity": "Severity",
"status": "Status",
"onset": "First noticed",
"reaction": "Reaction",
"reactionPlaceholder": "e.g. hives, swelling, anaphylaxis",
"note": "Note",
"notePlaceholder": "Anything else worth remembering",
"requiredError": "Enter the substance.",
"savedToast": "Allergy saved",
"updatedToast": "Allergy updated",
"saveError": "Couldn't save the allergy."
}
},
"family": {
"cardTitle": "Family history",
"cardDescription": "Conditions in your relatives, one entry per condition.",
"description": "Record a condition in a relative.",
"add": "Add entry",
"addFirst": "Add your first entry",
"addTitle": "Add family history",
"editTitle": "Edit family history",
"formDescription": "The relationship and condition are stored as plain text; the note is encrypted.",
"emptyTitle": "No family history recorded",
"emptyDescription": "Add conditions that run in your family.",
"loadError": "Couldn't load your family history.",
"deletedToast": "Entry deleted",
"deleteError": "Couldn't delete the entry.",
"deleteConfirmTitle": "Delete this entry?",
"deleteConfirmDescription": "This removes the record. It can't be undone.",
"ageAtOnsetShort": "Age {age}",
"relationship": {
"MOTHER": "Mother",
"FATHER": "Father",
"SISTER": "Sister",
"BROTHER": "Brother",
"DAUGHTER": "Daughter",
"SON": "Son",
"GRANDMOTHER_MATERNAL": "Grandmother (maternal)",
"GRANDFATHER_MATERNAL": "Grandfather (maternal)",
"GRANDMOTHER_PATERNAL": "Grandmother (paternal)",
"GRANDFATHER_PATERNAL": "Grandfather (paternal)",
"AUNT": "Aunt",
"UNCLE": "Uncle",
"COUSIN": "Cousin",
"HALF_SIBLING": "Half-sibling",
"OTHER": "Other relative"
},
"form": {
"relationship": "Relative",
"condition": "Condition",
"conditionPlaceholder": "e.g. Type 2 diabetes, Breast cancer",
"ageAtOnset": "Age at onset",
"ageAtOnsetPlaceholder": "e.g. 55",
"note": "Note",
"notePlaceholder": "Anything else worth remembering",
"requiredError": "Enter the condition.",
"ageError": "Enter an age between 0 and 120.",
"savedToast": "Entry saved",
"updatedToast": "Entry updated",
"saveError": "Couldn't save the entry."
}
}
}
}
+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "Conector MCP",
"subtitle": "Conecta un asistente externo a tus propios datos de salud."
},
"anamnesis": {
"title": "Historial médico",
"subtitle": "Gestiona tus alergias y tus antecedentes familiares.",
"description": "Registros estructurados de alergias y antecedentes familiares."
}
},
"profile": "Perfil",
@@ -7426,5 +7431,113 @@
"reload": "Recargar",
"dismiss": "Descartar"
}
},
"records": {
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
"allergies": {
"cardTitle": "Alergias e intolerancias",
"cardDescription": "Sustancias a las que reaccionas, con la reacción y su gravedad.",
"description": "Registrar una alergia o intolerancia.",
"add": "Añadir alergia",
"addFirst": "Añade tu primera alergia",
"addTitle": "Añadir alergia",
"editTitle": "Editar alergia",
"formDescription": "La sustancia se guarda como texto; la reacción y la nota se cifran.",
"emptyTitle": "No hay alergias registradas",
"emptyDescription": "Añade sustancias a las que reaccionas y sus reacciones.",
"loadError": "No se han podido cargar tus alergias.",
"deletedToast": "Alergia eliminada",
"deleteError": "No se ha podido eliminar la alergia.",
"deleteConfirmTitle": "¿Eliminar esta alergia?",
"deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.",
"category": {
"FOOD": "Alimento",
"MEDICATION": "Medicamento",
"ENVIRONMENT": "Ambiental",
"BIOLOGIC": "Biológico",
"OTHER": "Otro"
},
"type": {
"ALLERGY": "Alergia",
"INTOLERANCE": "Intolerancia"
},
"severity": {
"NONE": "Sin evaluar",
"MILD": "Leve",
"MODERATE": "Moderada",
"SEVERE": "Grave"
},
"status": {
"ACTIVE": "Activa",
"INACTIVE": "Inactiva",
"RESOLVED": "Resuelta"
},
"form": {
"substance": "Sustancia",
"substancePlaceholder": "p. ej. Penicilina, Cacahuetes",
"category": "Categoría",
"type": "Tipo",
"severity": "Gravedad",
"status": "Estado",
"onset": "Primera vez observada",
"reaction": "Reacción",
"reactionPlaceholder": "p. ej. urticaria, hinchazón, anafilaxia",
"note": "Nota",
"notePlaceholder": "Cualquier otra cosa que merezca recordar",
"requiredError": "Introduce la sustancia.",
"savedToast": "Alergia guardada",
"updatedToast": "Alergia actualizada",
"saveError": "No se ha podido guardar la alergia."
}
},
"family": {
"cardTitle": "Antecedentes familiares",
"cardDescription": "Enfermedades de tus familiares, una entrada por enfermedad.",
"description": "Registrar una enfermedad de un familiar.",
"add": "Añadir entrada",
"addFirst": "Añade tu primera entrada",
"addTitle": "Añadir antecedente familiar",
"editTitle": "Editar antecedente familiar",
"formDescription": "El parentesco y la enfermedad se guardan como texto; la nota se cifra.",
"emptyTitle": "No hay antecedentes familiares",
"emptyDescription": "Añade enfermedades que aparecen en tu familia.",
"loadError": "No se han podido cargar tus antecedentes familiares.",
"deletedToast": "Entrada eliminada",
"deleteError": "No se ha podido eliminar la entrada.",
"deleteConfirmTitle": "¿Eliminar esta entrada?",
"deleteConfirmDescription": "Esto elimina el registro. No se puede deshacer.",
"ageAtOnsetShort": "Edad {age}",
"relationship": {
"MOTHER": "Madre",
"FATHER": "Padre",
"SISTER": "Hermana",
"BROTHER": "Hermano",
"DAUGHTER": "Hija",
"SON": "Hijo",
"GRANDMOTHER_MATERNAL": "Abuela (materna)",
"GRANDFATHER_MATERNAL": "Abuelo (materno)",
"GRANDMOTHER_PATERNAL": "Abuela (paterna)",
"GRANDFATHER_PATERNAL": "Abuelo (paterno)",
"AUNT": "Tía",
"UNCLE": "Tío",
"COUSIN": "Primo/a",
"HALF_SIBLING": "Medio hermano/a",
"OTHER": "Otro familiar"
},
"form": {
"relationship": "Familiar",
"condition": "Enfermedad",
"conditionPlaceholder": "p. ej. Diabetes tipo 2, Cáncer de mama",
"ageAtOnset": "Edad de inicio",
"ageAtOnsetPlaceholder": "p. ej. 55",
"note": "Nota",
"notePlaceholder": "Cualquier otra cosa que merezca recordar",
"requiredError": "Introduce la enfermedad.",
"ageError": "Introduce una edad entre 0 y 120.",
"savedToast": "Entrada guardada",
"updatedToast": "Entrada actualizada",
"saveError": "No se ha podido guardar la entrada."
}
}
}
}
+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "Connecteur MCP",
"subtitle": "Connectez un assistant externe à vos propres données de santé."
},
"anamnesis": {
"title": "Antécédents médicaux",
"subtitle": "Gérez vos allergies et vos antécédents familiaux.",
"description": "Dossiers structurés des allergies et des antécédents familiaux."
}
},
"profile": "Profil",
@@ -7426,5 +7431,113 @@
"reload": "Recharger",
"dismiss": "Ignorer"
}
},
"records": {
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
"allergies": {
"cardTitle": "Allergies & intolérances",
"cardDescription": "Substances auxquelles vous réagissez, avec la réaction et sa gravité.",
"description": "Enregistrer une allergie ou une intolérance.",
"add": "Ajouter une allergie",
"addFirst": "Ajoutez votre première allergie",
"addTitle": "Ajouter une allergie",
"editTitle": "Modifier l'allergie",
"formDescription": "La substance est stockée en clair ; la réaction et la note sont chiffrées.",
"emptyTitle": "Aucune allergie enregistrée",
"emptyDescription": "Ajoutez les substances auxquelles vous réagissez et leurs réactions.",
"loadError": "Impossible de charger vos allergies.",
"deletedToast": "Allergie supprimée",
"deleteError": "Impossible de supprimer l'allergie.",
"deleteConfirmTitle": "Supprimer cette allergie ?",
"deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.",
"category": {
"FOOD": "Aliment",
"MEDICATION": "Médicament",
"ENVIRONMENT": "Environnement",
"BIOLOGIC": "Biologique",
"OTHER": "Autre"
},
"type": {
"ALLERGY": "Allergie",
"INTOLERANCE": "Intolérance"
},
"severity": {
"NONE": "Non évaluée",
"MILD": "Légère",
"MODERATE": "Modérée",
"SEVERE": "Sévère"
},
"status": {
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"RESOLVED": "Résolue"
},
"form": {
"substance": "Substance",
"substancePlaceholder": "p. ex. Pénicilline, Arachides",
"category": "Catégorie",
"type": "Type",
"severity": "Gravité",
"status": "Statut",
"onset": "Première fois remarquée",
"reaction": "Réaction",
"reactionPlaceholder": "p. ex. urticaire, gonflement, anaphylaxie",
"note": "Note",
"notePlaceholder": "Tout autre élément à retenir",
"requiredError": "Saisissez la substance.",
"savedToast": "Allergie enregistrée",
"updatedToast": "Allergie mise à jour",
"saveError": "Impossible d'enregistrer l'allergie."
}
},
"family": {
"cardTitle": "Antécédents familiaux",
"cardDescription": "Maladies de vos proches, une entrée par maladie.",
"description": "Enregistrer une maladie d'un proche.",
"add": "Ajouter une entrée",
"addFirst": "Ajoutez votre première entrée",
"addTitle": "Ajouter un antécédent familial",
"editTitle": "Modifier l'antécédent familial",
"formDescription": "Le lien de parenté et la maladie sont stockés en clair ; la note est chiffrée.",
"emptyTitle": "Aucun antécédent familial",
"emptyDescription": "Ajoutez les maladies présentes dans votre famille.",
"loadError": "Impossible de charger vos antécédents familiaux.",
"deletedToast": "Entrée supprimée",
"deleteError": "Impossible de supprimer l'entrée.",
"deleteConfirmTitle": "Supprimer cette entrée ?",
"deleteConfirmDescription": "Cela supprime l'enregistrement. C'est irréversible.",
"ageAtOnsetShort": "Âge {age}",
"relationship": {
"MOTHER": "Mère",
"FATHER": "Père",
"SISTER": "Sœur",
"BROTHER": "Frère",
"DAUGHTER": "Fille",
"SON": "Fils",
"GRANDMOTHER_MATERNAL": "Grand-mère (maternelle)",
"GRANDFATHER_MATERNAL": "Grand-père (maternel)",
"GRANDMOTHER_PATERNAL": "Grand-mère (paternelle)",
"GRANDFATHER_PATERNAL": "Grand-père (paternel)",
"AUNT": "Tante",
"UNCLE": "Oncle",
"COUSIN": "Cousin/cousine",
"HALF_SIBLING": "Demi-frère/demi-sœur",
"OTHER": "Autre proche"
},
"form": {
"relationship": "Proche",
"condition": "Maladie",
"conditionPlaceholder": "p. ex. Diabète de type 2, Cancer du sein",
"ageAtOnset": "Âge d'apparition",
"ageAtOnsetPlaceholder": "p. ex. 55",
"note": "Note",
"notePlaceholder": "Tout autre élément à retenir",
"requiredError": "Saisissez la maladie.",
"ageError": "Saisissez un âge entre 0 et 120.",
"savedToast": "Entrée enregistrée",
"updatedToast": "Entrée mise à jour",
"saveError": "Impossible d'enregistrer l'entrée."
}
}
}
}
+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "Connettore MCP",
"subtitle": "Collega un assistente esterno ai tuoi dati sulla salute."
},
"anamnesis": {
"title": "Anamnesi",
"subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.",
"description": "Record strutturati di allergie e anamnesi familiare."
}
},
"profile": "Profilo",
@@ -7426,5 +7431,113 @@
"reload": "Ricarica",
"dismiss": "Ignora"
}
},
"records": {
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
"allergies": {
"cardTitle": "Allergie e intolleranze",
"cardDescription": "Sostanze a cui reagisci, con la reazione e la sua gravità.",
"description": "Registra un'allergia o un'intolleranza.",
"add": "Aggiungi allergia",
"addFirst": "Aggiungi la tua prima allergia",
"addTitle": "Aggiungi allergia",
"editTitle": "Modifica allergia",
"formDescription": "La sostanza è salvata in chiaro; la reazione e la nota sono cifrate.",
"emptyTitle": "Nessuna allergia registrata",
"emptyDescription": "Aggiungi le sostanze a cui reagisci e le reazioni.",
"loadError": "Impossibile caricare le tue allergie.",
"deletedToast": "Allergia eliminata",
"deleteError": "Impossibile eliminare l'allergia.",
"deleteConfirmTitle": "Eliminare questa allergia?",
"deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.",
"category": {
"FOOD": "Alimento",
"MEDICATION": "Farmaco",
"ENVIRONMENT": "Ambiente",
"BIOLOGIC": "Biologico",
"OTHER": "Altro"
},
"type": {
"ALLERGY": "Allergia",
"INTOLERANCE": "Intolleranza"
},
"severity": {
"NONE": "Non valutata",
"MILD": "Lieve",
"MODERATE": "Moderata",
"SEVERE": "Grave"
},
"status": {
"ACTIVE": "Attiva",
"INACTIVE": "Inattiva",
"RESOLVED": "Risolta"
},
"form": {
"substance": "Sostanza",
"substancePlaceholder": "es. Penicillina, Arachidi",
"category": "Categoria",
"type": "Tipo",
"severity": "Gravità",
"status": "Stato",
"onset": "Notata per la prima volta",
"reaction": "Reazione",
"reactionPlaceholder": "es. orticaria, gonfiore, anafilassi",
"note": "Nota",
"notePlaceholder": "Qualsiasi altra cosa da ricordare",
"requiredError": "Inserisci la sostanza.",
"savedToast": "Allergia salvata",
"updatedToast": "Allergia aggiornata",
"saveError": "Impossibile salvare l'allergia."
}
},
"family": {
"cardTitle": "Anamnesi familiare",
"cardDescription": "Patologie dei tuoi parenti, una voce per patologia.",
"description": "Registra una patologia di un parente.",
"add": "Aggiungi voce",
"addFirst": "Aggiungi la tua prima voce",
"addTitle": "Aggiungi anamnesi familiare",
"editTitle": "Modifica anamnesi familiare",
"formDescription": "La parentela e la patologia sono salvate in chiaro; la nota è cifrata.",
"emptyTitle": "Nessuna anamnesi familiare",
"emptyDescription": "Aggiungi le patologie presenti nella tua famiglia.",
"loadError": "Impossibile caricare la tua anamnesi familiare.",
"deletedToast": "Voce eliminata",
"deleteError": "Impossibile eliminare la voce.",
"deleteConfirmTitle": "Eliminare questa voce?",
"deleteConfirmDescription": "Questo rimuove il record. Non può essere annullato.",
"ageAtOnsetShort": "Età {age}",
"relationship": {
"MOTHER": "Madre",
"FATHER": "Padre",
"SISTER": "Sorella",
"BROTHER": "Fratello",
"DAUGHTER": "Figlia",
"SON": "Figlio",
"GRANDMOTHER_MATERNAL": "Nonna (materna)",
"GRANDFATHER_MATERNAL": "Nonno (materno)",
"GRANDMOTHER_PATERNAL": "Nonna (paterna)",
"GRANDFATHER_PATERNAL": "Nonno (paterno)",
"AUNT": "Zia",
"UNCLE": "Zio",
"COUSIN": "Cugino/a",
"HALF_SIBLING": "Fratellastro/sorellastra",
"OTHER": "Altro parente"
},
"form": {
"relationship": "Parente",
"condition": "Patologia",
"conditionPlaceholder": "es. Diabete di tipo 2, Cancro al seno",
"ageAtOnset": "Età di insorgenza",
"ageAtOnsetPlaceholder": "es. 55",
"note": "Nota",
"notePlaceholder": "Qualsiasi altra cosa da ricordare",
"requiredError": "Inserisci la patologia.",
"ageError": "Inserisci un'età tra 0 e 120.",
"savedToast": "Voce salvata",
"updatedToast": "Voce aggiornata",
"saveError": "Impossibile salvare la voce."
}
}
}
}
+113
View File
@@ -4266,6 +4266,11 @@
"mcp": {
"title": "Łącznik MCP",
"subtitle": "Połącz zewnętrznego asystenta z własnymi danymi zdrowotnymi."
},
"anamnesis": {
"title": "Wywiad medyczny",
"subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.",
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
}
},
"profile": "Profil",
@@ -7426,5 +7431,113 @@
"reload": "Załaduj ponownie",
"dismiss": "Odrzuć"
}
},
"records": {
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
"allergies": {
"cardTitle": "Alergie i nietolerancje",
"cardDescription": "Substancje, na które reagujesz, wraz z reakcją i jej nasileniem.",
"description": "Zapisz alergię lub nietolerancję.",
"add": "Dodaj alergię",
"addFirst": "Dodaj swoją pierwszą alergię",
"addTitle": "Dodaj alergię",
"editTitle": "Edytuj alergię",
"formDescription": "Substancja jest przechowywana jako tekst; reakcja i notatka są szyfrowane.",
"emptyTitle": "Brak zapisanych alergii",
"emptyDescription": "Dodaj substancje, na które reagujesz, i ich reakcje.",
"loadError": "Nie udało się wczytać Twoich alergii.",
"deletedToast": "Alergia usunięta",
"deleteError": "Nie udało się usunąć alergii.",
"deleteConfirmTitle": "Usunąć tę alergię?",
"deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.",
"category": {
"FOOD": "Pokarm",
"MEDICATION": "Lek",
"ENVIRONMENT": "Środowisko",
"BIOLOGIC": "Biologiczny",
"OTHER": "Inne"
},
"type": {
"ALLERGY": "Alergia",
"INTOLERANCE": "Nietolerancja"
},
"severity": {
"NONE": "Nieoceniona",
"MILD": "Łagodna",
"MODERATE": "Umiarkowana",
"SEVERE": "Ciężka"
},
"status": {
"ACTIVE": "Aktywna",
"INACTIVE": "Nieaktywna",
"RESOLVED": "Ustąpiła"
},
"form": {
"substance": "Substancja",
"substancePlaceholder": "np. Penicylina, Orzeszki ziemne",
"category": "Kategoria",
"type": "Typ",
"severity": "Nasilenie",
"status": "Status",
"onset": "Pierwszy raz zauważono",
"reaction": "Reakcja",
"reactionPlaceholder": "np. pokrzywka, obrzęk, anafilaksja",
"note": "Notatka",
"notePlaceholder": "Cokolwiek innego, co warto zapamiętać",
"requiredError": "Podaj substancję.",
"savedToast": "Alergia zapisana",
"updatedToast": "Alergia zaktualizowana",
"saveError": "Nie udało się zapisać alergii."
}
},
"family": {
"cardTitle": "Wywiad rodzinny",
"cardDescription": "Choroby u Twoich krewnych, jeden wpis na chorobę.",
"description": "Zapisz chorobę u krewnego.",
"add": "Dodaj wpis",
"addFirst": "Dodaj swój pierwszy wpis",
"addTitle": "Dodaj wywiad rodzinny",
"editTitle": "Edytuj wywiad rodzinny",
"formDescription": "Pokrewieństwo i choroba są przechowywane jako tekst; notatka jest szyfrowana.",
"emptyTitle": "Brak wywiadu rodzinnego",
"emptyDescription": "Dodaj choroby występujące w Twojej rodzinie.",
"loadError": "Nie udało się wczytać Twojego wywiadu rodzinnego.",
"deletedToast": "Wpis usunięty",
"deleteError": "Nie udało się usunąć wpisu.",
"deleteConfirmTitle": "Usunąć ten wpis?",
"deleteConfirmDescription": "To usuwa wpis. Nie można tego cofnąć.",
"ageAtOnsetShort": "Wiek {age}",
"relationship": {
"MOTHER": "Matka",
"FATHER": "Ojciec",
"SISTER": "Siostra",
"BROTHER": "Brat",
"DAUGHTER": "Córka",
"SON": "Syn",
"GRANDMOTHER_MATERNAL": "Babcia (ze strony matki)",
"GRANDFATHER_MATERNAL": "Dziadek (ze strony matki)",
"GRANDMOTHER_PATERNAL": "Babcia (ze strony ojca)",
"GRANDFATHER_PATERNAL": "Dziadek (ze strony ojca)",
"AUNT": "Ciotka",
"UNCLE": "Wujek",
"COUSIN": "Kuzyn/kuzynka",
"HALF_SIBLING": "Przyrodnie rodzeństwo",
"OTHER": "Inny krewny"
},
"form": {
"relationship": "Krewny",
"condition": "Choroba",
"conditionPlaceholder": "np. Cukrzyca typu 2, Rak piersi",
"ageAtOnset": "Wiek zachorowania",
"ageAtOnsetPlaceholder": "np. 55",
"note": "Notatka",
"notePlaceholder": "Cokolwiek innego, co warto zapamiętać",
"requiredError": "Podaj chorobę.",
"ageError": "Podaj wiek od 0 do 120.",
"savedToast": "Wpis zapisany",
"updatedToast": "Wpis zaktualizowany",
"saveError": "Nie udało się zapisać wpisu."
}
}
}
}
+2
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import type { JSX } from "react";
import { AccountSection } from "@/components/settings/account-section";
import { AnamnesisSection } from "@/components/settings/anamnesis-section";
import { SecuritySection } from "@/components/settings/security-section";
import { AboutSection } from "@/components/settings/about-section";
import { AdvancedSection } from "@/components/settings/advanced-section";
@@ -74,6 +75,7 @@ const SECTION_COMPONENTS: Record<
mood: MoodSection,
labs: LabsSection,
illness: IllnessSection,
anamnesis: AnamnesisSection,
vorsorge: VorsorgeSection,
thresholds: ThresholdsSection,
api: ApiSection,
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
import {
allergyCategoryEnum,
allergySeverityEnum,
allergyStatusEnum,
allergyTypeEnum,
} from "@/lib/validations/allergy";
import { useTranslations } from "@/lib/i18n/context";
import type { AllergyDTO } from "@/lib/records/dto";
const TEXT_MAX_LENGTH = 2000;
/** Select sentinel for the optional "not assessed" severity. */
const SEVERITY_NONE = "NONE";
interface AllergyFormProps {
/** When set the form edits this record; otherwise it creates a new one. */
existing?: AllergyDTO;
onSuccess?: (saved: AllergyDTO) => void;
onCancel?: () => void;
/** The sheet footer slot the Cancel / Save row portals into when present. */
footerSlot?: HTMLElement | null;
}
/** ISO date input (YYYY-MM-DD) → start-of-day UTC instant, or undefined. */
function toInstant(value: string): string | undefined {
if (!value) return undefined;
const d = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
}
/**
* v1.25 (W-RECORDS) — create or edit a structured allergy/intolerance record.
*
* `substance` is the queryable label; the free-text reaction + note are
* encrypted server-side. Patient-reported reference data, not a clinical
* diagnosis.
*/
export function AllergyForm({
existing,
onSuccess,
onCancel,
footerSlot,
}: AllergyFormProps) {
const { t } = useTranslations();
const formId = useId();
const [substance, setSubstance] = useState(existing?.substance ?? "");
const [category, setCategory] = useState(existing?.category ?? "OTHER");
const [type, setType] = useState(existing?.type ?? "ALLERGY");
const [severity, setSeverity] = useState(existing?.severity ?? SEVERITY_NONE);
const [status, setStatus] = useState(existing?.status ?? "ACTIVE");
const [onsetDate, setOnsetDate] = useState(
existing?.onsetAt ? existing.onsetAt.slice(0, 10) : "",
);
const [reaction, setReaction] = useState(existing?.reaction ?? "");
const [note, setNote] = useState(existing?.note ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!substance.trim()) {
setError(t("records.allergies.form.requiredError"));
return;
}
const body = {
substance: substance.trim(),
category,
type,
severity: severity === SEVERITY_NONE ? null : severity,
status,
onsetAt: onsetDate ? (toInstant(onsetDate) ?? null) : null,
reaction: reaction.trim() ? reaction.trim() : null,
note: note.trim() ? note.trim() : null,
};
setSubmitting(true);
try {
const saved = existing
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
: await apiPost<AllergyDTO>("/api/allergies", body);
toast.success(
existing
? t("records.allergies.form.updatedToast")
: t("records.allergies.form.savedToast"),
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("records.allergies.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</>
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="allergy-substance">
{t("records.allergies.form.substance")}
</Label>
<Input
id="allergy-substance"
value={substance}
onChange={(e) => setSubstance(e.target.value)}
placeholder={t("records.allergies.form.substancePlaceholder")}
maxLength={160}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="allergy-category">
{t("records.allergies.form.category")}
</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="allergy-category" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{allergyCategoryEnum.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`records.allergies.category.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="allergy-type">
{t("records.allergies.form.type")}
</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="allergy-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{allergyTypeEnum.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`records.allergies.type.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="allergy-severity">
{t("records.allergies.form.severity")}
</Label>
<Select value={severity} onValueChange={setSeverity}>
<SelectTrigger id="allergy-severity" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SEVERITY_NONE}>
{t("records.allergies.severity.NONE")}
</SelectItem>
{allergySeverityEnum.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`records.allergies.severity.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="allergy-status">
{t("records.allergies.form.status")}
</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger id="allergy-status" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{allergyStatusEnum.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`records.allergies.status.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="allergy-onset">
{t("records.allergies.form.onset")}
</Label>
<Input
id="allergy-onset"
type="date"
value={onsetDate}
onChange={(e) => setOnsetDate(e.target.value)}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="allergy-reaction">
{t("records.allergies.form.reaction")}
</Label>
<Textarea
id="allergy-reaction"
value={reaction}
onChange={(e) => setReaction(e.target.value)}
placeholder={t("records.allergies.form.reactionPlaceholder")}
maxLength={TEXT_MAX_LENGTH}
rows={2}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="allergy-note">
{t("records.allergies.form.note")}
</Label>
<Textarea
id="allergy-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("records.allergies.form.notePlaceholder")}
maxLength={TEXT_MAX_LENGTH}
rows={2}
/>
</div>
</div>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Plus, ShieldAlert } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { AllergyForm } from "./allergy-form";
import type { AllergyDTO } from "@/lib/records/dto";
/**
* v1.25 (W-RECORDS) — the structured allergy/intolerance manager. Renders in
* the Settings → Anamnese section: a list of records with add / edit / delete.
* Patient-reported reference data, not a clinical diagnosis.
*/
export function AllergyManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<AllergyDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.allergyList(true),
queryFn: () => apiGet<AllergyDTO[]>("/api/allergies"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/allergies/${id}`),
onSuccess: () => {
toast.success(t("records.allergies.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
},
onError: () => toast.error(t("records.allergies.deleteError")),
});
function openNew() {
setEditing(null);
setFormOpen(true);
}
function openEdit(row: AllergyDTO) {
setEditing(row);
setFormOpen(true);
}
function afterSave() {
setFormOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: queryKeys.allergies() });
}
const rows = data ?? [];
function renderRow(row: AllergyDTO) {
const detail = [
t(`records.allergies.category.${row.category}`),
row.severity ? t(`records.allergies.severity.${row.severity}`) : null,
row.reaction,
]
.filter(Boolean)
.join(" · ");
return (
<li
key={row.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="truncate text-sm font-medium">
{row.substance}
</span>
{row.type === "INTOLERANCE" ? (
<Badge variant="secondary" className="text-xs">
{t("records.allergies.type.INTOLERANCE")}
</Badge>
) : null}
{row.status !== "ACTIVE" ? (
<Badge variant="outline" className="text-xs">
{t(`records.allergies.status.${row.status}`)}
</Badge>
) : null}
</div>
{detail ? (
<p className="text-muted-foreground truncate text-xs">{detail}</p>
) : null}
</div>
<Button
size="icon"
variant="ghost"
className="size-11 sm:size-9"
onClick={() => openEdit(row)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(row.id)}
title={t("records.allergies.deleteConfirmTitle")}
description={t("records.allergies.deleteConfirmDescription")}
confirmLabel={t("common.delete")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</li>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("records.allergies.description")}
</p>
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<p className="text-destructive py-6 text-center text-sm">
{t("records.allergies.loadError")}
</p>
) : rows.length === 0 ? (
<EmptyState
icon={<ShieldAlert className="size-6" />}
title={t("records.allergies.emptyTitle")}
description={t("records.allergies.emptyDescription")}
action={
<Button onClick={openNew}>{t("records.allergies.addFirst")}</Button>
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
)}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
title={
editing
? t("records.allergies.editTitle")
: t("records.allergies.addTitle")
}
description={t("records.allergies.formDescription")}
footer={
<div
ref={setFormFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
<AllergyForm
existing={editing ?? undefined}
footerSlot={formFooterEl}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
@@ -0,0 +1,205 @@
"use client";
import { useId, useState } from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch";
import { familyRelationshipEnum } from "@/lib/validations/family-history";
import { useTranslations } from "@/lib/i18n/context";
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
const TEXT_MAX_LENGTH = 2000;
interface FamilyHistoryFormProps {
/** When set the form edits this entry; otherwise it creates a new one. */
existing?: FamilyHistoryEntryDTO;
onSuccess?: (saved: FamilyHistoryEntryDTO) => void;
onCancel?: () => void;
/** The sheet footer slot the Cancel / Save row portals into when present. */
footerSlot?: HTMLElement | null;
}
/**
* v1.25 (W-RECORDS) — create or edit a structured family-history entry (one
* condition for one relative). `condition` is the queryable label; the
* free-text note is encrypted server-side. Patient-reported.
*/
export function FamilyHistoryForm({
existing,
onSuccess,
onCancel,
footerSlot,
}: FamilyHistoryFormProps) {
const { t } = useTranslations();
const formId = useId();
const [relationship, setRelationship] = useState(
existing?.relationship ?? "MOTHER",
);
const [condition, setCondition] = useState(existing?.condition ?? "");
const [ageAtOnset, setAgeAtOnset] = useState(
existing?.ageAtOnset != null ? String(existing.ageAtOnset) : "",
);
const [note, setNote] = useState(existing?.note ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!condition.trim()) {
setError(t("records.family.form.requiredError"));
return;
}
let age: number | null = null;
if (ageAtOnset.trim() !== "") {
const parsed = Number(ageAtOnset.trim());
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 120) {
setError(t("records.family.form.ageError"));
return;
}
age = parsed;
}
const body = {
relationship,
condition: condition.trim(),
ageAtOnset: age,
note: note.trim() ? note.trim() : null,
};
setSubmitting(true);
try {
const saved = existing
? await apiPut<FamilyHistoryEntryDTO>(
`/api/family-history/${existing.id}`,
body,
)
: await apiPost<FamilyHistoryEntryDTO>("/api/family-history", body);
toast.success(
existing
? t("records.family.form.updatedToast")
: t("records.family.form.savedToast"),
);
onSuccess?.(saved);
} catch (err) {
const message =
err instanceof ApiError
? err.message
: t("records.family.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
const footerNode = (
<>
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("common.save")}
</Button>
</>
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="family-relationship">
{t("records.family.form.relationship")}
</Label>
<Select value={relationship} onValueChange={setRelationship}>
<SelectTrigger id="family-relationship" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{familyRelationshipEnum.options.map((opt) => (
<SelectItem key={opt} value={opt}>
{t(`records.family.relationship.${opt}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="family-age">
{t("records.family.form.ageAtOnset")}
</Label>
<Input
id="family-age"
inputMode="numeric"
value={ageAtOnset}
onChange={(e) => setAgeAtOnset(e.target.value)}
placeholder={t("records.family.form.ageAtOnsetPlaceholder")}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="family-condition">
{t("records.family.form.condition")}
</Label>
<Input
id="family-condition"
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder={t("records.family.form.conditionPlaceholder")}
maxLength={160}
autoFocus
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="family-note">{t("records.family.form.note")}</Label>
<Textarea
id="family-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("records.family.form.notePlaceholder")}
maxLength={TEXT_MAX_LENGTH}
rows={2}
/>
</div>
</div>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
{footerSlot ? (
createPortal(footerNode, footerSlot)
) : (
<div className="flex justify-end gap-2">{footerNode}</div>
)}
</form>
);
}
@@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Pencil, Plus, Users } from "lucide-react";
import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { FamilyHistoryForm } from "./family-history-form";
import type { FamilyHistoryEntryDTO } from "@/lib/records/dto";
/**
* v1.25 (W-RECORDS) — the structured family-history manager. Renders in the
* Settings → Anamnese section: a list of condition-by-relative records with
* add / edit / delete. Patient-reported reference data, not a clinical
* diagnosis.
*/
export function FamilyHistoryManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<FamilyHistoryEntryDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.familyHistoryList(),
queryFn: () => apiGet<FamilyHistoryEntryDTO[]>("/api/family-history"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/family-history/${id}`),
onSuccess: () => {
toast.success(t("records.family.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
},
onError: () => toast.error(t("records.family.deleteError")),
});
function openNew() {
setEditing(null);
setFormOpen(true);
}
function openEdit(row: FamilyHistoryEntryDTO) {
setEditing(row);
setFormOpen(true);
}
function afterSave() {
setFormOpen(false);
setEditing(null);
queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() });
}
const rows = data ?? [];
function renderRow(row: FamilyHistoryEntryDTO) {
const detail = [
t(`records.family.relationship.${row.relationship}`),
row.ageAtOnset != null
? t("records.family.ageAtOnsetShort", { age: row.ageAtOnset })
: null,
]
.filter(Boolean)
.join(" · ");
return (
<li
key={row.id}
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
>
<div className="min-w-0 flex-1">
<span className="truncate text-sm font-medium">{row.condition}</span>
<p className="text-muted-foreground truncate text-xs">{detail}</p>
</div>
<Button
size="icon"
variant="ghost"
className="size-11 sm:size-9"
onClick={() => openEdit(row)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
onConfirm={() => deleteMutation.mutate(row.id)}
title={t("records.family.deleteConfirmTitle")}
description={t("records.family.deleteConfirmDescription")}
confirmLabel={t("common.delete")}
className="size-11 sm:size-9"
iconClassName="h-4 w-4"
/>
</li>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("records.family.description")}
</p>
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("records.family.add")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : isError ? (
<p className="text-destructive py-6 text-center text-sm">
{t("records.family.loadError")}
</p>
) : rows.length === 0 ? (
<EmptyState
icon={<Users className="size-6" />}
title={t("records.family.emptyTitle")}
description={t("records.family.emptyDescription")}
action={
<Button onClick={openNew}>{t("records.family.addFirst")}</Button>
}
/>
) : (
<ul className="space-y-2">{rows.map(renderRow)}</ul>
)}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
title={
editing ? t("records.family.editTitle") : t("records.family.addTitle")
}
description={t("records.family.formDescription")}
footer={
<div
ref={setFormFooterEl}
className="flex w-full justify-end gap-2"
/>
}
>
<FamilyHistoryForm
existing={editing ?? undefined}
footerSlot={formFooterEl}
onSuccess={afterSave}
onCancel={() => setFormOpen(false)}
/>
</ResponsiveSheet>
</div>
);
}
@@ -112,6 +112,8 @@ describe("SETTINGS_SECTION_SLUGS", () => {
"mood",
"labs",
"illness",
// v1.25 (W-RECORDS) — Anamnese (allergies + family history), always shown.
"anamnesis",
"vorsorge",
"thresholds",
"ai",
@@ -0,0 +1,56 @@
"use client";
/**
* v1.25 (W-RECORDS) — Settings → Anamnese (medical history).
*
* The home for the structured health records: allergies / intolerances and
* family history. Each is a self-contained CRUD manager rendered in its own
* card. These are patient-reported reference records — not a time-series
* signal and not a clinical diagnosis — surfaced alongside the existing
* tracking-domain settings sections (Labs / Illness / Vorsorge).
*/
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { AllergyManager } from "@/components/records/allergy-manager";
import { FamilyHistoryManager } from "@/components/records/family-history-manager";
export function AnamnesisSection() {
const { t } = useTranslations();
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t("records.allergies.cardTitle")}</CardTitle>
<CardDescription>
{t("records.allergies.cardDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<AllergyManager />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("records.family.cardTitle")}</CardTitle>
<CardDescription>
{t("records.family.cardDescription")}
</CardDescription>
</CardHeader>
<CardContent>
<FamilyHistoryManager />
</CardContent>
</Card>
<p className="text-muted-foreground text-xs">{t("records.disclaimer")}</p>
</div>
);
}
@@ -17,6 +17,7 @@ import {
Bot,
CalendarCheck,
Download,
ClipboardList,
FileHeart,
FlaskConical,
Info,
@@ -59,6 +60,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
mood: Smile,
labs: FlaskConical,
illness: Thermometer,
anamnesis: ClipboardList,
vorsorge: CalendarCheck,
thresholds: SlidersHorizontal,
ai: Sparkles,
+4
View File
@@ -69,6 +69,10 @@ export const SETTINGS_SECTION_SLUGS = [
"mood",
"labs",
"illness",
// v1.25 (W-RECORDS) — "Anamnese" (medical history): the structured-record
// home for allergies + family history. Always shown (not module-gated, like
// `vorsorge`); the records are foundational health-profile data.
"anamnesis",
"vorsorge",
"thresholds",
"ai",
@@ -22,6 +22,7 @@ import {
Blocks,
Bot,
CalendarCheck,
ClipboardList,
Download,
FileHeart,
FlaskConical,
@@ -191,6 +192,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
icon: Thermometer,
moduleGate: "illness",
},
// v1.25 (W-RECORDS) — Anamnese (medical history): the structured-record home
// for allergies + family history. NOT module-gated (like Vorsorge) — these
// are foundational health-profile records, always available. Sits between
// the illness journal and the preventive-care reminders.
{
slug: "anamnesis",
titleKey: "settings.sections.anamnesis.title",
icon: ClipboardList,
},
// v1.18.7 — Vorsorge: the preventive-care reminders customise surface
// (view, order, individual reminders). NOT module-gated — preventive-care
// reminders are not a toggleable module — so the entry is always shown.