reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

UserDetail

surface

사용자 상세 프로필 Surface. Avatar + StatCard + GaugeChart + Progress + DataTable + FilterBar 조합.

컴포넌트 의존 관계

깊이
▼ USES (9)UserDetailgauge-chartavatarbadgeprogressdata-tablefilter-barloading-overlaysummary-rowtime-range-selector
100%

기본 사용

User Profile

User ProfileJD
Jane DoeActive

jane@example.com

ID: usr_abc123

power-userbeta
User Stats
Total Events2,340↑ +15%
Sessions128↑ +8%
First SeenJan 5→
Last Seen2 min ago→

Engagement Score

78Engagement Score
Recency
92
Frequency
65
Monetary
54

Properties

CountrySouth KoreaBrowserChrome 121DeviceDesktopOSmacOS 14.3PlanProSignup2025-01-05

Event Timeline

Data Table

EventPathTime
page_view/dashboard2 min ago
click/settings5 min ago
form_submit/profile12 min ago

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

UserDetail 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

UserDetail Props

Prop타입기본값설명
profile*UserProfile—사용자 프로필 정보 (id, name, email, avatarUrl, tags, lifecycle)
statsStatCardType[]—통계 카드 배열 (총 이벤트, 세션, 첫 방문, 마지막 방문 등)
engagementEngagementScore—참여도 점수 (overall, recency, frequency, monetary)
propertiesRecord<string, unknown>—사용자 속성 키-값 맵
eventsEventRow[]—이벤트 타임라인 데이터
filtersFilterGroupDef[]—타임라인 필터 정의
pageSizenumber—이벤트 타임라인 페이지 크기

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add user-detail

Consumer 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.tsx→user-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";