mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
636 lines
23 KiB
TypeScript
636 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import { useId, useMemo, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { showErrorToast } from "@/lib/errors/error-toast";
|
|
import { Loader2, Plus, Trash2 } from "lucide-react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
|
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
|
import type {
|
|
MedicationSideEffectCategory,
|
|
MedicationSideEffectEntry,
|
|
} from "@/generated/prisma/client";
|
|
import {
|
|
SIDE_EFFECT_CATEGORY_ORDER,
|
|
SIDE_EFFECT_SEVERITY_LADDER,
|
|
categoryForEntry,
|
|
entriesByCategory,
|
|
severityLikertLabel,
|
|
} from "@/lib/medications/side-effects/taxonomy";
|
|
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
|
|
import { apiDelete, apiFetchEnvelope, apiPost } from "@/lib/api/api-fetch";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
|
|
/**
|
|
* v1.4.25 W19d — GLP-1 side-effect section for the medication detail
|
|
* page. Sits below the drug-level chart and above the intake history
|
|
* timeline. Mounted only for `treatmentClass === "GLP1"` rows.
|
|
*
|
|
* UX shape:
|
|
* - Section header + "Log side effect" CTA opens a modal dialog.
|
|
* - Inside the dialog: category picker → entry chip-picker (filtered
|
|
* by category) → 5-Likert severity ladder → optional notes.
|
|
* - Below the header: timeline of the last 30 days, newest first,
|
|
* each row showing category badge + entry label + severity chip +
|
|
* timestamp + delete button + collapsed notes.
|
|
*
|
|
* v1.4.25 W21 Fix-N — wraps the shared `<MedicationDetailSection>` so
|
|
* the three sibling sections share one chrome contract, and swaps the
|
|
* native `<select>` for the shadcn-radix `<Select>` so the picker
|
|
* matches the rest of the form library (focus rings, keyboard nav,
|
|
* theme tokens).
|
|
*
|
|
* Component-tests cover: empty state, category → entry filtering,
|
|
* submit-and-refetch flow, severity-label translation lookup.
|
|
*/
|
|
|
|
interface SideEffectRow {
|
|
id: string;
|
|
category: MedicationSideEffectCategory;
|
|
entry: MedicationSideEffectEntry;
|
|
severity: number;
|
|
occurredAt: string;
|
|
notes: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface SideEffectsSectionProps {
|
|
medicationId: string;
|
|
}
|
|
|
|
const NOTES_MAX = 280;
|
|
|
|
function categoryI18nKey(category: MedicationSideEffectCategory): string {
|
|
// GI → gi, METABOLIC → metabolic, INJECTION_SITE → injectionSite,
|
|
// COGNITIVE → cognitive, GLP1_SPECIFIC → glp1Specific.
|
|
switch (category) {
|
|
case "GI":
|
|
return "gi";
|
|
case "METABOLIC":
|
|
return "metabolic";
|
|
case "INJECTION_SITE":
|
|
return "injectionSite";
|
|
case "COGNITIVE":
|
|
return "cognitive";
|
|
case "GLP1_SPECIFIC":
|
|
return "glp1Specific";
|
|
}
|
|
}
|
|
|
|
function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
|
// SCREAMING_SNAKE → camelCase.
|
|
return entry
|
|
.toLowerCase()
|
|
.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
}
|
|
|
|
/** Entries per page of the side-effect list. */
|
|
const SIDE_EFFECTS_PAGE_SIZE = 50;
|
|
|
|
interface SideEffectPageParam {
|
|
/** The window start, fixed when the first page loaded. */
|
|
from: string;
|
|
/** Exclusive upper bound: just after the oldest entry already shown. */
|
|
to: string;
|
|
}
|
|
|
|
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
|
const { t } = useTranslations();
|
|
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
|
|
const canAddSideEffect = canWriteDomain("medications");
|
|
const canManageMedications = canManageDomain("medications");
|
|
const fmt = useFormatters();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [open, setOpen] = useState(false);
|
|
const [category, setCategory] = useState<MedicationSideEffectCategory>("GI");
|
|
const [entry, setEntry] = useState<MedicationSideEffectEntry | null>(null);
|
|
const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null);
|
|
const [notes, setNotes] = useState<string>("");
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
// v1.4.27 R4 RC2 — sticky-pin the Save / Cancel row by portalling it
|
|
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
|
|
// its `<form>` association via the HTML `form` attribute.
|
|
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
const formId = useId();
|
|
|
|
const listKey = queryKeys.medicationSideEffects(medicationId);
|
|
|
|
// The last 30 days, newest first, one page at a time. The window is fixed
|
|
// when the first page loads; each further page asks for the entries before
|
|
// the oldest one shown (`to` is exclusive, so the boundary instant is
|
|
// re-asked one millisecond later and the overlap is dropped by id).
|
|
const {
|
|
data,
|
|
isLoading,
|
|
isError,
|
|
refetch,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
} = useInfiniteQuery({
|
|
queryKey: listKey,
|
|
initialPageParam: null as SideEffectPageParam | null,
|
|
queryFn: async ({ pageParam }) => {
|
|
const to = pageParam ? new Date(pageParam.to) : new Date();
|
|
const from = pageParam
|
|
? new Date(pageParam.from)
|
|
: new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
const url = `/api/medications/${medicationId}/side-effects?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}&limit=${SIDE_EFFECTS_PAGE_SIZE}`;
|
|
const { data: payload } = await apiFetchEnvelope<{
|
|
items: SideEffectRow[];
|
|
meta: { total: number };
|
|
}>(url);
|
|
return { ...payload, from: from.toISOString() };
|
|
},
|
|
getNextPageParam: (lastPage): SideEffectPageParam | undefined => {
|
|
if (lastPage.items.length < SIDE_EFFECTS_PAGE_SIZE) return undefined;
|
|
const oldest = lastPage.items[lastPage.items.length - 1];
|
|
return {
|
|
from: lastPage.from,
|
|
to: new Date(Date.parse(oldest.occurredAt) + 1).toISOString(),
|
|
};
|
|
},
|
|
staleTime: 30 * 1000,
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: async (payload: {
|
|
entry: MedicationSideEffectEntry;
|
|
severity: number;
|
|
notes: string | null;
|
|
}) => {
|
|
// v1.4.25 W21 Fix-N (code-M6) — drop client-side `category`
|
|
// from the wire payload. The server derives it from the entry
|
|
// via `categoryForEntry`. Backwards-compatible with older
|
|
// clients during the cut window (route accepts both shapes).
|
|
return apiPost<SideEffectRow>(
|
|
`/api/medications/${medicationId}/side-effects`,
|
|
payload,
|
|
);
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: listKey });
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (logId: string) => {
|
|
await apiDelete(`/api/medications/${medicationId}/side-effects/${logId}`);
|
|
return true;
|
|
},
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: listKey });
|
|
},
|
|
// v1.16.4 — a failed delete used to fail silently, leaving the row
|
|
// in place with no signal that the request was rejected.
|
|
onError: (failure) => {
|
|
showErrorToast(t("medications.sideEffects.deleteError"), {
|
|
cause: failure,
|
|
});
|
|
},
|
|
});
|
|
|
|
const items = useMemo(() => {
|
|
const seen = new Set<string>();
|
|
const out: SideEffectRow[] = [];
|
|
for (const page of data?.pages ?? []) {
|
|
for (const row of page.items) {
|
|
if (seen.has(row.id)) continue;
|
|
seen.add(row.id);
|
|
out.push(row);
|
|
}
|
|
}
|
|
return out;
|
|
}, [data]);
|
|
|
|
const filteredEntries = useMemo(
|
|
() => entriesByCategory(category),
|
|
[category],
|
|
);
|
|
|
|
const { getRadioProps: getEntryRadioProps } = useRovingRadioGroup({
|
|
count: filteredEntries.length,
|
|
selectedIndex: entry ? filteredEntries.indexOf(entry) : -1,
|
|
onSelect: (index) => setEntry(filteredEntries[index]!),
|
|
});
|
|
const { getRadioProps: getSeverityRadioProps } = useRovingRadioGroup({
|
|
count: SIDE_EFFECT_SEVERITY_LADDER.length,
|
|
selectedIndex: severity !== null ? severity - 1 : -1,
|
|
onSelect: (index) => setSeverity((index + 1) as 1 | 2 | 3 | 4 | 5),
|
|
});
|
|
|
|
function resetForm() {
|
|
setCategory("GI");
|
|
setEntry(null);
|
|
setSeverity(null);
|
|
setNotes("");
|
|
setFormError(null);
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setFormError(null);
|
|
if (!entry) {
|
|
setFormError(t("medications.sideEffects.errorEntry"));
|
|
return;
|
|
}
|
|
if (!severity) {
|
|
setFormError(t("medications.sideEffects.errorSeverity"));
|
|
return;
|
|
}
|
|
// Sanity-check the entry still belongs to the picked category;
|
|
// the server derives the category authoritatively from `entry`.
|
|
void categoryForEntry(entry);
|
|
createMutation.mutate(
|
|
{
|
|
entry,
|
|
severity,
|
|
notes: notes.trim() ? notes.trim().slice(0, NOTES_MAX) : null,
|
|
},
|
|
{
|
|
onSuccess: () => {
|
|
resetForm();
|
|
setOpen(false);
|
|
},
|
|
onError: (err) => {
|
|
setFormError(err instanceof Error ? err.message : String(err));
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
const headerExtras = (
|
|
<>
|
|
{items.length > 0 && (
|
|
<span className="text-muted-foreground mr-2 font-normal">
|
|
({items.length})
|
|
</span>
|
|
)}
|
|
{/* v1.36.x — noting a side effect is an admitted delegated write.
|
|
Removing one that is already noted is not. */}
|
|
{canAddSideEffect && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
resetForm();
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
{t("medications.sideEffects.addCta")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<MedicationDetailSection
|
|
titleId="side-effects-heading"
|
|
title={t("medications.sideEffects.section")}
|
|
headerExtras={headerExtras}
|
|
>
|
|
{isLoading && (
|
|
<div className="text-muted-foreground flex items-center gap-2">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
<span>{t("medications.sideEffects.loading")}</span>
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && isError && (
|
|
<QueryErrorCard onRetry={() => void refetch()} />
|
|
)}
|
|
|
|
{!isLoading && !isError && items.length === 0 && (
|
|
<p className="text-muted-foreground py-1">
|
|
{t("medications.sideEffects.emptyState")}
|
|
</p>
|
|
)}
|
|
|
|
{!isLoading && !isError && items.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
|
{t("medications.sideEffects.recentTitle")}
|
|
</p>
|
|
<ul className="space-y-1.5">
|
|
{items.map((row) => (
|
|
<li
|
|
key={row.id}
|
|
className="bg-muted/30 flex items-start justify-between gap-2 rounded-md px-2.5 py-2"
|
|
>
|
|
<div className="min-w-0 flex-1 space-y-1">
|
|
{/* v1.4.28 FB-F2 — fixed-width date slot pinned to
|
|
the right of the row meta so the date stamp keeps
|
|
its column across rows. The category badge +
|
|
entry label + severity chip share the left-most
|
|
slot; the date claims a 4ch-wide right column. */}
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
|
<Badge variant="outline" className="text-xs">
|
|
{t(
|
|
`medications.sideEffects.categories.${categoryI18nKey(row.category)}`,
|
|
)}
|
|
</Badge>
|
|
<span className="text-foreground truncate text-sm font-medium">
|
|
{t(
|
|
`medications.sideEffects.entries.${entryI18nKey(row.entry)}`,
|
|
)}
|
|
</span>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{t(
|
|
`medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`,
|
|
)}
|
|
</Badge>
|
|
</div>
|
|
<p
|
|
// D-H7 — narrowed from `w-[5.5rem]` (88 px) to
|
|
// `w-14` (56 px). The longest short-date variant
|
|
// ("15. Mai") fits inside 56 px with slack;
|
|
// recovering 32 px relieves the left slot's
|
|
// wrap rhythm at 320 px so the category badge +
|
|
// entry label + severity chip stop pushing the
|
|
// free-text notes off-screen.
|
|
className="text-muted-foreground w-14 shrink-0 text-right text-xs tabular-nums"
|
|
data-slot="side-effect-row-date"
|
|
>
|
|
{fmt.dateShortSmart(new Date(row.occurredAt))}
|
|
</p>
|
|
</div>
|
|
{row.notes && (
|
|
<p
|
|
className="text-foreground/80 line-clamp-2 text-sm break-words"
|
|
data-slot="side-effect-row-notes"
|
|
>
|
|
{row.notes}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{canManageMedications && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
|
|
aria-label={t("medications.sideEffects.deleteCta")}
|
|
onClick={() => setPendingDeleteId(row.id)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{hasNextPage ? (
|
|
<div className="flex justify-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={isFetchingNextPage}
|
|
onClick={() => void fetchNextPage()}
|
|
>
|
|
{isFetchingNextPage
|
|
? t("common.loading")
|
|
: t("medications.sideEffects.loadMore")}
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Themed confirm — replaces the native window.confirm so the delete
|
|
gate matches every other destructive action in the app. */}
|
|
<AlertDialog
|
|
open={!!pendingDeleteId}
|
|
onOpenChange={(o) => {
|
|
if (!o) setPendingDeleteId(null);
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>
|
|
{t("medications.sideEffects.deleteConfirmTitle")}
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{t("medications.sideEffects.deleteConfirmBody")}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
|
{t("common.cancel")}
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
|
|
setPendingDeleteId(null);
|
|
}}
|
|
disabled={deleteMutation.isPending}
|
|
aria-busy={deleteMutation.isPending || undefined}
|
|
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
|
|
>
|
|
{t("medications.sideEffects.deleteCta")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<ResponsiveSheet
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
title={t("medications.sideEffects.sheetTitle")}
|
|
footer={<div ref={setFooterEl} className="flex w-full" />}
|
|
>
|
|
<form
|
|
id={formId}
|
|
onSubmit={handleSubmit}
|
|
className="space-y-4"
|
|
aria-label={t("medications.sideEffects.sheetTitle")}
|
|
>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="side-effect-category">
|
|
{t("medications.sideEffects.categoryLabel")}
|
|
</Label>
|
|
<Select
|
|
value={category}
|
|
onValueChange={(next) => {
|
|
setCategory(next as MedicationSideEffectCategory);
|
|
// Reset entry — the previously-selected entry probably
|
|
// doesn't belong to the new category.
|
|
setEntry(null);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id="side-effect-category"
|
|
className="w-full"
|
|
data-slot="side-effect-category-trigger"
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
|
<SelectItem key={c} value={c}>
|
|
{t(
|
|
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
|
<div
|
|
className="flex flex-wrap gap-1.5"
|
|
role="radiogroup"
|
|
aria-label={t("medications.sideEffects.entryLabel")}
|
|
>
|
|
{filteredEntries.map((candidate, index) => {
|
|
const selected = candidate === entry;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={candidate}
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onClick={() => setEntry(candidate)}
|
|
{...getEntryRadioProps(index)}
|
|
className={`flex min-h-11 items-center justify-center rounded-full border px-2.5 py-1 text-xs transition-colors sm:min-h-9 ${
|
|
selected
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background text-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
{t(
|
|
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
|
<div
|
|
className="flex flex-wrap gap-1.5"
|
|
role="radiogroup"
|
|
aria-label={t("medications.sideEffects.severityLabel")}
|
|
>
|
|
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
|
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
|
const selected = severity === value;
|
|
return (
|
|
<button
|
|
type="button"
|
|
key={label}
|
|
role="radio"
|
|
aria-checked={selected}
|
|
onClick={() => setSeverity(value)}
|
|
{...getSeverityRadioProps(idx)}
|
|
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
|
selected
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-border bg-background text-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
<span aria-hidden className="block text-sm">
|
|
{value}
|
|
</span>
|
|
<span className="block text-xs opacity-80">
|
|
{t(`medications.sideEffects.severity.${label}`)}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="side-effect-notes">
|
|
{t("medications.sideEffects.notesLabel")}
|
|
</Label>
|
|
<Textarea
|
|
id="side-effect-notes"
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
|
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
|
rows={3}
|
|
maxLength={NOTES_MAX}
|
|
enterKeyHint="done"
|
|
// Compact notes-row padding override; primitive's
|
|
// px-3 py-2 is too tall inside the side-effect modal.
|
|
className="px-2 py-1.5"
|
|
/>
|
|
<p className="text-muted-foreground text-right text-xs">
|
|
{notes.length} / {NOTES_MAX}
|
|
</p>
|
|
</div>
|
|
|
|
{formError && (
|
|
<p className="text-destructive text-sm" role="alert">
|
|
{formError}
|
|
</p>
|
|
)}
|
|
</form>
|
|
{footerEl
|
|
? createPortal(
|
|
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("medications.sideEffects.cancelCta")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
form={formId}
|
|
disabled={createMutation.isPending}
|
|
>
|
|
{createMutation.isPending && (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
|
)}
|
|
{t("medications.sideEffects.submitCta")}
|
|
</Button>
|
|
</div>,
|
|
footerEl,
|
|
)
|
|
: null}
|
|
</ResponsiveSheet>
|
|
</MedicationDetailSection>
|
|
);
|
|
}
|