"use client"; import { useId, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; 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"; /** * 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 `` so * the three sibling sections share one chrome contract, and swaps the * native `` 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()); } export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); const fmt = useFormatters(); const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [category, setCategory] = useState("GI"); const [entry, setEntry] = useState(null); const [severity, setSeverity] = useState<1 | 2 | 3 | 4 | 5 | null>(null); const [notes, setNotes] = useState(""); const [formError, setFormError] = useState(null); // v1.4.27 R4 RC2 — sticky-pin the Save / Cancel row by portalling it // into the `` footer slot. The submit button keeps // its `
` association via the HTML `form` attribute. const [footerEl, setFooterEl] = useState(null); const formId = useId(); const listKey = [ "medications", medicationId, "side-effects", "list", ] as const; const { data, isLoading } = useQuery({ queryKey: listKey, queryFn: async () => { // Last 30 days, newest-first. const to = new Date(); const 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=100`; const res = await fetch(url); if (!res.ok) return { items: [] as SideEffectRow[], meta: { total: 0 } }; const json = (await res.json()) as { data: { items: SideEffectRow[]; meta: { total: number } }; }; return json.data; }, 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). const res = await fetch(`/api/medications/${medicationId}/side-effects`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(body.error ?? "Failed to log side effect"); } return (await res.json()).data as SideEffectRow; }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: listKey }); }, }); const deleteMutation = useMutation({ mutationFn: async (logId: string) => { const res = await fetch( `/api/medications/${medicationId}/side-effects/${logId}`, { method: "DELETE" }, ); if (!res.ok) throw new Error("Failed to delete entry"); return true; }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: listKey }); }, }); const items = data?.items ?? []; 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) { 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 && ( ({items.length}) )} ); return ( {isLoading && (
{t("medications.sideEffects.loading")}
)} {!isLoading && items.length === 0 && (

{t("medications.sideEffects.emptyState")}

)} {!isLoading && items.length > 0 && (

{t("medications.sideEffects.recentTitle")}

    {items.map((row) => (
  • {/* 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. */}
    {t( `medications.sideEffects.categories.${categoryI18nKey(row.category)}`, )} {t( `medications.sideEffects.entries.${entryI18nKey(row.entry)}`, )} {t( `medications.sideEffects.severity.${severityLikertLabel(row.severity as 1 | 2 | 3 | 4 | 5)}`, )}

    {fmt.dateShort(new Date(row.occurredAt))}

    {row.notes && (

    {row.notes}

    )}
  • ))}
)} } >
{filteredEntries.map((candidate, index) => { const selected = candidate === entry; return ( ); })}
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => { const value = (idx + 1) as 1 | 2 | 3 | 4 | 5; const selected = severity === value; return ( ); })}