diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index d0b685f1c..8ef9835e3 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -3,12 +3,18 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Loader2, Plus } from "lucide-react"; +import { FlaskConical, Loader2, MoreVertical, Plus } from "lucide-react"; import { BiomarkerManager } from "@/components/labs/biomarker-manager"; import { LabForm } from "@/components/labs/lab-form"; import { LabList } from "@/components/labs/lab-list"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { useAuth } from "@/hooks/use-auth"; @@ -25,6 +31,9 @@ export default function LabsPage() { const { t } = useTranslations(); const [dialogOpen, setDialogOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false); + // Sticky-footer slot element for the add-result sheet (filled by the + // ResponsiveSheet `footer` ref so the form portals its action row there). + const [addFooterEl, setAddFooterEl] = useState(null); const refreshVisible = useCallback( () => queryClient.invalidateQueries({ type: "active" }), @@ -61,17 +70,7 @@ export default function LabsPage() { {t("labs.subtitle")}

-
- +
+ {/* Secondary actions fold into one overflow kebab beside the primary + Add, mirroring the medication-card header (one cluster, not a + two-button row). */} + + + + + + setManageOpen(true)}> + + {t("labs.biomarker.manage")} + + +
@@ -87,8 +107,10 @@ export default function LabsPage() { onOpenChange={setDialogOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={
} > { setDialogOpen(false); queryClient.invalidateQueries({ diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index c1dfcfacd..1689ed852 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -120,7 +120,10 @@ export default function MeasurementsPage() { {t("measurements.subtitle")}

- diff --git a/src/components/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index e814ef717..056de1a3c 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -41,6 +42,13 @@ interface BiomarkerFormProps { existing?: BiomarkerDto; onSuccess?: (saved: BiomarkerDto) => void; onCancel?: () => void; + /** + * When mounted inside a `` the caller passes the sheet's + * footer slot element here; the Cancel / Save row portals into it so the + * bottom-sheet branch can sticky-pin it. The Save button stays tied to the + * `
` via the HTML `form` attribute. + */ + footerSlot?: HTMLElement | null; } /** @@ -57,8 +65,10 @@ export function BiomarkerForm({ existing, onSuccess, onCancel, + footerSlot, }: BiomarkerFormProps) { const { t } = useTranslations(); + const formId = useId(); const [name, setName] = useState(existing?.name ?? ""); const [unit, setUnit] = useState(existing?.unit ?? ""); @@ -129,8 +139,29 @@ export function BiomarkerForm({ } } + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return ( - + {!existing ? (
{latest ? ( @@ -156,6 +189,14 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { {readings.length > 0 ? (

{t("labs.detail.history")}

+ {truncated ? ( +

+ {t("labs.showingLatestOf", { + shown: readings.length, + total, + })} +

+ ) : null} @@ -169,13 +210,36 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onOpenChange={setAddOpen} title={t("labs.addResult")} description={t("labs.addDescription")} + footer={ +
+ } > setAddOpen(false)} /> + + + } + > + {marker ? ( + setEditOpen(false)} + /> + ) : null} +
); } diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 04aebe218..b36ceb170 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState } from "react"; +import { useId, useState } from "react"; +import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; @@ -54,6 +55,14 @@ interface LabFormProps { lockedBiomarkerId?: string; onSuccess?: (created: LabResultDto) => void; onCancel?: () => void; + /** + * When mounted inside a `` the caller passes the sheet's + * footer slot element here. The Cancel / Save action row is portalled into + * it so the bottom-sheet branch can sticky-pin it above the keyboard; the + * Save button stays tied to the `
` via the HTML `form` attribute so + * submit-on-Enter and portalled-click both still submit. + */ + footerSlot?: HTMLElement | null; } /** @@ -70,9 +79,11 @@ export function LabForm({ lockedBiomarkerId, onSuccess, onCancel, + footerSlot, }: LabFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const formId = useId(); const { data: catalog, isLoading: catalogLoading } = useQuery({ queryKey: queryKeys.biomarkers(), @@ -88,6 +99,9 @@ export function LabForm({ const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [defineOpen, setDefineOpen] = useState(false); + const [defineFooterEl, setDefineFooterEl] = useState( + null, + ); const markers = catalog?.biomarkers ?? []; const selected = markers.find((m) => m.id === biomarkerId); @@ -148,9 +162,30 @@ export function LabForm({ ) : ""; + const footerNode = ( + <> + {onCancel ? ( + + ) : null} + + + ); + return ( <> - +