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:
parent
297b14d706
commit
516517eb34
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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">참 -></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">거짓 -></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">이면 -></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">그 외 -></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">-></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({
|
||||||
|
|
|
||||||
|
|
@ -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 || "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue