reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 2026 reopt-ai. All rights reserved.

SegmentManager

surface

세그먼트 관리 Surface. DataTable + SegmentBuilder 조합.

컴포넌트 의존 관계

깊이
▼ USES (4)SegmentManagerdata-tablesegment-builderloading-overlaybutton
100%

기본 사용

Segments

Data Table

NameUsersCreated
Power Users1,2342024-01-15
Churning Users5672024-02-01

테스트 커버리지

2026년 2월 4일

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

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

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

SegmentManager Props

Prop타입기본값설명
segments*SegmentDef[]—세그먼트 배열
onChange*(segments: SegmentDef[]) => void—변경 핸들러

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add segment-manager

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { SegmentManager } from "@/components/surfaces/segment-manager";

Registry metadata

설명
세그먼트 관리 Surface. DataTable + SegmentBuilder 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
table
Install notes
없음

포함 파일

  • segment-manager.tsx→segment-manager.tsx
Surface 소스 보기
segment-manager.tsx
"use client";

import * as React from "react";
import {
  Badge,
  DataTable,
  SegmentBuilder,
  Button,
  SurfaceLayout,
  SummaryRow,
  cn,
  type ColumnDef,
  type SegmentGroup,
  type StatCardType,
} from "@reopt-ai/opt-ui";

/** Definition shape for `Segment`. */
export interface SegmentDef {
  id: string;
  name: string;
  userCount: number;
  groups: SegmentGroup[];
  createdAt: string;
  /** Whether the cached user count is stale */
  userCountStale?: boolean;
  /** Last time the user count was updated */
  userCountUpdatedAt?: string;
  /** Segment status for badge display */
  status?: "active" | "draft" | "archived";
  /** Custom badges to display on the segment */
  badges?: Array<{ label: string; variant: string }>;
}

/** View modes supported by `SegmentManager`. */
export type SegmentManagerViewMode = "table" | "grid";

/** Labels for `SegmentManager`. */
export interface SegmentManagerLabels {
  segments?: string;
  newSegment?: string;
  editPrefix?: string;
  recompute?: string;
  previewLabel?: string;
  tableViewLabel?: string;
  gridViewLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  staleCountLabel?: string;
}

const defaultLabels: Required<SegmentManagerLabels> = {
  segments: "Segments",
  newSegment: "+ New Segment",
  editPrefix: "Edit: ",
  recompute: "Recompute",
  previewLabel: "Estimated users",
  tableViewLabel: "Table",
  gridViewLabel: "Cards",
  emptyTitle: "No segments created",
  emptyDescription: "Create your first segment to start targeting users.",
  staleCountLabel: "Stale",
};

/** Props for `SegmentManager`. */
export interface SegmentManagerProps {
  segments: SegmentDef[];
  onChange: (segments: SegmentDef[]) => void;
  /** Estimated user count shown when editing a segment */
  previewCount?: number | null;
  /** Called when recompute button is clicked on a segment */
  onRecompute?: (segmentId: string) => void;
  /** Stats cards displayed above the segment list */
  stats?: StatCardType[];
  /** Custom render function for condition summary on each segment row */
  renderConditionSummary?: (segment: SegmentDef) => React.ReactNode;
  /** Display mode: "table" (default) or "grid" (card grid) */
  viewMode?: SegmentManagerViewMode;
  /** Render function for grid cards. Required when viewMode="grid". */
  renderCard?: (segment: SegmentDef) => React.ReactNode;
  /** Show table/grid toggle buttons. Requires renderCard. */
  showViewToggle?: boolean;
  /** Called when the view mode changes */
  onViewModeChange?: (mode: SegmentManagerViewMode) => void;
  loading?: boolean;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: SegmentManagerLabels;
  className?: string;
}

type BadgeVariant = "default" | "success" | "warning" | "error" | "info";

const statusVariantMap: Record<string, BadgeVariant> = {
  active: "success",
  draft: "warning",
  archived: "default",
};

function collectSegmentIds(segments: SegmentDef[]) {
  const segmentIds = new Set<string>();
  const groupIds = new Set<string>();
  const conditionIds = new Set<string>();

  for (const segment of segments) {
    segmentIds.add(segment.id);
    for (const group of segment.groups) {
      groupIds.add(group.id);
      for (const condition of group.conditions) {
        conditionIds.add(condition.id);
      }
    }
  }

  return { segmentIds, groupIds, conditionIds };
}

function buildColumns(staleLabel: string): ColumnDef<SegmentDef>[] {
  return [
    {
      id: "name",
      header: "Name",
      accessor: (row) => (
        <span className="gap-element inline-flex flex-wrap items-center">
          <span>{row.name}</span>
          {row.status && (
            <Badge
              variant={statusVariantMap[row.status] ?? "default"}
              size="sm"
            >
              {row.status}
            </Badge>
          )}
          {row.badges?.map((b, i) => (
            <Badge
              key={i}
              variant={(b.variant as BadgeVariant) ?? "default"}
              size="sm"
            >
              {b.label}
            </Badge>
          ))}
        </span>
      ),
    },
    {
      id: "userCount",
      header: "Users",
      accessor: (row) => (
        <span className="gap-element inline-flex items-center">
          <span className={row.userCountStale ? "italic" : undefined}>
            {row.userCount.toLocaleString()}
          </span>
          {row.userCountStale && (
            <Badge variant="warning" size="sm">
              {staleLabel}
            </Badge>
          )}
        </span>
      ),
    },
    { id: "createdAt", header: "Created", accessor: "createdAt" },
  ];
}

/** Renders the `SegmentManager` component. */
export function SegmentManager({
  segments,
  onChange,
  previewCount,
  onRecompute,
  stats,
  renderConditionSummary,
  viewMode: controlledViewMode,
  renderCard,
  showViewToggle = false,
  onViewModeChange,
  loading = false,
  header,
  actions,
  labels: customLabels,
  className,
}: SegmentManagerProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const titleId = React.useId();
  const [internalViewMode, setInternalViewMode] =
    React.useState<SegmentManagerViewMode>("table");
  const viewMode = controlledViewMode ?? internalViewMode;

  const handleViewModeChange = React.useCallback(
    (mode: SegmentManagerViewMode) => {
      setInternalViewMode(mode);
      onViewModeChange?.(mode);
    },
    [onViewModeChange],
  );

  const hasStats = stats && stats.length > 0;
  const tableColumns = React.useMemo(
    () => buildColumns(labels.staleCountLabel),
    [labels.staleCountLabel],
  );
  const [editing, setEditing] = React.useState<string | null>(null);
  const editingSegment = segments.find((s) => s.id === editing);
  const nextSegmentIdRef = React.useRef(1);
  const nextGroupIdRef = React.useRef(1);
  const nextConditionIdRef = React.useRef(1);

  const createSegmentEntityId = React.useCallback(
    (
      prefix: "seg" | "g" | "c",
      ref: React.MutableRefObject<number>,
      reserved: Set<string>,
    ) => {
      let id = "";

      do {
        id = `${prefix}_${ref.current++}`;
      } while (reserved.has(id));

      return id;
    },
    [],
  );

  const addSegment = () => {
    const { segmentIds, groupIds, conditionIds } = collectSegmentIds(segments);
    const newSeg: SegmentDef = {
      id: createSegmentEntityId("seg", nextSegmentIdRef, segmentIds),
      name: "New Segment",
      userCount: 0,
      groups: [
        {
          id: createSegmentEntityId("g", nextGroupIdRef, groupIds),
          combinator: "and",
          conditions: [
            {
              id: createSegmentEntityId("c", nextConditionIdRef, conditionIds),
              type: "event",
              field: "page_view",
              operator: "performed",
              value: "",
            },
          ],
        },
      ],
      createdAt: new Date().toISOString().split("T")[0],
    };
    onChange([...segments, newSeg]);
    setEditing(newSeg.id);
  };

  const updateSegmentGroups = (groups: SegmentGroup[]) => {
    if (!editing) return;
    onChange(segments.map((s) => (s.id === editing ? { ...s, groups } : s)));
  };

  const isEmpty = segments.length === 0;

  return (
    <SurfaceLayout loading={loading} className={className}>
      <div className="flex items-center justify-between">
        {header ? (
          <div>{header}</div>
        ) : (
          <h2 id={titleId} className="text-text-primary text-lg font-semibold">
            {labels.segments}
          </h2>
        )}
        <div className="gap-element flex items-center">
          {showViewToggle && renderCard && (
            <div
              className="border-border inline-flex rounded-md border"
              role="group"
              aria-label="View mode"
            >
              <button
                type="button"
                className={cn(
                  "rounded-l-md px-3 py-1.5 text-sm font-medium transition-colors",
                  viewMode === "table"
                    ? "bg-accent-subtle text-text-primary"
                    : "text-text-secondary hover:bg-bg-subtle",
                )}
                aria-pressed={viewMode === "table"}
                onClick={() => handleViewModeChange("table")}
              >
                {labels.tableViewLabel}
              </button>
              <button
                type="button"
                className={cn(
                  "rounded-r-md px-3 py-1.5 text-sm font-medium transition-colors",
                  viewMode === "grid"
                    ? "bg-accent-subtle text-text-primary"
                    : "text-text-secondary hover:bg-bg-subtle",
                )}
                aria-pressed={viewMode === "grid"}
                onClick={() => handleViewModeChange("grid")}
              >
                {labels.gridViewLabel}
              </button>
            </div>
          )}
          {actions}
          <Button variant="primary" size="sm" onClick={addSegment}>
            {labels.newSegment}
          </Button>
        </div>
      </div>

      {hasStats && <SummaryRow stats={stats!} />}

      {isEmpty ? (
        <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>
      ) : (
        <>
          {viewMode === "grid" && renderCard ? (
            <div className="gap-group grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
              {segments.map((segment) => (
                <div
                  key={segment.id}
                  role="button"
                  tabIndex={0}
                  className="cursor-pointer rounded-lg"
                  onClick={() => setEditing(segment.id)}
                  onKeyDown={(e: React.KeyboardEvent) => {
                    if (e.key === "Enter" || e.key === " ") {
                      e.preventDefault();
                      setEditing(segment.id);
                    }
                  }}
                >
                  {renderCard(segment)}
                </div>
              ))}
            </div>
          ) : (
            <DataTable
              columns={tableColumns}
              data={segments}
              keyExtractor={(s) => s.id}
              onRowClick={(row) => setEditing(row.id)}
              expandable={!!renderConditionSummary}
              renderExpanded={
                renderConditionSummary
                  ? (segment) => (
                      <div className="text-text-tertiary text-xs">
                        {renderConditionSummary(segment)}
                      </div>
                    )
                  : undefined
              }
            />
          )}

          {editingSegment && (
            <div className="border-border border-t pt-4">
              <h3 className="text-text-primary mb-3 text-sm font-medium">
                {labels.editPrefix}
                {editingSegment.name}
              </h3>
              <SegmentBuilder
                groups={editingSegment.groups}
                onChange={updateSegmentGroups}
              />
              {(previewCount != null || onRecompute) && (
                <div className="gap-group mt-3 flex items-center">
                  {previewCount != null && (
                    <span className="text-text-secondary text-sm">
                      {labels.previewLabel}: {previewCount.toLocaleString()}
                    </span>
                  )}
                  {onRecompute && (
                    <Button
                      variant="ghost"
                      size="sm"
                      onClick={() => onRecompute(editingSegment.id)}
                    >
                      {labels.recompute}
                    </Button>
                  )}
                </div>
              )}
            </div>
          )}
        </>
      )}
    </SurfaceLayout>
  );
}

SegmentManager.displayName = "SegmentManager";