mirror of
https://github.com/temetro/temetro.git
synced 2026-09-01 05:18:57 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
|
CalendarDays,
|
||||||
FileDown,
|
FileDown,
|
||||||
|
type LucideIcon,
|
||||||
|
ListTodo,
|
||||||
Mic,
|
Mic,
|
||||||
Network,
|
Network,
|
||||||
|
NotebookPen,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Pill,
|
||||||
Send,
|
Send,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -15,7 +21,21 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Sparkline } from "@/components/chat/sparkline";
|
import { Sparkline } from "@/components/chat/sparkline";
|
||||||
import { AttachmentsSection } from "@/components/patients/patient-files";
|
import { AttachmentsSection } from "@/components/patients/patient-files";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
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 { printPatientSummary } from "@/lib/patient-pdf";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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<ActivityEntityType, LucideIcon> = {
|
||||||
|
appointment: CalendarDays,
|
||||||
|
note: NotebookPen,
|
||||||
|
patient: UserRound,
|
||||||
|
prescription: Pill,
|
||||||
|
task: ListTodo,
|
||||||
|
};
|
||||||
|
|
||||||
// The patient's record history: every audited add/change on this chart, newest
|
// 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 }) {
|
function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
|
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
|
||||||
@@ -152,25 +182,36 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
|||||||
{t("patientCard.history.empty")}
|
{t("patientCard.history.empty")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="flex flex-col gap-3">
|
// Every entry is a past, audited event, so mark them all completed
|
||||||
{entries.map((e) => (
|
// (filled indicators) by seeding the active step past the last item.
|
||||||
<li className="flex items-start gap-3" key={e.id}>
|
<Timeline defaultValue={entries.length}>
|
||||||
<Avatar className="mt-0.5 size-7 shrink-0">
|
{entries.map((e, i) => {
|
||||||
<AvatarFallback className="text-[11px]">
|
const Icon = historyIcon[e.entityType] ?? Pencil;
|
||||||
{e.actorInitials}
|
return (
|
||||||
</AvatarFallback>
|
<TimelineItem
|
||||||
</Avatar>
|
className="group-data-[orientation=vertical]/timeline:ms-10"
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
key={e.id}
|
||||||
<span className="text-foreground text-sm">
|
step={i + 1}
|
||||||
<span className="font-medium">{e.actorName}</span> {e.action}
|
>
|
||||||
</span>
|
<TimelineHeader>
|
||||||
<span className="text-muted-foreground text-xs">
|
<TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5" />
|
||||||
{new Date(e.createdAt).toLocaleString()}
|
<TimelineTitle className="mt-0.5">
|
||||||
</span>
|
{e.actorName}
|
||||||
</div>
|
</TimelineTitle>
|
||||||
</li>
|
<TimelineIndicator className="group-data-[orientation=vertical]/timeline:-left-7 flex size-6 items-center justify-center border-none bg-primary/10 text-primary group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground">
|
||||||
))}
|
<Icon size={14} />
|
||||||
</ol>
|
</TimelineIndicator>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent>
|
||||||
|
{e.action}
|
||||||
|
<TimelineDate className="mt-1 mb-0">
|
||||||
|
{new Date(e.createdAt).toLocaleString()}
|
||||||
|
</TimelineDate>
|
||||||
|
</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<StepperContextValue | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||||
|
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<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<StepperContext.Provider
|
||||||
|
value={{
|
||||||
|
activeStep: currentStep,
|
||||||
|
orientation,
|
||||||
|
setActiveStep,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/stepper inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-slot="stepper"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</StepperContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperItem
|
||||||
|
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<StepItemContext.Provider
|
||||||
|
value={{ isDisabled: disabled, isLoading, state, step }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/step flex items-center group-data-[orientation=horizontal]/stepper:flex-row group-data-[orientation=vertical]/stepper:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="stepper-item"
|
||||||
|
data-state={state}
|
||||||
|
{...(isLoading ? { "data-loading": true } : {})}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</StepItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperTrigger
|
||||||
|
interface StepperTriggerProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
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 (
|
||||||
|
<Comp className={className} data-slot="stepper-trigger">
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-3 rounded-full outline-none focus-visible:z-10 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="stepper-trigger"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => setActiveStep(step)}
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperIndicator
|
||||||
|
interface StepperIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperIndicator({
|
||||||
|
asChild = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StepperIndicatorProps) {
|
||||||
|
const { state, step, isLoading } = useStepItem();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-6 shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground text-xs data-[state=active]:bg-primary data-[state=completed]:bg-primary data-[state=active]:text-primary-foreground data-[state=completed]:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="stepper-indicator"
|
||||||
|
data-state={state}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{asChild ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="transition-all group-data-[state=completed]/step:scale-0 group-data-loading/step:scale-0 group-data-[state=completed]/step:opacity-0 group-data-loading/step:opacity-0 group-data-loading/step:transition-none">
|
||||||
|
{step}
|
||||||
|
</span>
|
||||||
|
<CheckIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute scale-0 opacity-0 transition-all group-data-[state=completed]/step:scale-100 group-data-[state=completed]/step:opacity-100"
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="absolute transition-all">
|
||||||
|
<LoaderCircleIcon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="animate-spin"
|
||||||
|
size={14}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperTitle
|
||||||
|
function StepperTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("font-medium text-sm", className)}
|
||||||
|
data-slot="stepper-title"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperDescription
|
||||||
|
function StepperDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
data-slot="stepper-description"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// StepperSeparator
|
||||||
|
function StepperSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"m-0.5 bg-muted group-data-[orientation=horizontal]/stepper:h-0.5 group-data-[orientation=vertical]/stepper:h-12 group-data-[orientation=horizontal]/stepper:w-full group-data-[orientation=vertical]/stepper:w-0.5 group-data-[orientation=horizontal]/stepper:flex-1 group-data-[state=completed]/step:bg-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="stepper-separator"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Stepper,
|
||||||
|
StepperDescription,
|
||||||
|
StepperIndicator,
|
||||||
|
StepperItem,
|
||||||
|
StepperSeparator,
|
||||||
|
StepperTitle,
|
||||||
|
StepperTrigger,
|
||||||
|
};
|
||||||
@@ -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<TimelineContextValue | undefined>(
|
||||||
|
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<HTMLDivElement> {
|
||||||
|
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 (
|
||||||
|
<TimelineContext.Provider
|
||||||
|
value={{ activeStep: currentStep, setActiveStep }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-orientation={orientation}
|
||||||
|
data-slot="timeline"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TimelineContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineContent
|
||||||
|
function TimelineContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
data-slot="timeline-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineDate
|
||||||
|
interface TimelineDateProps extends React.HTMLAttributes<HTMLTimeElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineDate({
|
||||||
|
asChild = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: TimelineDateProps) {
|
||||||
|
const Comp = asChild ? Slot.Root : "time";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(
|
||||||
|
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="timeline-date"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineHeader
|
||||||
|
function TimelineHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn(className)} data-slot="timeline-header" {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineIndicator
|
||||||
|
interface TimelineIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineIndicator({
|
||||||
|
asChild = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TimelineIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="timeline-indicator"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineItem
|
||||||
|
interface TimelineItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
step: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineItem({ step, className, ...props }: TimelineItemProps) {
|
||||||
|
const { activeStep } = useTimeline();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-12 has-[+[data-completed]]:[&_[data-slot=timeline-separator]]:bg-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-completed={step <= activeStep || undefined}
|
||||||
|
data-slot="timeline-item"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineSeparator
|
||||||
|
function TimelineSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-slot="timeline-separator"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimelineTitle
|
||||||
|
function TimelineTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return (
|
||||||
|
<h3
|
||||||
|
className={cn("font-medium text-sm", className)}
|
||||||
|
data-slot="timeline-title"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
};
|
||||||
@@ -5,57 +5,41 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DialogFooter, DialogPanel } from "@/components/ui/dialog";
|
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";
|
import type { UseWalletSync } from "./use-wallet-sync";
|
||||||
|
|
||||||
// Two-step header shown at the top of a dialog when the selected patient has a
|
// 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" }) {
|
export function DialogStepper({ step }: { step: "form" | "wallet" }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const steps = [
|
const steps = [
|
||||||
{ key: "form", label: t("walletSync.step1") },
|
{ value: 1, label: t("walletSync.step1") },
|
||||||
{ key: "wallet", label: t("walletSync.step2") },
|
{ value: 2, label: t("walletSync.step2") },
|
||||||
] as const;
|
];
|
||||||
const activeIndex = step === "form" ? 0 : 1;
|
const activeValue = step === "form" ? 1 : 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<Stepper className="mx-auto mt-3 max-w-sm" value={activeValue}>
|
||||||
{steps.map((s, i) => {
|
{steps.map(({ value, label }, i) => (
|
||||||
const done = i < activeIndex;
|
<StepperItem className="not-last:flex-1" key={value} step={value}>
|
||||||
const active = i === activeIndex;
|
<span className="inline-flex items-center gap-2">
|
||||||
return (
|
<StepperIndicator />
|
||||||
<div className="flex flex-1 items-center gap-2" key={s.key}>
|
<StepperTitle className="whitespace-nowrap text-xs">
|
||||||
<span
|
{label}
|
||||||
className={cn(
|
</StepperTitle>
|
||||||
"flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold transition-colors",
|
</span>
|
||||||
done && "bg-primary text-primary-foreground",
|
{i < steps.length - 1 && <StepperSeparator className="mx-3" />}
|
||||||
active && "bg-primary/15 text-primary ring-1 ring-primary/40",
|
</StepperItem>
|
||||||
!done && !active && "bg-muted text-muted-foreground",
|
))}
|
||||||
)}
|
</Stepper>
|
||||||
>
|
|
||||||
{done ? <Check className="size-3" /> : i + 1}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium",
|
|
||||||
active || done ? "text-foreground" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
{i < steps.length - 1 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-1 h-px flex-1",
|
|
||||||
done ? "bg-primary/40" : "bg-border",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user