UserDetail
surface사용자 상세 프로필 Surface. Avatar + StatCard + GaugeChart + Progress + DataTable + FilterBar 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
User Profile
Jane DoeActive
jane@example.com
ID: usr_abc123
power-userbeta
Total Events2,340↑ +15%
Sessions128↑ +8%
First SeenJan 5→
Last Seen2 min ago→
Engagement Score
Recency92
Frequency65
Monetary54
Properties
CountrySouth KoreaBrowserChrome 121DeviceDesktopOSmacOS 14.3PlanProSignup2025-01-05
Event Timeline
Data Table
| Event | Path | Time |
|---|---|---|
| page_view | /dashboard | 2 min ago |
| click | /settings | 5 min ago |
| form_submit | /profile | 12 min ago |
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
UserDetail 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
UserDetail Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
profile* | UserProfile | — | 사용자 프로필 정보 (id, name, email, avatarUrl, tags, lifecycle) |
stats | StatCardType[] | — | 통계 카드 배열 (총 이벤트, 세션, 첫 방문, 마지막 방문 등) |
engagement | EngagementScore | — | 참여도 점수 (overall, recency, frequency, monetary) |
properties | Record<string, unknown> | — | 사용자 속성 키-값 맵 |
events | EventRow[] | — | 이벤트 타임라인 데이터 |
filters | FilterGroupDef[] | — | 타임라인 필터 정의 |
pageSize | number | — | 이벤트 타임라인 페이지 크기 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add user-detailConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { UserDetail } from "@/components/surfaces/user-detail";Registry metadata
- 설명
- 사용자 상세 프로필 Surface. Avatar + StatCard + GaugeChart + Progress + DataTable + FilterBar 조합.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- charttablefilter
- Install notes
- 없음
포함 파일
user-detail.tsxuser-detail.tsx
Surface 소스 보기
user-detail.tsx
"use client";
import * as React from "react";
import {
Avatar,
Badge,
DataTable,
FilterBar,
Progress,
SurfaceLayout,
SummaryRow,
TimeRangeSelector,
type ColumnDef,
type DateRange,
type FilterGroupDef,
type StatCardType,
} from "@reopt-ai/opt-ui";
import { GaugeChart } from "@reopt-ai/opt-charts";
/** Profile data for `User`. */
export interface UserProfile {
id: string;
name?: string;
email?: string;
avatarUrl?: string;
tags?: string[];
lifecycle?: string;
}
/** Score data for `Engagement`. */
export interface EngagementScore {
overall: number;
recency?: number;
frequency?: number;
monetary?: number;
}
interface EventRow {
id: string;
[k: string]: unknown;
}
/** Labels for `UserDetail`. */
export interface UserDetailLabels {
profile?: string;
stats?: string;
engagement?: string;
properties?: string;
timeline?: string;
idPrefix?: string;
noEventsMessage?: string;
emptyTitle?: string;
emptyDescription?: string;
recency?: string;
frequency?: string;
monetary?: string;
autoTagsLabel?: string;
}
const defaultLabels: Required<UserDetailLabels> = {
profile: "User Profile",
stats: "User Stats",
engagement: "Engagement Score",
properties: "Properties",
timeline: "Event Timeline",
idPrefix: "ID: ",
noEventsMessage: "No events recorded yet.",
emptyTitle: "No user data",
emptyDescription: "User profile information is not available.",
recency: "Recency",
frequency: "Frequency",
monetary: "Monetary",
autoTagsLabel: "Auto Tags",
};
/** Props for `UserDetail`. */
export interface UserDetailProps {
profile: UserProfile;
stats?: StatCardType[];
engagement?: EngagementScore;
properties?: Record<string, unknown>;
events?: EventRow[];
eventColumns?: ColumnDef<EventRow>[];
/** FilterBar definitions for timeline filtering */
filters?: FilterGroupDef[];
onFilterChange?: (
filterId: string,
value: string | string[] | boolean,
) => void;
/** Pagination page size for event timeline */
pageSize?: number;
/** System-generated auto tags */
autoTags?: string[];
/** Callback when an auto tag is clicked */
onAutoTagClick?: (tag: string) => void;
loading?: boolean;
timeRange?: DateRange;
onTimeRangeChange?: (range: DateRange) => void;
header?: React.ReactNode;
actions?: React.ReactNode;
labels?: UserDetailLabels;
className?: string;
}
/** Renders the `UserDetail` component. */
export function UserDetail({
profile,
stats,
engagement,
properties,
events = [],
eventColumns = [
{ id: "event", header: "Event", accessor: "event" as keyof EventRow },
{ id: "path", header: "Path", accessor: "path" as keyof EventRow },
{
id: "timestamp",
header: "Time",
accessor: "timestamp" as keyof EventRow,
},
],
filters,
onFilterChange,
pageSize,
autoTags,
onAutoTagClick,
loading = false,
timeRange,
onTimeRangeChange,
header,
actions,
labels: customLabels,
className,
}: UserDetailProps) {
const labels = { ...defaultLabels, ...customLabels };
const sectionId = React.useId();
const hasStats = stats && stats.length > 0;
const hasEngagement = engagement != null;
const hasProperties = properties && Object.keys(properties).length > 0;
const hasEvents = events.length > 0;
const hasFilters = filters && filters.length > 0;
const propertyEntries = hasProperties ? Object.entries(properties!) : [];
return (
<SurfaceLayout loading={loading} className={className}>
{/* Header */}
{header || actions ? (
<div className="flex items-center justify-between">
{header && <div>{header}</div>}
<div className="gap-element flex items-center">
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
{actions}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<h2 className="text-text-primary text-lg font-semibold">
{labels.profile}
</h2>
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
</div>
)}
{/* Profile card */}
<section
aria-labelledby={`${sectionId}-profile`}
className="border-border gap-group flex items-center rounded-lg border p-4"
>
<span id={`${sectionId}-profile`} className="sr-only">
{labels.profile}
</span>
<Avatar
src={profile.avatarUrl}
name={profile.name ?? profile.id}
size="lg"
/>
<div className="min-w-0 flex-1">
<div className="gap-element flex items-center">
<span className="text-text-primary truncate text-base font-medium">
{profile.name ?? profile.id}
</span>
{profile.lifecycle && (
<Badge variant="info" size="sm">
{profile.lifecycle}
</Badge>
)}
</div>
{profile.email && (
<p className="text-text-tertiary truncate text-sm">
{profile.email}
</p>
)}
<p className="text-text-tertiary text-xs">
{labels.idPrefix}
{profile.id}
</p>
{(profile.tags?.length || autoTags?.length) && (
<div className="mt-1 flex flex-wrap gap-1">
{profile.tags?.map((tag) => (
<Badge key={tag} variant="default" size="sm">
{tag}
</Badge>
))}
{autoTags &&
autoTags.length > 0 &&
autoTags.map((tag) => (
<Badge
key={`auto-${tag}`}
variant="info"
size="sm"
className="border-info/30 cursor-pointer border border-dashed"
role={onAutoTagClick ? "button" : undefined}
tabIndex={onAutoTagClick ? 0 : undefined}
title={`${labels.autoTagsLabel}: ${tag}`}
onClick={
onAutoTagClick ? () => onAutoTagClick(tag) : undefined
}
onKeyDown={
onAutoTagClick
? (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAutoTagClick(tag);
}
}
: undefined
}
>
{tag}
</Badge>
))}
</div>
)}
</div>
</section>
{/* Stats cards */}
{hasStats && (
<section aria-labelledby={`${sectionId}-stats`}>
<span id={`${sectionId}-stats`} className="sr-only">
{labels.stats}
</span>
<SummaryRow stats={stats!} />
</section>
)}
{/* Engagement score + Properties */}
<div className="gap-section grid md:grid-cols-2">
{hasEngagement && (
<section
aria-labelledby={`${sectionId}-engagement`}
className="border-border rounded-lg border p-4"
>
<h3
id={`${sectionId}-engagement`}
className="text-text-secondary mb-3 text-sm font-medium"
>
{labels.engagement}
</h3>
<div className="flex items-center justify-center">
<GaugeChart
value={engagement!.overall}
max={100}
size={140}
strokeWidth={14}
label={labels.engagement}
valueLabel={`${engagement!.overall}`}
aria-label={`Engagement score: ${engagement!.overall}`}
/>
</div>
<div className="mt-group gap-element flex flex-col">
{engagement!.recency != null && (
<div className="gap-element flex items-center text-sm">
<span className="text-text-secondary w-20">
{labels.recency}
</span>
<Progress
value={engagement!.recency}
max={100}
className="flex-1"
/>
<span className="text-text-secondary w-8 text-right">
{engagement!.recency}
</span>
</div>
)}
{engagement!.frequency != null && (
<div className="gap-element flex items-center text-sm">
<span className="text-text-secondary w-20">
{labels.frequency}
</span>
<Progress
value={engagement!.frequency}
max={100}
className="flex-1"
/>
<span className="text-text-secondary w-8 text-right">
{engagement!.frequency}
</span>
</div>
)}
{engagement!.monetary != null && (
<div className="gap-element flex items-center text-sm">
<span className="text-text-secondary w-20">
{labels.monetary}
</span>
<Progress
value={engagement!.monetary}
max={100}
className="flex-1"
/>
<span className="text-text-secondary w-8 text-right">
{engagement!.monetary}
</span>
</div>
)}
</div>
</section>
)}
{hasProperties && (
<section
aria-labelledby={`${sectionId}-properties`}
className="border-border rounded-lg border p-4"
>
<h3
id={`${sectionId}-properties`}
className="text-text-secondary mb-3 text-sm font-medium"
>
{labels.properties}
</h3>
<div className="gap-x-group gap-y-element grid grid-cols-2 text-sm">
{propertyEntries.map(([key, val]) => (
<React.Fragment key={key}>
<span className="text-text-tertiary truncate">{key}</span>
<span className="text-text-primary truncate">
{String(val ?? "—")}
</span>
</React.Fragment>
))}
</div>
</section>
)}
</div>
{/* Event timeline */}
{(hasEvents || hasFilters) && (
<section aria-labelledby={`${sectionId}-timeline`}>
<h3
id={`${sectionId}-timeline`}
className="text-text-secondary mb-3 text-sm font-medium"
>
{labels.timeline}
</h3>
{hasFilters && (
<div className="mb-3">
<FilterBar filters={filters} onFilterChange={onFilterChange} />
</div>
)}
{hasEvents ? (
<DataTable
columns={eventColumns}
data={events}
keyExtractor={(r) => r.id}
pageSize={pageSize}
/>
) : (
<div
role="status"
className="flex flex-col items-center justify-center py-8 text-center"
>
<p className="text-text-tertiary text-sm">
{labels.noEventsMessage}
</p>
</div>
)}
</section>
)}
{/* Global empty state (no profile data at all) */}
{!hasStats && !hasEngagement && !hasProperties && !hasEvents && (
<div
role="status"
className="flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
)}
</SurfaceLayout>
);
}
UserDetail.displayName = "UserDetail";