feat(pop-button): 설정 패널 UX/UI 전면 개선 - 비개발자 친화적 설정 경험

화면 디자이너(비개발자)가 버튼 작업 설정을 직관적으로 할 수 있도록
설정 패널의 용어, 레이아웃, 구조를 전면 개선한다.
[디자인 통일]
- Input/Select 높이 h-8, 라벨 text-xs font-medium, 도움말 text-[11px]로 통일
- db-conditional UI를 가로 나열에서 세로 스택으로 전환 (좁은 패널 잘림 방지)
- 작업 항목 간 간격, 패딩, 둥근 모서리 일관성 확보
[자연어 라벨]
- "대상 테이블" → "어떤 테이블을 수정할까요?"
- "변경 컬럼" → "어떤 항목(컬럼)을 바꿀까요?"
- "연산" → "어떻게 바꿀까요?" + 각 연산별 설명 도움말
- "값 출처: 고정값" → "직접 입력", "연결 데이터" → "화면 데이터에서 가져오기"
- 비교 연산자에 한글 설명 추가 (">=" → ">= (이상이면)")
[구조 개선]
- "조회 키"를 "고급 설정" 토글로 숨김 (기본 접힘, 대부분 자동 매칭)
- "연결 필드명" 수동 입력 → 카드 컴포넌트 필드 목록에서 Select 선택
- 접힌 헤더에 요약 텍스트 표시 + 마우스 호버 시 전체 툴팁
- 펼친 상태 하단에 설정 요약 미리보기
[컬럼 코멘트 표시]
- 백엔드: getTableSchema SQL에 col_description() 추가
- 프론트: ColumnCombobox에서 코멘트 표시 + 한글명 검색 지원
- ColumnInfo 인터페이스에 comment 필드 추가
This commit is contained in:
SeongHyun Kim 2026-03-06 14:06:53 +09:00
parent 297b14d706
commit 516517eb34
4 changed files with 529 additions and 222 deletions

View File

@ -4446,26 +4446,30 @@ export class TableManagementService {
const rawColumns = await query<any>(
`SELECT
column_name as "columnName",
column_name as "displayName",
data_type as "dataType",
udt_name as "dbType",
is_nullable as "isNullable",
column_default as "defaultValue",
character_maximum_length as "maxLength",
numeric_precision as "numericPrecision",
numeric_scale as "numericScale",
c.column_name as "columnName",
c.column_name as "displayName",
c.data_type as "dataType",
c.udt_name as "dbType",
c.is_nullable as "isNullable",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
c.numeric_precision as "numericPrecision",
c.numeric_scale as "numericScale",
CASE
WHEN column_name IN (
SELECT column_name FROM information_schema.key_column_usage
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
WHEN c.column_name IN (
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
) THEN true
ELSE false
END as "isPrimaryKey"
FROM information_schema.columns
WHERE table_name = $1
AND table_schema = 'public'
ORDER BY ordinal_position`,
END as "isPrimaryKey",
col_description(
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
c.ordinal_position
) as "columnComment"
FROM information_schema.columns c
WHERE c.table_name = $1
AND c.table_schema = 'public'
ORDER BY c.ordinal_position`,
[tableName]
);
@ -4475,10 +4479,10 @@ export class TableManagementService {
displayName: col.displayName,
dataType: col.dataType,
dbType: col.dbType,
webType: "text", // 기본값
webType: "text",
inputType: "direct",
detailSettings: "{}",
description: "", // 필수 필드 추가
description: col.columnComment || "",
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
@ -4489,6 +4493,7 @@ export class TableManagementService {
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
displayOrder: 0,
isVisible: true,
columnComment: col.columnComment || "",
}));
logger.info(

View File

@ -1267,11 +1267,51 @@ interface PopButtonConfigPanelProps {
componentId?: string;
}
/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */
function extractCardFields(
allComponents?: PopButtonConfigPanelProps["allComponents"],
): { value: string; label: string; source: string }[] {
if (!allComponents) return [];
const fields: { value: string; label: string; source: string }[] = [];
for (const comp of allComponents) {
if (comp.type !== "pop-card-list" || !comp.config) continue;
const tpl = (comp.config as Record<string, unknown>).cardTemplate as
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
| undefined;
if (!tpl) continue;
if (tpl.header?.codeField) {
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" });
}
if (tpl.header?.titleField) {
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" });
}
for (const f of tpl.body?.fields ?? []) {
if (f.valueType === "column" && f.columnName) {
fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" });
} else if (f.valueType === "formula" && f.label) {
const formulaKey = `__formula_${f.id || f.label}`;
fields.push({ value: formulaKey, label: f.label, source: "수식" });
}
}
// 시스템 필드 추가
fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" });
fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" });
fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" });
}
return fields;
}
export function PopButtonConfigPanel({
config,
onUpdate,
allComponents,
}: PopButtonConfigPanelProps) {
const v2 = useMemo(() => migrateButtonConfig(config), [config]);
const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]);
const updateV2 = useCallback(
(partial: Partial<PopButtonConfigV2>) => {
@ -1449,9 +1489,9 @@ export function PopButtonConfigPanel({
{/* 작업 목록 */}
<SectionDivider label="작업 목록" />
<div className="space-y-1.5">
<div className="space-y-2.5">
{v2.tasks.length === 0 && (
<p className="text-[10px] text-muted-foreground py-2 text-center">
<p className="text-[11px] text-muted-foreground py-2 text-center">
. .
</p>
)}
@ -1465,6 +1505,7 @@ export function PopButtonConfigPanel({
onUpdate={(partial) => updateTask(task.id, partial)}
onRemove={() => removeTask(task.id)}
onMove={(dir) => moveTask(task.id, dir)}
cardFields={cardFields}
/>
))}
@ -1490,6 +1531,41 @@ export function PopButtonConfigPanel({
// 작업 항목 에디터 (접힘/펼침)
// ========================================
/** 작업 항목의 요약 텍스트 생성 */
function buildTaskSummary(task: ButtonTask): string {
switch (task.type) {
case "data-update": {
if (!task.targetTable) return "";
const col = task.targetColumn ? `.${task.targetColumn}` : "";
const opLabels: Record<string, string> = {
assign: "값 지정",
add: "더하기",
subtract: "빼기",
multiply: "곱하기",
divide: "나누기",
conditional: "조건 분기",
"db-conditional": "조건 비교",
};
const op = opLabels[task.operationType || "assign"] || "";
return `${task.targetTable}${col} ${op}`;
}
case "data-delete":
return task.targetTable || "";
case "navigate":
return task.targetScreenId ? `화면 ${task.targetScreenId}` : "";
case "modal-open":
return task.modalTitle || task.modalScreenId || "";
case "cart-save":
return task.cartScreenId ? `화면 ${task.cartScreenId}` : "";
case "api-call":
return task.apiEndpoint || "";
case "custom-event":
return task.eventName || "";
default:
return "";
}
}
function TaskItemEditor({
task,
index,
@ -1497,6 +1573,7 @@ function TaskItemEditor({
onUpdate,
onRemove,
onMove,
cardFields,
}: {
task: ButtonTask;
index: number;
@ -1504,55 +1581,61 @@ function TaskItemEditor({
onUpdate: (partial: Partial<ButtonTask>) => void;
onRemove: () => void;
onMove: (direction: "up" | "down") => void;
cardFields: { value: string; label: string; source: string }[];
}) {
const [expanded, setExpanded] = useState(false);
const designerCtx = usePopDesignerContext();
const summary = buildTaskSummary(task);
return (
<div className="rounded border border-border">
{/* 헤더: 타입 + 순서 + 삭제 */}
<div className="rounded-md border border-border">
<div
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 hover:bg-muted/30"
className="flex cursor-pointer items-center gap-1.5 px-2.5 py-2 hover:bg-muted/30"
onClick={() => setExpanded(!expanded)}
>
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", expanded && "rotate-90")}
/>
<GripVertical className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="text-[10px] font-medium">
{index + 1}. {TASK_TYPE_LABELS[task.type]}
</span>
{task.label && (
<span className="ml-1 text-[10px] text-muted-foreground truncate">
({task.label})
</span>
)}
<div className="ml-auto flex items-center gap-0.5">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-baseline gap-1">
<span className="shrink-0 whitespace-nowrap text-xs font-medium">
{index + 1}. {TASK_TYPE_LABELS[task.type]}
</span>
{summary && (
<span
className="truncate text-[11px] text-muted-foreground"
title={summary}
>
- {summary}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{index > 0 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
<span className="text-[10px]">^</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
<ChevronRight className="h-3 w-3 -rotate-90" />
</Button>
)}
{index < totalCount - 1 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
<span className="text-[10px]">v</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
<ChevronRight className="h-3 w-3 rotate-90" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive"
className="h-6 w-6 text-destructive"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
>
<Trash2 className="h-3 w-3" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 펼침: 타입별 설정 폼 */}
{expanded && (
<div className="space-y-2 border-t px-2 py-2">
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} />
<div className="space-y-3 border-t px-2.5 py-3">
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} cardFields={cardFields} />
</div>
)}
</div>
@ -1567,10 +1650,12 @@ function TaskDetailForm({
task,
onUpdate,
designerCtx,
cardFields,
}: {
task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void;
designerCtx: ReturnType<typeof usePopDesignerContext>;
cardFields: { value: string; label: string; source: string }[];
}) {
// 테이블/컬럼 조회 (data-update, data-delete용)
const [tables, setTables] = useState<TableInfo[]>([]);
@ -1592,7 +1677,7 @@ function TaskDetailForm({
switch (task.type) {
case "data-save":
return (
<p className="text-[10px] text-muted-foreground">
<p className="text-[11px] text-muted-foreground">
. .
</p>
);
@ -1604,13 +1689,14 @@ function TaskDetailForm({
onUpdate={onUpdate}
tables={tables}
columns={columns}
cardFields={cardFields}
/>
);
case "data-delete":
return (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="space-y-2">
<Label className="text-xs font-medium"> ?</Label>
<TableCombobox
tables={tables}
value={task.targetTable || ""}
@ -1621,27 +1707,27 @@ function TaskDetailForm({
case "cart-save":
return (
<div className="space-y-1">
<Label className="text-[10px]"> ID ( )</Label>
<div className="space-y-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={task.cartScreenId || ""}
onChange={(e) => onUpdate({ cartScreenId: e.target.value })}
placeholder="비워두면 이동 없이 저장만"
className="h-7 text-xs"
className="h-8 text-xs"
/>
</div>
);
case "modal-open":
return (
<div className="space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Select
value={task.modalMode || "fullscreen"}
onValueChange={(v) => onUpdate({ modalMode: v as ModalMode })}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -1652,36 +1738,36 @@ function TaskDetailForm({
</Select>
</div>
{task.modalMode === "screen-ref" && (
<div>
<Label className="text-[10px]"> ID</Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={task.modalScreenId || ""}
onChange={(e) => onUpdate({ modalScreenId: e.target.value })}
placeholder="화면 ID"
className="h-7 text-xs"
className="h-8 text-xs"
/>
</div>
)}
<div>
<Label className="text-[10px]"> </Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Input
value={task.modalTitle || ""}
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
placeholder="모달 제목 (선택)"
className="h-7 text-xs"
className="h-8 text-xs"
/>
</div>
{task.modalMode === "fullscreen" && designerCtx && (
<div>
{task.modalScreenId ? (
<Button variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => designerCtx.navigateToCanvas(task.modalScreenId!)}>
<Button variant="outline" size="sm" className="h-8 w-full text-xs" onClick={() => designerCtx.navigateToCanvas(task.modalScreenId!)}>
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-7 w-full text-xs"
className="h-8 w-full text-xs"
onClick={() => {
const selectedId = designerCtx.selectedComponentId;
if (!selectedId) return;
@ -1699,36 +1785,36 @@ function TaskDetailForm({
case "navigate":
return (
<div className="space-y-1">
<Label className="text-[10px]"> ID</Label>
<div className="space-y-2">
<Label className="text-xs font-medium"> ID</Label>
<Input
value={task.targetScreenId || ""}
onChange={(e) => onUpdate({ targetScreenId: e.target.value })}
placeholder="이동할 화면 ID"
className="h-7 text-xs"
placeholder="화면 ID 입력"
className="h-8 text-xs"
/>
</div>
);
case "api-call":
return (
<div className="space-y-2">
<div>
<Label className="text-[10px]"></Label>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs font-medium"></Label>
<Input
value={task.apiEndpoint || ""}
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
placeholder="/api/..."
className="h-7 text-xs"
className="h-8 text-xs"
/>
</div>
<div>
<Label className="text-[10px]">HTTP </Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium">HTTP </Label>
<Select
value={task.apiMethod || "POST"}
onValueChange={(v) => onUpdate({ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE" })}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -1743,13 +1829,13 @@ function TaskDetailForm({
case "custom-event":
return (
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<div className="space-y-2">
<Label className="text-xs font-medium"></Label>
<Input
value={task.eventName || ""}
onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected"
className="h-7 text-xs"
className="h-8 text-xs"
/>
</div>
);
@ -1757,7 +1843,7 @@ function TaskDetailForm({
case "refresh":
case "close-modal":
return (
<p className="text-[10px] text-muted-foreground"> </p>
<p className="text-[11px] text-muted-foreground"> </p>
);
default:
@ -1769,19 +1855,71 @@ function TaskDetailForm({
// 데이터 수정 작업 폼 (data-update 전용)
// ========================================
/** 연산 타입 한글 라벨 */
const OPERATION_LABELS: Record<string, { label: string; desc: string }> = {
assign: { label: "값 지정", desc: "선택한 값으로 덮어씁니다" },
add: { label: "더하기", desc: "기존 값에 더합니다" },
subtract: { label: "빼기", desc: "기존 값에서 뺍니다" },
multiply: { label: "곱하기", desc: "기존 값에 곱합니다" },
divide: { label: "나누기", desc: "기존 값을 나눕니다" },
conditional: { label: "조건 분기", desc: "입력된 값에 따라 다른 결과를 지정합니다" },
"db-conditional": { label: "조건 비교", desc: "DB 컬럼 값을 비교해서 결과를 정합니다" },
};
/** 비교 연산자 한글 라벨 */
const COMPARE_OP_LABELS: Record<string, string> = {
"=": "= (같으면)",
"!=": "!= (다르면)",
">": "> (크면)",
"<": "< (작으면)",
">=": ">= (이상이면)",
"<=": "<= (이하이면)",
};
/** data-update 설정 요약 생성 */
function buildUpdateSummaryText(task: ButtonTask): string | null {
if (!task.targetTable || !task.targetColumn) return null;
const op = task.operationType || "assign";
const opLabel = OPERATION_LABELS[op]?.label || op;
if (op === "db-conditional") {
const a = task.compareColumn || "?";
const b = task.compareWith || "?";
const opStr = task.compareOperator || ">=";
return `[${task.targetTable}].${task.targetColumn}${opLabel}로 변경\n${a} ${opStr} ${b} → "${task.dbThenValue || "?"}", 그 외 → "${task.dbElseValue || "?"}"`;
}
if (op === "conditional") {
const conds = task.conditionalValue?.conditions ?? [];
const lines = conds.map((c) => `${c.whenColumn} ${c.operator} ${c.whenValue} → "${c.thenValue}"`);
const def = task.conditionalValue?.defaultValue;
if (def) lines.push(`그 외 → "${def}"`);
return `[${task.targetTable}].${task.targetColumn}${opLabel}로 변경\n${lines.join("\n")}`;
}
const val = task.valueSource === "linked"
? `화면 데이터(${task.sourceField || "?"})`
: `"${task.fixedValue || "?"}"`;
return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`;
}
function DataUpdateTaskForm({
task,
onUpdate,
tables,
columns,
cardFields,
}: {
task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void;
tables: TableInfo[];
columns: ColumnInfo[];
cardFields: { value: string; label: string; source: string }[];
}) {
const [showAdvanced, setShowAdvanced] = useState(task.lookupMode === "manual");
const conditions = task.conditionalValue?.conditions ?? [];
const defaultValue = task.conditionalValue?.defaultValue ?? "";
const isConditional = task.operationType === "conditional" || task.operationType === "db-conditional";
const summaryText = buildUpdateSummaryText(task);
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
const next = [...conditions];
@ -1805,10 +1943,10 @@ function DataUpdateTaskForm({
};
return (
<div className="space-y-2">
{/* 대상 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="space-y-3">
{/* 1. 대상 테이블 */}
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label>
<TableCombobox
tables={tables}
value={task.targetTable || ""}
@ -1816,10 +1954,10 @@ function DataUpdateTaskForm({
/>
</div>
{/* 변경 컬럼 */}
{/* 2. 변경 컬럼 */}
{task.targetTable && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> () ?</Label>
<ColumnCombobox
columns={columns}
value={task.targetColumn || ""}
@ -1828,168 +1966,309 @@ function DataUpdateTaskForm({
</div>
)}
{/* 연산 타입 */}
{/* 3. 연산 방식 */}
{task.targetColumn && (
<>
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label>
<Select
value={task.operationType || "assign"}
onValueChange={(v) => onUpdate({ operationType: v as UpdateOperationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="assign" className="text-xs"> (=)</SelectItem>
<SelectItem value="add" className="text-xs"> (+=)</SelectItem>
<SelectItem value="subtract" className="text-xs"> (-=)</SelectItem>
<SelectItem value="multiply" className="text-xs"> (*=)</SelectItem>
<SelectItem value="divide" className="text-xs"> (/=)</SelectItem>
<SelectItem value="conditional" className="text-xs"> ()</SelectItem>
<SelectItem value="db-conditional" className="text-xs"> (DB )</SelectItem>
{Object.entries(OPERATION_LABELS).map(([key, { label }]) => (
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{OPERATION_LABELS[task.operationType || "assign"]?.desc}
</p>
</div>
{/* 값 출처 (conditional/db-conditional이 아닐 때) */}
{task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={task.valueSource || "fixed"}
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed" className="text-xs"></SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 고정값 입력 */}
{task.valueSource === "fixed" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<Input
value={task.fixedValue || ""}
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
className="h-7 text-xs"
placeholder="변경할 값"
/>
)}
{/* 연결 데이터 필드명 */}
{task.valueSource === "linked" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
<Input
value={task.sourceField || ""}
onChange={(e) => onUpdate({ sourceField: e.target.value })}
className="h-7 text-xs"
placeholder="연결 필드명 (예: qty)"
/>
)}
{/* DB 컬럼 비교 조건부 설정 */}
{task.operationType === "db-conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
<p className="text-[10px] text-muted-foreground">DB에서 A와 B를 </p>
<div className="flex items-center gap-1">
<ColumnCombobox columns={columns} value={task.compareColumn ?? ""} onSelect={(v) => onUpdate({ compareColumn: v })} placeholder="비교 컬럼 A" />
<Select value={task.compareOperator ?? ">="} onValueChange={(v) => onUpdate({ compareOperator: v as ButtonTask["compareOperator"] })}>
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
{/* 4. 단순 연산: 값 출처 + 값 입력 */}
{!isConditional && (
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label>
<Select
value={task.valueSource || "fixed"}
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
))}
<SelectItem value="fixed" className="text-xs"> </SelectItem>
<SelectItem value="linked" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
<ColumnCombobox columns={columns} value={task.compareWith ?? ""} onSelect={(v) => onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" />
</div>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={task.dbThenValue ?? ""} onChange={(e) => onUpdate({ dbThenValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 입고완료" />
</div>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={task.dbElseValue ?? ""} onChange={(e) => onUpdate({ dbElseValue: e.target.value })} className="h-7 flex-1 text-[10px]" placeholder="예: 부분입고" />
</div>
</div>
)}
{/* 조건부 값 설정 */}
{task.operationType === "conditional" && (
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
{conditions.map((cond, cIdx) => (
<div key={cIdx} className="space-y-1">
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<ColumnCombobox columns={columns} value={cond.whenColumn} onSelect={(v) => updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" />
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}>
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
{task.valueSource === "fixed" && (
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Input
value={task.fixedValue || ""}
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
className="h-8 text-xs"
placeholder="변경할 값 입력"
/>
</div>
)}
{task.valueSource === "linked" && (
<div className="space-y-1.5">
<Label className="text-xs font-medium"> ?</Label>
{cardFields.length > 0 ? (
<Select
value={task.sourceField || ""}
onValueChange={(v) => onUpdate({ sourceField: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
{cardFields.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-xs">
<div className="flex items-center gap-2">
<span>{f.label}</span>
<span className="text-[10px] text-muted-foreground">{f.source}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Input value={cond.whenValue} onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" />
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
<X className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 pl-4">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
<Input value={cond.thenValue} onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" />
</div>
) : (
<Input
value={task.sourceField || ""}
onChange={(e) => onUpdate({ sourceField: e.target.value })}
className="h-8 text-xs"
placeholder="필드명 직접 입력 (예: qty)"
/>
)}
<p className="text-[11px] text-muted-foreground">
{cardFields.length > 0
? "카드에 표시되는 데이터 중 하나를 선택합니다"
: "카드 컴포넌트가 없으면 직접 입력해주세요"}
</p>
</div>
))}
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
<Plus className="mr-1 h-3 w-3" />
</Button>
<div className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span>
)}
</div>
)}
{/* 5. 조건 비교 (db-conditional) - 세로 스택 */}
{task.operationType === "db-conditional" && (
<div className="space-y-3 rounded-md border border-dashed border-muted-foreground/30 p-3">
<p className="text-[11px] text-muted-foreground">
DB
</p>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<ColumnCombobox
columns={columns}
value={task.compareColumn ?? ""}
onSelect={(v) => onUpdate({ compareColumn: v })}
placeholder="비교할 컬럼 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Select
value={task.compareOperator ?? ">="}
onValueChange={(v) => onUpdate({ compareOperator: v as ButtonTask["compareOperator"] })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(COMPARE_OP_LABELS).map(([op, label]) => (
<SelectItem key={op} value={op} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<ColumnCombobox
columns={columns}
value={task.compareWith ?? ""}
onSelect={(v) => onUpdate({ compareWith: v })}
placeholder="비교 대상 컬럼 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Input
value={defaultValue}
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })}
className="h-7 text-[10px]"
placeholder="기본값"
value={task.dbThenValue ?? ""}
onChange={(e) => onUpdate({ dbThenValue: e.target.value })}
className="h-8 text-xs"
placeholder="예: 입고완료"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Input
value={task.dbElseValue ?? ""}
onChange={(e) => onUpdate({ dbElseValue: e.target.value })}
className="h-8 text-xs"
placeholder="예: 부분입고"
/>
</div>
</div>
)}
{/* 조회 키 */}
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-[10px]"> </Label>
<Select
value={task.lookupMode ?? "auto"}
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
>
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-[10px]"></SelectItem>
<SelectItem value="manual" className="text-[10px]"></SelectItem>
</SelectContent>
</Select>
{/* 6. 조건 분기 (conditional) */}
{task.operationType === "conditional" && (
<div className="space-y-3 rounded-md border border-dashed border-muted-foreground/30 p-3">
<p className="text-[11px] text-muted-foreground">
</p>
{conditions.map((cond, cIdx) => (
<div key={cIdx} className="space-y-2 rounded border border-border bg-muted/20 p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium"> {cIdx + 1}</span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeCondition(cIdx)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<ColumnCombobox
columns={columns}
value={cond.whenColumn}
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
placeholder="컬럼 선택"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}>
<SelectTrigger className="h-8 w-24 shrink-0 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(COMPARE_OP_LABELS).map(([op, label]) => (
<SelectItem key={op} value={op} className="text-xs">{label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={cond.whenValue}
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
className="h-8 flex-1 text-xs"
placeholder="비교할 값"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input
value={cond.thenValue}
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
className="h-8 text-xs"
placeholder="변경할 값"
/>
</div>
</div>
))}
<Button variant="outline" size="sm" className="h-8 w-full text-xs" onClick={addCondition}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
</Button>
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Input
value={defaultValue}
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })}
className="h-8 text-xs"
placeholder="기본값 입력"
/>
</div>
</div>
{task.lookupMode === "manual" && (
<div className="flex items-center gap-1">
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="카드 항목 필드" /></SelectTrigger>
<SelectContent>
{KNOWN_ITEM_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="shrink-0 text-[10px] text-muted-foreground">-&gt;</span>
<ColumnCombobox columns={columns} value={task.manualPkColumn ?? ""} onSelect={(v) => onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" />
)}
{/* 7. 고급 설정 (조회 키) */}
<div className="pt-1">
<button
type="button"
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showAdvanced && "rotate-90")} />
<span> ( )</span>
</button>
{showAdvanced && (
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/10 p-3">
<div className="space-y-1.5">
<Label className="text-xs font-medium"> </Label>
<Select
value={task.lookupMode ?? "auto"}
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs"> ()</SelectItem>
<SelectItem value="manual" className="text-xs"> </SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{task.lookupMode === "manual"
? "카드 항목의 필드를 직접 지정하여 대상 행을 찾습니다"
: "카드 항목과 테이블 PK를 자동으로 매칭합니다"}
</p>
</div>
{task.lookupMode === "manual" && (
<div className="space-y-2">
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="필드 선택" /></SelectTrigger>
<SelectContent>
{KNOWN_ITEM_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-xs">
<div className="flex flex-col">
<span>{f.label}</span>
<span className="text-[10px] text-muted-foreground">{f.desc}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> PK </Label>
<ColumnCombobox
columns={columns}
value={task.manualPkColumn ?? ""}
onSelect={(v) => onUpdate({ manualPkColumn: v })}
placeholder="PK 컬럼 선택"
/>
</div>
</div>
)}
</div>
)}
</div>
{/* 8. 설정 요약 */}
{summaryText && (
<div className="rounded-md bg-muted/50 p-3">
<p className="mb-1 text-xs font-medium text-muted-foreground"> </p>
<p className="whitespace-pre-line text-xs leading-relaxed">{summaryText}</p>
</div>
)}
</>
)}
</div>
@ -2326,10 +2605,10 @@ function PopButtonPreviewComponent({
// ========================================
const KNOWN_ITEM_FIELDS = [
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
{ value: "id", label: "id" },
{ value: "row_key", label: "row_key" },
{ value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" },
{ value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" },
{ value: "id", label: "id", desc: "데이터의 id 컬럼" },
{ value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" },
];
function StatusChangeRuleEditor({

View File

@ -34,6 +34,7 @@ export interface ColumnInfo {
type: string;
udtName: string;
isPrimaryKey?: boolean;
comment?: string;
}
// ===== SQL 값 이스케이프 =====
@ -330,6 +331,7 @@ export async function fetchTableColumns(
type: col.dataType || col.data_type || col.type || "unknown",
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
comment: col.columnComment || col.description || "",
}));
}
}

View File

@ -38,9 +38,23 @@ export function ColumnCombobox({
const filtered = useMemo(() => {
if (!search) return columns;
const q = search.toLowerCase();
return columns.filter((c) => c.name.toLowerCase().includes(q));
return columns.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.comment && c.comment.toLowerCase().includes(q))
);
}, [columns, search]);
const selectedCol = useMemo(
() => columns.find((c) => c.name === value),
[columns, value],
);
const displayValue = selectedCol
? selectedCol.comment
? `${selectedCol.name} (${selectedCol.comment})`
: selectedCol.name
: "";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@ -50,7 +64,7 @@ export function ColumnCombobox({
aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs"
>
{value || placeholder}
<span className="truncate">{displayValue || placeholder}</span>
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
@ -61,7 +75,7 @@ export function ColumnCombobox({
>
<Command shouldFilter={false}>
<CommandInput
placeholder="컬럼명 검색..."
placeholder="컬럼명 또는 한글명 검색..."
className="text-xs"
value={search}
onValueChange={setSearch}
@ -88,8 +102,15 @@ export function ColumnCombobox({
value === col.name ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<span>{col.name}</span>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span>{col.name}</span>
{col.comment && (
<span className="text-[11px] text-muted-foreground">
({col.comment})
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{col.type}
</span>