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

View File

@ -1267,11 +1267,51 @@ interface PopButtonConfigPanelProps {
componentId?: string; 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({ export function PopButtonConfigPanel({
config, config,
onUpdate, onUpdate,
allComponents,
}: PopButtonConfigPanelProps) { }: PopButtonConfigPanelProps) {
const v2 = useMemo(() => migrateButtonConfig(config), [config]); const v2 = useMemo(() => migrateButtonConfig(config), [config]);
const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]);
const updateV2 = useCallback( const updateV2 = useCallback(
(partial: Partial<PopButtonConfigV2>) => { (partial: Partial<PopButtonConfigV2>) => {
@ -1449,9 +1489,9 @@ export function PopButtonConfigPanel({
{/* 작업 목록 */} {/* 작업 목록 */}
<SectionDivider label="작업 목록" /> <SectionDivider label="작업 목록" />
<div className="space-y-1.5"> <div className="space-y-2.5">
{v2.tasks.length === 0 && ( {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> </p>
)} )}
@ -1465,6 +1505,7 @@ export function PopButtonConfigPanel({
onUpdate={(partial) => updateTask(task.id, partial)} onUpdate={(partial) => updateTask(task.id, partial)}
onRemove={() => removeTask(task.id)} onRemove={() => removeTask(task.id)}
onMove={(dir) => moveTask(task.id, dir)} 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({ function TaskItemEditor({
task, task,
index, index,
@ -1497,6 +1573,7 @@ function TaskItemEditor({
onUpdate, onUpdate,
onRemove, onRemove,
onMove, onMove,
cardFields,
}: { }: {
task: ButtonTask; task: ButtonTask;
index: number; index: number;
@ -1504,55 +1581,61 @@ function TaskItemEditor({
onUpdate: (partial: Partial<ButtonTask>) => void; onUpdate: (partial: Partial<ButtonTask>) => void;
onRemove: () => void; onRemove: () => void;
onMove: (direction: "up" | "down") => void; onMove: (direction: "up" | "down") => void;
cardFields: { value: string; label: string; source: string }[];
}) { }) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const designerCtx = usePopDesignerContext(); const designerCtx = usePopDesignerContext();
const summary = buildTaskSummary(task);
return ( return (
<div className="rounded border border-border"> <div className="rounded-md border border-border">
{/* 헤더: 타입 + 순서 + 삭제 */}
<div <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)} onClick={() => setExpanded(!expanded)}
> >
<ChevronRight <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" /> <div className="min-w-0 flex-1 overflow-hidden">
<span className="text-[10px] font-medium"> <div className="flex items-baseline gap-1">
{index + 1}. {TASK_TYPE_LABELS[task.type]} <span className="shrink-0 whitespace-nowrap text-xs font-medium">
</span> {index + 1}. {TASK_TYPE_LABELS[task.type]}
{task.label && ( </span>
<span className="ml-1 text-[10px] text-muted-foreground truncate"> {summary && (
({task.label}) <span
</span> className="truncate text-[11px] text-muted-foreground"
)} title={summary}
<div className="ml-auto flex items-center gap-0.5"> >
- {summary}
</span>
)}
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{index > 0 && ( {index > 0 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("up"); }}> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
<span className="text-[10px]">^</span> <ChevronRight className="h-3 w-3 -rotate-90" />
</Button> </Button>
)} )}
{index < totalCount - 1 && ( {index < totalCount - 1 && (
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("down"); }}> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
<span className="text-[10px]">v</span> <ChevronRight className="h-3 w-3 rotate-90" />
</Button> </Button>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-5 w-5 text-destructive" className="h-6 w-6 text-destructive"
onClick={(e) => { e.stopPropagation(); onRemove(); }} onClick={(e) => { e.stopPropagation(); onRemove(); }}
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</div> </div>
{/* 펼침: 타입별 설정 폼 */}
{expanded && ( {expanded && (
<div className="space-y-2 border-t px-2 py-2"> <div className="space-y-3 border-t px-2.5 py-3">
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} /> <TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} cardFields={cardFields} />
</div> </div>
)} )}
</div> </div>
@ -1567,10 +1650,12 @@ function TaskDetailForm({
task, task,
onUpdate, onUpdate,
designerCtx, designerCtx,
cardFields,
}: { }: {
task: ButtonTask; task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void; onUpdate: (partial: Partial<ButtonTask>) => void;
designerCtx: ReturnType<typeof usePopDesignerContext>; designerCtx: ReturnType<typeof usePopDesignerContext>;
cardFields: { value: string; label: string; source: string }[];
}) { }) {
// 테이블/컬럼 조회 (data-update, data-delete용) // 테이블/컬럼 조회 (data-update, data-delete용)
const [tables, setTables] = useState<TableInfo[]>([]); const [tables, setTables] = useState<TableInfo[]>([]);
@ -1592,7 +1677,7 @@ function TaskDetailForm({
switch (task.type) { switch (task.type) {
case "data-save": case "data-save":
return ( return (
<p className="text-[10px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
. . . .
</p> </p>
); );
@ -1604,13 +1689,14 @@ function TaskDetailForm({
onUpdate={onUpdate} onUpdate={onUpdate}
tables={tables} tables={tables}
columns={columns} columns={columns}
cardFields={cardFields}
/> />
); );
case "data-delete": case "data-delete":
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-[10px]"> </Label> <Label className="text-xs font-medium"> ?</Label>
<TableCombobox <TableCombobox
tables={tables} tables={tables}
value={task.targetTable || ""} value={task.targetTable || ""}
@ -1621,27 +1707,27 @@ function TaskDetailForm({
case "cart-save": case "cart-save":
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-[10px]"> ID ( )</Label> <Label className="text-xs font-medium"> ID</Label>
<Input <Input
value={task.cartScreenId || ""} value={task.cartScreenId || ""}
onChange={(e) => onUpdate({ cartScreenId: e.target.value })} onChange={(e) => onUpdate({ cartScreenId: e.target.value })}
placeholder="비워두면 이동 없이 저장만" placeholder="비워두면 이동 없이 저장만"
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
); );
case "modal-open": case "modal-open":
return ( return (
<div className="space-y-2"> <div className="space-y-3">
<div> <div className="space-y-1.5">
<Label className="text-[10px]"> </Label> <Label className="text-xs font-medium"> </Label>
<Select <Select
value={task.modalMode || "fullscreen"} value={task.modalMode || "fullscreen"}
onValueChange={(v) => onUpdate({ modalMode: v as ModalMode })} onValueChange={(v) => onUpdate({ modalMode: v as ModalMode })}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1652,36 +1738,36 @@ function TaskDetailForm({
</Select> </Select>
</div> </div>
{task.modalMode === "screen-ref" && ( {task.modalMode === "screen-ref" && (
<div> <div className="space-y-1.5">
<Label className="text-[10px]"> ID</Label> <Label className="text-xs font-medium"> ID</Label>
<Input <Input
value={task.modalScreenId || ""} value={task.modalScreenId || ""}
onChange={(e) => onUpdate({ modalScreenId: e.target.value })} onChange={(e) => onUpdate({ modalScreenId: e.target.value })}
placeholder="화면 ID" placeholder="화면 ID"
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
)} )}
<div> <div className="space-y-1.5">
<Label className="text-[10px]"> </Label> <Label className="text-xs font-medium"> </Label>
<Input <Input
value={task.modalTitle || ""} value={task.modalTitle || ""}
onChange={(e) => onUpdate({ modalTitle: e.target.value })} onChange={(e) => onUpdate({ modalTitle: e.target.value })}
placeholder="모달 제목 (선택)" placeholder="모달 제목 (선택)"
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
{task.modalMode === "fullscreen" && designerCtx && ( {task.modalMode === "fullscreen" && designerCtx && (
<div> <div>
{task.modalScreenId ? ( {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>
) : ( ) : (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 w-full text-xs" className="h-8 w-full text-xs"
onClick={() => { onClick={() => {
const selectedId = designerCtx.selectedComponentId; const selectedId = designerCtx.selectedComponentId;
if (!selectedId) return; if (!selectedId) return;
@ -1699,36 +1785,36 @@ function TaskDetailForm({
case "navigate": case "navigate":
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-[10px]"> ID</Label> <Label className="text-xs font-medium"> ID</Label>
<Input <Input
value={task.targetScreenId || ""} value={task.targetScreenId || ""}
onChange={(e) => onUpdate({ targetScreenId: e.target.value })} onChange={(e) => onUpdate({ targetScreenId: e.target.value })}
placeholder="이동할 화면 ID" placeholder="화면 ID 입력"
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
); );
case "api-call": case "api-call":
return ( return (
<div className="space-y-2"> <div className="space-y-3">
<div> <div className="space-y-1.5">
<Label className="text-[10px]"></Label> <Label className="text-xs font-medium"></Label>
<Input <Input
value={task.apiEndpoint || ""} value={task.apiEndpoint || ""}
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })} onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
placeholder="/api/..." placeholder="/api/..."
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<Label className="text-[10px]">HTTP </Label> <Label className="text-xs font-medium">HTTP </Label>
<Select <Select
value={task.apiMethod || "POST"} value={task.apiMethod || "POST"}
onValueChange={(v) => onUpdate({ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE" })} onValueChange={(v) => onUpdate({ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE" })}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1743,13 +1829,13 @@ function TaskDetailForm({
case "custom-event": case "custom-event":
return ( return (
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-[10px]"></Label> <Label className="text-xs font-medium"></Label>
<Input <Input
value={task.eventName || ""} value={task.eventName || ""}
onChange={(e) => onUpdate({ eventName: e.target.value })} onChange={(e) => onUpdate({ eventName: e.target.value })}
placeholder="예: data-saved, item-selected" placeholder="예: data-saved, item-selected"
className="h-7 text-xs" className="h-8 text-xs"
/> />
</div> </div>
); );
@ -1757,7 +1843,7 @@ function TaskDetailForm({
case "refresh": case "refresh":
case "close-modal": case "close-modal":
return ( return (
<p className="text-[10px] text-muted-foreground"> </p> <p className="text-[11px] text-muted-foreground"> </p>
); );
default: default:
@ -1769,19 +1855,71 @@ function TaskDetailForm({
// 데이터 수정 작업 폼 (data-update 전용) // 데이터 수정 작업 폼 (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({ function DataUpdateTaskForm({
task, task,
onUpdate, onUpdate,
tables, tables,
columns, columns,
cardFields,
}: { }: {
task: ButtonTask; task: ButtonTask;
onUpdate: (partial: Partial<ButtonTask>) => void; onUpdate: (partial: Partial<ButtonTask>) => void;
tables: TableInfo[]; tables: TableInfo[];
columns: ColumnInfo[]; columns: ColumnInfo[];
cardFields: { value: string; label: string; source: string }[];
}) { }) {
const [showAdvanced, setShowAdvanced] = useState(task.lookupMode === "manual");
const conditions = task.conditionalValue?.conditions ?? []; const conditions = task.conditionalValue?.conditions ?? [];
const defaultValue = task.conditionalValue?.defaultValue ?? ""; 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 updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
const next = [...conditions]; const next = [...conditions];
@ -1805,10 +1943,10 @@ function DataUpdateTaskForm({
}; };
return ( return (
<div className="space-y-2"> <div className="space-y-3">
{/* 대상 테이블 */} {/* 1. 대상 테이블 */}
<div className="space-y-1"> <div className="space-y-1.5">
<Label className="text-[10px]"> </Label> <Label className="text-xs font-medium"> ?</Label>
<TableCombobox <TableCombobox
tables={tables} tables={tables}
value={task.targetTable || ""} value={task.targetTable || ""}
@ -1816,10 +1954,10 @@ function DataUpdateTaskForm({
/> />
</div> </div>
{/* 변경 컬럼 */} {/* 2. 변경 컬럼 */}
{task.targetTable && ( {task.targetTable && (
<div className="space-y-1"> <div className="space-y-1.5">
<Label className="text-[10px]"> </Label> <Label className="text-xs font-medium"> () ?</Label>
<ColumnCombobox <ColumnCombobox
columns={columns} columns={columns}
value={task.targetColumn || ""} value={task.targetColumn || ""}
@ -1828,168 +1966,309 @@ function DataUpdateTaskForm({
</div> </div>
)} )}
{/* 연산 타입 */} {/* 3. 연산 방식 */}
{task.targetColumn && ( {task.targetColumn && (
<> <>
<div className="space-y-1"> <div className="space-y-1.5">
<Label className="text-[10px]"></Label> <Label className="text-xs font-medium"> ?</Label>
<Select <Select
value={task.operationType || "assign"} value={task.operationType || "assign"}
onValueChange={(v) => onUpdate({ operationType: v as UpdateOperationType })} onValueChange={(v) => onUpdate({ operationType: v as UpdateOperationType })}
> >
<SelectTrigger className="h-7 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="assign" className="text-xs"> (=)</SelectItem> {Object.entries(OPERATION_LABELS).map(([key, { label }]) => (
<SelectItem value="add" className="text-xs"> (+=)</SelectItem> <SelectItem key={key} value={key} className="text-xs">{label}</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>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[11px] text-muted-foreground">
{OPERATION_LABELS[task.operationType || "assign"]?.desc}
</p>
</div> </div>
{/* 값 출처 (conditional/db-conditional이 아닐 때) */} {/* 4. 단순 연산: 값 출처 + 값 입력 */}
{task.operationType !== "conditional" && task.operationType !== "db-conditional" && ( {!isConditional && (
<div className="space-y-1"> <div className="space-y-2">
<Label className="text-[10px]"> </Label> <div className="space-y-1.5">
<Select <Label className="text-xs font-medium"> ?</Label>
value={task.valueSource || "fixed"} <Select
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })} value={task.valueSource || "fixed"}
> onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
<SelectTrigger className="h-7 text-xs"> >
<SelectValue /> <SelectTrigger className="h-8 text-xs">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<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>
<SelectContent> <SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => ( <SelectItem value="fixed" className="text-xs"> </SelectItem>
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem> <SelectItem value="linked" className="text-xs"> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<ColumnCombobox columns={columns} value={task.compareWith ?? ""} onSelect={(v) => onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" />
</div> </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.valueSource === "fixed" && (
{task.operationType === "conditional" && ( <div className="space-y-1.5">
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2"> <Label className="text-xs font-medium"> </Label>
{conditions.map((cond, cIdx) => ( <Input
<div key={cIdx} className="space-y-1"> value={task.fixedValue || ""}
<div className="flex items-center gap-1"> onChange={(e) => onUpdate({ fixedValue: e.target.value })}
<span className="shrink-0 text-[10px] text-muted-foreground"></span> className="h-8 text-xs"
<ColumnCombobox columns={columns} value={cond.whenColumn} onSelect={(v) => updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" /> placeholder="변경할 값 입력"
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}> />
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger> </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> <SelectContent>
{["=", "!=", ">", "<", ">=", "<="].map((op) => ( {cardFields.map((f) => (
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem> <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> </SelectContent>
</Select> </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)}> <Input
<X className="h-3 w-3" /> value={task.sourceField || ""}
</Button> onChange={(e) => onUpdate({ sourceField: e.target.value })}
</div> className="h-8 text-xs"
<div className="flex items-center gap-1 pl-4"> placeholder="필드명 직접 입력 (예: qty)"
<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> <p className="text-[11px] text-muted-foreground">
{cardFields.length > 0
? "카드에 표시되는 데이터 중 하나를 선택합니다"
: "카드 컴포넌트가 없으면 직접 입력해주세요"}
</p>
</div> </div>
))} )}
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}> </div>
<Plus className="mr-1 h-3 w-3" /> )}
</Button>
<div className="flex items-center gap-1"> {/* 5. 조건 비교 (db-conditional) - 세로 스택 */}
<span className="shrink-0 text-[10px] text-muted-foreground"> -&gt;</span> {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 <Input
value={defaultValue} value={task.dbThenValue ?? ""}
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })} onChange={(e) => onUpdate({ dbThenValue: e.target.value })}
className="h-7 text-[10px]" className="h-8 text-xs"
placeholder="기본값" 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> </div>
)} )}
{/* 조회 키 */} {/* 6. 조건 분기 (conditional) */}
<div className="space-y-1"> {task.operationType === "conditional" && (
<div className="flex items-center gap-2"> <div className="space-y-3 rounded-md border border-dashed border-muted-foreground/30 p-3">
<Label className="text-[10px]"> </Label> <p className="text-[11px] text-muted-foreground">
<Select
value={task.lookupMode ?? "auto"} </p>
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
> {conditions.map((cond, cIdx) => (
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger> <div key={cIdx} className="space-y-2 rounded border border-border bg-muted/20 p-2.5">
<SelectContent> <div className="flex items-center justify-between">
<SelectItem value="auto" className="text-[10px]"></SelectItem> <span className="text-xs font-medium"> {cIdx + 1}</span>
<SelectItem value="manual" className="text-[10px]"></SelectItem> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeCondition(cIdx)}>
</SelectContent> <X className="h-3.5 w-3.5" />
</Select> </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> </div>
{task.lookupMode === "manual" && ( )}
<div className="flex items-center gap-1">
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}> {/* 7. 고급 설정 (조회 키) */}
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="카드 항목 필드" /></SelectTrigger> <div className="pt-1">
<SelectContent> <button
{KNOWN_ITEM_FIELDS.map((f) => ( type="button"
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem> className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
))} onClick={() => setShowAdvanced(!showAdvanced)}
</SelectContent> >
</Select> <ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showAdvanced && "rotate-90")} />
<span className="shrink-0 text-[10px] text-muted-foreground">-&gt;</span> <span> ( )</span>
<ColumnCombobox columns={columns} value={task.manualPkColumn ?? ""} onSelect={(v) => onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" /> </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>
)} )}
</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> </div>
@ -2326,10 +2605,10 @@ function PopButtonPreviewComponent({
// ======================================== // ========================================
const KNOWN_ITEM_FIELDS = [ const KNOWN_ITEM_FIELDS = [
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" }, { value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" },
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" }, { value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" },
{ value: "id", label: "id" }, { value: "id", label: "id", desc: "데이터의 id 컬럼" },
{ value: "row_key", label: "row_key" }, { value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" },
]; ];
function StatusChangeRuleEditor({ function StatusChangeRuleEditor({

View File

@ -34,6 +34,7 @@ export interface ColumnInfo {
type: string; type: string;
udtName: string; udtName: string;
isPrimaryKey?: boolean; isPrimaryKey?: boolean;
comment?: string;
} }
// ===== SQL 값 이스케이프 ===== // ===== SQL 값 이스케이프 =====
@ -330,6 +331,7 @@ export async function fetchTableColumns(
type: col.dataType || col.data_type || col.type || "unknown", type: col.dataType || col.data_type || col.type || "unknown",
udtName: col.dbType || col.udt_name || col.udtName || "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", 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(() => { const filtered = useMemo(() => {
if (!search) return columns; if (!search) return columns;
const q = search.toLowerCase(); 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]); }, [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 ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -50,7 +64,7 @@ export function ColumnCombobox({
aria-expanded={open} aria-expanded={open}
className="mt-1 h-8 w-full justify-between text-xs" 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" /> <ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -61,7 +75,7 @@ export function ColumnCombobox({
> >
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="컬럼명 검색..." placeholder="컬럼명 또는 한글명 검색..."
className="text-xs" className="text-xs"
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
@ -88,8 +102,15 @@ export function ColumnCombobox({
value === col.name ? "opacity-100" : "opacity-0" value === col.name ? "opacity-100" : "opacity-0"
)} )}
/> />
<div className="flex items-center gap-2"> <div className="flex flex-col">
<span>{col.name}</span> <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"> <span className="text-[10px] text-muted-foreground">
{col.type} {col.type}
</span> </span>