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) => ( -
  1. - - - {e.actorInitials} - - -
    - - {e.actorName} {e.action} - - - {new Date(e.createdAt).toLocaleString()} - -
    -
  2. - ))} -
+ // 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} + + + ); +} + +// 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 ( + + ); +} + +// 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 ( +