mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Let a person name their own medication categories
A medication can be filed under a category the person named, next to the built-in ones (#1041). The labels live in medication_category_labels, encrypted at rest, capped at 20 per account; a medication filed under one carries its custom:<uuid> key in the category column. - GET/POST /api/medications/categories (POST idempotent, 30/min per actor, admitted at MANAGE like the cycle symptom vocabulary) and owner-only PATCH/DELETE /api/medications/categories/{key}. DELETE moves every medication filed under the category to OTHER in the same transaction and answers movedCount. - category is widened to a built-in value or custom:<uuid> on the wire, the create and update bodies refuse a key that is not the caller's (422 medications.category.unknown), and list, detail, the insights medication tiles and the MCP medications resource carry categoryLabel. A key with no owned label reads as OTHER and is counted on the event. - The wizard's class step lists the person's categories under the built-in rows with an inline add row; the list page gains a category filter (session only) and a manage sheet for rename, hide and delete. - Backup carries customMedicationCategories (decrypted labels) and the restore writes them before the medications; wipe plan, encrypted column registry, rotation script, sharing guards and OpenAPI follow.
This commit is contained in:
1 parent
350c646463
commit
4cb8118802
55 files changed
+3201
-187
No files matched your search
@@ -135,6 +135,8 @@ export interface MedicationDetailSnapshot {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.40 — the label of a custom category; null for a built-in one. */
|
||||
categoryLabel?: string | null;
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface Glp1Medication {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.40 — the label of a custom category; null for a built-in one. */
|
||||
categoryLabel?: string | null;
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (drives the injection-site prompt). */
|
||||
@@ -476,7 +478,11 @@ export function Glp1MedicationCard({
|
||||
/>
|
||||
);
|
||||
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const categoryLabel = getMedicationCategoryLabel(
|
||||
medication.category,
|
||||
t,
|
||||
medication.categoryLabel,
|
||||
);
|
||||
|
||||
// The upcoming-injection line value. The card owns this VALUE content —
|
||||
// the liked relative-day phrasing ("Samstag 13.7. (in 7 Tagen)") — while
|
||||
|
||||
@@ -63,6 +63,8 @@ interface Medication {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.40 — the label of a custom category; null for a built-in one. */
|
||||
categoryLabel?: string | null;
|
||||
/**
|
||||
* v1.4.25 W4d — Prisma treatment-class discriminator. When set to
|
||||
* "GLP1" the parent should render the {@link Glp1MedicationCard}
|
||||
@@ -254,7 +256,11 @@ export function MedicationCard({
|
||||
// background/border tint. Defaults to "upcoming" (calm) when the display
|
||||
// block is absent (older mocks).
|
||||
const doseStatus = display?.currentDose.status ?? "upcoming";
|
||||
const categoryLabel = getMedicationCategoryLabel(medication.category, t);
|
||||
const categoryLabel = getMedicationCategoryLabel(
|
||||
medication.category,
|
||||
t,
|
||||
medication.categoryLabel,
|
||||
);
|
||||
const sortedSchedules = [...medication.schedules].sort(
|
||||
(a, b) =>
|
||||
a.windowStart.localeCompare(b.windowStart) ||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.40 (#1041) — manage the person's own medication categories: rename,
|
||||
* hide from the picker, delete, add. Opens from the category filter on the
|
||||
* medications list. Rows follow the mood manager's archived-tag row anatomy;
|
||||
* the delete confirmation names how many medications move to Other.
|
||||
*/
|
||||
import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { AddMedicationCategoryRow } from "./medication-category-add-row";
|
||||
import {
|
||||
type MedicationCategoryLabelDTO,
|
||||
useDeleteMedicationCategory,
|
||||
useMedicationCategories,
|
||||
useUpdateMedicationCategory,
|
||||
} from "./use-medication-categories";
|
||||
|
||||
function CategoryRow({
|
||||
category,
|
||||
onDelete,
|
||||
}: {
|
||||
category: MedicationCategoryLabelDTO;
|
||||
onDelete: (category: MedicationCategoryLabelDTO) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const update = useUpdateMedicationCategory();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [label, setLabel] = useState(category.label);
|
||||
|
||||
async function rename() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed || trimmed === category.label) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({ key: category.key, label: trimmed });
|
||||
setEditing(false);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHidden() {
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
key: category.key,
|
||||
isActive: !category.isActive,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div
|
||||
className="border-border space-y-2 rounded-md border p-3"
|
||||
data-slot="medication-category-rename"
|
||||
>
|
||||
<Input
|
||||
value={label}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
aria-label={t("medications.category.custom.label")}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void rename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setLabel(category.label);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={!label.trim() || update.isPending}
|
||||
onClick={() => void rename()}
|
||||
>
|
||||
{update.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="medication-category-row"
|
||||
data-key={category.key}
|
||||
className="border-border flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<Tag
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={category.label}>
|
||||
{category.label}
|
||||
</span>
|
||||
{!category.isActive && (
|
||||
<Badge variant="secondary">
|
||||
{t("medications.category.custom.hidden")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
|
||||
{t("medications.category.custom.count", {
|
||||
count: String(category.medicationCount),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label={`${t("medications.category.custom.rename")}: ${category.label}`}
|
||||
title={t("medications.category.custom.rename")}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
disabled={update.isPending}
|
||||
onClick={() => void toggleHidden()}
|
||||
aria-label={`${
|
||||
category.isActive
|
||||
? t("medications.category.custom.hide")
|
||||
: t("medications.category.custom.show")
|
||||
}: ${category.label}`}
|
||||
title={
|
||||
category.isActive
|
||||
? t("medications.category.custom.hide")
|
||||
: t("medications.category.custom.show")
|
||||
}
|
||||
>
|
||||
{category.isActive ? (
|
||||
<EyeOff className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="size-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive size-11 sm:size-9"
|
||||
onClick={() => onDelete(category)}
|
||||
aria-label={`${t("medications.category.custom.delete")}: ${category.label}`}
|
||||
title={t("medications.category.custom.delete")}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MedicationCategoriesSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const categories = useMedicationCategories();
|
||||
const remove = useDeleteMedicationCategory();
|
||||
const [pendingDelete, setPendingDelete] =
|
||||
useState<MedicationCategoryLabelDTO | null>(null);
|
||||
|
||||
async function confirmDelete(category: MedicationCategoryLabelDTO) {
|
||||
try {
|
||||
const result = await remove.mutateAsync(category.key);
|
||||
toast.success(
|
||||
t("medications.category.custom.movedToOther", {
|
||||
count: String(result.movedCount),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("medications.category.custom.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("medications.category.custom.manage")}
|
||||
description={t("medications.category.custom.manageDescription")}
|
||||
>
|
||||
<div className="space-y-2" data-slot="medication-categories-sheet">
|
||||
{categories.isError ? (
|
||||
<QueryErrorRow onRetry={() => void categories.refetch()} />
|
||||
) : (
|
||||
(categories.data ?? []).map((category) => (
|
||||
<CategoryRow
|
||||
key={category.key}
|
||||
category={category}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<AddMedicationCategoryRow />
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.category.custom.bpGateNote")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setPendingDelete(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.category.custom.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.category.custom.deleteBody", {
|
||||
count: String(pendingDelete?.medicationCount ?? 0),
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const category = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
if (category) void confirmDelete(category);
|
||||
}}
|
||||
>
|
||||
{t("medications.category.custom.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import {
|
||||
isCategoryLimitError,
|
||||
useCreateMedicationCategory,
|
||||
} from "./use-medication-categories";
|
||||
|
||||
/**
|
||||
* v1.40 (#1041) — the inline "New category" row: a dashed row (the cycle
|
||||
* log's add-symptom pattern) that opens into a label field in place, so
|
||||
* creating a category never leaves the wizard or the manage sheet. The
|
||||
* caller hears the new key as soon as the category exists.
|
||||
*/
|
||||
export function AddMedicationCategoryRow({
|
||||
onCreated,
|
||||
}: {
|
||||
onCreated?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const create = useCreateMedicationCategory();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setLabel("");
|
||||
create.reset();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const created = await create.mutateAsync({ label: trimmed });
|
||||
onCreated?.(created.key);
|
||||
close();
|
||||
} catch {
|
||||
// `create.error` drives the inline message below.
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="border-border text-muted-foreground hover:border-primary hover:text-primary focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-3 rounded-md border border-dashed p-3 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
data-slot="wizard-class-add"
|
||||
>
|
||||
<Plus className="h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
{t("medications.category.custom.add")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
data-slot="wizard-class-add-form"
|
||||
>
|
||||
<Input
|
||||
value={label}
|
||||
maxLength={40}
|
||||
autoFocus
|
||||
aria-label={t("medications.category.custom.label")}
|
||||
placeholder={t("medications.category.custom.labelPlaceholder")}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{create.error ? (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{isCategoryLimitError(create.error)
|
||||
? t("medications.category.custom.limitReached")
|
||||
: t("medications.category.custom.saveFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={close}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => void submit()}
|
||||
disabled={!label.trim() || create.isPending}
|
||||
>
|
||||
{create.isPending ? (
|
||||
<Loader2
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{t("medications.category.custom.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.40 (#1041) — the record's own medication categories: read, create,
|
||||
* rename / hide, delete. A rename or a delete changes what every card and the
|
||||
* insights medication tiles show (a delete moves medications to Other), so
|
||||
* both refetch the medication reads and the insights reads. Neither touches a
|
||||
* dose, so the daily reads the intake bundle refreshes stay as they are.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
ApiError,
|
||||
apiDelete,
|
||||
apiGet,
|
||||
apiPatch,
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const CATEGORY_DEPENDENT_KEYS = [
|
||||
queryKeys.medications(),
|
||||
queryKeys.insightsRoot(),
|
||||
] as const;
|
||||
|
||||
export interface MedicationCategoryLabelDTO {
|
||||
key: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
medicationCount: number;
|
||||
}
|
||||
|
||||
/** The `errorCode` the create POST answers when the per-account cap is hit. */
|
||||
export const MEDICATION_CATEGORY_LIMIT_ERROR_CODE =
|
||||
"medications.category.limitReached";
|
||||
|
||||
/** Whether a failed create hit the cap rather than anything transient. */
|
||||
export function isCategoryLimitError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === MEDICATION_CATEGORY_LIMIT_ERROR_CODE
|
||||
);
|
||||
}
|
||||
|
||||
export function useMedicationCategories(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
enabled: options?.enabled ?? true,
|
||||
queryKey: queryKeys.medicationCategories(),
|
||||
queryFn: () =>
|
||||
apiGet<{ categories: MedicationCategoryLabelDTO[] }>(
|
||||
"/api/medications/categories",
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
select: (data) => data.categories,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMedicationCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: { label: string }) =>
|
||||
apiPost<MedicationCategoryLabelDTO>("/api/medications/categories", input),
|
||||
onSuccess: () =>
|
||||
qc.invalidateQueries({ queryKey: queryKeys.medicationCategories() }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMedicationCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: {
|
||||
key: string;
|
||||
label?: string;
|
||||
isActive?: boolean;
|
||||
sortOrder?: number;
|
||||
}) => {
|
||||
const { key, ...body } = input;
|
||||
return apiPatch<MedicationCategoryLabelDTO>(
|
||||
`/api/medications/categories/${encodeURIComponent(key)}`,
|
||||
body,
|
||||
);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMedicationCategory() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (key: string) =>
|
||||
apiDelete<{ key: string; movedCount: number }>(
|
||||
`/api/medications/categories/${encodeURIComponent(key)}`,
|
||||
),
|
||||
onSuccess: () => invalidateKeys(qc, CATEGORY_DEPENDENT_KEYS),
|
||||
});
|
||||
}
|
||||
@@ -1330,3 +1330,85 @@ describe("category on edit (v1.39.4)", () => {
|
||||
expect(body.category).toBe("SKIN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom categories (v1.40, #1041)", () => {
|
||||
const KEY = "custom:11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_KEY = "custom:22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function hydrate(category: string, treatmentClass = "GENERIC") {
|
||||
return hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Tamiflu",
|
||||
dose: "75 mg",
|
||||
category,
|
||||
treatmentClass,
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("hydrates a custom category onto the Other row with its key", () => {
|
||||
const payload = hydrate(KEY);
|
||||
expect(payload.treatmentRow).toBe("other");
|
||||
expect(payload.customCategoryKey).toBe(KEY);
|
||||
});
|
||||
|
||||
it("keeps the custom category on an edit that leaves it alone", () => {
|
||||
const body = buildCreateBody({ ...hydrate(KEY), name: "Renamed" }, "edit");
|
||||
expect("category" in body).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the new key when the person picks another custom category", () => {
|
||||
const body = buildCreateBody(
|
||||
{ ...hydrate(KEY), customCategoryKey: OTHER_KEY },
|
||||
"edit",
|
||||
);
|
||||
expect(body.category).toBe(OTHER_KEY);
|
||||
expect(body.treatmentClass).toBe("GENERIC");
|
||||
});
|
||||
|
||||
it("sends the built-in value when the person moves it back to a built-in row", () => {
|
||||
const body = buildCreateBody(
|
||||
{ ...hydrate(KEY), treatmentRow: "vitamin", customCategoryKey: null },
|
||||
"edit",
|
||||
);
|
||||
expect(body.category).toBe("VITAMIN");
|
||||
});
|
||||
|
||||
it("sends the built-in Other when the person picks the Other row itself", () => {
|
||||
const body = buildCreateBody(
|
||||
{ ...hydrate(KEY), customCategoryKey: null },
|
||||
"edit",
|
||||
);
|
||||
expect(body.category).toBe("OTHER");
|
||||
});
|
||||
|
||||
it("files a new medication under a custom category on create", () => {
|
||||
const body = buildCreateBody(
|
||||
{
|
||||
...hydrate("VITAMIN"),
|
||||
treatmentRow: "other",
|
||||
customCategoryKey: KEY,
|
||||
},
|
||||
"create",
|
||||
);
|
||||
expect(body.category).toBe(KEY);
|
||||
expect(body.treatmentClass).toBe("GENERIC");
|
||||
});
|
||||
|
||||
it("keeps a GLP-1 medication on the GLP-1 row whatever its category", () => {
|
||||
const payload = hydrate(KEY, "GLP1");
|
||||
expect(payload.treatmentRow).toBe("glp1");
|
||||
expect(payload.customCategoryKey).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,12 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import { AddMedicationCategoryRow } from "../../medication-category-add-row";
|
||||
import { useMedicationCategories } from "../../use-medication-categories";
|
||||
|
||||
import {
|
||||
type WizardTreatmentRow,
|
||||
WIZARD_TREATMENT_ROWS,
|
||||
@@ -48,8 +52,60 @@ const ROW_ICONS: Record<WizardTreatmentRow, ComponentType<LucideProps>> = {
|
||||
other: Tag as unknown as LucideIcon,
|
||||
};
|
||||
|
||||
/** One selectable row: the same anatomy for built-in and custom categories. */
|
||||
function ClassRow({
|
||||
value,
|
||||
label,
|
||||
Icon,
|
||||
selected,
|
||||
onSelect,
|
||||
slot,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
Icon: ComponentType<LucideProps>;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
slot: "wizard-class-row" | "wizard-custom-class-row";
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
className={[
|
||||
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/40",
|
||||
].join(" ")}
|
||||
data-slot={slot}
|
||||
data-row={value}
|
||||
data-selected={selected ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="wizard-class"
|
||||
value={value}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
className="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 truncate text-sm font-medium">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step2Class({ payload, applyPartial }: StepProps) {
|
||||
const { t } = useTranslations();
|
||||
const categories = useMedicationCategories();
|
||||
// Creating a category is admitted at MANAGE in a shared record.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const customKey = payload.customCategoryKey ?? null;
|
||||
// Hidden categories leave the picker, except the one this medication is
|
||||
// already filed under: an edit must be able to show what it has.
|
||||
const customRows = (categories.data ?? []).filter(
|
||||
(c) => c.isActive || c.key === customKey,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -57,49 +113,53 @@ export function Step2Class({ payload, applyPartial }: StepProps) {
|
||||
className="space-y-2"
|
||||
data-slot="wizard-step2"
|
||||
>
|
||||
{WIZARD_TREATMENT_ROWS.map((row) => {
|
||||
const Icon = ROW_ICONS[row];
|
||||
const selected = payload.treatmentRow === row;
|
||||
const label = t(`medications.wizard.classRow.${row}`);
|
||||
return (
|
||||
<label
|
||||
key={row}
|
||||
className={[
|
||||
"flex min-h-11 cursor-pointer items-center gap-3 rounded-md border p-3 transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/40",
|
||||
].join(" ")}
|
||||
data-slot="wizard-class-row"
|
||||
data-row={row}
|
||||
data-selected={selected ? "true" : "false"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="wizard-class"
|
||||
value={row}
|
||||
checked={selected}
|
||||
onChange={() =>
|
||||
applyPartial(
|
||||
// The GLP-1 row is always injected; nudge the
|
||||
// delivery form so the common case needs no extra
|
||||
// tap. The user can still flip it on Step 3.
|
||||
row === "glp1"
|
||||
? { treatmentRow: row, deliveryForm: "INJECTION" }
|
||||
: { treatmentRow: row },
|
||||
)
|
||||
}
|
||||
className="sr-only"
|
||||
aria-label={label}
|
||||
/>
|
||||
<Icon
|
||||
className="text-primary h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{WIZARD_TREATMENT_ROWS.map((row) => (
|
||||
<ClassRow
|
||||
key={row}
|
||||
value={row}
|
||||
label={t(`medications.wizard.classRow.${row}`)}
|
||||
Icon={ROW_ICONS[row]}
|
||||
selected={payload.treatmentRow === row && customKey === null}
|
||||
slot="wizard-class-row"
|
||||
onSelect={() =>
|
||||
applyPartial(
|
||||
// The GLP-1 row is always injected; nudge the
|
||||
// delivery form so the common case needs no extra
|
||||
// tap. The user can still flip it on Step 3.
|
||||
row === "glp1"
|
||||
? {
|
||||
treatmentRow: row,
|
||||
customCategoryKey: null,
|
||||
deliveryForm: "INJECTION",
|
||||
}
|
||||
: { treatmentRow: row, customCategoryKey: null },
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{customRows.map((c) => (
|
||||
<ClassRow
|
||||
key={c.key}
|
||||
value={c.key}
|
||||
label={c.label}
|
||||
Icon={Tag as unknown as LucideIcon}
|
||||
selected={customKey === c.key}
|
||||
slot="wizard-custom-class-row"
|
||||
onSelect={() =>
|
||||
applyPartial({ treatmentRow: "other", customCategoryKey: c.key })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{canManageDomain("medications") && (
|
||||
<AddMedicationCategoryRow
|
||||
onCreated={(key) =>
|
||||
applyPartial({ treatmentRow: "other", customCategoryKey: key })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.category.custom.bpGateNote")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -192,6 +192,24 @@ export function rowFromTreatment(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.40 (#1041) — the custom category an edit hydrates to. A GLP-1
|
||||
* medication is the GLP-1 row whatever its category says, as above.
|
||||
*/
|
||||
function customCategoryFields(
|
||||
treatmentClass: string | undefined,
|
||||
category: string | undefined,
|
||||
): {
|
||||
customCategoryKey: string | null;
|
||||
initialCustomCategoryKey: string | null;
|
||||
} {
|
||||
const key =
|
||||
treatmentClass !== "GLP1" && category?.startsWith("custom:")
|
||||
? category
|
||||
: null;
|
||||
return { customCategoryKey: key, initialCustomCategoryKey: key };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Wizard payload + per-schedule draft
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
@@ -257,6 +275,15 @@ export interface WizardPayload {
|
||||
* Absent on create.
|
||||
*/
|
||||
initialTreatmentRow?: WizardTreatmentRow | null;
|
||||
/**
|
||||
* v1.40 (#1041) — one of the person's own categories, picked from the
|
||||
* custom rows under the built-in ones. Set together with `treatmentRow:
|
||||
* "other"` (the treatment class a custom category files under); null when
|
||||
* a built-in row is picked.
|
||||
*/
|
||||
customCategoryKey?: string | null;
|
||||
/** v1.40 — the custom category the edit hydrated to, for the same reason. */
|
||||
initialCustomCategoryKey?: string | null;
|
||||
/**
|
||||
* v1.6.0 — route of administration. Drives the injection-site
|
||||
* rotation preview (shown when `INJECTION`) and, paired with
|
||||
@@ -628,8 +655,12 @@ export function firstInvalidIndex(
|
||||
export interface CreateMedicationBody {
|
||||
name: string;
|
||||
dose: string;
|
||||
/** Omitted on an edit that left the category row untouched (v1.39.4). */
|
||||
category?: MedicationCategoryValue;
|
||||
/**
|
||||
* Omitted on an edit that left the category row untouched (v1.39.4). A
|
||||
* built-in value or, since v1.40, the key of one of the person's own
|
||||
* categories.
|
||||
*/
|
||||
category?: MedicationCategoryValue | `custom:${string}`;
|
||||
treatmentClass?: MedicationTreatmentClass;
|
||||
/** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
@@ -843,16 +874,21 @@ export function buildCreateBody(
|
||||
// v1.39.4 — an edit that did not touch the category row leaves the stored
|
||||
// category (and treatment class) alone rather than re-deriving them from
|
||||
// the row, which cannot represent every stored value.
|
||||
const customCategoryKey = committed.customCategoryKey ?? null;
|
||||
const keepStoredCategory =
|
||||
forMode === "edit" &&
|
||||
committed.initialTreatmentRow !== undefined &&
|
||||
committed.treatmentRow === committed.initialTreatmentRow;
|
||||
committed.treatmentRow === committed.initialTreatmentRow &&
|
||||
customCategoryKey === (committed.initialCustomCategoryKey ?? null);
|
||||
const body: CreateMedicationBody = {
|
||||
name: committed.name.trim(),
|
||||
dose,
|
||||
...(!keepStoredCategory && {
|
||||
category: mapping.category,
|
||||
treatmentClass: mapping.treatmentClass,
|
||||
// v1.40 — a custom row files under the person's own category with the
|
||||
// generic treatment class, like the built-in Other row.
|
||||
category: (customCategoryKey ??
|
||||
mapping.category) as CreateMedicationBody["category"],
|
||||
treatmentClass: customCategoryKey ? "GENERIC" : mapping.treatmentClass,
|
||||
}),
|
||||
deliveryForm: committed.deliveryForm,
|
||||
...(Number.isFinite(parsedDosesPerUnit) &&
|
||||
@@ -1092,6 +1128,8 @@ export interface MedicationPayload {
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
/** v1.40 — the label of a custom category; null for a built-in one. */
|
||||
categoryLabel?: string | null;
|
||||
treatmentClass?: string;
|
||||
/** v1.6.0 — route of administration. Defaults to ORAL when absent. */
|
||||
deliveryForm?: string;
|
||||
@@ -1239,6 +1277,7 @@ export function hydrateWizardPayload(
|
||||
initial.treatmentClass,
|
||||
initial.category,
|
||||
),
|
||||
...customCategoryFields(initial.treatmentClass, initial.category),
|
||||
deliveryForm: normaliseDeliveryForm(initial.deliveryForm),
|
||||
dosesPerUnit:
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
|
||||
Reference in new issue
Block a user