mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Keep dose marking under a switch, and nothing else on medications
Marking a dose taken or skipped is the verb the delegation was written for: somebody looking after a parent confirms the morning tablet. Adding a medication and noting a side effect come with it. The card menu keeps only what survives and disappears entirely when nothing does. Four detail tabs leave the registry inside somebody else's record, because their whole content is owner work: the schedule editor and its plan history, the supply ledger, the external-token row, and the advanced surface holding the wizard, the import and the destructive zone. A stale deep link into one lands on the landing tab, the way an inapplicable tab already does. The take-all-due sweep goes too. It rides the bulk intake route, which the delegation does not name, and a control that half-works is worse than one that is not offered.
This commit is contained in:
1 parent
7817632385
commit
ab53d42874
14 files changed
+394
-256
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
@@ -159,6 +160,11 @@ function MedicationCardSkeleton() {
|
||||
export default function MedicationsPageClient() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — adding a medication and marking a dose are both admitted
|
||||
// delegated writes, so a WRITE delegate keeps the Add menu whole. The
|
||||
// take-all-due sweep is not (it rides the bulk intake route), and neither is
|
||||
// the customize page, which sharing does not cover at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
@@ -417,7 +423,7 @@ export default function MedicationsPageClient() {
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{dueMeds.length >= 2 && (
|
||||
{canManage && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
@@ -433,47 +439,51 @@ export default function MedicationsPageClient() {
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -511,10 +521,12 @@ export default function MedicationsPageClient() {
|
||||
title={t("medications.emptyTitle")}
|
||||
description={t("medications.emptyDescription")}
|
||||
action={
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
canAdd ? (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.firstMedication")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : tableView ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { getHourForTimeZone } from "@/components/dashboard/range-display";
|
||||
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
|
||||
|
||||
@@ -37,6 +38,11 @@ export function DashboardHeader({
|
||||
const { user } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const nutrientsEnabled = useModuleEnabled("nutrients");
|
||||
// v1.36.x — the dashboard quick-add offers four kinds; a delegation admits
|
||||
// two of them (a reading, a dose). Mood and water stay with the account
|
||||
// whose record it is, and the customize shortcut points at a settings page
|
||||
// sharing does not cover at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
|
||||
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
|
||||
// from the auth query, which can resolve before this boundary
|
||||
@@ -87,24 +93,27 @@ export function DashboardHeader({
|
||||
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
|
||||
touch-target contract the add button also honours, shrinking
|
||||
back to the 40 px icon footprint on sm+. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
data-testid="dashboard-customize-shortcut"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
<Link
|
||||
href="/settings/layout/dashboard"
|
||||
aria-label={t("dashboard.customizeDashboard")}
|
||||
title={t("dashboard.customizeDashboard")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* v1.4.33 maintainer-item-7 — restore proportional sizing
|
||||
across viewports. The v1.4.27 fix pinned a `size="sm"
|
||||
min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
|
||||
contract on mobile, but `size="sm"` is h-8 (32 px) and
|
||||
@@ -116,53 +125,56 @@ export function DashboardHeader({
|
||||
44 px tall under finger pressure and shrinks back to
|
||||
the desktop-friendly 36 px on `sm:` upwards. The icon
|
||||
+ label keep the same visual contract. */}
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
<Button
|
||||
size="default"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-tour-id="dashboard-quick-add"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="max-w-[calc(100vw-2rem)]"
|
||||
>
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
{/* Menu items must each carry a self-contained verb-phrase
|
||||
("Log measurement", "Log mood") — the trigger above already
|
||||
says "Add", and the icon is `aria-hidden`, so the visible
|
||||
text is the only thing distinguishing the rows. v1.4.15
|
||||
phase-A3 fix #1 hardened this with a unit guard at
|
||||
`src/app/__tests__/quick-add-labels.test.ts` — both labels
|
||||
must differ from each other AND from `common.add`. */}
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
|
||||
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMeasurement")}
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
|
||||
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMood")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* v1.4.37 W7b — third quick-add row: medication intake.
|
||||
Same Sheet-on-mobile / Dialog-on-desktop primitive as
|
||||
the other two; the menu label is a self-contained
|
||||
verb-phrase so it doesn't collide with the trigger or
|
||||
the sibling rows (cf. quick-add-labels.test.ts). */}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
{nutrientsEnabled && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
|
||||
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWater")}
|
||||
<DropdownMenuItem
|
||||
onClick={() => onQuickEntry("medicationIntake")}
|
||||
>
|
||||
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddMedicationIntake")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{nutrientsEnabled && canManage && (
|
||||
<DropdownMenuItem onClick={() => onQuickEntry("water")}>
|
||||
<GlassWater className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("dashboard.quickAddWater")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -40,10 +40,16 @@ function makeClient(): QueryClient {
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client?: QueryClient) {
|
||||
const tree = client ? (
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
) : (
|
||||
node
|
||||
// v1.36.x — every card part now asks what the record on screen allows
|
||||
// before it renders a control, and that answer rides the account query.
|
||||
// The parts that carry no query of their own were rendered here without a
|
||||
// provider; they need one now. Fixtures are the caller's own record, which
|
||||
// is every capability — the delegation cases live in
|
||||
// `src/components/__tests__/delegated-write-affordances.test.tsx`.
|
||||
const tree = (
|
||||
<QueryClientProvider client={client ?? makeClient()}>
|
||||
{node}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{tree}</I18nProvider>,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Check, Loader2, SkipForward } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
|
||||
interface MedicationIntakeActionsProps {
|
||||
/** "take" | "skip" while the matching request is in flight, else null. */
|
||||
@@ -27,6 +28,11 @@ export function MedicationIntakeActions({
|
||||
onRecordIntake,
|
||||
}: MedicationIntakeActionsProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
|
||||
// written for. A read-only delegate sees the card and its schedule and no
|
||||
// action row; the card is otherwise unchanged.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
if (!canAdd) return null;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
* administration.
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -171,6 +172,19 @@ const TAB_SLUGS = [
|
||||
] as const;
|
||||
type TabSlug = (typeof TAB_SLUGS)[number];
|
||||
|
||||
/**
|
||||
* Tabs whose entire content is owner-only work: the schedule editor and its
|
||||
* plan history, the supply ledger, the external-token row, and the advanced
|
||||
* surface (wizard, import, pause, end, purge, delete). A delegation admits
|
||||
* none of it at any level.
|
||||
*/
|
||||
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = new Set([
|
||||
"zeitplan",
|
||||
"bestand",
|
||||
"api",
|
||||
"erweitert",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Legacy slugs that point at a dissolved tab. The Erinnerung tab folded
|
||||
* into Zeitplan in v1.15.20; old deep-links keep landing on the surface
|
||||
@@ -222,6 +236,7 @@ export function MedicationDetailTabs({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -273,15 +288,23 @@ export function MedicationDetailTabs({
|
||||
// reminds, so the Zeitplan tab (times editor + reminder card +
|
||||
// plan-history) does not apply and is dropped from the registry; a
|
||||
// stale `?tab=zeitplan` deep-link falls back to the landing tab.
|
||||
// v1.36.x — four tabs are nothing BUT owner work: the schedule editor, the
|
||||
// supply ledger, the external-token row and the advanced surface that holds
|
||||
// the wizard, the import and the destructive zone. None of it is delegated,
|
||||
// so inside somebody else's record they leave the registry entirely rather
|
||||
// than opening onto controls the server refuses — which also means a stale
|
||||
// `?tab=erweitert` deep link lands on the landing tab, the same way an
|
||||
// inapplicable tab already does.
|
||||
const availableTabs = useMemo<TabSlug[]>(
|
||||
() =>
|
||||
TAB_SLUGS.filter(
|
||||
(slug) =>
|
||||
(slug !== "injektion" || isInjectable) &&
|
||||
(slug !== "zeitplan" || !asNeeded) &&
|
||||
(slug !== "wirkung" || hasEfficacyTarget),
|
||||
(slug !== "wirkung" || hasEfficacyTarget) &&
|
||||
(canManage || !OWNER_ONLY_TABS.has(slug)),
|
||||
),
|
||||
[isInjectable, asNeeded, hasEfficacyTarget],
|
||||
[isInjectable, asNeeded, hasEfficacyTarget, canManage],
|
||||
);
|
||||
|
||||
const requestedRaw = searchParams?.get("tab");
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
* the ledger uses the semantic feedback vocabulary on the row, not the card.
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -162,6 +163,10 @@ export function DoseHistoryLedger({
|
||||
const fmt = useFormatters();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const { user } = useAuth();
|
||||
// v1.36.x — a delegate may record a dose, which is what "add" and the
|
||||
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
|
||||
// existing event are the owner's, so the row menu is absent for them.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
const queryClient = useQueryClient();
|
||||
const timeZone = user?.timezone || "Europe/Berlin";
|
||||
|
||||
@@ -413,16 +418,18 @@ export function DoseHistoryLedger({
|
||||
{t("medications.detail.verlauf.summaryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="ledger-add"
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.verlauf.add")}
|
||||
</Button>
|
||||
{canAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="ledger-add"
|
||||
>
|
||||
<Plus aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.verlauf.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
@@ -571,6 +578,7 @@ function LedgerRowItem({
|
||||
onUnpin: (eventId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const actionable = isSlotActionable(row);
|
||||
@@ -676,7 +684,7 @@ function LedgerRowItem({
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showTakenButton && (
|
||||
{showTakenButton && canAdd && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -697,118 +705,126 @@ function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
|
||||
time: timeLabel,
|
||||
})}
|
||||
data-slot="ledger-row-menu"
|
||||
>
|
||||
<MoreVertical aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" data-slot="ledger-row-menu-content">
|
||||
{actionable && !showTakenButton && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "taken")}
|
||||
data-slot="ledger-menu-taken"
|
||||
>
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{actionable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "skipped")}
|
||||
data-slot="ledger-mark-skipped"
|
||||
>
|
||||
{skipBusy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canPin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPin(intake!.id as string, dueContext!.at)}
|
||||
data-slot="ledger-pin"
|
||||
>
|
||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.action", {
|
||||
time: fmt.time(new Date(dueContext!.at)),
|
||||
{canManage && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("medications.detail.verlauf.rowActionsLabel", {
|
||||
time: timeLabel,
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onUnpin(intake!.id as string)}
|
||||
data-slot="ledger-unpin"
|
||||
data-slot="ledger-row-menu"
|
||||
>
|
||||
<Unlink aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.release")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{intake?.id && (
|
||||
<>
|
||||
<MoreVertical aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
data-slot="ledger-row-menu-content"
|
||||
>
|
||||
{actionable && !showTakenButton && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onEdit({
|
||||
id: intake.id as string,
|
||||
takenAt: intake.takenAt,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-edit"
|
||||
onClick={() => onMark(row, "taken")}
|
||||
data-slot="ledger-menu-taken"
|
||||
>
|
||||
<Pencil aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</DropdownMenuItem>
|
||||
{(row.status === "skipped" ||
|
||||
row.status === "taken_on_time" ||
|
||||
row.status === "taken_late") && (
|
||||
)}
|
||||
{actionable && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMark(row, "skipped")}
|
||||
data-slot="ledger-mark-skipped"
|
||||
>
|
||||
{skipBusy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canPin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPin(intake!.id as string, dueContext!.at)}
|
||||
data-slot="ledger-pin"
|
||||
>
|
||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.action", {
|
||||
time: fmt.time(new Date(dueContext!.at)),
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onUnpin(intake!.id as string)}
|
||||
data-slot="ledger-unpin"
|
||||
>
|
||||
<Unlink aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.verlauf.pin.release")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{intake?.id && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onToggleSkipped({
|
||||
onEdit({
|
||||
id: intake.id as string,
|
||||
takenAt: intake.takenAt,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-toggle-skipped"
|
||||
data-slot="ledger-edit"
|
||||
>
|
||||
{intake.skipped ? (
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="mr-2 size-4" />
|
||||
)}
|
||||
{intake.skipped
|
||||
? t("medications.detail.verlauf.markAsTaken")
|
||||
: t("medications.detail.verlauf.markAsSkipped")}
|
||||
<Pencil aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.edit")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(intake.id as string)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-slot="ledger-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{(row.status === "skipped" ||
|
||||
row.status === "taken_on_time" ||
|
||||
row.status === "taken_late") && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
onToggleSkipped({
|
||||
id: intake.id as string,
|
||||
skipped: intake.skipped,
|
||||
scheduledFor: intake.scheduledFor,
|
||||
})
|
||||
}
|
||||
data-slot="ledger-toggle-skipped"
|
||||
>
|
||||
{intake.skipped ? (
|
||||
<Check aria-hidden="true" className="mr-2 size-4" />
|
||||
) : (
|
||||
<SkipForward
|
||||
aria-hidden="true"
|
||||
className="mr-2 size-4"
|
||||
/>
|
||||
)}
|
||||
{intake.skipped
|
||||
? t("medications.detail.verlauf.markAsTaken")
|
||||
: t("medications.detail.verlauf.markAsSkipped")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(intake.id as string)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-slot="ledger-delete"
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="mr-2 size-4" />
|
||||
{t("medications.detail.intake.rowActions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* never float to the top of the table (O-1).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Trash2 } from "lucide-react";
|
||||
@@ -61,6 +62,8 @@ export function IntakeHistoryEditable({
|
||||
defaultSortBy,
|
||||
}: IntakeHistoryEditableProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -132,7 +135,7 @@ export function IntakeHistoryEditable({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3" data-slot="intake-history-editable-body">
|
||||
{selected.size > 0 && (
|
||||
{canManage && selected.size > 0 && (
|
||||
<div
|
||||
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
|
||||
role="region"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -586,6 +587,9 @@ function IntakeRowActions({
|
||||
onDeleteIntake?: (eventId: string) => void;
|
||||
t: ReturnType<typeof useTranslations>["t"];
|
||||
}) {
|
||||
// v1.36.x — editing or deleting a recorded dose is the owner's.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
if (!canManage) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* the form state + the medication/slot pickers.
|
||||
*/
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
@@ -116,6 +117,7 @@ export function LogIntakeDialog({
|
||||
medications,
|
||||
}: LogIntakeDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
@@ -210,6 +212,7 @@ export function LogIntakeDialog({
|
||||
...(doseDeviates && { doseTaken: trimmedDose }),
|
||||
t,
|
||||
queryClient,
|
||||
recordName,
|
||||
// v1.30.1 M7 — a backdated/manual intake now gets the same
|
||||
// Undo affordance the card's take/skip path already has.
|
||||
undoIntake: (eventId) =>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
* navigation.
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { History, MoreVertical, Pencil, Stethoscope } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -57,6 +58,13 @@ export function MedicationCardMenu({
|
||||
onLogSideEffect,
|
||||
}: MedicationCardMenuProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — noting a side effect is an admitted delegated write; editing the
|
||||
// medication and opening its history editor are not. When nothing in the
|
||||
// menu survives, the trigger goes with it rather than opening onto an empty
|
||||
// sheet. The card itself is untouched: no wash, no badge, no colour change.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const showSideEffect = onLogSideEffect != null && canAdd;
|
||||
if (!canManage && !showSideEffect) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -79,17 +87,21 @@ export function MedicationCardMenu({
|
||||
className="w-56"
|
||||
data-slot="medication-card-menu"
|
||||
>
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
{onLogSideEffect && (
|
||||
{canManage && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onOpenHistory}>
|
||||
<History className="mr-2 h-4 w-4" />
|
||||
{t("medications.detail.header.historyLabel")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{showSideEffect && onLogSideEffect && (
|
||||
<>
|
||||
{canManage && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem onClick={onLogSideEffect}>
|
||||
<Stethoscope className="mr-2 h-4 w-4" />
|
||||
{t("medications.glp1LogSideEffect")}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -402,6 +403,9 @@ function MedicationTableRowItem({
|
||||
// failure toast + Undo. No card-specific follow-up here; the
|
||||
// injection-site prompt stays a card affordance.
|
||||
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
|
||||
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
|
||||
// without its action pair.
|
||||
const { canAdd } = useRecordCapabilities();
|
||||
|
||||
// SAME batched compliance source as the cards.
|
||||
const { data: compliance } = useMedicationComplianceSummary(medication.id);
|
||||
@@ -675,7 +679,7 @@ function MedicationTableRowItem({
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{stockCell}</TableCell>
|
||||
<TableCell>
|
||||
{medication.active ? (
|
||||
{medication.active && canAdd ? (
|
||||
<div className="flex gap-1.5">
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* redundant link is removed (R-medui §4.3).
|
||||
*/
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,6 +35,8 @@ export function IntakeHistoryPreview({
|
||||
onImportOpenChange,
|
||||
}: IntakeHistoryPreviewProps) {
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — importing dose history is not a delegated verb.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -42,16 +45,18 @@ export function IntakeHistoryPreview({
|
||||
title={t("medications.detail.intake.title")}
|
||||
dataSlot="medication-detail-intake-history-section"
|
||||
headerExtras={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onImportOpenChange(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-import"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
canManage ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onImportOpenChange(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-slot="intake-history-import"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -111,6 +112,7 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
|
||||
|
||||
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -245,17 +247,21 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
({items.length})
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("medications.sideEffects.addCta")}
|
||||
</Button>
|
||||
{/* v1.36.x — noting a side effect is an admitted delegated write.
|
||||
Removing one that is already noted is not. */}
|
||||
{canAdd && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("medications.sideEffects.addCta")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -340,16 +346,18 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive 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>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-destructive 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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -94,6 +95,13 @@ export async function runRecordIntake(deps: {
|
||||
setIntakeLoading: (value: string | null) => void;
|
||||
undoIntake: (eventId: string) => void | Promise<void>;
|
||||
onRecorded?: (eventId: string | undefined, skipped: boolean) => void;
|
||||
/**
|
||||
* v1.36.x — the record this dose was written into, when it is not the
|
||||
* caller's own. Two consequences: the toast names it, and the Undo action
|
||||
* goes. A delegate may record a dose and may not remove one, so an Undo
|
||||
* they can see is an Undo the server refuses.
|
||||
*/
|
||||
recordName?: string | null;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
medication,
|
||||
@@ -104,6 +112,7 @@ export async function runRecordIntake(deps: {
|
||||
setIntakeLoading,
|
||||
undoIntake,
|
||||
onRecorded,
|
||||
recordName,
|
||||
} = deps;
|
||||
|
||||
setIntakeLoading(skipped ? "skip" : "take");
|
||||
@@ -137,14 +146,20 @@ export async function runRecordIntake(deps: {
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medication.name },
|
||||
),
|
||||
eventId
|
||||
recordName
|
||||
? {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () => void undoIntake(eventId),
|
||||
},
|
||||
description: t("recordSharing.toast.savedTo", {
|
||||
name: recordName,
|
||||
}),
|
||||
}
|
||||
: undefined,
|
||||
: eventId
|
||||
? {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () => void undoIntake(eventId),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
onRecorded?.(eventId, skipped);
|
||||
@@ -195,6 +210,8 @@ export async function runLogIntake(deps: {
|
||||
* carries no action when it's absent.
|
||||
*/
|
||||
undoIntake?: (eventId: string) => void | Promise<void>;
|
||||
/** See {@link runRecordIntake}: names the record, and drops Undo. */
|
||||
recordName?: string | null;
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
medication,
|
||||
@@ -205,6 +222,7 @@ export async function runLogIntake(deps: {
|
||||
t,
|
||||
queryClient,
|
||||
undoIntake,
|
||||
recordName,
|
||||
} = deps;
|
||||
try {
|
||||
const body: Record<string, unknown> = { skipped };
|
||||
@@ -230,7 +248,11 @@ export async function runLogIntake(deps: {
|
||||
// a real Undo action to attach; keeps the no-undo call signature
|
||||
// identical to the pre-fix behaviour (existing unit tests assert the
|
||||
// single-argument call).
|
||||
if (eventId && undoIntake) {
|
||||
if (recordName) {
|
||||
toast.success(message, {
|
||||
description: t("recordSharing.toast.savedTo", { name: recordName }),
|
||||
});
|
||||
} else if (eventId && undoIntake) {
|
||||
toast.success(message, {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
@@ -273,6 +295,7 @@ export function useMedicationIntake({
|
||||
}: UseMedicationIntakeParams): UseMedicationIntakeResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const recordName = useActiveRecordName();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
|
||||
const undoIntake = (eventId: string) =>
|
||||
@@ -288,6 +311,7 @@ export function useMedicationIntake({
|
||||
setIntakeLoading,
|
||||
undoIntake,
|
||||
onRecorded,
|
||||
recordName,
|
||||
});
|
||||
|
||||
return { intakeLoading, recordIntake, undoIntake };
|
||||
|
||||
Reference in new issue
Block a user