feat(modal-repeater-table): 동적 데이터 소스 전환 기능 및 UniversalFormModal 저장 버튼 옵션 추가
- ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환 - 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원 - DynamicDataSourceConfig, MultiTableJoinStep 타입 추가 - 설정 패널에 동적 데이터 소스 설정 모달 추가 - UniversalFormModal: showSaveButton 옵션 추가
This commit is contained in:
@@ -9,7 +9,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule } from "./types";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, ColumnMapping, CalculationRule, DynamicDataSourceConfig, DynamicDataSourceOption, MultiTableJoinStep } from "./types";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -169,6 +170,10 @@ export function ModalRepeaterTableConfigPanel({
|
||||
const [openTableCombo, setOpenTableCombo] = useState(false);
|
||||
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
|
||||
const [openUniqueFieldCombo, setOpenUniqueFieldCombo] = useState(false);
|
||||
|
||||
// 동적 데이터 소스 설정 모달
|
||||
const [dynamicSourceModalOpen, setDynamicSourceModalOpen] = useState(false);
|
||||
const [editingDynamicSourceColumnIndex, setEditingDynamicSourceColumnIndex] = useState<number | null>(null);
|
||||
|
||||
// config 변경 시 localConfig 동기화 (cleanupInitialConfig 적용)
|
||||
useEffect(() => {
|
||||
@@ -397,6 +402,101 @@ export function ModalRepeaterTableConfigPanel({
|
||||
updateConfig({ calculationRules: rules });
|
||||
};
|
||||
|
||||
// 동적 데이터 소스 설정 함수들
|
||||
const openDynamicSourceModal = (columnIndex: number) => {
|
||||
setEditingDynamicSourceColumnIndex(columnIndex);
|
||||
setDynamicSourceModalOpen(true);
|
||||
};
|
||||
|
||||
const toggleDynamicDataSource = (columnIndex: number, enabled: boolean) => {
|
||||
const columns = [...(localConfig.columns || [])];
|
||||
if (enabled) {
|
||||
columns[columnIndex] = {
|
||||
...columns[columnIndex],
|
||||
dynamicDataSource: {
|
||||
enabled: true,
|
||||
options: [],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { dynamicDataSource, ...rest } = columns[columnIndex];
|
||||
columns[columnIndex] = rest;
|
||||
}
|
||||
updateConfig({ columns });
|
||||
};
|
||||
|
||||
const addDynamicSourceOption = (columnIndex: number) => {
|
||||
const columns = [...(localConfig.columns || [])];
|
||||
const col = columns[columnIndex];
|
||||
const newOption: DynamicDataSourceOption = {
|
||||
id: `option_${Date.now()}`,
|
||||
label: "새 옵션",
|
||||
sourceType: "table",
|
||||
tableConfig: {
|
||||
tableName: "",
|
||||
valueColumn: "",
|
||||
joinConditions: [],
|
||||
},
|
||||
};
|
||||
|
||||
columns[columnIndex] = {
|
||||
...col,
|
||||
dynamicDataSource: {
|
||||
...col.dynamicDataSource!,
|
||||
enabled: true,
|
||||
options: [...(col.dynamicDataSource?.options || []), newOption],
|
||||
},
|
||||
};
|
||||
updateConfig({ columns });
|
||||
};
|
||||
|
||||
const updateDynamicSourceOption = (columnIndex: number, optionIndex: number, updates: Partial<DynamicDataSourceOption>) => {
|
||||
const columns = [...(localConfig.columns || [])];
|
||||
const col = columns[columnIndex];
|
||||
const options = [...(col.dynamicDataSource?.options || [])];
|
||||
options[optionIndex] = { ...options[optionIndex], ...updates };
|
||||
|
||||
columns[columnIndex] = {
|
||||
...col,
|
||||
dynamicDataSource: {
|
||||
...col.dynamicDataSource!,
|
||||
options,
|
||||
},
|
||||
};
|
||||
updateConfig({ columns });
|
||||
};
|
||||
|
||||
const removeDynamicSourceOption = (columnIndex: number, optionIndex: number) => {
|
||||
const columns = [...(localConfig.columns || [])];
|
||||
const col = columns[columnIndex];
|
||||
const options = [...(col.dynamicDataSource?.options || [])];
|
||||
options.splice(optionIndex, 1);
|
||||
|
||||
columns[columnIndex] = {
|
||||
...col,
|
||||
dynamicDataSource: {
|
||||
...col.dynamicDataSource!,
|
||||
options,
|
||||
},
|
||||
};
|
||||
updateConfig({ columns });
|
||||
};
|
||||
|
||||
const setDefaultDynamicSourceOption = (columnIndex: number, optionId: string) => {
|
||||
const columns = [...(localConfig.columns || [])];
|
||||
const col = columns[columnIndex];
|
||||
|
||||
columns[columnIndex] = {
|
||||
...col,
|
||||
dynamicDataSource: {
|
||||
...col.dynamicDataSource!,
|
||||
defaultOptionId: optionId,
|
||||
},
|
||||
};
|
||||
updateConfig({ columns });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
{/* 소스/저장 테이블 설정 */}
|
||||
@@ -1327,6 +1427,60 @@ export function ModalRepeaterTableConfigPanel({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. 동적 데이터 소스 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
동적 데이터 소스
|
||||
</Label>
|
||||
<Switch
|
||||
checked={col.dynamicDataSource?.enabled || false}
|
||||
onCheckedChange={(checked) => toggleDynamicDataSource(index, checked)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
컬럼 헤더 클릭으로 데이터 소스 전환 (예: 거래처별 단가, 품목별 단가)
|
||||
</p>
|
||||
|
||||
{col.dynamicDataSource?.enabled && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.dynamicDataSource.options.length}개 옵션 설정됨
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openDynamicSourceModal(index)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
옵션 설정
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 옵션 미리보기 */}
|
||||
{col.dynamicDataSource.options.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{col.dynamicDataSource.options.map((opt) => (
|
||||
<span
|
||||
key={opt.id}
|
||||
className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full",
|
||||
col.dynamicDataSource?.defaultOptionId === opt.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{col.dynamicDataSource?.defaultOptionId === opt.id && " (기본)"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1493,6 +1647,650 @@ export function ModalRepeaterTableConfigPanel({
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 동적 데이터 소스 설정 모달 */}
|
||||
<Dialog open={dynamicSourceModalOpen} onOpenChange={setDynamicSourceModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
동적 데이터 소스 설정
|
||||
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
|
||||
<span className="text-primary ml-2">
|
||||
({localConfig.columns[editingDynamicSourceColumnIndex].label})
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
컬럼 헤더 클릭 시 선택할 수 있는 데이터 소스 옵션을 설정합니다
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{editingDynamicSourceColumnIndex !== null && localConfig.columns?.[editingDynamicSourceColumnIndex] && (
|
||||
<div className="space-y-4">
|
||||
{/* 옵션 목록 */}
|
||||
<div className="space-y-3">
|
||||
{(localConfig.columns[editingDynamicSourceColumnIndex].dynamicDataSource?.options || []).map((option, optIndex) => (
|
||||
<div key={option.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">옵션 {optIndex + 1}</span>
|
||||
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId === option.id && (
|
||||
<span className="text-[10px] bg-primary text-primary-foreground px-1.5 py-0.5 rounded">기본</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{localConfig.columns![editingDynamicSourceColumnIndex].dynamicDataSource?.defaultOptionId !== option.id && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDefaultDynamicSourceOption(editingDynamicSourceColumnIndex, option.id)}
|
||||
className="h-6 text-[10px] px-2"
|
||||
>
|
||||
기본으로 설정
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex)}
|
||||
className="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 라벨 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">표시 라벨 *</Label>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, { label: e.target.value })}
|
||||
placeholder="예: 거래처별 단가"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 소스 타입 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">조회 방식 *</Label>
|
||||
<Select
|
||||
value={option.sourceType}
|
||||
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
sourceType: value as "table" | "multiTable" | "api",
|
||||
tableConfig: value === "table" ? { tableName: "", valueColumn: "", joinConditions: [] } : undefined,
|
||||
multiTableConfig: value === "multiTable" ? { joinChain: [], valueColumn: "" } : undefined,
|
||||
apiConfig: value === "api" ? { endpoint: "", parameterMappings: [], responseValueField: "" } : undefined,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="table">테이블 직접 조회 (단순 조인)</SelectItem>
|
||||
<SelectItem value="multiTable">테이블 복합 조인 (2개 이상)</SelectItem>
|
||||
<SelectItem value="api">전용 API 호출</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 직접 조회 설정 */}
|
||||
{option.sourceType === "table" && (
|
||||
<div className="space-y-3 p-3 bg-blue-50 dark:bg-blue-950 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs font-medium">테이블 조회 설정</p>
|
||||
|
||||
{/* 참조 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">참조 테이블 *</Label>
|
||||
<Select
|
||||
value={option.tableConfig?.tableName || ""}
|
||||
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, tableName: value },
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 값 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">값 컬럼 (가져올 컬럼) *</Label>
|
||||
<ReferenceColumnSelector
|
||||
referenceTable={option.tableConfig?.tableName || ""}
|
||||
value={option.tableConfig?.valueColumn || ""}
|
||||
onChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, valueColumn: value },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">조인 조건 *</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newConditions = [...(option.tableConfig?.joinConditions || []), { sourceField: "", targetField: "" }];
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||
});
|
||||
}}
|
||||
className="h-6 text-[10px] px-2"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(option.tableConfig?.joinConditions || []).map((cond, condIndex) => (
|
||||
<div key={condIndex} className="flex items-center gap-2 p-2 bg-background rounded">
|
||||
<Select
|
||||
value={cond.sourceField}
|
||||
onValueChange={(value) => {
|
||||
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||
newConditions[condIndex] = { ...newConditions[condIndex], sourceField: value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px] flex-1">
|
||||
<SelectValue placeholder="현재 행 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(localConfig.columns || []).map((col) => (
|
||||
<SelectItem key={col.field} value={col.field}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">=</span>
|
||||
<ReferenceColumnSelector
|
||||
referenceTable={option.tableConfig?.tableName || ""}
|
||||
value={cond.targetField}
|
||||
onChange={(value) => {
|
||||
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||
newConditions[condIndex] = { ...newConditions[condIndex], targetField: value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newConditions = [...(option.tableConfig?.joinConditions || [])];
|
||||
newConditions.splice(condIndex, 1);
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
tableConfig: { ...option.tableConfig!, joinConditions: newConditions },
|
||||
});
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 복합 조인 설정 (2개 이상 테이블) */}
|
||||
{option.sourceType === "multiTable" && (
|
||||
<div className="space-y-3 p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
|
||||
<div>
|
||||
<p className="text-xs font-medium">복합 조인 설정</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
여러 테이블을 순차적으로 조인합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조인 체인 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">조인 체인 *</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newChain: MultiTableJoinStep[] = [
|
||||
...(option.multiTableConfig?.joinChain || []),
|
||||
{ tableName: "", joinCondition: { fromField: "", toField: "" }, outputField: "" }
|
||||
];
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
className="h-6 text-[10px] px-2"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조인 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 시작점 안내 */}
|
||||
<div className="p-2 bg-background rounded border-l-2 border-primary">
|
||||
<p className="text-[10px] font-medium text-primary">시작: 현재 행 데이터</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
첫 번째 조인은 현재 행의 필드에서 시작합니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조인 단계들 */}
|
||||
{(option.multiTableConfig?.joinChain || []).map((step, stepIndex) => (
|
||||
<div key={stepIndex} className="p-3 border rounded-md bg-background space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center text-[10px] font-bold">
|
||||
{stepIndex + 1}
|
||||
</div>
|
||||
<span className="text-xs font-medium">조인 단계 {stepIndex + 1}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain.splice(stepIndex, 1);
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 조인할 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">조인할 테이블 *</Label>
|
||||
<Select
|
||||
value={step.tableName}
|
||||
onValueChange={(value) => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain[stepIndex] = { ...newChain[stepIndex], tableName: value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{table.displayName || table.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div className="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">
|
||||
{stepIndex === 0 ? "현재 행 필드" : "이전 단계 출력 필드"}
|
||||
</Label>
|
||||
{stepIndex === 0 ? (
|
||||
<Select
|
||||
value={step.joinCondition.fromField}
|
||||
onValueChange={(value) => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain[stepIndex] = {
|
||||
...newChain[stepIndex],
|
||||
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: value }
|
||||
};
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(localConfig.columns || []).map((col) => (
|
||||
<SelectItem key={col.field} value={col.field}>
|
||||
{col.label} ({col.field})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={step.joinCondition.fromField}
|
||||
onChange={(e) => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain[stepIndex] = {
|
||||
...newChain[stepIndex],
|
||||
joinCondition: { ...newChain[stepIndex].joinCondition, fromField: e.target.value }
|
||||
};
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
placeholder={option.multiTableConfig?.joinChain[stepIndex - 1]?.outputField || "이전 출력 필드"}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center pb-1">
|
||||
<span className="text-xs text-muted-foreground">=</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 테이블 필드</Label>
|
||||
<ReferenceColumnSelector
|
||||
referenceTable={step.tableName}
|
||||
value={step.joinCondition.toField}
|
||||
onChange={(value) => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain[stepIndex] = {
|
||||
...newChain[stepIndex],
|
||||
joinCondition: { ...newChain[stepIndex].joinCondition, toField: value }
|
||||
};
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다음 단계로 전달할 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">다음 단계로 전달할 필드 (출력)</Label>
|
||||
<ReferenceColumnSelector
|
||||
referenceTable={step.tableName}
|
||||
value={step.outputField || ""}
|
||||
onChange={(value) => {
|
||||
const newChain = [...(option.multiTableConfig?.joinChain || [])];
|
||||
newChain[stepIndex] = { ...newChain[stepIndex], outputField: value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, joinChain: newChain },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{stepIndex < (option.multiTableConfig?.joinChain.length || 0) - 1
|
||||
? "다음 조인 단계에서 사용할 필드"
|
||||
: "마지막 단계면 비워두세요"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조인 미리보기 */}
|
||||
{step.tableName && step.joinCondition.fromField && step.joinCondition.toField && (
|
||||
<div className="p-2 bg-muted/50 rounded text-[10px] font-mono">
|
||||
<span className="text-blue-600 dark:text-blue-400">
|
||||
{stepIndex === 0 ? "현재행" : option.multiTableConfig?.joinChain[stepIndex - 1]?.tableName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">.{step.joinCondition.fromField}</span>
|
||||
<span className="mx-2 text-green-600 dark:text-green-400">=</span>
|
||||
<span className="text-green-600 dark:text-green-400">{step.tableName}</span>
|
||||
<span className="text-muted-foreground">.{step.joinCondition.toField}</span>
|
||||
{step.outputField && (
|
||||
<span className="ml-2 text-purple-600 dark:text-purple-400">
|
||||
→ {step.outputField}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 조인 체인이 없을 때 안내 */}
|
||||
{(!option.multiTableConfig?.joinChain || option.multiTableConfig.joinChain.length === 0) && (
|
||||
<div className="p-4 border-2 border-dashed rounded-lg text-center">
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
조인 체인이 없습니다
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
"조인 추가" 버튼을 클릭하여 테이블 조인을 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 최종 값 컬럼 */}
|
||||
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
|
||||
<div className="space-y-1 pt-2 border-t">
|
||||
<Label className="text-[10px]">최종 값 컬럼 (가져올 값) *</Label>
|
||||
<ReferenceColumnSelector
|
||||
referenceTable={option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName || ""}
|
||||
value={option.multiTableConfig.valueColumn || ""}
|
||||
onChange={(value) => {
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
multiTableConfig: { ...option.multiTableConfig!, valueColumn: value },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
마지막 테이블에서 가져올 값
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 전체 조인 경로 미리보기 */}
|
||||
{option.multiTableConfig?.joinChain && option.multiTableConfig.joinChain.length > 0 && (
|
||||
<div className="p-3 bg-muted rounded-md">
|
||||
<p className="text-[10px] font-medium mb-2">조인 경로 미리보기</p>
|
||||
<div className="text-[10px] font-mono space-y-1">
|
||||
{option.multiTableConfig.joinChain.map((step, idx) => (
|
||||
<div key={idx} className="flex items-center gap-1">
|
||||
{idx === 0 && (
|
||||
<>
|
||||
<span className="text-blue-600">현재행</span>
|
||||
<span>.{step.joinCondition.fromField}</span>
|
||||
<span className="text-muted-foreground mx-1">→</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-green-600">{step.tableName}</span>
|
||||
<span>.{step.joinCondition.toField}</span>
|
||||
{step.outputField && idx < option.multiTableConfig!.joinChain.length - 1 && (
|
||||
<>
|
||||
<span className="text-muted-foreground mx-1">→</span>
|
||||
<span className="text-purple-600">{step.outputField}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{option.multiTableConfig.valueColumn && (
|
||||
<div className="pt-1 border-t mt-1">
|
||||
<span className="text-orange-600">최종 값: </span>
|
||||
<span>{option.multiTableConfig.joinChain[option.multiTableConfig.joinChain.length - 1]?.tableName}.{option.multiTableConfig.valueColumn}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 호출 설정 */}
|
||||
{option.sourceType === "api" && (
|
||||
<div className="space-y-3 p-3 bg-purple-50 dark:bg-purple-950 rounded-md border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-xs font-medium">API 호출 설정</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
복잡한 다중 조인은 백엔드 API로 처리합니다
|
||||
</p>
|
||||
|
||||
{/* API 엔드포인트 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">API 엔드포인트 *</Label>
|
||||
<Input
|
||||
value={option.apiConfig?.endpoint || ""}
|
||||
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, endpoint: e.target.value },
|
||||
})}
|
||||
placeholder="/api/price/customer"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={option.apiConfig?.method || "GET"}
|
||||
onValueChange={(value) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, method: value as "GET" | "POST" },
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 파라미터 매핑 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">파라미터 매핑 *</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newMappings = [...(option.apiConfig?.parameterMappings || []), { paramName: "", sourceField: "" }];
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||
});
|
||||
}}
|
||||
className="h-6 text-[10px] px-2"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(option.apiConfig?.parameterMappings || []).map((mapping, mapIndex) => (
|
||||
<div key={mapIndex} className="flex items-center gap-2 p-2 bg-background rounded">
|
||||
<Input
|
||||
value={mapping.paramName}
|
||||
onChange={(e) => {
|
||||
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||
newMappings[mapIndex] = { ...newMappings[mapIndex], paramName: e.target.value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||
});
|
||||
}}
|
||||
placeholder="파라미터명"
|
||||
className="h-7 text-[10px] flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">=</span>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) => {
|
||||
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||
newMappings[mapIndex] = { ...newMappings[mapIndex], sourceField: value };
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-[10px] flex-1">
|
||||
<SelectValue placeholder="현재 행 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(localConfig.columns || []).map((col) => (
|
||||
<SelectItem key={col.field} value={col.field}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newMappings = [...(option.apiConfig?.parameterMappings || [])];
|
||||
newMappings.splice(mapIndex, 1);
|
||||
updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, parameterMappings: newMappings },
|
||||
});
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 응답 값 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">응답 값 필드 *</Label>
|
||||
<Input
|
||||
value={option.apiConfig?.responseValueField || ""}
|
||||
onChange={(e) => updateDynamicSourceOption(editingDynamicSourceColumnIndex, optIndex, {
|
||||
apiConfig: { ...option.apiConfig!, responseValueField: e.target.value },
|
||||
})}
|
||||
placeholder="price (또는 data.price)"
|
||||
className="h-8 text-xs font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
API 응답에서 값을 가져올 필드 (중첩 경로 지원: data.price)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 옵션 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => addDynamicSourceOption(editingDynamicSourceColumnIndex)}
|
||||
className="w-full h-10"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
데이터 소스 옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="p-3 bg-muted rounded-md text-xs text-muted-foreground">
|
||||
<p className="font-medium mb-1">사용 예시</p>
|
||||
<ul className="space-y-1 text-[10px]">
|
||||
<li>- <strong>거래처별 단가</strong>: customer_item_price 테이블에서 조회</li>
|
||||
<li>- <strong>품목별 단가</strong>: item_info 테이블에서 기준 단가 조회</li>
|
||||
<li>- <strong>계약 단가</strong>: 전용 API로 복잡한 조인 처리</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDynamicSourceModalOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user