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.

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";