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:
@@ -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> 의 값을 보고 J01→active 변환 시 평가 필드 ={" "}
|
||||
<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 : "",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user