geo/frontend/components/business/timeline-step.tsx

159 lines
4.8 KiB
TypeScript

"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type TimelineStepStatus = "completed" | "active" | "pending" | "error" | "skipped";
export interface TimelineStepData {
id: string;
title: string;
description?: string;
timestamp?: string;
status: TimelineStepStatus;
metadata?: React.ReactNode;
}
export interface TimelineStepProps extends React.HTMLAttributes<HTMLDivElement> {
step: TimelineStepData;
isLast?: boolean;
}
export interface TimelineProps extends React.HTMLAttributes<HTMLDivElement> {
steps: TimelineStepData[];
}
const stepIconConfig: Record<TimelineStepStatus, { iconBg: string; iconColor: string; connectorClass: string }> = {
completed: {
iconBg: "bg-primary",
iconColor: "text-primary-foreground",
connectorClass: "bg-primary",
},
active: {
iconBg: "bg-white border-2 border-primary",
iconColor: "text-primary",
connectorClass: "bg-border",
},
pending: {
iconBg: "bg-white border-2 border-border",
iconColor: "text-muted-foreground",
connectorClass: "bg-border",
},
error: {
iconBg: "bg-destructive",
iconColor: "text-destructive-foreground",
connectorClass: "bg-destructive/30",
},
skipped: {
iconBg: "bg-muted border-2 border-border",
iconColor: "text-muted-foreground",
connectorClass: "bg-border",
},
};
const StepIcon = ({ status }: { status: TimelineStepStatus }) => {
switch (status) {
case "completed":
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
case "active":
return <div className="h-2 w-2 rounded-full bg-primary" />;
case "error":
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
<path d="M4 4L10 10M10 4L4 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
case "skipped":
return (
<svg className="h-3.5 w-3.5" viewBox="0 0 14 14" fill="none">
<path d="M5 4L9 7L5 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 4V10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
default:
return <div className="h-2 w-2 rounded-full bg-border" />;
}
};
const TimelineStep = React.forwardRef<HTMLDivElement, TimelineStepProps>(
({ className, step, isLast = false, ...props }, ref) => {
const config = stepIconConfig[step.status];
return (
<div ref={ref} className={cn("flex gap-4", className)} {...props}>
{/* Icon + connector column */}
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full shadow-sm",
config.iconBg,
config.iconColor
)}
>
<StepIcon status={step.status} />
</div>
{!isLast && (
<div
className={cn("w-0.5 flex-1 my-1.5", config.connectorClass)}
style={{ minHeight: "1.5rem" }}
/>
)}
</div>
{/* Content */}
<div className={cn("flex-1 pb-6", isLast && "pb-0")}>
<div className="flex items-start justify-between gap-2">
<div>
<p
className={cn(
"text-sm font-semibold leading-7",
step.status === "pending" || step.status === "skipped"
? "text-muted-foreground"
: "text-foreground"
)}
>
{step.title}
</p>
{step.description && (
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">
{step.description}
</p>
)}
{step.metadata && (
<div className="mt-2">{step.metadata}</div>
)}
</div>
{step.timestamp && (
<span className="shrink-0 text-xs text-muted-foreground pt-1">{step.timestamp}</span>
)}
</div>
</div>
</div>
);
}
);
TimelineStep.displayName = "TimelineStep";
const Timeline = React.forwardRef<HTMLDivElement, TimelineProps>(
({ className, steps, ...props }, ref) => {
return (
<div ref={ref} className={cn("flex flex-col", className)} {...props}>
{steps.map((step, idx) => (
<TimelineStep
key={step.id}
step={step}
isLast={idx === steps.length - 1}
/>
))}
</div>
);
}
);
Timeline.displayName = "Timeline";
export { TimelineStep, Timeline };