SegmentManager
surface세그먼트 관리 Surface. DataTable + SegmentBuilder 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
Segments
Data Table
| Name | Users | Created |
|---|---|---|
| Power Users | 1,234 | 2024-01-15 |
| Churning Users | 567 | 2024-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-managerConsumer 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.tsxsegment-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";