mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
7ec2555d9a
commit
a37883e3f8
16 files changed
+1582
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user