diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17cfd4dd7..1138e83df 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6741,6 +6741,8 @@ paths: - type: string const: "" - type: "null" + hidden: + type: boolean responses: "200": description: Updated biomarker. @@ -20212,6 +20214,8 @@ components: anyOf: - type: string - type: "null" + hidden: + type: boolean createdAt: type: string updatedAt: @@ -20225,11 +20229,14 @@ components: - panel - hasContext - context + - hidden - createdAt - updatedAt additionalProperties: false description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its - presence. A `LabResult` linking this marker resolves its unit + reference bounds from here. + presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it + (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds + from here. CreateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index c07f8cf1f..5db331c6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6720,7 +6720,14 @@ "savedToast": "Biomarker gespeichert", "updatedToast": "Biomarker aktualisiert", "saveError": "Biomarker konnte nicht gespeichert werden" - } + }, + "hide": "Ausblenden", + "unhide": "Wiederherstellen", + "hiddenToast": "Biomarker ausgeblendet", + "unhiddenToast": "Biomarker wiederhergestellt", + "hideError": "Biomarker konnte nicht aktualisiert werden", + "allHidden": "Alle Biomarker sind ausgeblendet.", + "hiddenSection": "Ausgeblendet" }, "catalog": { "total-cholesterol": "Gesamtcholesterin", diff --git a/messages/en.json b/messages/en.json index f8cfcef9f..029f3f1ec 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6720,7 +6720,14 @@ "savedToast": "Biomarker saved", "updatedToast": "Biomarker updated", "saveError": "Could not save the biomarker" - } + }, + "hide": "Hide", + "unhide": "Restore", + "hiddenToast": "Biomarker hidden", + "unhiddenToast": "Biomarker restored", + "hideError": "Could not update the biomarker", + "allHidden": "Every biomarker is hidden.", + "hiddenSection": "Hidden" }, "catalog": { "total-cholesterol": "Total cholesterol", diff --git a/prisma/migrations/0195_biomarker_hidden/migration.sql b/prisma/migrations/0195_biomarker_hidden/migration.sql new file mode 100644 index 000000000..6d0eba592 --- /dev/null +++ b/prisma/migrations/0195_biomarker_hidden/migration.sql @@ -0,0 +1,9 @@ +-- v1.22.0 — Biomarker catalog: per-marker "hidden" flag. +-- +-- A marker the user no longer needs is hidden rather than deleted: it leaves +-- the active catalog list and the lab-entry pickers, but its readings and its +-- canonical unit/reference-range definition survive (unhide restores it). A +-- plain additive boolean column with a server-side default; no existing row is +-- touched (every back-filled row defaults to visible). `IF NOT EXISTS` makes +-- the rerun safe. +ALTER TABLE "biomarkers" ADD COLUMN IF NOT EXISTS "hidden" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0c8bb68b..bd9779f55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3657,6 +3657,10 @@ model Biomarker { /// Optional panel grouping moved off the per-entry row, e.g. /// "Großes Blutbild". NULL = standalone marker. panel String? + /// A marker the user no longer needs: dropped from the active catalog list + /// and from the lab-entry pickers, but never deleted — its readings and + /// canonical unit/range definition survive. + hidden Boolean @default(false) @map("hidden") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 87ec6f236..3b533241b 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -39,6 +39,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -54,6 +55,7 @@ function serialiseBiomarker(row: { context: row.contextEncrypted ? decryptContextFromBytes(row.contextEncrypted) : null, + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -137,6 +139,7 @@ export const PUT = apiHandler( if (d.lowerBound !== undefined) data.lowerBound = d.lowerBound; if (d.upperBound !== undefined) data.upperBound = d.upperBound; if (d.panel !== undefined) data.panel = d.panel; + if (d.hidden !== undefined) data.hidden = d.hidden; // Inverted-range guard for a PARTIAL bound update. The schema refine only // fires when both bounds arrive together; moving a single bound past the diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b30c84be6..e4c753bee 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -50,6 +50,7 @@ function serialiseBiomarker(row: { lowerBound: number | null; upperBound: number | null; panel: string | null; + hidden: boolean; contextEncrypted: Uint8Array | null; createdAt: Date; updatedAt: Date; @@ -63,6 +64,7 @@ function serialiseBiomarker(row: { panel: row.panel, hasContext: row.contextEncrypted !== null, context: decryptContextSoft(row.contextEncrypted), + hidden: row.hidden, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), }; @@ -71,9 +73,11 @@ function serialiseBiomarker(row: { export const GET = apiHandler(async () => { const { user } = await requireAuth(); + // Visible markers first, then hidden — the manager renders them in two + // sections and the lab-entry pickers drop the hidden tail client-side. const rows = await prisma.biomarker.findMany({ where: { userId: user.id }, - orderBy: [{ name: "asc" }], + orderBy: [{ hidden: "asc" }, { name: "asc" }], }); annotate({ diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 1bcfce316..8608c0a8c 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -2,16 +2,15 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Pencil, Plus } from "lucide-react"; +import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; 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 { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -21,12 +20,18 @@ import { BiomarkerForm } from "./biomarker-form"; import type { BiomarkerDto, BiomarkerListResponse } from "./types"; /** - * v1.18.1 — the Biomarker catalog manager. + * v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows. * * Lives on the Labs page (not Settings) because the catalog IS the Labs - * feature's primary object. Lists every defined marker with its unit + - * reference range, opens a define-new / edit sheet, and wires delete (the - * `onDelete: SetNull` FK keeps existing readings, just unlinks them). + * feature's primary object. Each marker renders as a single compact row + * (name + unit inline, reference range / panel on a muted sub-line) with a + * hide toggle, an edit button (reusing the define/edit sheet), and a delete + * (the `onDelete: SetNull` FK keeps existing readings, just unlinks them). + * + * "Hide" is a soft remove: a marker the user no longer needs leaves the + * active list and the lab-entry pickers but keeps its readings + canonical + * unit/range. Hidden markers collect in a separate section where they can be + * restored or deleted outright. */ export function BiomarkerManager() { const { t } = useTranslations(); @@ -50,6 +55,20 @@ export function BiomarkerManager() { onError: () => toast.error(t("labs.biomarker.deleteError")), }); + const hiddenMutation = useMutation({ + mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) => + apiPut(`/api/biomarkers/${id}`, { hidden }), + onSuccess: (_data, { hidden }) => { + toast.success( + hidden + ? t("labs.biomarker.hiddenToast") + : t("labs.biomarker.unhiddenToast"), + ); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + }, + onError: () => toast.error(t("labs.biomarker.hideError")), + }); + function openNew() { setEditing(null); setFormOpen(true); @@ -68,6 +87,73 @@ export function BiomarkerManager() { } const markers = data?.biomarkers ?? []; + const visible = markers.filter((m) => !m.hidden); + const hidden = markers.filter((m) => m.hidden); + + function renderRow(marker: BiomarkerDto) { + const range = formatReferenceRange( + marker.lowerBound, + marker.upperBound, + formatLabValue, + ); + return ( +
  • +
    +
    + {marker.name} + {marker.unit} +
    +

    + {range + ? `${t("labs.referenceLabel")} ${range} ${marker.unit}` + : t("labs.biomarker.noRange")} + {marker.panel ? ` · ${marker.panel}` : ""} +

    +
    + + + deleteMutation.mutate(marker.id)} + title={t("labs.biomarker.deleteConfirmTitle")} + description={t("labs.biomarker.deleteConfirmDescription")} + confirmLabel={t("labs.biomarker.delete")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
  • + ); + } return (
    @@ -84,7 +170,7 @@ export function BiomarkerManager() { {isLoading ? (
    {Array.from({ length: 3 }, (_, i) => ( - + ))}
    ) : isError ? ( @@ -101,60 +187,24 @@ export function BiomarkerManager() { } /> ) : ( - +
    + {visible.length > 0 ? ( + + ) : ( +

    + {t("labs.biomarker.allHidden")} +

    + )} + + {hidden.length > 0 ? ( +
    +

    + {t("labs.biomarker.hiddenSection")} +

    +
      {hidden.map(renderRow)}
    +
    + ) : null} +
    )} ) : ( -
    +
    {marker.context}

    ) : null} - - {isLoading ? ( diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 273a26208..4dd9792a9 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -110,8 +110,12 @@ export function LabForm({ null, ); - const markers = catalog?.biomarkers ?? []; - const selected = markers.find((m) => m.id === biomarkerId); + const allMarkers = catalog?.biomarkers ?? []; + const selected = allMarkers.find((m) => m.id === biomarkerId); + // v1.22 — hidden markers drop from the picker, but keep the currently + // selected one visible (e.g. editing a reading whose marker was later + // hidden) so the Select still resolves its label. + const markers = allMarkers.filter((m) => !m.hidden || m.id === biomarkerId); function handleSelect(next: string) { if (next === DEFINE_NEW) { diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index ff7e8d49d..842bd959f 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -220,11 +220,12 @@ export function OcrReviewDialog({ if (file) onFilePicked(file); }} /> - +
    ) : (
    diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index 1c10ef77a..a3448eb69 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -42,6 +42,8 @@ export interface BiomarkerDto { panel: string | null; hasContext: boolean; context: string | null; + /** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */ + hidden: boolean; createdAt: string; updatedAt: string; } diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 5f3aa1e9e..11f27dca8 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -30,7 +30,7 @@ createBiomarkerSchema.meta({ updateBiomarkerSchema.meta({ id: "UpdateBiomarkerRequest", description: - "Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. A rename that collides with another of the caller's markers is rejected 409.", + "Partial edit of a biomarker. An omitted key leaves the column untouched; an explicit `null` on `context` / `panel` / a bound clears it. Set `hidden` to drop / restore the marker in the active list + lab-entry pickers. A rename that collides with another of the caller's markers is rejected 409.", }); const biomarkerRow = z @@ -43,13 +43,14 @@ const biomarkerRow = z panel: z.string().nullable(), hasContext: z.boolean(), context: z.string().nullable(), + hidden: z.boolean(), createdAt: z.string(), updatedAt: z.string(), }) .meta({ id: "Biomarker", description: - "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. A `LabResult` linking this marker resolves its unit + reference bounds from here.", + "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); const listResponse = z diff --git a/src/lib/validations/__tests__/biomarkers.test.ts b/src/lib/validations/__tests__/biomarkers.test.ts index de69fe151..40eaab85f 100644 --- a/src/lib/validations/__tests__/biomarkers.test.ts +++ b/src/lib/validations/__tests__/biomarkers.test.ts @@ -72,4 +72,21 @@ describe("updateBiomarkerSchema", () => { }); expect(r.success).toBe(false); }); + + it("accepts a hidden toggle", () => { + const r = updateBiomarkerSchema.safeParse({ hidden: true }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.hidden).toBe(true); + }); + + it("leaves hidden undefined when omitted", () => { + const r = updateBiomarkerSchema.safeParse({ unit: "mmol/L" }); + expect(r.success).toBe(true); + if (r.success) expect(r.data.hidden).toBeUndefined(); + }); + + it("rejects a non-boolean hidden", () => { + const r = updateBiomarkerSchema.safeParse({ hidden: "yes" }); + expect(r.success).toBe(false); + }); }); diff --git a/src/lib/validations/biomarkers.ts b/src/lib/validations/biomarkers.ts index 23a9e4ebb..7718fa087 100644 --- a/src/lib/validations/biomarkers.ts +++ b/src/lib/validations/biomarkers.ts @@ -72,6 +72,9 @@ export const updateBiomarkerSchema = z upperBound: upperBound.or(z.null()), context: optionalContext.or(z.null()), panel: optionalPanel.or(z.null()), + // v1.22 — hide / unhide a marker. An omitted key leaves visibility + // untouched; `true` drops the marker from the active list + pickers. + hidden: z.boolean().optional(), }) .refine( (d) =>