From 591b2f917075734d9f6a33bf1d246c35f063e63f Mon Sep 17 00:00:00 2001
From: Khalid Abdi
Date: Fri, 10 Jul 2026 20:50:54 +0300
Subject: [PATCH] frontend: center wallet-sync stepper and record-history
timeline
Replace the bespoke left-aligned DialogStepper with the Origin UI Stepper
primitive (components/ui/stepper.tsx) so the numbered indicators sit centered
inline with their labels. Rebuild the patient sheet's Record History section as
a vertical Timeline (components/ui/timeline.tsx) showing the actor name, an
entity-type icon, the action, and the date, replacing the flat avatar list.
Co-Authored-By: Claude Opus 4.8
---
.../components/patients/patient-detail.tsx | 83 +++--
frontend/components/ui/stepper.tsx | 293 ++++++++++++++++++
frontend/components/ui/timeline.tsx | 210 +++++++++++++
.../components/wallet/wallet-sync-step.tsx | 68 ++--
4 files changed, 591 insertions(+), 63 deletions(-)
create mode 100644 frontend/components/ui/stepper.tsx
create mode 100644 frontend/components/ui/timeline.tsx
diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx
index 8f0d9de..b41cfd4 100644
--- a/frontend/components/patients/patient-detail.tsx
+++ b/frontend/components/patients/patient-detail.tsx
@@ -2,12 +2,18 @@
import {
ArrowLeftRight,
+ CalendarDays,
FileDown,
+ type LucideIcon,
+ ListTodo,
Mic,
Network,
+ NotebookPen,
Pencil,
+ Pill,
Send,
Trash2,
+ UserRound,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -15,7 +21,21 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
-import { type ActivityEntry, listPatientActivity } from "@/lib/activity";
+import {
+ Timeline,
+ TimelineContent,
+ TimelineDate,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineSeparator,
+ TimelineTitle,
+} from "@/components/ui/timeline";
+import {
+ type ActivityEntityType,
+ type ActivityEntry,
+ listPatientActivity,
+} from "@/lib/activity";
import { printPatientSummary } from "@/lib/patient-pdf";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -122,8 +142,18 @@ function TrendBlock({ trend }: { trend: Trend }) {
);
}
+// Which icon marks each kind of audited change on the timeline.
+const historyIcon: Record = {
+ appointment: CalendarDays,
+ note: NotebookPen,
+ patient: UserRound,
+ prescription: Pill,
+ task: ListTodo,
+};
+
// The patient's record history: every audited add/change on this chart, newest
-// first. Reuses the clinic activity log scoped to this file number.
+// first. Reuses the clinic activity log scoped to this file number, laid out as
+// a vertical timeline — who made the change, what happened, and when.
function RecordHistory({ fileNumber }: { fileNumber: string }) {
const { t } = useTranslation();
const [entries, setEntries] = useState(null);
@@ -152,25 +182,36 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")}
) : (
-
- {entries.map((e) => (
- -
-
-
- {e.actorInitials}
-
-
-
-
- {e.actorName} {e.action}
-
-
- {new Date(e.createdAt).toLocaleString()}
-
-
-
- ))}
-
+ // Every entry is a past, audited event, so mark them all completed
+ // (filled indicators) by seeding the active step past the last item.
+
+ {entries.map((e, i) => {
+ const Icon = historyIcon[e.entityType] ?? Pencil;
+ return (
+
+
+
+
+ {e.actorName}
+
+
+
+
+
+
+ {e.action}
+
+ {new Date(e.createdAt).toLocaleString()}
+
+
+
+ );
+ })}
+
)}
);
diff --git a/frontend/components/ui/stepper.tsx b/frontend/components/ui/stepper.tsx
new file mode 100644
index 0000000..6b349f5
--- /dev/null
+++ b/frontend/components/ui/stepper.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { CheckIcon, LoaderCircleIcon } from "lucide-react";
+import { Slot } from "radix-ui";
+import * as React from "react";
+import { createContext, useContext } from "react";
+
+import { cn } from "@/lib/utils";
+
+// Types
+type StepperContextValue = {
+ activeStep: number;
+ setActiveStep: (step: number) => void;
+ orientation: "horizontal" | "vertical";
+};
+
+type StepItemContextValue = {
+ step: number;
+ state: StepState;
+ isDisabled: boolean;
+ isLoading: boolean;
+};
+
+type StepState = "active" | "completed" | "inactive" | "loading";
+
+// Contexts
+const StepperContext = createContext(
+ undefined,
+);
+const StepItemContext = createContext(
+ undefined,
+);
+
+const useStepper = () => {
+ const context = useContext(StepperContext);
+ if (!context) {
+ throw new Error("useStepper must be used within a Stepper");
+ }
+ return context;
+};
+
+const useStepItem = () => {
+ const context = useContext(StepItemContext);
+ if (!context) {
+ throw new Error("useStepItem must be used within a StepperItem");
+ }
+ return context;
+};
+
+// Components
+interface StepperProps extends React.HTMLAttributes {
+ defaultValue?: number;
+ value?: number;
+ onValueChange?: (value: number) => void;
+ orientation?: "horizontal" | "vertical";
+}
+
+function Stepper({
+ defaultValue = 0,
+ value,
+ onValueChange,
+ orientation = "horizontal",
+ className,
+ ...props
+}: StepperProps) {
+ const [activeStep, setInternalStep] = React.useState(defaultValue);
+
+ const setActiveStep = React.useCallback(
+ (step: number) => {
+ if (value === undefined) {
+ setInternalStep(step);
+ }
+ onValueChange?.(step);
+ },
+ [value, onValueChange],
+ );
+
+ const currentStep = value ?? activeStep;
+
+ return (
+
+
+
+ );
+}
+
+// StepperItem
+interface StepperItemProps extends React.HTMLAttributes {
+ step: number;
+ completed?: boolean;
+ disabled?: boolean;
+ loading?: boolean;
+}
+
+function StepperItem({
+ step,
+ completed = false,
+ disabled = false,
+ loading = false,
+ className,
+ children,
+ ...props
+}: StepperItemProps) {
+ const { activeStep } = useStepper();
+
+ const state: StepState =
+ completed || step < activeStep
+ ? "completed"
+ : activeStep === step
+ ? "active"
+ : "inactive";
+
+ const isLoading = loading && step === activeStep;
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+// StepperTrigger
+interface StepperTriggerProps
+ extends React.ButtonHTMLAttributes {
+ asChild?: boolean;
+}
+
+function StepperTrigger({
+ asChild = false,
+ className,
+ children,
+ ...props
+}: StepperTriggerProps) {
+ const { setActiveStep } = useStepper();
+ const { step, isDisabled } = useStepItem();
+
+ if (asChild) {
+ const Comp = asChild ? Slot.Root : "span";
+ return (
+
+ {children}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+// StepperIndicator
+interface StepperIndicatorProps extends React.HTMLAttributes {
+ asChild?: boolean;
+}
+
+function StepperIndicator({
+ asChild = false,
+ className,
+ children,
+ ...props
+}: StepperIndicatorProps) {
+ const { state, step, isLoading } = useStepItem();
+
+ return (
+
+ {asChild ? (
+ children
+ ) : (
+ <>
+
+ {step}
+
+
+ {isLoading && (
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
+
+// StepperTitle
+function StepperTitle({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+// StepperDescription
+function StepperDescription({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+// StepperSeparator
+function StepperSeparator({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export {
+ Stepper,
+ StepperDescription,
+ StepperIndicator,
+ StepperItem,
+ StepperSeparator,
+ StepperTitle,
+ StepperTrigger,
+};
diff --git a/frontend/components/ui/timeline.tsx b/frontend/components/ui/timeline.tsx
new file mode 100644
index 0000000..74f30de
--- /dev/null
+++ b/frontend/components/ui/timeline.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { Slot } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+// Types
+type TimelineContextValue = {
+ activeStep: number;
+ setActiveStep: (step: number) => void;
+};
+
+// Context
+const TimelineContext = React.createContext(
+ undefined,
+);
+
+const useTimeline = () => {
+ const context = React.useContext(TimelineContext);
+ if (!context) {
+ throw new Error("useTimeline must be used within a Timeline");
+ }
+ return context;
+};
+
+// Components
+interface TimelineProps extends React.HTMLAttributes {
+ defaultValue?: number;
+ value?: number;
+ onValueChange?: (value: number) => void;
+ orientation?: "horizontal" | "vertical";
+}
+
+function Timeline({
+ defaultValue = 1,
+ value,
+ onValueChange,
+ orientation = "vertical",
+ className,
+ ...props
+}: TimelineProps) {
+ const [activeStep, setInternalStep] = React.useState(defaultValue);
+
+ const setActiveStep = React.useCallback(
+ (step: number) => {
+ if (value === undefined) {
+ setInternalStep(step);
+ }
+ onValueChange?.(step);
+ },
+ [value, onValueChange],
+ );
+
+ const currentStep = value ?? activeStep;
+
+ return (
+
+
+
+ );
+}
+
+// TimelineContent
+function TimelineContent({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+// TimelineDate
+interface TimelineDateProps extends React.HTMLAttributes {
+ asChild?: boolean;
+}
+
+function TimelineDate({
+ asChild = false,
+ className,
+ ...props
+}: TimelineDateProps) {
+ const Comp = asChild ? Slot.Root : "time";
+
+ return (
+
+ );
+}
+
+// TimelineHeader
+function TimelineHeader({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+// TimelineIndicator
+interface TimelineIndicatorProps extends React.HTMLAttributes {
+ asChild?: boolean;
+}
+
+function TimelineIndicator({
+ asChild = false,
+ className,
+ children,
+ ...props
+}: TimelineIndicatorProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// TimelineItem
+interface TimelineItemProps extends React.HTMLAttributes {
+ step: number;
+}
+
+function TimelineItem({ step, className, ...props }: TimelineItemProps) {
+ const { activeStep } = useTimeline();
+
+ return (
+
+ );
+}
+
+// TimelineSeparator
+function TimelineSeparator({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+// TimelineTitle
+function TimelineTitle({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export {
+ Timeline,
+ TimelineContent,
+ TimelineDate,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineSeparator,
+ TimelineTitle,
+};
diff --git a/frontend/components/wallet/wallet-sync-step.tsx b/frontend/components/wallet/wallet-sync-step.tsx
index 08c4d99..3548969 100644
--- a/frontend/components/wallet/wallet-sync-step.tsx
+++ b/frontend/components/wallet/wallet-sync-step.tsx
@@ -5,57 +5,41 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogPanel } from "@/components/ui/dialog";
-import { cn } from "@/lib/utils";
+import {
+ Stepper,
+ StepperIndicator,
+ StepperItem,
+ StepperSeparator,
+ StepperTitle,
+} from "@/components/ui/stepper";
import type { UseWalletSync } from "./use-wallet-sync";
// Two-step header shown at the top of a dialog when the selected patient has a
-// linked wallet: "Details" → "Sync to wallet".
+// linked wallet: "Details" → "Sync to wallet". Centered so the numbered
+// indicators sit inline with their labels.
export function DialogStepper({ step }: { step: "form" | "wallet" }) {
const { t } = useTranslation();
const steps = [
- { key: "form", label: t("walletSync.step1") },
- { key: "wallet", label: t("walletSync.step2") },
- ] as const;
- const activeIndex = step === "form" ? 0 : 1;
+ { value: 1, label: t("walletSync.step1") },
+ { value: 2, label: t("walletSync.step2") },
+ ];
+ const activeValue = step === "form" ? 1 : 2;
return (
-
- {steps.map((s, i) => {
- const done = i < activeIndex;
- const active = i === activeIndex;
- return (
-
-
- {done ? : i + 1}
-
-
- {s.label}
-
- {i < steps.length - 1 && (
-
- )}
-
- );
- })}
-
+
+ {steps.map(({ value, label }, i) => (
+
+
+
+
+ {label}
+
+
+ {i < steps.length - 1 && }
+
+ ))}
+
);
}