Hide the controls a delegate cannot use, surface by surface

Every page a switch can reach now renders the controls the grant covers and
none of the others. Readings, lab results and illness entries keep their add
path at WRITE and lose edit, delete and selection at both levels. Mood,
screeners, cycle days, checkup reminders and the document vault lose every
mutation control at both levels, because no grant admits those verbs and the
routes behind them refuse a switched session outright.

Absent, not disabled, throughout. The banner already says whose record this
is, so no chip repeats it beside each missing button; the one exception is
the measurements empty state, where somebody who may add will look for the
edit control beside the row they just entered, and one muted line says where
the limit is.

Two dead ends went with them: the custom-metric section on the measurements
page linked every row into a route the shell turns a delegate away from, and
the wrench glyphs point at settings pages sharing does not cover.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:11:42 +02:00
1 parent bc43a9f062
commit 7817632385
23 files changed
+703 -447

No files matched your search

+24 -16
View File
@@ -30,12 +30,18 @@ import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicato
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
// v1.36.x — entering a lab result by hand is an admitted delegated write.
// Scanning a report is not: it files a document and commits through the OCR
// route, and neither is delegated. So a WRITE delegate keeps the manual add
// and loses the choice menu around it.
const { canAdd, canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -94,27 +100,29 @@ export default function LabsPage() {
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
<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/labs"
aria-label={t("labs.customize")}
title={t("labs.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/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.18.10 — the "Add" action is a CHOICE when scanning is
available: scan a document (OCR) or add a value by hand. The scan
option only appears when the capability probe reports a usable
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available ? (
{ocrCapability.data?.available && canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -133,7 +141,7 @@ export default function LabsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
) : canAdd ? (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -141,7 +149,7 @@ export default function LabsPage() {
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
) : null}
</>
}
/>
+17 -10
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
MeasurementForm,
@@ -35,6 +36,7 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canAdd } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -149,17 +151,22 @@ export default function MeasurementsPage() {
</span>
}
description={t("measurements.subtitle")}
// v1.36.x — entering a reading is one of the verbs a WRITE grant
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
<Button
className="min-h-11 sm:min-h-9"
onClick={() => {
setReturnTo(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
canAdd ? (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => {
setReturnTo(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
) : null
}
/>
+14 -7
View File
@@ -4,6 +4,7 @@ import { useCallback, useState } from "react";
import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { MoodForm } from "@/components/mood/mood-form";
@@ -20,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPage() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManage } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -95,13 +97,18 @@ export default function MoodPage() {
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
{/* v1.36.x — a mood entry is not one of the verbs a grant
admits, so it stays with the account whose record it is. A
delegate at any level gets no add path here. */}
{canManage && (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
)}
</>
}
/>
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -29,6 +30,14 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
// v1.36.x — the section is dropped whole inside somebody else's record.
// Defining a metric is not a delegated verb, and every row here links to
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
// delegate away at that route, so the rows would be doors into a wall.
// Logging a value against a custom metric IS admitted, but there is no
// reachable surface for it under a switch, and inventing one is a read-model
// change this does not make.
const { canManage } = useRecordCapabilities();
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
@@ -47,6 +56,8 @@ export function CustomMetricList() {
router.push(`/custom-metrics/${saved.id}`);
}
if (!canManage) return null;
return (
<section
className="border-border space-y-3 border-t pt-6"
+48 -29
View File
@@ -70,7 +70,12 @@ export interface CycleCalendarProps {
* renders as the distinct light oval instead of the predicted dot.
*/
confirmedOvulation?: string | null;
onSelectDay: (date: string) => void;
/**
* Open the day. Omitted inside somebody else's record: no grant level
* admits a cycle write, so the cells stay readable and stop being controls
* rather than opening a sheet whose every button the server would refuse.
*/
onSelectDay?: (date: string) => void;
className?: string;
}
@@ -208,34 +213,8 @@ export function CycleCalendar({
? "UNGRADED"
: undefined;
return (
<button
key={date}
type="button"
role="gridcell"
aria-label={aria}
aria-current={isToday ? "date" : undefined}
data-flow-level={flowLevel}
data-fertile={info?.isFertileWindow ? "true" : undefined}
data-predicted={
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined
}
data-ovulation={
isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined
}
onClick={() => onSelectDay(date)}
className={cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
isToday && "font-semibold",
)}
>
const content = (
<>
{/* Fertile window: a soft full-cell band (calm fill, not a hard
ring) so the window reads as a continuous range across days. */}
{info?.isFertileWindow ? (
@@ -311,7 +290,47 @@ export function CycleCalendar({
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
) : null}
</span>
</>
);
const cellClass = cn(
"relative flex aspect-square min-h-11 flex-col items-center justify-center rounded-lg text-sm transition-colors",
isToday && "font-semibold",
);
const cellAttrs = {
role: "gridcell" as const,
"aria-label": aria,
"aria-current": isToday ? ("date" as const) : undefined,
"data-flow-level": flowLevel,
"data-fertile": info?.isFertileWindow ? "true" : undefined,
"data-predicted":
info?.isPredictedPeriod && !info?.isPeriodLogged
? "true"
: undefined,
"data-ovulation": isConfirmedOvulation
? "confirmed"
: isPredictedOvulationDot
? "predicted"
: undefined,
};
return onSelectDay ? (
<button
key={date}
type="button"
{...cellAttrs}
onClick={() => onSelectDay(date)}
className={cn(
cellClass,
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{content}
</button>
) : (
<div key={date} {...cellAttrs} className={cellClass}>
{content}
</div>
);
})}
</div>
+41 -27
View File
@@ -16,6 +16,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
@@ -92,6 +93,10 @@ type CycleTab = (typeof CYCLE_TABS)[number];
export function CycleView() {
const { t } = useTranslations();
// v1.36.x — no grant level admits a cycle write. Inside somebody else's
// record the page stays readable and every path into the log-day sheet and
// the profile settings is absent, rather than opening onto a refusal.
const { canManage } = useRecordCapabilities();
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
@@ -163,24 +168,28 @@ export function CycleView() {
description={t("cycle.subtitle")}
actions={
<>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("cycle.tabs.settings")}
title={t("cycle.tabs.settings")}
data-slot="cycle-settings-wrench"
onClick={() => setTab("settings")}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
{canManage && (
<>
<Button
variant="outline"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
aria-label={t("cycle.tabs.settings")}
title={t("cycle.tabs.settings")}
data-slot="cycle-settings-wrench"
onClick={() => setTab("settings")}
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("cycle.logToday")}
</Button>
</>
)}
</>
}
/>
@@ -260,7 +269,10 @@ export function CycleView() {
) : null}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{!loading && !calendarError && verdict?.dayOfCycle == null ? (
{canManage &&
!loading &&
!calendarError &&
verdict?.dayOfCycle == null ? (
<Button
variant="outline"
className="mt-1 w-full"
@@ -315,12 +327,14 @@ export function CycleView() {
>
{t("cycle.tabs.insights")}
</TabsTrigger>
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
{canManage && (
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
>
{t("cycle.tabs.settings")}
</TabsTrigger>
)}
</TabsList>
<TabsContent value="calendar" className="mt-4 space-y-4">
@@ -359,7 +373,7 @@ export function CycleView() {
null)
: null
}
onSelectDay={openSheet}
onSelectDay={canManage ? openSheet : undefined}
/>
</>
)}
@@ -429,7 +443,7 @@ export function CycleView() {
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data ? (
) : profileQuery.data && canManage ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
+23 -20
View File
@@ -55,7 +55,8 @@ export function DocumentCard({
document: InboundDocumentDto;
selected: boolean;
/** `range` = extend the selection from the last anchor (shift-click). */
onToggleSelected: (id: string, range?: boolean) => void;
/** Omitted inside somebody else's record: selection feeds bulk edits. */
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — undo-able delete owned by the page. */
onDelete?: (id: string) => void;
@@ -173,23 +174,25 @@ export function DocumentCard({
</p>
) : null}
</div>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
{onToggleSelected && (
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
}}
aria-label={t("documents.card.selectLabel", { title })}
className={cn(
"relative z-10 shrink-0 transition-opacity",
"opacity-0 group-focus-within:opacity-100 group-hover:opacity-100",
selected && "opacity-100",
)}
/>
)}
</div>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
@@ -266,7 +269,7 @@ export function DocumentCard({
const action = documentCardKeyAction(e.key);
if (action === "select") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
onToggleSelected?.(document.id, e.shiftKey);
} else if (action === "delete") {
e.preventDefault();
onDelete?.(document.id);
@@ -277,7 +280,7 @@ export function DocumentCard({
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
onToggleSelected?.(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
@@ -16,6 +16,7 @@
* copy. A Share action in the footer opens the clinician share-link create
* flow (shared `ShareLinkCreateForm`) with this document pre-attached.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
@@ -246,6 +247,7 @@ export function DocumentDetailSheet({
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
).length ?? 0;
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
@@ -513,15 +515,23 @@ export function DocumentDetailSheet({
// labels collapse to icon-only (aria-labelled) so three controls
// never overflow the sheet at 360 px; the labels return at `sm+`.
<div className="flex w-full items-center justify-between gap-2">
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
{/* v1.36.x — deleting, sharing and re-filing a document are the
owner's alone. Inside somebody else's record the sheet reads
the document and offers the download, and the leading slot
simply has nothing in it. */}
{canManage ? (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => remove.mutate(doc.id)}
disabled={remove.isPending}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.detail.delete")}
</Button>
) : (
<span />
)}
<div className="flex items-center gap-2">
{aiEnabled ? (
// v1.28.52 (Documents R3) — "Ask the Coach" opens the REAL
@@ -546,17 +556,19 @@ export function DocumentDetailSheet({
</span>
</Button>
) : null}
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
{canManage && (
<Button
variant="outline"
onClick={() => setShareOpen(true)}
data-slot="document-share"
aria-label={t("documents.detail.share")}
>
<Share2 className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.detail.share")}
</span>
</Button>
)}
<Button
variant="outline"
asChild
@@ -620,6 +632,10 @@ export function DocumentDetailSheet({
<DocumentSummaryBlock
summary={doc.summary}
// The stored summary is read; generating one writes to the
// owner's record, so a delegate reads what is there and has no
// generate control.
canGenerate={canManage}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
@@ -632,41 +648,43 @@ export function DocumentDetailSheet({
onGenerate={generateStoredSummary}
/>
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
{canManage && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
indexEnabled={indexEnabled}
unavailableReason={capability.data?.reason ?? null}
actionsDisabled={capability.isPending}
onSuggest={runSuggest}
suggestPending={suggest.isPending}
suggestErrorKey={
suggest.isError ? documentAiErrorKey(suggest.error) : null
}
onSummarise={runSummary}
summaryPending={summary.isPending}
suggestion={suggestion}
suggestionKindLabel={suggestionKindLabel}
suggestionDateLabel={suggestionDateLabel}
appliedFields={appliedFields}
onUseTitle={applyTitle}
onUseKind={applyKind}
onUseDate={applyDate}
onDismissSuggestion={() => setSuggestion(null)}
summaryOutput={summaryOutput}
summaryResult={summary.data ?? null}
summaryErrorKey={
summary.isError ? documentAiErrorKey(summary.error) : null
}
onCloseSummary={() => {
setSummaryOutput(null);
summary.reset();
}}
hasContentIndex={doc.hasContentIndex}
contentIndexSource={doc.contentIndexSource}
indexPending={indexDoc.isPending}
onIndex={runIndex}
/>
)}
{mutationError ? (
<p role="alert" className="text-destructive text-sm">
@@ -693,6 +711,16 @@ export function DocumentDetailSheet({
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : !canManage ? (
<p
id="document-title-input"
className={cn(
"min-h-10 px-3 py-2 text-sm",
doc.title === null && "text-muted-foreground",
)}
>
{doc.title ?? t("documents.detail.titlePlaceholder")}
</p>
) : (
<button
type="button"
@@ -724,8 +752,12 @@ export function DocumentDetailSheet({
<Label htmlFor="document-kind-select">
{t("documents.detail.kindLabel")}
</Label>
{/* Read-only rather than absent: the value IS the content
a delegate came to read, and a form field that shows it
without accepting a change is the honest rendering. */}
<Select
value={doc.kind}
disabled={!canManage}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
@@ -748,6 +780,7 @@ export function DocumentDetailSheet({
</Label>
<DateField
id="document-date-field"
disabled={!canManage}
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
@@ -778,7 +811,7 @@ export function DocumentDetailSheet({
{t("documents.detail.noConditions")}
</span>
) : null}
{(episodes.data?.length ?? 0) > 0 ? (
{canManage && (episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -47,6 +47,7 @@ export function DocumentSummaryBlock({
aiEnabled,
isGenerating,
actionsDisabled,
canGenerate = true,
onGenerate,
}: {
/** The stored, decrypted summary, or null when there is none to show. */
@@ -59,6 +60,12 @@ export function DocumentSummaryBlock({
isGenerating: boolean;
/** Capability still resolving — the transport mode isn't known yet. */
actionsDisabled: boolean;
/**
* v1.36.x — false inside somebody else's record: a stored summary is
* readable, generating one writes to the owner's record and no grant level
* admits it. The state line stays; the button goes.
*/
canGenerate?: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslations();
@@ -103,7 +110,7 @@ export function DocumentSummaryBlock({
<p className="text-muted-foreground text-xs">
{t(absentCopyKey(summaryState))}
</p>
{aiEnabled ? (
{aiEnabled && canGenerate ? (
<Button
type="button"
variant="outline"
@@ -63,7 +63,8 @@ export function DocumentTimeline({
isFetchingNextPage: boolean;
onLoadMore: () => void;
selectedIds: ReadonlySet<string>;
onToggleSelected: (id: string, range?: boolean) => void;
/** Omitted inside somebody else's record: selection feeds bulk edits. */
onToggleSelected?: (id: string, range?: boolean) => void;
onOpen: (id: string) => void;
/** Delete key on the focused card — the page owns the undo-able delete. */
onDelete?: (id: string) => void;
+30 -17
View File
@@ -14,6 +14,7 @@
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
* the gate server-side — this is a UX redirect, not the security boundary.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
useInfiniteQuery,
useMutation,
@@ -179,6 +180,11 @@ export function closeDocumentSelectionAfterCoachHandoff(
export function DocumentsView() {
const { t } = useTranslations();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
// about it are none of them delegated verbs. Inside somebody else's record
// the vault reads and nothing more: no upload path, no selection, no bulk
// bar, no corpus backfill.
const { canManage } = useRecordCapabilities();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -369,7 +375,7 @@ export function DocumentsView() {
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(enqueueFiles);
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
const episodes = useIllnessEpisodes(true);
@@ -381,6 +387,7 @@ export function DocumentsView() {
const contentIndex = usage.data?.contentIndex;
const canIndexContent = contentIndex?.enabled ?? false;
const showIndexAll =
canManage &&
canIndexContent &&
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
@@ -749,18 +756,22 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
) : null
}
/>
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
{canManage && (
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
)}
<DocumentFilterBar
searchValue={searchDraft}
@@ -802,10 +813,12 @@ export function DocumentsView() {
description={t("documents.empty.description")}
ctaSize="lg"
action={
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
) : undefined
}
/>
) : showEmpty && isFiltered ? (
@@ -828,9 +841,9 @@ export function DocumentsView() {
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={toggleSelected}
onToggleSelected={canManage ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={(id) => deleteBulk([id])}
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
@@ -844,7 +857,7 @@ export function DocumentsView() {
contentIndexEnabled={usage.data?.contentIndex.enabled}
/>
{selectedIds.size > 0 ? (
{canManage && selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
+12 -4
View File
@@ -22,18 +22,26 @@
*/
import { useEffect, useRef, useState } from "react";
export function usePageFileDrop(onFiles: (files: File[]) => void): {
/**
* @param onFiles Where dropped or pasted files go. Omitted inside somebody
* else's record: uploading is not a delegated verb, so the listeners are
* never attached and the drop overlay never appears. Offering the overlay
* and swallowing the file would be worse than the browser's own behaviour.
*/
export function usePageFileDrop(onFiles?: (files: File[]) => void): {
dropActive: boolean;
} {
const [dropActive, setDropActive] = useState(false);
const depthRef = useRef(0);
const onFilesRef = useRef(onFiles);
const enabled = onFiles != null;
useEffect(() => {
onFilesRef.current = onFiles;
}, [onFiles]);
useEffect(() => {
if (!enabled) return;
const isFileDrag = (event: DragEvent) =>
event.dataTransfer?.types?.includes("Files") ?? false;
@@ -59,7 +67,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
depthRef.current = 0;
setDropActive(false);
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.length > 0) onFilesRef.current(files);
if (files.length > 0) onFilesRef.current?.(files);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || depthRef.current === 0) return;
@@ -70,7 +78,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
const files = Array.from(event.clipboardData?.files ?? []);
if (files.length === 0) return;
event.preventDefault();
onFilesRef.current(files);
onFilesRef.current?.(files);
};
window.addEventListener("dragenter", onDragEnter);
@@ -87,7 +95,7 @@ export function usePageFileDrop(onFiles: (files: File[]) => void): {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("paste", onPaste);
};
}, []);
}, [enabled]);
return { dropActive };
}
@@ -12,6 +12,7 @@
* day-log history, paginated server-side); when nothing has been logged it
* renders a calm prompt to log a day.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -91,6 +92,8 @@ export function IllnessDayTimeline({
onLogDay: () => void;
}) {
const { t } = useTranslations();
// v1.36.x — logging a day is not a delegated verb.
const { canManage } = useRecordCapabilities();
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
const dayLogs = data?.dayLogs ?? [];
@@ -117,13 +120,15 @@ export function IllnessDayTimeline({
<p className="text-muted-foreground text-sm">
{t("illness.timeline.empty")}
</p>
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
{canManage && (
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
onClick={onLogDay}
>
{t("illness.logDay")}
</Button>
)}
</div>
) : (
<div className="space-y-4">
@@ -8,6 +8,7 @@
* red-flag / pre-onset / nadir). Calm + retrospective — no prediction UI, no
* alarming colour.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
@@ -36,6 +37,9 @@ function todayLocal(): string {
export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
// v1.36.x — logging a day, editing and resolving an episode are the
// owner's; a delegate reads the episode.
const { canManage } = useRecordCapabilities();
const fmt = useFormatters();
const {
data: episode,
@@ -93,26 +97,28 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
)}
</div>
{episode?.note ? (
+78 -60
View File
@@ -15,6 +15,7 @@
* per-day timeline and correlation card live. Retrospective-only copy — a
* journal, not a medical device, and it does not diagnose.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useId, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
@@ -73,6 +74,7 @@ function EpisodeCard({
view,
}: EpisodeCardProps) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const fmt = useFormatters();
const active = episode.resolvedAt === null;
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
@@ -107,24 +109,26 @@ function EpisodeCard({
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</div>
)}
</CardContent>
</Card>
);
@@ -141,16 +145,18 @@ function EpisodeCard({
<span className="block truncate">{episode.label}</span>
</Link>
</CardTitle>
<CardAction>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</CardAction>
{canManage && (
<CardAction>
<EpisodeMenu
episode={episode}
onEdit={() => onEdit(episode)}
onResolve={
active && !isChronic ? () => onResolve(episode.id) : undefined
}
resolving={resolving}
/>
</CardAction>
)}
</CardHeader>
<CardContent className="flex h-full flex-col space-y-3">
@@ -205,14 +211,16 @@ function EpisodeCard({
flow, promoted from a busy inline cluster. The card navigates to
the detail surface on its title link; this is the one explicit
button. */}
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
</div>
{canManage && (
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
</Button>
</div>
)}
</CardContent>
</Card>
);
@@ -308,6 +316,10 @@ function EpisodeGroup({
export function IllnessView() {
const { t } = useTranslations();
// v1.36.x — opening an illness entry is an admitted delegated write. Logging
// a day against one, editing it, resolving it and deleting it are not, so a
// delegate can start the record of an illness and nothing more.
const { canAdd, canManage } = useRecordCapabilities();
const {
data: episodes,
isLoading,
@@ -380,29 +392,33 @@ export function IllnessView() {
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
<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/illness"
aria-label={t("illness.customize")}
title={t("illness.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/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
other module, not the bespoke "neue Episode". */}
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
{canAdd && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
)}
</>
}
/>
@@ -458,10 +474,12 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
canAdd ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
</Button>
) : undefined
}
/>
)}
+36 -26
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,6 +94,7 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { canAdd, canManage } = useRecordCapabilities();
const { t, locale } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
@@ -346,17 +348,19 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
@@ -378,18 +382,22 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</div>
</div>
@@ -429,9 +437,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
canAdd ? (
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
) : undefined
}
/>
</CardContent>
+15 -11
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pencil } from "lucide-react";
@@ -74,6 +75,7 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
@@ -361,17 +363,19 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
size="icon"
variant="ghost"
// v1.18.10 (W10) — 44px touch target on mobile (WCAG
// 2.5.5), compact 36px on desktop.
className="size-11 sm:size-9"
onClick={() => void openEdit(r)}
aria-label={t("labs.editReading")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
+3 -1
View File
@@ -1,5 +1,6 @@
"use client";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useMemo } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
@@ -77,6 +78,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const { canAdd } = useRecordCapabilities();
const { prefs } = useModuleListPrefs("labs");
// v1.22 — a short, factual line under each marker heading describing what the
@@ -167,7 +169,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst ? (
onAddFirst && canAdd ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
@@ -28,6 +28,7 @@
* hosting the per-reminder enable/disable toggles (moved off the
* card so the card carries a single med-style kebab).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -219,6 +220,7 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const router = useRouter();
const {
data: reminders,
@@ -359,7 +361,11 @@ export function VorsorgeSection({
// v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like
// every other module's add button.
const addButton = (
// v1.36.x — a checkup reminder belongs to the person whose schedule it is,
// and no grant level admits creating, editing or satisfying one. Inside
// somebody else's record the schedule stays readable and every control that
// would change it is absent.
const addButton = canManage ? (
<Button
type="button"
className="min-h-11 shrink-0 sm:min-h-9"
@@ -368,13 +374,13 @@ export function VorsorgeSection({
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
);
) : null;
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
// reorder, per-reminder enable toggles), left of the Add button — the
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
// gone (MOD-07); its toggles moved to the settings page.
const wrenchButton = (
const wrenchButton = canManage ? (
<Button
asChild
variant="ghost"
@@ -389,7 +395,7 @@ export function VorsorgeSection({
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
);
) : null;
return (
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
@@ -709,9 +715,11 @@ export function VorsorgeSection({
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
<Button type="button" onClick={openCreate}>
{t("common.add")}
</Button>
canManage ? (
<Button type="button" onClick={openCreate}>
{t("common.add")}
</Button>
) : undefined
}
/>
)}
@@ -787,6 +795,7 @@ function VorsorgeCard({
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `fmt.date` renders in (mirror → Berlin).
// The next-due phrase reads off the SAME zone as the last-done date below
@@ -855,7 +864,7 @@ function VorsorgeCard({
// Single med-style kebab: Edit + Delete. The Delete item is the shared
// confirm-on-delete control rendered as a full-width menu row.
const headerActions = (
const headerActions = canManage ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -898,14 +907,14 @@ function VorsorgeCard({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
) : null;
// The measure / mark-done action. One constant appearance in every state —
// no due-driven tint, no label swap (v1.27.5 retired the former green
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
const primaryButton = (
const primaryButton = canManage ? (
<Button
type="button"
className="min-h-11 w-full"
@@ -928,7 +937,7 @@ function VorsorgeCard({
</>
)}
</Button>
);
) : null;
const deleteDialog = (
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
@@ -1114,7 +1123,9 @@ function VorsorgeCard({
)}
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
<div className="mt-auto pt-0">{primaryButton}</div>
{primaryButton ? (
<div className="mt-auto pt-0">{primaryButton}</div>
) : null}
</CardContent>
</Card>
+108 -65
View File
@@ -1,8 +1,10 @@
"use client";
import { accountLabel } from "@/lib/sharing/account-access-view";
import { useRouter } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -283,7 +285,20 @@ export function MeasurementList({
}: MeasurementListProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
// v1.36.x — inside somebody else's record, only the add path survives:
// entering a reading is an admitted delegated write, editing and deleting
// one are not. The row controls are dropped rather than disabled.
const { canAdd, canManage, inSharedRecord, canWrite } =
useRecordCapabilities();
const activeRecord = user?.accountAccess?.active ?? null;
// Named only when the sentence below is owed: a delegate who may add but
// not change. In the caller's own record, and for a read-only delegate who
// was offered no add button to be surprised by, there is nothing to explain.
const delegateName =
inSharedRecord && canWrite && activeRecord
? accountLabel(activeRecord)
: null;
const unitDisplay = useUnitDisplay();
// v1.32.26 — a single-reading row renders its value + unit in the user's
// preferred unit (kg↔lb, cm↔in, °C↔°F). A canonical row converts through the
@@ -928,38 +943,58 @@ export function MeasurementList({
// an outage never reads as "you have no measurements".
<QueryErrorCard onRetry={() => void refetch()} />
) : !data?.measurements?.length ? (
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
// measurements yet") from filtered-out lists ("no measurements
// match this filter") and exposes the right CTA for each
// case.
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button variant="outline" onClick={() => setTypeFilter("ALL")}>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
}
/>
<>
{/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
Filter-aware copy distinguishes brand-new accounts ("no
measurements yet") from filtered-out lists ("no measurements
match this filter") and exposes the right CTA for each case. */}
<EmptyState
icon={<Activity className="size-6" />}
title={
typeFilter === "ALL" || lockedType
? t("measurements.emptyTitle")
: t("measurements.emptyFilteredTitle")
}
description={
typeFilter === "ALL" || lockedType
? t("measurements.emptyDescription")
: t("measurements.emptyFilteredDescription")
}
action={
// v1.8.5 — a locked-type list has no "reset filter" path;
// the metric is fixed by the route.
typeFilter !== "ALL" && !lockedType ? (
<Button
variant="outline"
onClick={() => setTypeFilter("ALL")}
>
{t("measurements.emptyResetFilter")}
</Button>
) : onAddFirst && canAdd ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
</Button>
) : undefined
}
/>
{/* The one place in the app the asymmetry is spelled out. A
delegate who may add will look for the edit control beside the
row they just entered, so the list that carries the add button
says once, in one muted line, where the limit is. Every other
hidden control stays silent: the banner already names the mode,
and a chip beside each absent button would be noise. */}
{delegateName && (
<p
data-slot="delegate-add-only-note"
className="text-muted-foreground mx-auto max-w-prose text-center text-xs"
>
{t("recordSharing.delegate.addOnlyNote", {
name: delegateName,
})}
</p>
)}
</>
) : (
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
@@ -978,19 +1013,23 @@ export function MeasurementList({
<TableHeader>
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
{/* v1.15.13 — select-all-on-page header checkbox.
v1.36.x — bulk delete is the owner's alone, so a
delegate gets no selection column. */}
{canManage && (
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
)}
</TableHead>
<TableHead className="w-28">
{t("measurements.type")}
@@ -1051,7 +1090,7 @@ export function MeasurementList({
<TableCell className="pl-4">
{/* Grouped/synthetic rows aren't individually
deletable, so they have no checkbox. */}
{!isGrouped && (
{!isGrouped && canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(m.id)}
@@ -1178,15 +1217,17 @@ export function MeasurementList({
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
<DeleteButton
onConfirm={() =>
deleteMutation.mutate(m.id)
@@ -1247,7 +1288,7 @@ export function MeasurementList({
<div className="flex items-center gap-2 overflow-hidden">
{/* v1.15.13 — multi-select checkbox in a 44px tap
target; absent for synthetic grouped rows. */}
{!isGrouped && (
{!isGrouped && canManage && (
// v1.15.13 MEDIUM-1 kept the 16px Radix Checkbox
// (itself a `<button role=checkbox>`) inside a
// 44px wrapper `<button>` for WCAG 2.5.5 — but a
@@ -1375,14 +1416,16 @@ export function MeasurementList({
</Button>
) : (
<>
<Button
variant="ghost"
size="icon-lg"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon-lg"
onClick={() => startEdit(m)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
iconClassName="h-4 w-4"
onConfirm={() => deleteMutation.mutate(m.id)}
@@ -39,6 +39,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import {
useFormatters,
@@ -71,6 +72,10 @@ export function InstrumentCard({
}) {
const { t, locale } = useTranslations();
const { date: formatDate } = useFormatters();
// v1.36.x — a screener is answered by the person it is about. No grant
// level admits it, so inside somebody else's record the card reads as
// history and carries no way to start one.
const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -173,11 +178,13 @@ export function InstrumentCard({
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
</div>
{canManage && (
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
</Button>
</div>
)}
</CardContent>
</Card>
);
@@ -10,6 +10,7 @@
* landing stays a quiet card grid, so the trend is opt-in behind a
* deliberate click, never pushed at the moment someone arrives to check in.
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import {
useFormatters,
@@ -35,6 +36,8 @@ export function InstrumentDetail({
}) {
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
// v1.36.x — see the card: starting a screener is not a delegated verb.
const { canManage } = useRecordCapabilities();
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -73,14 +76,16 @@ export function InstrumentDetail({
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
{canManage && (
<Button
type="button"
className="min-h-11 w-full"
onClick={onStart}
data-slot="instrument-detail-start"
>
{t("mentalHealth.start")}
</Button>
)}
<AssessmentHistory rows={rows} instrument={instrument} />
+58 -44
View File
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
@@ -139,6 +140,9 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { isAuthenticated } = useAuth();
// v1.36.x — mood is not a delegated verb. Inside somebody else's record the
// list stays readable and every control that would change it is absent.
const { canManage } = useRecordCapabilities();
const queryClient = useQueryClient();
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
// v1.15.13 — management-list source filter + optional date range.
@@ -618,7 +622,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
>
{t("mood.emptyResetFilter")}
</Button>
) : onAddFirst ? (
) : onAddFirst && canManage ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("mood.emptyAddFirst")}
@@ -644,18 +648,20 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
{canManage && (
<Checkbox
checked={
selectAll === "all"
? true
: selectAll === "some"
? "indeterminate"
: false
}
disabled={pageIds.length === 0}
onCheckedChange={onToggleSelectAll}
aria-label={t("dataList.selectAll")}
/>
)}
</TableHead>
<SortableHead
column="mood"
@@ -695,11 +701,13 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
{canManage && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
/>
)}
</TableCell>
<TableCell className="font-semibold tabular-nums">
{entry.score}{" "}
@@ -737,15 +745,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
@@ -789,14 +799,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
stays the single control and owns the 44px hit area
via an `after` hit-slop (clicks on a pseudo-element
hit-test against its host button). */}
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</div>
{canManage && (
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
aria-label={t("dataList.selectRow")}
className="relative after:absolute after:-inset-3.5"
/>
</div>
)}
<div className="bg-muted flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
<span
data-testid="mood-row-score"
@@ -834,15 +846,17 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
and delete actions meet WCAG 2.5.5 on touch
devices. The desktop table keeps its denser
h-8 w-8 since pointer targets allow it. */}
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
onClick={() => startEdit(entry)}
aria-label={t("common.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
<DeleteButton
onConfirm={() => deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"