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>(
|
||||
`SELECT
|
||||
column_name as "columnName",
|
||||
column_name as "displayName",
|
||||
data_type as "dataType",
|
||||
udt_name as "dbType",
|
||||
is_nullable as "isNullable",
|
||||
column_default as "defaultValue",
|
||||
character_maximum_length as "maxLength",
|
||||
numeric_precision as "numericPrecision",
|
||||
numeric_scale as "numericScale",
|
||||
c.column_name as "columnName",
|
||||
c.column_name as "displayName",
|
||||
c.data_type as "dataType",
|
||||
c.udt_name as "dbType",
|
||||
c.is_nullable as "isNullable",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
c.numeric_precision as "numericPrecision",
|
||||
c.numeric_scale as "numericScale",
|
||||
CASE
|
||||
WHEN column_name IN (
|
||||
SELECT column_name FROM information_schema.key_column_usage
|
||||
WHERE table_name = $1 AND constraint_name LIKE '%_pkey'
|
||||
WHEN c.column_name IN (
|
||||
SELECT kcu.column_name FROM information_schema.key_column_usage kcu
|
||||
WHERE kcu.table_name = $1 AND kcu.constraint_name LIKE '%_pkey'
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as "isPrimaryKey"
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
END as "isPrimaryKey",
|
||||
col_description(
|
||||
(SELECT oid FROM pg_class WHERE relname = $1 AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')),
|
||||
c.ordinal_position
|
||||
) as "columnComment"
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_name = $1
|
||||
AND c.table_schema = 'public'
|
||||
ORDER BY c.ordinal_position`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
|
|
@ -4475,10 +4479,10 @@ export class TableManagementService {
|
|||
displayName: col.displayName,
|
||||
dataType: col.dataType,
|
||||
dbType: col.dbType,
|
||||
webType: "text", // 기본값
|
||||
webType: "text",
|
||||
inputType: "direct",
|
||||
detailSettings: "{}",
|
||||
description: "", // 필수 필드 추가
|
||||
description: col.columnComment || "",
|
||||
isNullable: col.isNullable,
|
||||
isPrimaryKey: col.isPrimaryKey,
|
||||
defaultValue: col.defaultValue,
|
||||
|
|
@ -4489,6 +4493,7 @@ export class TableManagementService {
|
|||
numericScale: col.numericScale ? Number(col.numericScale) : undefined,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
columnComment: col.columnComment || "",
|
||||
}));
|
||||
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -1267,11 +1267,51 @@ interface PopButtonConfigPanelProps {
|
|||
componentId?: string;
|
||||
}
|
||||
|
||||
/** 화면 내 카드 컴포넌트에서 사용 가능한 필드 목록 추출 */
|
||||
function extractCardFields(
|
||||
allComponents?: PopButtonConfigPanelProps["allComponents"],
|
||||
): { value: string; label: string; source: string }[] {
|
||||
if (!allComponents) return [];
|
||||
const fields: { value: string; label: string; source: string }[] = [];
|
||||
|
||||
for (const comp of allComponents) {
|
||||
if (comp.type !== "pop-card-list" || !comp.config) continue;
|
||||
const tpl = (comp.config as Record<string, unknown>).cardTemplate as
|
||||
| { header?: Record<string, unknown>; body?: { fields?: { id?: string; label?: string; valueType?: string; columnName?: string }[] } }
|
||||
| undefined;
|
||||
if (!tpl) continue;
|
||||
|
||||
if (tpl.header?.codeField) {
|
||||
fields.push({ value: String(tpl.header.codeField), label: String(tpl.header.codeField), source: "헤더 코드" });
|
||||
}
|
||||
if (tpl.header?.titleField) {
|
||||
fields.push({ value: String(tpl.header.titleField), label: String(tpl.header.titleField), source: "헤더 제목" });
|
||||
}
|
||||
for (const f of tpl.body?.fields ?? []) {
|
||||
if (f.valueType === "column" && f.columnName) {
|
||||
fields.push({ value: f.columnName, label: f.label || f.columnName, source: "본문" });
|
||||
} else if (f.valueType === "formula" && f.label) {
|
||||
const formulaKey = `__formula_${f.id || f.label}`;
|
||||
fields.push({ value: formulaKey, label: f.label, source: "수식" });
|
||||
}
|
||||
}
|
||||
|
||||
// 시스템 필드 추가
|
||||
fields.push({ value: "__cart_quantity", label: "수량 (장바구니)", source: "시스템" });
|
||||
fields.push({ value: "__cart_row_key", label: "원본 키", source: "시스템" });
|
||||
fields.push({ value: "__cart_id", label: "카드 항목 ID", source: "시스템" });
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function PopButtonConfigPanel({
|
||||
config,
|
||||
onUpdate,
|
||||
allComponents,
|
||||
}: PopButtonConfigPanelProps) {
|
||||
const v2 = useMemo(() => migrateButtonConfig(config), [config]);
|
||||
const cardFields = useMemo(() => extractCardFields(allComponents), [allComponents]);
|
||||
|
||||
const updateV2 = useCallback(
|
||||
(partial: Partial<PopButtonConfigV2>) => {
|
||||
|
|
@ -1449,9 +1489,9 @@ export function PopButtonConfigPanel({
|
|||
|
||||
{/* 작업 목록 */}
|
||||
<SectionDivider label="작업 목록" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-2.5">
|
||||
{v2.tasks.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground py-2 text-center">
|
||||
<p className="text-[11px] text-muted-foreground py-2 text-center">
|
||||
작업이 없습니다. 빠른 시작 또는 아래 버튼으로 추가하세요.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -1465,6 +1505,7 @@ export function PopButtonConfigPanel({
|
|||
onUpdate={(partial) => updateTask(task.id, partial)}
|
||||
onRemove={() => removeTask(task.id)}
|
||||
onMove={(dir) => moveTask(task.id, dir)}
|
||||
cardFields={cardFields}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -1490,6 +1531,41 @@ export function PopButtonConfigPanel({
|
|||
// 작업 항목 에디터 (접힘/펼침)
|
||||
// ========================================
|
||||
|
||||
/** 작업 항목의 요약 텍스트 생성 */
|
||||
function buildTaskSummary(task: ButtonTask): string {
|
||||
switch (task.type) {
|
||||
case "data-update": {
|
||||
if (!task.targetTable) return "";
|
||||
const col = task.targetColumn ? `.${task.targetColumn}` : "";
|
||||
const opLabels: Record<string, string> = {
|
||||
assign: "값 지정",
|
||||
add: "더하기",
|
||||
subtract: "빼기",
|
||||
multiply: "곱하기",
|
||||
divide: "나누기",
|
||||
conditional: "조건 분기",
|
||||
"db-conditional": "조건 비교",
|
||||
};
|
||||
const op = opLabels[task.operationType || "assign"] || "";
|
||||
return `${task.targetTable}${col} ${op}`;
|
||||
}
|
||||
case "data-delete":
|
||||
return task.targetTable || "";
|
||||
case "navigate":
|
||||
return task.targetScreenId ? `화면 ${task.targetScreenId}` : "";
|
||||
case "modal-open":
|
||||
return task.modalTitle || task.modalScreenId || "";
|
||||
case "cart-save":
|
||||
return task.cartScreenId ? `화면 ${task.cartScreenId}` : "";
|
||||
case "api-call":
|
||||
return task.apiEndpoint || "";
|
||||
case "custom-event":
|
||||
return task.eventName || "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function TaskItemEditor({
|
||||
task,
|
||||
index,
|
||||
|
|
@ -1497,6 +1573,7 @@ function TaskItemEditor({
|
|||
onUpdate,
|
||||
onRemove,
|
||||
onMove,
|
||||
cardFields,
|
||||
}: {
|
||||
task: ButtonTask;
|
||||
index: number;
|
||||
|
|
@ -1504,55 +1581,61 @@ function TaskItemEditor({
|
|||
onUpdate: (partial: Partial<ButtonTask>) => void;
|
||||
onRemove: () => void;
|
||||
onMove: (direction: "up" | "down") => void;
|
||||
cardFields: { value: string; label: string; source: string }[];
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const designerCtx = usePopDesignerContext();
|
||||
const summary = buildTaskSummary(task);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border">
|
||||
{/* 헤더: 타입 + 순서 + 삭제 */}
|
||||
<div className="rounded-md border border-border">
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 hover:bg-muted/30"
|
||||
className="flex cursor-pointer items-center gap-1.5 px-2.5 py-2 hover:bg-muted/30"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", expanded && "rotate-90")}
|
||||
className={cn("h-3.5 w-3.5 shrink-0 transition-transform", expanded && "rotate-90")}
|
||||
/>
|
||||
<GripVertical className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium">
|
||||
{index + 1}. {TASK_TYPE_LABELS[task.type]}
|
||||
</span>
|
||||
{task.label && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground truncate">
|
||||
({task.label})
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="shrink-0 whitespace-nowrap text-xs font-medium">
|
||||
{index + 1}. {TASK_TYPE_LABELS[task.type]}
|
||||
</span>
|
||||
{summary && (
|
||||
<span
|
||||
className="truncate text-[11px] text-muted-foreground"
|
||||
title={summary}
|
||||
>
|
||||
- {summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{index > 0 && (
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
|
||||
<span className="text-[10px]">^</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("up"); }}>
|
||||
<ChevronRight className="h-3 w-3 -rotate-90" />
|
||||
</Button>
|
||||
)}
|
||||
{index < totalCount - 1 && (
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
|
||||
<span className="text-[10px]">v</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); onMove("down"); }}>
|
||||
<ChevronRight className="h-3 w-3 rotate-90" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 text-destructive"
|
||||
className="h-6 w-6 text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 펼침: 타입별 설정 폼 */}
|
||||
{expanded && (
|
||||
<div className="space-y-2 border-t px-2 py-2">
|
||||
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} />
|
||||
<div className="space-y-3 border-t px-2.5 py-3">
|
||||
<TaskDetailForm task={task} onUpdate={onUpdate} designerCtx={designerCtx} cardFields={cardFields} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1567,10 +1650,12 @@ function TaskDetailForm({
|
|||
task,
|
||||
onUpdate,
|
||||
designerCtx,
|
||||
cardFields,
|
||||
}: {
|
||||
task: ButtonTask;
|
||||
onUpdate: (partial: Partial<ButtonTask>) => void;
|
||||
designerCtx: ReturnType<typeof usePopDesignerContext>;
|
||||
cardFields: { value: string; label: string; source: string }[];
|
||||
}) {
|
||||
// 테이블/컬럼 조회 (data-update, data-delete용)
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
|
|
@ -1592,7 +1677,7 @@ function TaskDetailForm({
|
|||
switch (task.type) {
|
||||
case "data-save":
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
연결된 입력 컴포넌트의 저장 매핑을 사용합니다. 별도 설정 불필요.
|
||||
</p>
|
||||
);
|
||||
|
|
@ -1604,13 +1689,14 @@ function TaskDetailForm({
|
|||
onUpdate={onUpdate}
|
||||
tables={tables}
|
||||
columns={columns}
|
||||
cardFields={cardFields}
|
||||
/>
|
||||
);
|
||||
|
||||
case "data-delete":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 테이블</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">어떤 테이블에서 삭제할까요?</Label>
|
||||
<TableCombobox
|
||||
tables={tables}
|
||||
value={task.targetTable || ""}
|
||||
|
|
@ -1621,27 +1707,27 @@ function TaskDetailForm({
|
|||
|
||||
case "cart-save":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">장바구니 화면 ID (저장 후 이동)</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">저장 후 이동할 화면 ID</Label>
|
||||
<Input
|
||||
value={task.cartScreenId || ""}
|
||||
onChange={(e) => onUpdate({ cartScreenId: e.target.value })}
|
||||
placeholder="비워두면 이동 없이 저장만"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "modal-open":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">모달 방식</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">모달 방식</Label>
|
||||
<Select
|
||||
value={task.modalMode || "fullscreen"}
|
||||
onValueChange={(v) => onUpdate({ modalMode: v as ModalMode })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1652,36 +1738,36 @@ function TaskDetailForm({
|
|||
</Select>
|
||||
</div>
|
||||
{task.modalMode === "screen-ref" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">화면 ID</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">화면 ID</Label>
|
||||
<Input
|
||||
value={task.modalScreenId || ""}
|
||||
onChange={(e) => onUpdate({ modalScreenId: e.target.value })}
|
||||
placeholder="화면 ID"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label className="text-[10px]">모달 제목</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">모달 제목</Label>
|
||||
<Input
|
||||
value={task.modalTitle || ""}
|
||||
onChange={(e) => onUpdate({ modalTitle: e.target.value })}
|
||||
placeholder="모달 제목 (선택)"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{task.modalMode === "fullscreen" && designerCtx && (
|
||||
<div>
|
||||
{task.modalScreenId ? (
|
||||
<Button variant="outline" size="sm" className="h-7 w-full text-xs" onClick={() => designerCtx.navigateToCanvas(task.modalScreenId!)}>
|
||||
<Button variant="outline" size="sm" className="h-8 w-full text-xs" onClick={() => designerCtx.navigateToCanvas(task.modalScreenId!)}>
|
||||
모달 캔버스 열기
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-xs"
|
||||
className="h-8 w-full text-xs"
|
||||
onClick={() => {
|
||||
const selectedId = designerCtx.selectedComponentId;
|
||||
if (!selectedId) return;
|
||||
|
|
@ -1699,36 +1785,36 @@ function TaskDetailForm({
|
|||
|
||||
case "navigate":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 화면 ID</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">이동할 화면 ID</Label>
|
||||
<Input
|
||||
value={task.targetScreenId || ""}
|
||||
onChange={(e) => onUpdate({ targetScreenId: e.target.value })}
|
||||
placeholder="이동할 화면 ID"
|
||||
className="h-7 text-xs"
|
||||
placeholder="화면 ID 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "api-call":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-[10px]">엔드포인트</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">엔드포인트</Label>
|
||||
<Input
|
||||
value={task.apiEndpoint || ""}
|
||||
onChange={(e) => onUpdate({ apiEndpoint: e.target.value })}
|
||||
placeholder="/api/..."
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">HTTP 메서드</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={task.apiMethod || "POST"}
|
||||
onValueChange={(v) => onUpdate({ apiMethod: v as "GET" | "POST" | "PUT" | "DELETE" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1743,13 +1829,13 @@ function TaskDetailForm({
|
|||
|
||||
case "custom-event":
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">이벤트명</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">이벤트명</Label>
|
||||
<Input
|
||||
value={task.eventName || ""}
|
||||
onChange={(e) => onUpdate({ eventName: e.target.value })}
|
||||
placeholder="예: data-saved, item-selected"
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1757,7 +1843,7 @@ function TaskDetailForm({
|
|||
case "refresh":
|
||||
case "close-modal":
|
||||
return (
|
||||
<p className="text-[10px] text-muted-foreground">설정 불필요</p>
|
||||
<p className="text-[11px] text-muted-foreground">설정 불필요</p>
|
||||
);
|
||||
|
||||
default:
|
||||
|
|
@ -1769,19 +1855,71 @@ function TaskDetailForm({
|
|||
// 데이터 수정 작업 폼 (data-update 전용)
|
||||
// ========================================
|
||||
|
||||
/** 연산 타입 한글 라벨 */
|
||||
const OPERATION_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
assign: { label: "값 지정", desc: "선택한 값으로 덮어씁니다" },
|
||||
add: { label: "더하기", desc: "기존 값에 더합니다" },
|
||||
subtract: { label: "빼기", desc: "기존 값에서 뺍니다" },
|
||||
multiply: { label: "곱하기", desc: "기존 값에 곱합니다" },
|
||||
divide: { label: "나누기", desc: "기존 값을 나눕니다" },
|
||||
conditional: { label: "조건 분기", desc: "입력된 값에 따라 다른 결과를 지정합니다" },
|
||||
"db-conditional": { label: "조건 비교", desc: "DB 컬럼 값을 비교해서 결과를 정합니다" },
|
||||
};
|
||||
|
||||
/** 비교 연산자 한글 라벨 */
|
||||
const COMPARE_OP_LABELS: Record<string, string> = {
|
||||
"=": "= (같으면)",
|
||||
"!=": "!= (다르면)",
|
||||
">": "> (크면)",
|
||||
"<": "< (작으면)",
|
||||
">=": ">= (이상이면)",
|
||||
"<=": "<= (이하이면)",
|
||||
};
|
||||
|
||||
/** data-update 설정 요약 생성 */
|
||||
function buildUpdateSummaryText(task: ButtonTask): string | null {
|
||||
if (!task.targetTable || !task.targetColumn) return null;
|
||||
const op = task.operationType || "assign";
|
||||
const opLabel = OPERATION_LABELS[op]?.label || op;
|
||||
|
||||
if (op === "db-conditional") {
|
||||
const a = task.compareColumn || "?";
|
||||
const b = task.compareWith || "?";
|
||||
const opStr = task.compareOperator || ">=";
|
||||
return `[${task.targetTable}].${task.targetColumn}을 ${opLabel}로 변경\n${a} ${opStr} ${b} → "${task.dbThenValue || "?"}", 그 외 → "${task.dbElseValue || "?"}"`;
|
||||
}
|
||||
if (op === "conditional") {
|
||||
const conds = task.conditionalValue?.conditions ?? [];
|
||||
const lines = conds.map((c) => `${c.whenColumn} ${c.operator} ${c.whenValue} → "${c.thenValue}"`);
|
||||
const def = task.conditionalValue?.defaultValue;
|
||||
if (def) lines.push(`그 외 → "${def}"`);
|
||||
return `[${task.targetTable}].${task.targetColumn}을 ${opLabel}로 변경\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
const val = task.valueSource === "linked"
|
||||
? `화면 데이터(${task.sourceField || "?"})`
|
||||
: `"${task.fixedValue || "?"}"`;
|
||||
return `[${task.targetTable}].${task.targetColumn} ${opLabel} ${val}`;
|
||||
}
|
||||
|
||||
function DataUpdateTaskForm({
|
||||
task,
|
||||
onUpdate,
|
||||
tables,
|
||||
columns,
|
||||
cardFields,
|
||||
}: {
|
||||
task: ButtonTask;
|
||||
onUpdate: (partial: Partial<ButtonTask>) => void;
|
||||
tables: TableInfo[];
|
||||
columns: ColumnInfo[];
|
||||
cardFields: { value: string; label: string; source: string }[];
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(task.lookupMode === "manual");
|
||||
const conditions = task.conditionalValue?.conditions ?? [];
|
||||
const defaultValue = task.conditionalValue?.defaultValue ?? "";
|
||||
const isConditional = task.operationType === "conditional" || task.operationType === "db-conditional";
|
||||
const summaryText = buildUpdateSummaryText(task);
|
||||
|
||||
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
|
||||
const next = [...conditions];
|
||||
|
|
@ -1805,10 +1943,10 @@ function DataUpdateTaskForm({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 대상 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 테이블</Label>
|
||||
<div className="space-y-3">
|
||||
{/* 1. 대상 테이블 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">어떤 테이블을 수정할까요?</Label>
|
||||
<TableCombobox
|
||||
tables={tables}
|
||||
value={task.targetTable || ""}
|
||||
|
|
@ -1816,10 +1954,10 @@ function DataUpdateTaskForm({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 변경 컬럼 */}
|
||||
{/* 2. 변경 컬럼 */}
|
||||
{task.targetTable && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">변경 컬럼</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">어떤 항목(컬럼)을 바꿀까요?</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={task.targetColumn || ""}
|
||||
|
|
@ -1828,168 +1966,309 @@ function DataUpdateTaskForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산 타입 */}
|
||||
{/* 3. 연산 방식 */}
|
||||
{task.targetColumn && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">연산</Label>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">어떻게 바꿀까요?</Label>
|
||||
<Select
|
||||
value={task.operationType || "assign"}
|
||||
onValueChange={(v) => onUpdate({ operationType: v as UpdateOperationType })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="assign" className="text-xs">대입 (=)</SelectItem>
|
||||
<SelectItem value="add" className="text-xs">더하기 (+=)</SelectItem>
|
||||
<SelectItem value="subtract" className="text-xs">빼기 (-=)</SelectItem>
|
||||
<SelectItem value="multiply" className="text-xs">곱하기 (*=)</SelectItem>
|
||||
<SelectItem value="divide" className="text-xs">나누기 (/=)</SelectItem>
|
||||
<SelectItem value="conditional" className="text-xs">조건부 (클라이언트)</SelectItem>
|
||||
<SelectItem value="db-conditional" className="text-xs">조건부 (DB 컬럼 비교)</SelectItem>
|
||||
{Object.entries(OPERATION_LABELS).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{OPERATION_LABELS[task.operationType || "assign"]?.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 값 출처 (conditional/db-conditional이 아닐 때) */}
|
||||
{task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">값 출처</Label>
|
||||
<Select
|
||||
value={task.valueSource || "fixed"}
|
||||
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fixed" className="text-xs">고정값</SelectItem>
|
||||
<SelectItem value="linked" className="text-xs">연결 데이터</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 고정값 입력 */}
|
||||
{task.valueSource === "fixed" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
|
||||
<Input
|
||||
value={task.fixedValue || ""}
|
||||
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
placeholder="변경할 값"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 연결 데이터 필드명 */}
|
||||
{task.valueSource === "linked" && task.operationType !== "conditional" && task.operationType !== "db-conditional" && (
|
||||
<Input
|
||||
value={task.sourceField || ""}
|
||||
onChange={(e) => onUpdate({ sourceField: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
placeholder="연결 필드명 (예: qty)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DB 컬럼 비교 조건부 설정 */}
|
||||
{task.operationType === "db-conditional" && (
|
||||
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
|
||||
<p className="text-[10px] text-muted-foreground">DB에서 컬럼 A와 컬럼 B를 비교하여 값을 판정합니다</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<ColumnCombobox columns={columns} value={task.compareColumn ?? ""} onSelect={(v) => onUpdate({ compareColumn: v })} placeholder="비교 컬럼 A" />
|
||||
<Select value={task.compareOperator ?? ">="} onValueChange={(v) => onUpdate({ compareOperator: v as ButtonTask["compareOperator"] })}>
|
||||
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
{/* 4. 단순 연산: 값 출처 + 값 입력 */}
|
||||
{!isConditional && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">바꿀 값을 어디서 가져올까요?</Label>
|
||||
<Select
|
||||
value={task.valueSource || "fixed"}
|
||||
onValueChange={(v) => onUpdate({ valueSource: v as UpdateValueSource })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="fixed" className="text-xs">직접 입력</SelectItem>
|
||||
<SelectItem value="linked" className="text-xs">화면 데이터에서 가져오기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ColumnCombobox columns={columns} value={task.compareWith ?? ""} onSelect={(v) => onUpdate({ compareWith: v })} placeholder="비교 컬럼 B" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">참 -></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.operationType === "conditional" && (
|
||||
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
|
||||
{conditions.map((cond, cIdx) => (
|
||||
<div key={cIdx} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">만약</span>
|
||||
<ColumnCombobox columns={columns} value={cond.whenColumn} onSelect={(v) => updateCondition(cIdx, { whenColumn: v })} placeholder="컬럼" />
|
||||
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}>
|
||||
<SelectTrigger className="h-7 w-14 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
{task.valueSource === "fixed" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">변경할 값</Label>
|
||||
<Input
|
||||
value={task.fixedValue || ""}
|
||||
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="변경할 값 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.valueSource === "linked" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">어떤 화면 데이터를 사용할까요?</Label>
|
||||
{cardFields.length > 0 ? (
|
||||
<Select
|
||||
value={task.sourceField || ""}
|
||||
onValueChange={(v) => onUpdate({ sourceField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
|
||||
{cardFields.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{f.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{f.source}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input value={cond.whenValue} onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })} className="h-7 w-16 text-[10px]" placeholder="값" />
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-4">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">이면 -></span>
|
||||
<Input value={cond.thenValue} onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })} className="h-7 text-[10px]" placeholder="변경할 값" />
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={task.sourceField || ""}
|
||||
onChange={(e) => onUpdate({ sourceField: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="필드명 직접 입력 (예: qty)"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{cardFields.length > 0
|
||||
? "카드에 표시되는 데이터 중 하나를 선택합니다"
|
||||
: "카드 컴포넌트가 없으면 직접 입력해주세요"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
|
||||
<Plus className="mr-1 h-3 w-3" /> 조건 추가
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">그 외 -></span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. 조건 비교 (db-conditional) - 세로 스택 */}
|
||||
{task.operationType === "db-conditional" && (
|
||||
<div className="space-y-3 rounded-md border border-dashed border-muted-foreground/30 p-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
DB 컬럼 값을 비교해서 결과를 정합니다
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">비교 컬럼</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={task.compareColumn ?? ""}
|
||||
onSelect={(v) => onUpdate({ compareColumn: v })}
|
||||
placeholder="비교할 컬럼 선택"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">비교 방법</Label>
|
||||
<Select
|
||||
value={task.compareOperator ?? ">="}
|
||||
onValueChange={(v) => onUpdate({ compareOperator: v as ButtonTask["compareOperator"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(COMPARE_OP_LABELS).map(([op, label]) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">비교 대상</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={task.compareWith ?? ""}
|
||||
onSelect={(v) => onUpdate({ compareWith: v })}
|
||||
placeholder="비교 대상 컬럼 선택"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">조건이 맞으면</Label>
|
||||
<Input
|
||||
value={defaultValue}
|
||||
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })}
|
||||
className="h-7 text-[10px]"
|
||||
placeholder="기본값"
|
||||
value={task.dbThenValue ?? ""}
|
||||
onChange={(e) => onUpdate({ dbThenValue: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="예: 입고완료"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">조건이 틀리면</Label>
|
||||
<Input
|
||||
value={task.dbElseValue ?? ""}
|
||||
onChange={(e) => onUpdate({ dbElseValue: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="예: 부분입고"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 키 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[10px]">조회 키</Label>
|
||||
<Select
|
||||
value={task.lookupMode ?? "auto"}
|
||||
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-[10px]">자동</SelectItem>
|
||||
<SelectItem value="manual" className="text-[10px]">수동</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 6. 조건 분기 (conditional) */}
|
||||
{task.operationType === "conditional" && (
|
||||
<div className="space-y-3 rounded-md border border-dashed border-muted-foreground/30 p-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
입력된 값에 따라 다른 결과를 지정합니다
|
||||
</p>
|
||||
|
||||
{conditions.map((cond, cIdx) => (
|
||||
<div key={cIdx} className="space-y-2 rounded border border-border bg-muted/20 p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">조건 {cIdx + 1}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeCondition(cIdx)}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">만약 이 컬럼이</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={cond.whenColumn}
|
||||
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">이 값과</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={cond.operator} onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}>
|
||||
<SelectTrigger className="h-8 w-24 shrink-0 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(COMPARE_OP_LABELS).map(([op, label]) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={cond.whenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
placeholder="비교할 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">이면 이 값으로 변경</Label>
|
||||
<Input
|
||||
value={cond.thenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="변경할 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button variant="outline" size="sm" className="h-8 w-full text-xs" onClick={addCondition}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> 조건 추가
|
||||
</Button>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">위 조건에 해당하지 않으면</Label>
|
||||
<Input
|
||||
value={defaultValue}
|
||||
onChange={(e) => onUpdate({ conditionalValue: { conditions, defaultValue: e.target.value } })}
|
||||
className="h-8 text-xs"
|
||||
placeholder="기본값 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{task.lookupMode === "manual" && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="카드 항목 필드" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWN_ITEM_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">-></span>
|
||||
<ColumnCombobox columns={columns} value={task.manualPkColumn ?? ""} onSelect={(v) => onUpdate({ manualPkColumn: v })} placeholder="대상 PK 컬럼" />
|
||||
)}
|
||||
|
||||
{/* 7. 고급 설정 (조회 키) */}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showAdvanced && "rotate-90")} />
|
||||
<span>고급 설정 (대상 행 직접 지정)</span>
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-2 space-y-2 rounded-md border border-border bg-muted/10 p-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">대상 행 찾기</Label>
|
||||
<Select
|
||||
value={task.lookupMode ?? "auto"}
|
||||
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">자동 매칭 (권장)</SelectItem>
|
||||
<SelectItem value="manual" className="text-xs">수동 지정</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{task.lookupMode === "manual"
|
||||
? "카드 항목의 필드를 직접 지정하여 대상 행을 찾습니다"
|
||||
: "카드 항목과 테이블 PK를 자동으로 매칭합니다"}
|
||||
</p>
|
||||
</div>
|
||||
{task.lookupMode === "manual" && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">카드 항목 필드</Label>
|
||||
<Select value={task.manualItemField ?? ""} onValueChange={(v) => onUpdate({ manualItemField: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="필드 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWN_ITEM_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-xs">
|
||||
<div className="flex flex-col">
|
||||
<span>{f.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{f.desc}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">대상 PK 컬럼</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={task.manualPkColumn ?? ""}
|
||||
onSelect={(v) => onUpdate({ manualPkColumn: v })}
|
||||
placeholder="PK 컬럼 선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 8. 설정 요약 */}
|
||||
{summaryText && (
|
||||
<div className="rounded-md bg-muted/50 p-3">
|
||||
<p className="mb-1 text-xs font-medium text-muted-foreground">설정 요약</p>
|
||||
<p className="whitespace-pre-line text-xs leading-relaxed">{summaryText}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2326,10 +2605,10 @@ function PopButtonPreviewComponent({
|
|||
// ========================================
|
||||
|
||||
const KNOWN_ITEM_FIELDS = [
|
||||
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
|
||||
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
|
||||
{ value: "id", label: "id" },
|
||||
{ value: "row_key", label: "row_key" },
|
||||
{ value: "__cart_row_key", label: "카드 항목의 원본 키", desc: "DB에서 가져온 데이터의 PK (가장 일반적)" },
|
||||
{ value: "__cart_id", label: "카드 항목 ID", desc: "장바구니 내부 고유 ID" },
|
||||
{ value: "id", label: "id", desc: "데이터의 id 컬럼" },
|
||||
{ value: "row_key", label: "row_key", desc: "데이터의 row_key 컬럼" },
|
||||
];
|
||||
|
||||
function StatusChangeRuleEditor({
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export interface ColumnInfo {
|
|||
type: string;
|
||||
udtName: string;
|
||||
isPrimaryKey?: boolean;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
// ===== SQL 값 이스케이프 =====
|
||||
|
|
@ -330,6 +331,7 @@ export async function fetchTableColumns(
|
|||
type: col.dataType || col.data_type || col.type || "unknown",
|
||||
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
|
||||
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
|
||||
comment: col.columnComment || col.description || "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,9 +38,23 @@ export function ColumnCombobox({
|
|||
const filtered = useMemo(() => {
|
||||
if (!search) return columns;
|
||||
const q = search.toLowerCase();
|
||||
return columns.filter((c) => c.name.toLowerCase().includes(q));
|
||||
return columns.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.comment && c.comment.toLowerCase().includes(q))
|
||||
);
|
||||
}, [columns, search]);
|
||||
|
||||
const selectedCol = useMemo(
|
||||
() => columns.find((c) => c.name === value),
|
||||
[columns, value],
|
||||
);
|
||||
const displayValue = selectedCol
|
||||
? selectedCol.comment
|
||||
? `${selectedCol.name} (${selectedCol.comment})`
|
||||
: selectedCol.name
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -50,7 +64,7 @@ export function ColumnCombobox({
|
|||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value || placeholder}
|
||||
<span className="truncate">{displayValue || placeholder}</span>
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
|
@ -61,7 +75,7 @@ export function ColumnCombobox({
|
|||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼명 검색..."
|
||||
placeholder="컬럼명 또는 한글명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
|
|
@ -88,8 +102,15 @@ export function ColumnCombobox({
|
|||
value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
{col.comment && (
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
({col.comment})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.type}
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Reference in New Issue