mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(medications): make injection-site tracking and rotation real
The injection_site column on intake events was read across several surfaces but never written, so site tracking was effectively dead. Wire it end-to-end as a per-medication opt-in, gated on an INJECTION delivery form and default off. - Schema: add medications.track_injection_sites + allowed_injection_sites and users.global_excluded_injection_sites (migrations 0099, 0100; additive, defaulted, no backfill). - Intake write path: accept an optional injectionSite on the per-med, status-toggle, and bulk intake routes. Persist it only on a taken injection with tracking enabled, validated against the medication's effective allowed set (per-med allowed sites minus the user-level global exclusion, deny wins) — a disallowed site returns 422 (the bulk route skips the entry). - Rotation: extend the least-recently-used recommender with an allowed filter; the picker disables sites outside the effective set. - UI: per-medication tracking toggle + allowed-sites editor in the dose wizard step, a skippable post-dose site prompt on both medication cards, and a Settings card for the user-level global exclusion. - Contract: injectionSite is additive on the intake request; OpenAPI regenerated. New GET/PATCH /api/auth/me/injection-site-prefs. The site capture is always optional and never blocks logging a dose.
This commit is contained in:
1 parent
84e592b17e
commit
b99a015266
34 files changed
+1577
-77
No files matched your search
@@ -27,6 +27,8 @@ import {
|
||||
nextInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
toBerlinDate,
|
||||
@@ -79,6 +81,12 @@ export interface Glp1Medication {
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (drives the injection-site prompt). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -191,6 +199,12 @@ export function Glp1MedicationCard({
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
// v1.8.5 — post-dose injection-site prompt (see medication-card.tsx).
|
||||
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
|
||||
const globalExcluded = useGlobalExcludedInjectionSites();
|
||||
const tracksInjection =
|
||||
medication.deliveryForm === "INJECTION" &&
|
||||
medication.trackInjectionSites === true;
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
@@ -259,12 +273,39 @@ export function Glp1MedicationCard({
|
||||
),
|
||||
);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// v1.8.5 — prompt (skippably) for the injection site on a taken
|
||||
// dose when tracking is enabled.
|
||||
if (!skipped && tracksInjection) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
const eventId = json?.data?.id as string | undefined;
|
||||
if (eventId) setSiteIntakeId(eventId);
|
||||
} catch {
|
||||
/* dose recorded; the site prompt is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInjectionSite(site: InjectionSiteKey) {
|
||||
const intakeId = siteIntakeId;
|
||||
setSiteIntakeId(null);
|
||||
if (!intakeId) return;
|
||||
const res = await fetch("/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
toast.error(t("medications.logInjectionSiteNoneAvailable"));
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = medication.schedules[0] ?? null;
|
||||
const now = new Date();
|
||||
const next = nextInjectionFromServer(medication.nextDueAt, now);
|
||||
@@ -469,6 +510,25 @@ export function Glp1MedicationCard({
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
open={siteIntakeId !== null}
|
||||
medicationName={medication.name}
|
||||
allowedInjectionSites={
|
||||
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
|
||||
}
|
||||
globalExcludedInjectionSites={globalExcluded}
|
||||
history={
|
||||
recentInjections
|
||||
.map((r) => r.injectionSite)
|
||||
.filter(Boolean) as InjectionSiteKey[]
|
||||
}
|
||||
onConfirm={confirmInjectionSite}
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ interface InjectionSitePickerProps {
|
||||
/** Recent rotation history (most recent first). Powers the
|
||||
* "recommended next site" dashed-ring annotation. */
|
||||
history?: ReadonlyArray<InjectionSiteKey>;
|
||||
/**
|
||||
* v1.8.5 — the effective allowed set (per-medication allowed sites
|
||||
* minus the user's global exclusion). When supplied, sites outside it
|
||||
* render disabled + non-interactive and the rotation recommendation is
|
||||
* constrained to this set. `undefined` keeps the legacy all-sites
|
||||
* picker (e.g. the wizard rotation preview, which has no medication
|
||||
* context yet).
|
||||
*/
|
||||
allowed?: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Called when the user picks a site. */
|
||||
onChange: (site: InjectionSiteKey) => void;
|
||||
/** Optional class for the outer wrapper. */
|
||||
@@ -41,11 +50,15 @@ interface InjectionSitePickerProps {
|
||||
export function InjectionSitePicker({
|
||||
value,
|
||||
history = [],
|
||||
allowed,
|
||||
onChange,
|
||||
className,
|
||||
}: InjectionSitePickerProps) {
|
||||
const { t } = useTranslations();
|
||||
const recommended = nextInjectionSite(history);
|
||||
const recommended = nextInjectionSite(history, 4, allowed);
|
||||
const allowedSet = allowed ? new Set(allowed) : null;
|
||||
const isAllowed = (site: InjectionSiteKey) =>
|
||||
allowedSet === null || allowedSet.has(site);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -138,9 +151,13 @@ export function InjectionSitePicker({
|
||||
const coord = SITE_COORDS[site];
|
||||
const isActive = site === value;
|
||||
const isRecommended = site === recommended;
|
||||
// v1.8.5 — a site outside the effective allowed set renders
|
||||
// disabled (dimmed, non-interactive) so the user cannot pick a
|
||||
// site the server would reject.
|
||||
const disabled = !isAllowed(site);
|
||||
return (
|
||||
<g key={site}>
|
||||
{isRecommended && !isActive && (
|
||||
{isRecommended && !isActive && !disabled && (
|
||||
<circle
|
||||
cx={coord.x}
|
||||
cy={coord.y}
|
||||
@@ -156,9 +173,11 @@ export function InjectionSitePicker({
|
||||
cy={coord.y}
|
||||
r="6"
|
||||
className={
|
||||
isActive
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
disabled
|
||||
? "fill-muted/30 stroke-foreground/20"
|
||||
: isActive
|
||||
? "fill-primary stroke-primary-foreground"
|
||||
: "fill-muted stroke-foreground/60 hover:fill-accent"
|
||||
}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
@@ -175,17 +194,26 @@ export function InjectionSitePicker({
|
||||
r="12"
|
||||
fill="transparent"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
aria-disabled={disabled}
|
||||
aria-label={t(describeInjectionSite(site))}
|
||||
onClick={() => onChange(site)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onChange(site);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
|
||||
onClick={disabled ? undefined : () => onChange(site)}
|
||||
onKeyDown={
|
||||
disabled
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onChange(site);
|
||||
}
|
||||
}
|
||||
}
|
||||
className={
|
||||
disabled
|
||||
? "cursor-not-allowed focus:outline-none"
|
||||
: "cursor-pointer focus:outline-none focus-visible:stroke-current focus-visible:stroke-2"
|
||||
}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import {
|
||||
effectiveAllowedSites,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.8.5 — post-dose injection-site capture.
|
||||
*
|
||||
* Shown after a taken dose for a medication with
|
||||
* `deliveryForm === "INJECTION"` and `trackInjectionSites === true`. The
|
||||
* capture is OPTIONAL and always skippable — closing or "skip" records
|
||||
* no site, the dose stays logged regardless. The picker is constrained
|
||||
* to the medication's effective allowed set (per-medication allowed sites
|
||||
* minus the user's global exclusion), and the rotation suggestion runs
|
||||
* within that set.
|
||||
*/
|
||||
interface LogInjectionSiteDialogProps {
|
||||
open: boolean;
|
||||
medicationName: string;
|
||||
/** Per-medication allowed / preferred sites ([] = no restriction). */
|
||||
allowedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
/** User-level global exclusion deny-list. */
|
||||
globalExcludedInjectionSites: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Recent rotation history (most recent first) for the dashed-ring hint. */
|
||||
history: ReadonlyArray<InjectionSiteKey>;
|
||||
/** Submit the chosen site (the parent PATCHes it onto the intake). */
|
||||
onConfirm: (site: InjectionSiteKey) => void;
|
||||
/** Dismiss without recording a site (the dose stays taken). */
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function LogInjectionSiteDialog({
|
||||
open,
|
||||
medicationName,
|
||||
allowedInjectionSites,
|
||||
globalExcludedInjectionSites,
|
||||
history,
|
||||
onConfirm,
|
||||
onSkip,
|
||||
}: LogInjectionSiteDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const [selected, setSelected] = useState<InjectionSiteKey | null>(null);
|
||||
|
||||
const allowed = effectiveAllowedSites(
|
||||
allowedInjectionSites,
|
||||
globalExcludedInjectionSites,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onSkip();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.logInjectionSiteTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.logInjectionSiteDescription", {
|
||||
name: medicationName,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{allowed.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
{t("medications.logInjectionSiteNoneAvailable")}
|
||||
</p>
|
||||
) : (
|
||||
<InjectionSitePicker
|
||||
value={selected}
|
||||
history={history}
|
||||
allowed={allowed}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<Button variant="outline" onClick={onSkip}>
|
||||
{t("medications.logInjectionSiteSkip")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selected === null}
|
||||
onClick={() => {
|
||||
if (selected !== null) onConfirm(selected);
|
||||
}}
|
||||
>
|
||||
{t("medications.logInjectionSiteConfirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { LogInjectionSiteDialog } from "@/components/medications/log-injection-site-dialog";
|
||||
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -50,6 +53,12 @@ interface Medication {
|
||||
*/
|
||||
treatmentClass?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.6.0 — route of administration (drives the injection-site prompt). */
|
||||
deliveryForm?: string;
|
||||
/** v1.8.5 — per-medication injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
@@ -122,6 +131,14 @@ export function MedicationCard({
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
// v1.8.5 — post-dose injection-site prompt state. Holds the intake
|
||||
// event id returned by the take POST so the confirm handler can PATCH
|
||||
// the chosen site onto it. Null = dialog closed.
|
||||
const [siteIntakeId, setSiteIntakeId] = useState<string | null>(null);
|
||||
const globalExcluded = useGlobalExcludedInjectionSites();
|
||||
const tracksInjection =
|
||||
medication.deliveryForm === "INJECTION" &&
|
||||
medication.trackInjectionSites === true;
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: queryKeys.medicationCompliance(medication.id),
|
||||
@@ -171,12 +188,40 @@ export function MedicationCard({
|
||||
),
|
||||
);
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
// v1.8.5 — after a TAKEN dose on a tracking-enabled injection,
|
||||
// prompt (skippably) for the site. The dialog PATCHes it onto
|
||||
// the just-created event via the status-toggle route.
|
||||
if (!skipped && tracksInjection) {
|
||||
try {
|
||||
const json = await res.json();
|
||||
const eventId = json?.data?.id as string | undefined;
|
||||
if (eventId) setSiteIntakeId(eventId);
|
||||
} catch {
|
||||
/* dose recorded; the site prompt is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInjectionSite(site: InjectionSiteKey) {
|
||||
const intakeId = siteIntakeId;
|
||||
setSiteIntakeId(null);
|
||||
if (!intakeId) return;
|
||||
const res = await fetch("/api/medications/intake", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ intakeId, status: "taken", injectionSite: site }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
toast.error(t("medications.logInjectionSiteNoneAvailable"));
|
||||
}
|
||||
}
|
||||
|
||||
const rate7 = compliance?.compliance7?.rate ?? 0;
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
@@ -373,6 +418,21 @@ export function MedicationCard({
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* v1.8.5 — post-dose injection-site capture (optional, skippable). */}
|
||||
{tracksInjection && (
|
||||
<LogInjectionSiteDialog
|
||||
open={siteIntakeId !== null}
|
||||
medicationName={medication.name}
|
||||
allowedInjectionSites={
|
||||
(medication.allowedInjectionSites ?? []) as InjectionSiteKey[]
|
||||
}
|
||||
globalExcludedInjectionSites={globalExcluded}
|
||||
history={[]}
|
||||
onConfirm={confirmInjectionSite}
|
||||
onSkip={() => setSiteIntakeId(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,8 +13,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InjectionSitePicker } from "@/components/medications/injection-site-picker";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
describeInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
import type { MedicationDeliveryForm } from "@/lib/validations/medication";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -48,14 +53,24 @@ const DELIVERY_FORM_KEYS: readonly MedicationDeliveryForm[] = [
|
||||
|
||||
export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
const { t } = useTranslations();
|
||||
// The injection-site rotation map is a visual aide on the editor: the
|
||||
// chosen site is persisted per intake-event (no medication-level
|
||||
// column), so the picker selection here is a local preview the user
|
||||
// collapses when not needed.
|
||||
const [siteOpen, setSiteOpen] = useState(false);
|
||||
const [previewSite, setPreviewSite] = useState<InjectionSiteKey | null>(null);
|
||||
// v1.8.5 — injection-site tracking is a per-medication opt-in surfaced
|
||||
// only for an INJECTION delivery form. When enabled, the user can
|
||||
// restrict the allowed sites; an empty selection means "no
|
||||
// restriction" (every site offered, minus the global exclusion).
|
||||
const [allowedOpen, setAllowedOpen] = useState(false);
|
||||
const isInjection = payload.deliveryForm === "INJECTION";
|
||||
|
||||
function toggleAllowedSite(site: InjectionSiteKey, checked: boolean) {
|
||||
const current = new Set(payload.allowedInjectionSites);
|
||||
if (checked) current.add(site);
|
||||
else current.delete(site);
|
||||
applyPartial({
|
||||
allowedInjectionSites: INJECTION_SITE_KEYS.filter((s) =>
|
||||
current.has(s),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="wizard-step3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -151,40 +166,83 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v1.6.0 — collapsible injection-site rotation map. Shown only for
|
||||
INJECTION delivery; the per-dose site is logged at intake time
|
||||
so this surface is an optional rotation aide, not a persisted
|
||||
medication field. */}
|
||||
{/* v1.8.5 — injection-site tracking opt-in + allowed-sites editor.
|
||||
Shown only for an INJECTION delivery form. Default off; enabling
|
||||
it surfaces the post-dose site prompt and the rotation
|
||||
suggestion. The allowed-sites checklist restricts the picker
|
||||
(empty = no restriction). */}
|
||||
{isInjection && (
|
||||
<div
|
||||
className="border-border/60 rounded-lg border"
|
||||
className="border-border/60 space-y-3 rounded-lg border p-3"
|
||||
data-slot="wizard-injection-site"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 w-full justify-between px-3"
|
||||
aria-expanded={siteOpen}
|
||||
onClick={() => setSiteOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.wizard.steps.step3.injectionSiteToggle")}
|
||||
<label className="flex items-start justify-between gap-3">
|
||||
<span className="space-y-0.5">
|
||||
<span className="block text-sm font-medium">
|
||||
{t("medications.trackInjectionSitesToggle")}
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("medications.trackInjectionSitesHint")}
|
||||
</span>
|
||||
</span>
|
||||
{siteOpen ? (
|
||||
<ChevronUp aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{siteOpen && (
|
||||
<div className="space-y-2 px-3 pb-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.wizard.steps.step3.injectionSiteHint")}
|
||||
</p>
|
||||
<InjectionSitePicker
|
||||
value={previewSite}
|
||||
onChange={setPreviewSite}
|
||||
/>
|
||||
<Switch
|
||||
checked={payload.trackInjectionSites}
|
||||
onCheckedChange={(checked) =>
|
||||
applyPartial({
|
||||
trackInjectionSites: checked,
|
||||
// Clear the per-med restriction when tracking is turned off.
|
||||
...(checked ? {} : { allowedInjectionSites: [] }),
|
||||
})
|
||||
}
|
||||
aria-label={t("medications.trackInjectionSitesToggle")}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{payload.trackInjectionSites && (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-9 w-full justify-between px-2"
|
||||
aria-expanded={allowedOpen}
|
||||
onClick={() => setAllowedOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{t("medications.allowedSitesLabel")}
|
||||
</span>
|
||||
{allowedOpen ? (
|
||||
<ChevronUp aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
{allowedOpen && (
|
||||
<div className="space-y-2 px-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.allowedSitesHint")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{INJECTION_SITE_KEYS.map((site) => {
|
||||
const checked =
|
||||
payload.allowedInjectionSites.includes(site);
|
||||
return (
|
||||
<label
|
||||
key={site}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(c) =>
|
||||
toggleAllowedSite(site, c === true)
|
||||
}
|
||||
/>
|
||||
{t(describeInjectionSite(site))}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type MedicationCategoryValue,
|
||||
type MedicationDeliveryForm,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Step 2 — treatment-class taxonomy
|
||||
@@ -207,6 +208,18 @@ export interface WizardPayload {
|
||||
* inventory tracking off (maps to `undefined` / NULL on the wire).
|
||||
*/
|
||||
dosesPerUnit: string;
|
||||
/**
|
||||
* v1.8.5 — per-medication injection-site tracking opt-in. Only
|
||||
* surfaced in Step 3 when `deliveryForm === "INJECTION"`. Default
|
||||
* false (opt-in). Toggling it off clears `allowedInjectionSites`.
|
||||
*/
|
||||
trackInjectionSites: boolean;
|
||||
/**
|
||||
* v1.8.5 — per-medication allowed / preferred injection sites. Empty =
|
||||
* no restriction (every site offered). Only meaningful when
|
||||
* `trackInjectionSites` is true.
|
||||
*/
|
||||
allowedInjectionSites: InjectionSiteKey[];
|
||||
/** Set in Step 5 — mirrors `schedules[activeScheduleIndex].mode`. */
|
||||
mode: "oneShot" | "recurring" | null;
|
||||
/** Mirrors `schedules[activeScheduleIndex].cadence`. */
|
||||
@@ -253,6 +266,8 @@ export function emptyWizardPayload(): WizardPayload {
|
||||
treatmentRow: null,
|
||||
deliveryForm: "ORAL",
|
||||
dosesPerUnit: "",
|
||||
trackInjectionSites: false,
|
||||
allowedInjectionSites: [],
|
||||
mode: draft.mode,
|
||||
cadence: draft.cadence,
|
||||
subControls: draft.subControls,
|
||||
@@ -463,6 +478,10 @@ export interface CreateMedicationBody {
|
||||
deliveryForm: MedicationDeliveryForm;
|
||||
/** v1.6.0 — doses per pen / vial. Omitted when inventory is off. */
|
||||
dosesPerUnit?: number;
|
||||
/** v1.8.5 — injection-site tracking opt-in. Omitted for non-injections. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed sites. Omitted when tracking is off. */
|
||||
allowedInjectionSites?: InjectionSiteKey[];
|
||||
/**
|
||||
* v1.5.5 D-3 §10 invariant 16 — the wizard owns the create-time
|
||||
* default for `notificationsEnabled` but never overwrites it on
|
||||
@@ -602,6 +621,16 @@ export function buildCreateBody(
|
||||
parsedDosesPerUnit >= 1 && {
|
||||
dosesPerUnit: parsedDosesPerUnit,
|
||||
}),
|
||||
// v1.8.5 — injection-site tracking is only meaningful for an
|
||||
// INJECTION delivery form. Always send the boolean for an injection
|
||||
// (so edits can deactivate it); send the allowed list only when
|
||||
// tracking is on. Non-injections omit both (server keeps defaults).
|
||||
...(committed.deliveryForm === "INJECTION" && {
|
||||
trackInjectionSites: committed.trackInjectionSites,
|
||||
...(committed.trackInjectionSites && {
|
||||
allowedInjectionSites: committed.allowedInjectionSites,
|
||||
}),
|
||||
}),
|
||||
// v1.5.5 D-3 §10 invariant 16 — `notificationsEnabled` only on
|
||||
// create. Edits never round-trip the wizard's default because the
|
||||
// detail page's notifications switch is the single source of
|
||||
@@ -807,6 +836,10 @@ export interface MedicationPayload {
|
||||
deliveryForm?: string;
|
||||
/** v1.6.0 — doses per pen / vial. NULL = inventory tracking off. */
|
||||
dosesPerUnit?: number | null;
|
||||
/** v1.8.5 — injection-site tracking opt-in. */
|
||||
trackInjectionSites?: boolean;
|
||||
/** v1.8.5 — per-medication allowed / preferred injection sites. */
|
||||
allowedInjectionSites?: string[];
|
||||
notificationsEnabled: boolean;
|
||||
startsOn: Date | null;
|
||||
endsOn: Date | null;
|
||||
@@ -906,6 +939,9 @@ export function hydrateWizardPayload(initial: MedicationPayload): WizardPayload
|
||||
typeof initial.dosesPerUnit === "number" && initial.dosesPerUnit >= 1
|
||||
? String(initial.dosesPerUnit)
|
||||
: "",
|
||||
trackInjectionSites: initial.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (initial.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
mode: first.mode,
|
||||
cadence: first.cadence,
|
||||
subControls: first.subControls,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
import { UnitPreferenceCard } from "@/components/settings/unit-preference-card";
|
||||
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -28,6 +29,8 @@ export function DashboardSection() {
|
||||
|
||||
<UnitPreferenceCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<InjectionSitesCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Syringe } from "lucide-react";
|
||||
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
INJECTION_SITE_KEYS,
|
||||
describeInjectionSite,
|
||||
type InjectionSiteKey,
|
||||
} from "@/lib/medications/injection-sites";
|
||||
|
||||
/**
|
||||
* v1.8.5 — Settings → global injection-site exclusion.
|
||||
*
|
||||
* A per-user deny-list. Sites checked here are never offered for any
|
||||
* medication's injection-site picker and are rejected (422) at intake —
|
||||
* even when a medication lists them as preferred (deny always wins).
|
||||
* Reads + PATCHes `GET/PATCH /api/auth/me/injection-site-prefs`; the
|
||||
* mutation invalidates `injectionSitePrefs()` so every picker re-reads.
|
||||
*/
|
||||
export function InjectionSitesCard({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/auth/me/injection-site-prefs");
|
||||
if (!res.ok) return [] as InjectionSiteKey[];
|
||||
const json = await res.json();
|
||||
return (json.data?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[];
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const excluded: InjectionSiteKey[] = data ?? [];
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: InjectionSiteKey[]) => {
|
||||
const res = await fetch("/api/auth/me/injection-site-prefs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ globalExcludedInjectionSites: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error("patch_failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.injectionSitePrefs(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(site: InjectionSiteKey, checked: boolean) {
|
||||
const set = new Set(excluded);
|
||||
if (checked) set.add(site);
|
||||
else set.delete(site);
|
||||
mutation.mutate(INJECTION_SITE_KEYS.filter((s) => set.has(s)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4">
|
||||
<SettingsCardHeader
|
||||
icon={Syringe}
|
||||
title={t("settings.globalExcludedInjectionSitesLabel")}
|
||||
description={t("settings.globalExcludedInjectionSitesHint")}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{INJECTION_SITE_KEYS.map((site) => (
|
||||
<label key={site} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={excluded.includes(site)}
|
||||
disabled={mutation.isPending}
|
||||
onCheckedChange={(c) => toggle(site, c === true)}
|
||||
/>
|
||||
{t(describeInjectionSite(site))}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user