mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
bc43a9f062
commit
7817632385
23 files changed
+703
-447
No files matched your search
+24
-16
@@ -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}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user