feat(batch): Phase 2 — 프런트 ConditionalEditor + 조건 변환 매핑 UI

- batch.ts: ConditionalRule / ConditionalConfig 타입 추가,
  BatchMapping 에 mapping_type ('direct'|'fixed'|'conditional') + mapping_config 필드
- ConditionalEditor.tsx: 평가 필드 선택 + when/then 룰 add/remove + default 입력 컴포넌트.
  emptyConditionalConfig / normalizeConditionalConfig 헬퍼 동봉. vexplor_rps 1:1 포팅
- batchmngList/edit/[id]/page.tsx:
  · MappingItem.sourceType 에 'conditional' 추가 + conditionalConfig 필드
  · 소스타입 Select 에 "조건 변환" 옵션
  · Load: mapping_type=conditional 인 매핑은 mapping_config JSON 파싱 후 복원
  · Save: sourceType=conditional 매핑은 mapping_config 객체와 함께 전송

저장된 룰: {"rules":[{"when":"1","then":"Y"}],"default":"?"} 형태.
Phase 1 의 BatchService 직렬화 경로로 JSONB 에 저장된다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-13 10:25:08 +09:00
parent 2675c82904
commit f31a7f852f
3 changed files with 288 additions and 38 deletions
@@ -25,8 +25,14 @@ import {
ConnectionInfo,
type NodeFlowInfo,
type BatchExecutionType,
type ConditionalConfig,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
import {
ConditionalEditor,
emptyConditionalConfig,
normalizeConditionalConfig,
} from "@/components/admin/batch/ConditionalEditor";
const SCHEDULE_PRESETS = [
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
@@ -165,12 +171,17 @@ export default function BatchEditPage() {
const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static");
// 매핑 리스트 (새로운 UI용)
// sourceType:
// - "api" : apiField 의 값을 그대로 복사 (mapping_type=direct)
// - "fixed" : fixedValue 자체가 저장값 (mapping_type=fixed)
// - "conditional" : apiField 값을 conditionalConfig 룰로 변환 (mapping_type=conditional)
interface MappingItem {
id: string;
dbColumn: string;
sourceType: "api" | "fixed";
sourceType: "api" | "fixed" | "conditional";
apiField: string;
fixedValue: string;
conditionalConfig?: ConditionalConfig;
}
const [mappingList, setMappingList] = useState<MappingItem[]>([]);
@@ -377,13 +388,27 @@ export default function BatchEditPage() {
});
// 기존 매핑을 mappingList로 변환
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
// mapping_type 분기:
// "fixed" → from_column_name 자체가 고정값 → fixedValue
// "conditional" → from_column_name 이 평가 필드명 → apiField + conditionalConfig
// 그 외(direct) → from_column_name 이 API 필드명 → apiField
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => {
const mt = (mapping as any).mapping_type || "direct";
const sourceType: MappingItem["sourceType"] =
mt === "fixed" ? "fixed" : mt === "conditional" ? "conditional" : "api";
const conditionalConfig =
sourceType === "conditional"
? normalizeConditionalConfig((mapping as any).mapping_config)
: undefined;
return {
id: `mapping-${index}-${Date.now()}`,
dbColumn: mapping.to_column_name || "",
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const,
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
}));
sourceType,
apiField: sourceType === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: sourceType === "fixed" ? mapping.from_column_name || "" : "",
conditionalConfig,
};
});
setMappingList(convertedMappingList);
console.log("🔄 변환된 mappingList:", convertedMappingList);
}
@@ -679,13 +704,26 @@ export default function BatchEditPage() {
const first = batchConfig.batch_mappings[0] as any;
finalMappings = mappingList
.filter((m) => m.dbColumn) // DB 컬럼이 선택된 것만
.map((m, index) => ({
.map((m, index) => {
// from_column_name 결정:
// fixed → fixedValue 자체가 저장됨
// conditional → apiField (평가할 API 필드)
// direct(api) → apiField
const fromColumnName =
m.sourceType === "fixed" ? m.fixedValue : m.apiField;
const mappingType: "direct" | "fixed" | "conditional" =
m.sourceType === "fixed"
? "fixed"
: m.sourceType === "conditional"
? "conditional"
: "direct";
return {
// FROM: REST API (기존 설정 복사)
from_connection_type: "restapi" as any,
from_connection_id: first.from_connection_id,
from_table_name: first.from_table_name,
from_column_name: m.sourceType === "fixed" ? m.fixedValue : m.apiField,
from_column_type: m.sourceType === "fixed" ? "text" : "text",
from_column_name: fromColumnName,
from_column_type: "text",
from_api_url: mappings[0]?.from_api_url || first.from_api_url,
from_api_key: authTokenMode === "direct" ? fromApiKey : first.from_api_key,
from_api_method: mappings[0]?.from_api_method || first.from_api_method,
@@ -695,10 +733,17 @@ export default function BatchEditPage() {
to_connection_id: first.to_connection_id,
to_table_name: toTable || first.to_table_name,
to_column_name: m.dbColumn,
to_column_type: toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
mapping_type: m.sourceType === "fixed" ? "fixed" : "direct",
to_column_type:
toColumns.find((c) => c.column_name === m.dbColumn)?.data_type || "text",
mapping_type: mappingType,
// conditional 일 때만 룰 객체를 함께 전송. 백엔드가 JSONB 로 저장.
mapping_config:
m.sourceType === "conditional" && m.conditionalConfig
? m.conditionalConfig
: null,
mapping_order: index + 1,
})) as BatchMapping[];
};
}) as BatchMapping[];
}
await BatchAPI.updateBatchConfig(batchId, {
@@ -1617,14 +1662,22 @@ export default function BatchEditPage() {
<ArrowLeft className="text-muted-foreground h-4 w-4 shrink-0" />
{/* 소스 타입 선택 */}
<div className="w-24 shrink-0">
<div className="w-28 shrink-0">
<Select
value={mapping.sourceType}
onValueChange={(value: "api" | "fixed") =>
onValueChange={(value: "api" | "fixed" | "conditional") =>
updateMappingListItem(mapping.id, {
sourceType: value,
apiField: value === "fixed" ? "" : mapping.apiField,
fixedValue: value === "api" ? "" : mapping.fixedValue,
// 모드 전환 시 입력값 정리
apiField:
value === "api" || value === "conditional"
? mapping.apiField
: "",
fixedValue: value === "fixed" ? mapping.fixedValue : "",
conditionalConfig:
value === "conditional"
? mapping.conditionalConfig || emptyConditionalConfig()
: mapping.conditionalConfig,
})
}
>
@@ -1634,13 +1687,14 @@ export default function BatchEditPage() {
<SelectContent>
<SelectItem value="api">API </SelectItem>
<SelectItem value="fixed"></SelectItem>
<SelectItem value="conditional"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* API 필드 선택 또는 고정값 입력 (우측 - FROM) */}
{/* API 필드 선택 / 고정값 입력 / 조건 변환 (우측 - FROM) */}
<div className="min-w-0 flex-1">
{mapping.sourceType === "api" ? (
{mapping.sourceType === "api" && (
<Select
value={mapping.apiField || "none"}
onValueChange={(value) =>
@@ -1667,7 +1721,8 @@ export default function BatchEditPage() {
))}
</SelectContent>
</Select>
) : (
)}
{mapping.sourceType === "fixed" && (
<Input
value={mapping.fixedValue}
onChange={(e) => updateMappingListItem(mapping.id, { fixedValue: e.target.value })}
@@ -1675,6 +1730,19 @@ export default function BatchEditPage() {
className="h-9"
/>
)}
{mapping.sourceType === "conditional" && (
<ConditionalEditor
evaluateField={mapping.apiField}
fieldOptions={fromApiFields}
config={mapping.conditionalConfig || emptyConditionalConfig()}
onEvaluateFieldChange={(v) =>
updateMappingListItem(mapping.id, { apiField: v })
}
onConfigChange={(cfg) =>
updateMappingListItem(mapping.id, { conditionalConfig: cfg })
}
/>
)}
</div>
{/* 삭제 버튼 */}
@@ -0,0 +1,163 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ConditionalConfig, ConditionalRule } from "@/lib/api/batch";
interface ConditionalEditorProps {
// 평가 대상 필드명 (REST API 응답 필드명 또는 DB 컬럼명)
evaluateField: string;
// 선택 가능한 필드 후보 — REST API 응답 필드 목록 등
fieldOptions: string[];
// 현재 conditional 규칙
config: ConditionalConfig;
onEvaluateFieldChange: (field: string) => void;
onConfigChange: (cfg: ConditionalConfig) => void;
}
// vexplor_rps 의 ConditionalEditor 1:1 포팅.
// 예: enrlFg 가 "J01" → "active", "J05" → "inactive", 나머지 → "" 식 lookup.
export function ConditionalEditor({
evaluateField,
fieldOptions,
config,
onEvaluateFieldChange,
onConfigChange,
}: ConditionalEditorProps) {
const cfg: ConditionalConfig = config?.rules
? config
: { rules: [{ when: "", then: "" }], default: "" };
const isEvaluateFieldMissing = !evaluateField;
const updateRule = (idx: number, patch: Partial<ConditionalRule>) => {
const rules = cfg.rules.map((r, i) => (i === idx ? { ...r, ...patch } : r));
onConfigChange({ ...cfg, rules });
};
const addRule = () =>
onConfigChange({ ...cfg, rules: [...cfg.rules, { when: "", then: "" }] });
const removeRule = (idx: number) =>
onConfigChange({ ...cfg, rules: cfg.rules.filter((_, i) => i !== idx) });
return (
<div className="space-y-1.5 rounded border bg-muted/30 p-2">
<div className="space-y-0.5">
<div className="flex items-center gap-1.5">
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Select
value={evaluateField || "none"}
onValueChange={(v) => onEvaluateFieldChange(v === "none" ? "" : v)}
>
<SelectTrigger
className={cn(
"h-7 text-xs",
isEvaluateFieldMissing && "border-destructive ring-1 ring-destructive/40",
)}
>
<SelectValue placeholder="조건을 평가할 필드 선택 (필수)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{/* 저장된 evaluateField 가 fieldOptions 에 없을 때 동적 추가 (응답 미리보기 안 한 편집 모드 대응) */}
{evaluateField && !fieldOptions.includes(evaluateField) && (
<SelectItem value={evaluateField}>{evaluateField} ()</SelectItem>
)}
{fieldOptions.map((f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="pl-[60px] text-[10px] text-muted-foreground">
(: status {" "}
<span className="font-mono">enrlFg</span> J01active ={" "}
<span className="font-mono">enrlFg</span>)
</p>
</div>
<div className="space-y-1">
{cfg.rules.map((rule, idx) => (
<div key={idx} className="flex items-center gap-1">
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<Input
value={rule.when}
onChange={(e) => updateRule(idx, { when: e.target.value })}
placeholder="예: J01"
className="h-7 flex-1 text-xs"
/>
<span className="shrink-0 text-[10px] text-muted-foreground"></span>
<Input
value={rule.then}
onChange={(e) => updateRule(idx, { then: e.target.value })}
placeholder="저장값"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeRule(idx)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={addRule}
className="h-6 gap-1 px-2 text-[10px]"
>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center gap-1 border-t pt-1">
<span className="shrink-0 text-[10px] text-muted-foreground"> ()</span>
<Input
value={cfg.default}
onChange={(e) => onConfigChange({ ...cfg, default: e.target.value })}
placeholder="예: 0 또는 빈값"
className="h-7 flex-1 text-xs"
/>
</div>
</div>
);
}
// 빈 conditionalConfig 기본값 — page 측에서 모드 전환 시 사용
export const emptyConditionalConfig = (): ConditionalConfig => ({
rules: [{ when: "", then: "" }],
default: "",
});
// 저장된 mapping_config (string|object|null) 를 안전하게 ConditionalConfig 로 normalize
export function normalizeConditionalConfig(raw: unknown): ConditionalConfig {
if (!raw) return emptyConditionalConfig();
let parsed: any = raw;
if (typeof raw === "string") {
try {
parsed = JSON.parse(raw);
} catch {
return emptyConditionalConfig();
}
}
return {
rules: Array.isArray(parsed?.rules) ? parsed.rules : [],
default: typeof parsed?.default === "string" ? parsed.default : "",
};
}
+19
View File
@@ -64,6 +64,19 @@ export interface RecentLog {
duration_ms: number | null;
}
// 조건 변환 규칙 — mapping_type === 'conditional' 일 때 mapping_config 에 저장.
// 평가: row[from_column_name] 값을 cfg.rules 의 when 과 문자열 동등 비교, 매칭되는 then 반환.
// 매칭 없으면 cfg.default.
export interface ConditionalRule {
when: string;
then: string;
}
export interface ConditionalConfig {
rules: ConditionalRule[];
default: string;
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
@@ -82,6 +95,12 @@ export interface BatchMapping {
to_column_name: string;
to_column_type?: string;
// 매핑 유형 — 'direct' (그대로 복사) / 'fixed' (from_column_name 자체가 고정값) / 'conditional' (when/then 룰)
mapping_type?: 'direct' | 'fixed' | 'conditional';
// conditional 일 때 ConditionalConfig 의 JSON. 백엔드는 JSONB 로 저장.
// 요청 시 string(JSON) 또는 object 둘 다 허용 — 백엔드가 normalize.
mapping_config?: ConditionalConfig | string | null;
mapping_order?: number;
created_date?: Date;
created_by?: string;