fix(custom-metrics,medications): shared error cards, client nav, themed delete

- CustomMetricList load error uses QueryErrorCard with retry; post-create
  navigation switches from a full-page window.location.assign to router.push.
- CustomMetricDetail + medications list swap their hand-rolled error blocks
  for the shared QueryErrorCard.
- Side-effect delete now uses the themed AlertDialog confirm instead of a
  native window.confirm.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:55:19 +02:00
1 parent 930fa81124
commit 188662311a
10 files changed
+91 -53

No files matched your search

+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Verlangsamte Magenentleerung",
"dysgeusia": "Geschmacksveränderung",
"gallbladderDiscomfort": "Gallenblasen-Beschwerden"
}
},
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmBody": "Dieser Nebenwirkungs-Eintrag wird dauerhaft entfernt."
},
"scheduling": {
"naturalLanguage": {
+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Slow stomach emptying",
"dysgeusia": "Taste change",
"gallbladderDiscomfort": "Gallbladder discomfort"
}
},
"deleteConfirmTitle": "Delete this entry?",
"deleteConfirmBody": "This side-effect entry will be permanently removed."
},
"scheduling": {
"naturalLanguage": {
+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Vaciado gástrico lento",
"dysgeusia": "Alteración del gusto",
"gallbladderDiscomfort": "Molestia en la vesícula biliar"
}
},
"deleteConfirmTitle": "¿Eliminar esta entrada?",
"deleteConfirmBody": "Esta entrada de efecto secundario se eliminará de forma permanente."
},
"scheduling": {
"naturalLanguage": {
+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Vidange gastrique lente",
"dysgeusia": "Altération du goût",
"gallbladderDiscomfort": "Inconfort de la vésicule biliaire"
}
},
"deleteConfirmTitle": "Supprimer cette entrée ?",
"deleteConfirmBody": "Cette entrée d’effet secondaire sera définitivement supprimée."
},
"scheduling": {
"naturalLanguage": {
+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Svuotamento gastrico lento",
"dysgeusia": "Alterazione del gusto",
"gallbladderDiscomfort": "Fastidio alla cistifellea"
}
},
"deleteConfirmTitle": "Eliminare questa voce?",
"deleteConfirmBody": "Questa voce di effetto collaterale verrà rimossa definitivamente."
},
"scheduling": {
"naturalLanguage": {
+3 -1
View File
@@ -955,7 +955,9 @@
"gastroparesisLike": "Spowolnione opróżnianie żołądka",
"dysgeusia": "Zmiana smaku",
"gallbladderDiscomfort": "Dolegliwości pęcherzyka żółciowego"
}
},
"deleteConfirmTitle": "Usunąć ten wpis?",
"deleteConfirmBody": "Ten wpis o skutku ubocznym zostanie trwale usunięty."
},
"scheduling": {
"naturalLanguage": {
+6 -14
View File
@@ -26,6 +26,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import {
CheckCheck,
@@ -426,20 +427,11 @@ export default function MedicationsPage() {
)}
</div>
) : isError ? (
<div className="bg-card border-border flex h-64 items-center justify-center rounded-xl border">
<div className="flex flex-col items-center gap-3">
<p className="text-muted-foreground text-sm">
{t("medications.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => void refetchMedications()}
>
{t("medications.retryLoad")}
</Button>
</div>
</div>
<QueryErrorCard
description={t("medications.loadFailed")}
retryLabel={t("medications.retryLoad")}
onRetry={() => void refetchMedications()}
/>
) : !medications?.length ? (
// v1.4.15 phase-C5: refactor the inline icon+text+button block
// to the shared EmptyState primitive so the empty path matches
@@ -15,6 +15,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
@@ -178,23 +179,13 @@ export function CustomMetricDetail({
{description}
</p>
</header>
<Card>
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<p className="text-destructive text-sm">
{t("customMetrics.loadError")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
if (metricError) void refetchMetric();
if (listError) void refetchList();
}}
>
{t("common.retry")}
</Button>
</CardContent>
</Card>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => {
if (metricError) void refetchMetric();
if (listError) void refetchList();
}}
/>
</div>
);
}
@@ -2,11 +2,13 @@
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Gauge, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
@@ -27,10 +29,11 @@ import type { CustomMetricDto, CustomMetricListResponse } from "./types";
*/
export function CustomMetricList() {
const { t } = useTranslations();
const router = useRouter();
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
});
@@ -40,10 +43,8 @@ export function CustomMetricList() {
function afterAdd(saved: CustomMetricDto) {
setAddOpen(false);
// Land the user on the new metric's detail page so the next step (logging
// a value) is one tap away.
if (typeof window !== "undefined") {
window.location.assign(`/custom-metrics/${saved.id}`);
}
// a value) is one tap away — client nav, not a full-page reload.
router.push(`/custom-metrics/${saved.id}`);
}
return (
@@ -80,9 +81,10 @@ export function CustomMetricList() {
</CardContent>
</Card>
) : isError ? (
<p role="alert" className="text-destructive py-4 text-center text-sm">
{t("customMetrics.loadError")}
</p>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => refetch()}
/>
) : metrics.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
@@ -8,6 +8,16 @@ import { Loader2, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
@@ -113,6 +123,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
// into the `<ResponsiveSheet>` footer slot. The submit button keeps
// its `<form>` association via the HTML `form` attribute.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const formId = useId();
const listKey = queryKeys.medicationSideEffects(medicationId);
@@ -330,14 +341,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
size="icon"
className="text-muted-foreground hover:text-destructive size-11 shrink-0"
aria-label={t("medications.sideEffects.deleteCta")}
onClick={() => {
if (
typeof window === "undefined" ||
window.confirm(t("medications.sideEffects.deleteConfirm"))
) {
deleteMutation.mutate(row.id);
}
}}
onClick={() => setPendingDeleteId(row.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
@@ -348,6 +352,43 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
</div>
)}
{/* Themed confirm — replaces the native window.confirm so the delete
gate matches every other destructive action in the app. */}
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(o) => {
if (!o) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.sideEffects.deleteConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.sideEffects.deleteConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
if (pendingDeleteId) deleteMutation.mutate(pendingDeleteId);
setPendingDeleteId(null);
}}
disabled={deleteMutation.isPending}
aria-busy={deleteMutation.isPending || undefined}
className="bg-destructive dark:bg-destructive/60 text-white hover:bg-destructive/90"
>
{t("medications.sideEffects.deleteCta")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ResponsiveSheet
open={open}
onOpenChange={setOpen}