ERP-node/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPan...

2297 lines
114 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
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, 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";
interface ModalRepeaterTableConfigPanelProps {
config: Partial<ModalRepeaterTableProps>;
onChange: (config: Partial<ModalRepeaterTableProps>) => void;
onConfigChange?: (config: Partial<ModalRepeaterTableProps>) => void; // 하위 호환성
}
// 소스 컬럼 선택기 (동적 테이블별 컬럼 로드)
function SourceColumnSelector({
sourceTable,
value,
onChange,
}: {
sourceTable: string;
value: string;
onChange: (value: string) => void;
}) {
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadColumns = async () => {
if (!sourceTable) {
setColumns([]);
return;
}
setIsLoading(true);
try {
const response = await tableManagementApi.getColumnList(sourceTable);
if (response.success && response.data) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
loadColumns();
}, [sourceTable]);
return (
<Select value={value} onValueChange={onChange} disabled={!sourceTable || isLoading}>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder={isLoading ? "로딩 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// 참조 컬럼 선택기 (동적 테이블별 컬럼 로드)
function ReferenceColumnSelector({
referenceTable,
value,
onChange,
}: {
referenceTable: string;
value: string;
onChange: (value: string) => void;
}) {
const [columns, setColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const loadColumns = async () => {
if (!referenceTable) {
setColumns([]);
return;
}
setIsLoading(true);
try {
const response = await tableManagementApi.getColumnList(referenceTable);
if (response.success && response.data) {
setColumns(response.data.columns);
}
} catch (error) {
console.error("참조 컬럼 로드 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
loadColumns();
}, [referenceTable]);
return (
<Select value={value} onValueChange={onChange} disabled={!referenceTable || isLoading}>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder={isLoading ? "로딩 중..." : "컬럼 선택"} />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export function ModalRepeaterTableConfigPanel({
config,
onChange,
onConfigChange,
}: ModalRepeaterTableConfigPanelProps) {
// 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용
const handleConfigChange = onConfigChange || onChange;
// 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화
const cleanupInitialConfig = (initialConfig: Partial<ModalRepeaterTableProps>): Partial<ModalRepeaterTableProps> => {
// 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거
if (!initialConfig.calculationRules || initialConfig.calculationRules.length === 0) {
const cleanedColumns = (initialConfig.columns || []).map((col) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { calculated, ...rest } = col;
return { ...rest, editable: true };
});
return { ...initialConfig, columns: cleanedColumns };
}
// 계산 규칙이 있으면 결과 필드만 calculated=true
const resultFields = new Set(initialConfig.calculationRules.map((rule) => rule.result));
const cleanedColumns = (initialConfig.columns || []).map((col) => {
if (resultFields.has(col.field)) {
// 계산 결과 필드는 calculated=true, editable=false
return { ...col, calculated: true, editable: false };
} else {
// 나머지 필드는 calculated 제거, editable=true
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { calculated, ...rest } = col;
return { ...rest, editable: true };
}
});
return { ...initialConfig, columns: cleanedColumns };
};
const [localConfig, setLocalConfig] = useState(cleanupInitialConfig(config));
const [allTables, setAllTables] = useState<{ tableName: string; displayName?: string }[]>([]);
const [tableColumns, setTableColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [targetTableColumns, setTargetTableColumns] = useState<{ columnName: string; displayName?: string }[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false);
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(() => {
const cleanedConfig = cleanupInitialConfig(config);
setLocalConfig(cleanedConfig);
}, [config]);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 소스 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.sourceTable) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.sourceTable);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.sourceTable]);
// 저장 테이블의 컬럼 목록 로드
useEffect(() => {
const loadTargetColumns = async () => {
if (!localConfig.targetTable) {
setTargetTableColumns([]);
return;
}
setIsLoadingTargetColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.targetTable);
if (response.success && response.data) {
setTargetTableColumns(response.data.columns);
}
} catch (error) {
console.error("저장 테이블 컬럼 로드 실패:", error);
setTargetTableColumns([]);
} finally {
setIsLoadingTargetColumns(false);
}
};
loadTargetColumns();
}, [localConfig.targetTable]);
const updateConfig = (updates: Partial<ModalRepeaterTableProps>) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
handleConfigChange(newConfig);
};
const addSourceColumn = () => {
const columns = localConfig.sourceColumns || [];
updateConfig({ sourceColumns: [...columns, ""] });
};
const updateSourceColumn = (index: number, value: string) => {
const columns = [...(localConfig.sourceColumns || [])];
columns[index] = value;
updateConfig({ sourceColumns: columns });
};
const removeSourceColumn = (index: number) => {
const columns = [...(localConfig.sourceColumns || [])];
columns.splice(index, 1);
updateConfig({ sourceColumns: columns });
};
const addSearchField = () => {
const fields = localConfig.sourceSearchFields || [];
updateConfig({ sourceSearchFields: [...fields, ""] });
};
const updateSearchField = (index: number, value: string) => {
const fields = [...(localConfig.sourceSearchFields || [])];
fields[index] = value;
updateConfig({ sourceSearchFields: fields });
};
const removeSearchField = (index: number) => {
const fields = [...(localConfig.sourceSearchFields || [])];
fields.splice(index, 1);
updateConfig({ sourceSearchFields: fields });
};
// 🆕 저장 테이블 컬럼에서 반복 테이블 컬럼 추가
const addRepeaterColumnFromTarget = (columnName: string) => {
const columns = localConfig.columns || [];
// 이미 존재하는 컬럼인지 확인
if (columns.some((col) => col.field === columnName)) {
alert("이미 추가된 컬럼입니다.");
return;
}
const targetCol = targetTableColumns.find((c) => c.columnName === columnName);
const newColumn: RepeaterColumnConfig = {
field: columnName,
label: targetCol?.displayName || columnName,
type: "text",
width: "150px",
editable: true,
mapping: {
type: "manual",
referenceTable: localConfig.targetTable,
referenceField: columnName,
},
};
updateConfig({ columns: [...columns, newColumn] });
};
// 🆕 반복 테이블 컬럼 삭제
const removeRepeaterColumn = (index: number) => {
const columns = [...(localConfig.columns || [])];
columns.splice(index, 1);
updateConfig({ columns });
};
// 🆕 반복 테이블 컬럼 개별 수정
const updateRepeaterColumn = (index: number, updates: Partial<RepeaterColumnConfig>) => {
const columns = [...(localConfig.columns || [])];
columns[index] = { ...columns[index], ...updates };
updateConfig({ columns });
};
// 🆕 계산 규칙 관리
const addCalculationRule = () => {
const rules = localConfig.calculationRules || [];
const newRule: CalculationRule = {
result: "",
formula: "",
dependencies: [],
};
updateConfig({ calculationRules: [...rules, newRule] });
};
const updateCalculationRule = (index: number, updates: Partial<CalculationRule>) => {
const rules = [...(localConfig.calculationRules || [])];
const oldRule = rules[index];
const newRule = { ...oldRule, ...updates };
// 결과 필드가 변경된 경우
if (updates.result !== undefined && oldRule.result !== updates.result) {
const columns = [...(localConfig.columns || [])];
// 이전 결과 필드의 calculated 속성 제거
if (oldRule.result) {
const oldResultIndex = columns.findIndex((c) => c.field === oldRule.result);
if (oldResultIndex !== -1) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { calculated, ...rest } = columns[oldResultIndex];
columns[oldResultIndex] = { ...rest, editable: true };
}
}
// 새 결과 필드를 calculated=true, editable=false로 설정
if (updates.result) {
const newResultIndex = columns.findIndex((c) => c.field === updates.result);
if (newResultIndex !== -1) {
columns[newResultIndex] = {
...columns[newResultIndex],
calculated: true,
editable: false,
};
}
}
rules[index] = newRule;
updateConfig({ calculationRules: rules, columns });
return;
}
rules[index] = newRule;
updateConfig({ calculationRules: rules });
};
const removeCalculationRule = (index: number) => {
const rules = [...(localConfig.calculationRules || [])];
const removedRule = rules[index];
// 결과 필드의 calculated 속성 제거
if (removedRule.result) {
const columns = [...(localConfig.columns || [])];
const resultIndex = columns.findIndex((c) => c.field === removedRule.result);
if (resultIndex !== -1) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { calculated, ...rest } = columns[resultIndex];
columns[resultIndex] = { ...rest, editable: true };
}
rules.splice(index, 1);
updateConfig({ calculationRules: rules, columns });
return;
}
rules.splice(index, 1);
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">
{/* 소스/저장 테이블 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1">1. ( )</h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 소스 테이블 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.sourceTable
? allTables.find((t) => t.tableName === localConfig.sourceTable)?.displayName || localConfig.sourceTable
: isLoadingTables ? "로딩 중..." : "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({ sourceTable: table.tableName });
setOpenTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="border-t pt-4 mt-4">
<h3 className="text-sm font-semibold mb-1">2. ( )</h3>
<p className="text-xs text-muted-foreground mb-2">
</p>
</div>
{/* 저장 테이블 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label>
<Popover open={openTargetTableCombo} onOpenChange={setOpenTargetTableCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTargetTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.targetTable
? allTables.find((t) => t.tableName === localConfig.targetTable)?.displayName || localConfig.targetTable
: isLoadingTables ? "로딩 중..." : "저장할 테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({ targetTable: table.tableName });
setOpenTargetTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 소스 컬럼 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> ( )</Label>
<Button
size="sm"
variant="outline"
onClick={addSourceColumn}
className="h-7 text-xs"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-3">
{(localConfig.sourceColumns || []).map((column, index) => (
<div key={index} className="flex items-start gap-2 p-3 border rounded-md bg-background">
<div className="flex-1 space-y-2">
{/* 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Select
value={column}
onValueChange={(value) => updateSourceColumn(index, value)}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 라벨 입력 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={localConfig.sourceColumnLabels?.[column] || ""}
onChange={(e) => {
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
if (e.target.value) {
newLabels[column] = e.target.value;
} else {
delete newLabels[column];
}
updateConfig({ sourceColumnLabels: newLabels });
}}
placeholder={tableColumns.find(c => c.columnName === column)?.displayName || column || "라벨 입력"}
className="h-8 text-xs"
disabled={!column}
/>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
// 컬럼 삭제 시 해당 라벨도 삭제
const newLabels = { ...(localConfig.sourceColumnLabels || {}) };
delete newLabels[column];
updateConfig({ sourceColumnLabels: newLabels });
removeSourceColumn(index);
}}
className="h-8 w-8 p-0 mt-5"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
{(localConfig.sourceColumns || []).length === 0 && (
<div className="text-center py-4 border-2 border-dashed rounded-lg">
<p className="text-xs text-muted-foreground">
"추가"
</p>
</div>
)}
</div>
</div>
{/* 검색 필드 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2">
{(localConfig.sourceSearchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.sourceTable || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
{/* 중복 체크 필드 */}
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Popover open={openUniqueFieldCombo} onOpenChange={setOpenUniqueFieldCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openUniqueFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.sourceTable || isLoadingColumns}
>
{localConfig.uniqueField
? tableColumns.find((c) => c.columnName === localConfig.uniqueField)?.displayName || localConfig.uniqueField
: isLoadingColumns ? "로딩 중..." : "필드 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{tableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({ uniqueField: column.columnName });
setOpenUniqueFieldCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", localConfig.uniqueField === column.columnName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
(: 품목 )
</p>
</div>
</div>
{/* 모달 설정 */}
<div className="space-y-4 border rounded-lg p-4">
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalTitle || ""}
onChange={(e) => updateConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Input
value={localConfig.modalButtonText || ""}
onChange={(e) => updateConfig({ modalButtonText: e.target.value })}
placeholder="항목 검색"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.multiSelect ?? true}
onCheckedChange={(checked) =>
updateConfig({ multiSelect: checked })
}
/>
</div>
</div>
</div>
{/* 반복 테이블 컬럼 관리 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1">3. ( )</h3>
<p className="text-xs text-muted-foreground">
-
</p>
</div>
{/* 저장 테이블 컬럼 선택 */}
{localConfig.targetTable && targetTableColumns.length > 0 && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex flex-wrap gap-2">
{targetTableColumns.map((col) => (
<Button
key={col.columnName}
size="sm"
variant="outline"
onClick={() => addRepeaterColumnFromTarget(col.columnName)}
className="h-7 text-xs"
disabled={isLoadingTargetColumns}
>
<Plus className="h-3 w-3 mr-1" />
{col.displayName || col.columnName}
</Button>
))}
</div>
</div>
)}
{/* 추가된 컬럼 목록 */}
{localConfig.columns && localConfig.columns.length > 0 && (
<div className="space-y-3">
<Label className="text-xs sm:text-sm"> </Label>
<div className="flex flex-wrap gap-2">
{localConfig.columns.map((col, index) => (
<div
key={index}
className="inline-flex items-center gap-2 px-3 py-1.5 bg-primary/10 text-primary rounded-md text-xs"
>
<span className="font-medium">{col.label}</span>
<span className="text-[10px] opacity-70">
({col.mapping?.type === "source" ? "소스" : col.mapping?.type === "reference" ? "참조" : "수동입력"})
</span>
<button
onClick={() => removeRepeaterColumn(index)}
className="ml-1 hover:bg-primary/20 rounded p-0.5"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
{/* 개별 컬럼 설정 - 세로 레이아웃 */}
<div className="space-y-4">
{localConfig.columns.map((col, index) => (
<div key={index} className="border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">{col.label}</h4>
<Button
size="sm"
variant="ghost"
onClick={() => removeRepeaterColumn(index)}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
{/* 세로 레이아웃: 모든 항목 동일한 너비/높이 */}
<div className="grid grid-cols-1 gap-4">
{/* 1. 품목명 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Input
value={col.label}
onChange={(e) => updateRepeaterColumn(index, { label: e.target.value })}
placeholder="화면에 표시될 이름"
className="h-10 text-sm w-full"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 2. 컬럼명 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Input
value={col.field}
onChange={(e) => updateRepeaterColumn(index, { field: e.target.value })}
placeholder="데이터베이스 필드명"
className="h-10 text-sm w-full"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 3. 컬럼 타입 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Select
value={col.type || "text"}
onValueChange={(value) => updateRepeaterColumn(index, { type: value as "text" | "number" | "date" | "select" })}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
<SelectItem value="select"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 4. 매핑 설정 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
<Select
value={col.mapping?.type || "manual"}
onValueChange={(value) => updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: value as "source" | "reference" | "manual"
}
})}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="source"> </SelectItem>
<SelectItem value="reference"> </SelectItem>
<SelectItem value="manual"> ()</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{col.mapping?.type === "source" && "모달에서 선택한 항목의 값을 가져옴"}
{col.mapping?.type === "reference" && "다른 테이블의 값을 참조"}
{col.mapping?.type === "manual" && "사용자가 직접 입력"}
</p>
</div>
{/* 5. 소스 정보 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
</Label>
{col.mapping?.type === "source" && (
<div className="space-y-3">
<div className="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 mb-2"> </p>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
{/* 소스 테이블 선택 */}
<div className="space-y-1.5">
<Label className="text-[11px]"> *</Label>
<Select
value={col.mapping?.referenceTable || localConfig.sourceTable || ""}
onValueChange={(value) => updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "source",
referenceTable: value,
}
})}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 소스 컬럼 선택 */}
<div className="space-y-1.5">
<Label className="text-[11px]"> *</Label>
<SourceColumnSelector
sourceTable={col.mapping?.referenceTable || localConfig.sourceTable || ""}
value={col.mapping?.sourceField || ""}
onChange={(value) => updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "source",
sourceField: value
} as ColumnMapping
})}
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
)}
{col.mapping?.type === "reference" && (
<div className="space-y-3">
<div className="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 mb-2"> </p>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
{/* 참조 테이블 선택 */}
<div className="space-y-1.5">
<Label className="text-[11px]"> *</Label>
<Select
value={col.mapping?.referenceTable || ""}
onValueChange={(value) => updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
referenceTable: value,
}
})}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{allTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 참조 컬럼 선택 */}
<div className="space-y-1.5">
<Label className="text-[11px]"> *</Label>
<ReferenceColumnSelector
referenceTable={col.mapping?.referenceTable || ""}
value={col.mapping?.referenceField || ""}
onChange={(value) => updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
referenceField: value
} as ColumnMapping
})}
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 조인 조건 설정 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-[11px]"> *</Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentConditions = col.mapping?.joinCondition || [];
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: [
...currentConditions,
{ sourceField: "", targetField: "", operator: "=" }
]
} as ColumnMapping
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
</p>
{/* 조인 조건 목록 */}
<div className="space-y-2">
{(col.mapping?.joinCondition || []).map((condition, condIndex) => (
<div key={condIndex} className="p-3 border rounded-md bg-background space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-[10px] font-medium text-muted-foreground">
{condIndex + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
const currentConditions = [...(col.mapping?.joinCondition || [])];
currentConditions.splice(condIndex, 1);
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: currentConditions
} as ColumnMapping
});
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 소스 테이블 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<Select
value={condition.sourceTable || "target"}
onValueChange={(value) => {
const currentConditions = [...(col.mapping?.joinCondition || [])];
currentConditions[condIndex] = {
...currentConditions[condIndex],
sourceTable: value as "source" | "target"
};
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: currentConditions
} as ColumnMapping
});
}}
>
<SelectTrigger className="h-9 text-xs w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="target">
<div className="flex flex-col">
<span className="font-medium"> ()</span>
<span className="text-[10px] text-muted-foreground">
{localConfig.targetTable || "sales_order_mng"}
</span>
</div>
</SelectItem>
<SelectItem value="source">
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-[10px] text-muted-foreground">
{localConfig.sourceTable || "item_info"}
</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
=
</p>
</div>
{/* 소스 필드 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<Select
value={condition.sourceField || ""}
onValueChange={(value) => {
const currentConditions = [...(col.mapping?.joinCondition || [])];
currentConditions[condIndex] = {
...currentConditions[condIndex],
sourceField: value
};
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: currentConditions
} as ColumnMapping
});
}}
>
<SelectTrigger className="h-9 text-xs w-full">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{/* 저장 테이블 선택 시 */}
{(!condition.sourceTable || condition.sourceTable === "target") && (
<>
{/* 반복 테이블에 추가된 컬럼 */}
{(localConfig.columns || []).length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-semibold text-muted-foreground">
()
</div>
{localConfig.columns
.filter((c) => c.field !== col.field)
.map((repeaterCol) => (
<SelectItem key={`repeater-${repeaterCol.field}`} value={repeaterCol.field}>
<span className="font-medium">{repeaterCol.label}</span>
<span className="text-[10px] text-muted-foreground ml-1">({repeaterCol.field})</span>
</SelectItem>
))}
</>
)}
{/* 저장 테이블의 모든 컬럼 */}
{targetTableColumns.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-2">
({localConfig.targetTable || "sales_order_mng"})
</div>
{targetTableColumns.map((targetCol) => (
<SelectItem key={`target-${targetCol.columnName}`} value={targetCol.columnName}>
{targetCol.displayName || targetCol.columnName}
</SelectItem>
))}
</>
)}
</>
)}
{/* 소스 테이블 선택 시 */}
{condition.sourceTable === "source" && (
<>
{tableColumns.length > 0 && (
<>
<div className="px-2 py-1.5 text-[10px] font-semibold text-muted-foreground">
({localConfig.sourceTable || "item_info"})
</div>
{tableColumns.map((sourceCol) => (
<SelectItem key={`source-${sourceCol.columnName}`} value={sourceCol.columnName}>
{sourceCol.displayName || sourceCol.columnName}
</SelectItem>
))}
</>
)}
</>
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
{(!condition.sourceTable || condition.sourceTable === "target")
? "반복 테이블에 이미 추가된 컬럼"
: "모달에서 선택한 원본 데이터의 컬럼"}
</p>
</div>
{/* 연산자 */}
<div className="flex items-center justify-center">
<Select
value={condition.operator || "="}
onValueChange={(value) => {
const currentConditions = [...(col.mapping?.joinCondition || [])];
currentConditions[condIndex] = {
...currentConditions[condIndex],
operator: value as "=" | "!=" | ">" | "<" | ">=" | "<="
};
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: currentConditions
} as ColumnMapping
});
}}
>
<SelectTrigger className="h-9 text-xs w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
</SelectContent>
</Select>
</div>
{/* 대상 필드 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> ( )</Label>
<ReferenceColumnSelector
referenceTable={col.mapping?.referenceTable || ""}
value={condition.targetField || ""}
onChange={(value) => {
const currentConditions = [...(col.mapping?.joinCondition || [])];
currentConditions[condIndex] = {
...currentConditions[condIndex],
targetField: value
};
updateRepeaterColumn(index, {
mapping: {
...col.mapping,
type: "reference",
joinCondition: currentConditions
} as ColumnMapping
});
}}
/>
</div>
{/* 조인 조건 미리보기 */}
{condition.sourceField && condition.targetField && (
<div className="mt-2 p-2 bg-muted/50 rounded text-[10px] font-mono">
<span className="text-blue-600 dark:text-blue-400">
{condition.sourceTable === "source"
? localConfig.sourceTable
: localConfig.targetTable || "저장테이블"}
</span>
<span className="text-muted-foreground">.{condition.sourceField}</span>
<span className="mx-2 text-purple-600 dark:text-purple-400">{condition.operator || "="}</span>
<span className="text-purple-600 dark:text-purple-400">{col.mapping?.referenceTable}</span>
<span className="text-muted-foreground">.{condition.targetField}</span>
</div>
)}
</div>
))}
{/* 조인 조건 없을 때 안내 */}
{(!col.mapping?.joinCondition || col.mapping.joinCondition.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>
{/* 조인 조건 예시 */}
{col.mapping?.referenceTable && (
<div className="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 mb-2"> </p>
<div className="space-y-1 text-[10px] text-muted-foreground">
<p>) :</p>
<p className="ml-2 font-mono"> item_code = item_code</p>
<p className="ml-2 font-mono"> customer_code = customer_code</p>
</div>
</div>
)}
</div>
</div>
)}
{col.mapping?.type === "manual" && (
<div className="p-3 bg-green-50 dark:bg-green-950 rounded-md border border-green-200 dark:border-green-800">
<p className="text-xs font-medium mb-2"> ()</p>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
</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>
</div>
)}
</div>
{/* 계산 규칙 (자동 계산) */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1">4. ( )</h3>
<p className="text-xs text-muted-foreground">
(: 수량 x = )
</p>
</div>
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded-md border border-amber-200 dark:border-amber-800">
<p className="text-xs font-medium mb-1"> </p>
<ul className="text-[10px] text-muted-foreground space-y-1">
<li> <strong className="text-primary"> </strong> ( )</li>
<li> </li>
<li> 예시: order_qty * unit_price = total_amount</li>
</ul>
</div>
{localConfig.calculationRules && localConfig.calculationRules.length > 0 ? (
<div className="space-y-4">
{localConfig.calculationRules.map((rule, index) => (
<div key={index} className="border rounded-lg p-4 space-y-4 bg-gradient-to-br from-card to-muted/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
{index + 1}
</div>
<h4 className="text-sm font-semibold"> {index + 1}</h4>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeCalculationRule(index)}
className="h-7 w-7 p-0 hover:bg-destructive/10 hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{/* 결과 필드 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
( ) *
</Label>
<Select
value={rule.result || ""}
onValueChange={(value) => updateCalculationRule(index, { result: value })}
>
<SelectTrigger className="h-10 text-sm w-full">
<SelectValue placeholder="결과 컬럼 선택">
{rule.result
? localConfig.columns?.find((c) => c.field === rule.result)?.label || rule.result
: "결과 컬럼 선택"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{(localConfig.columns || []).map((col) => (
<SelectItem key={col.field} value={col.field}>
{col.label} ({col.field})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 계산 공식 */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
(JavaScript ) *
</Label>
<Input
value={rule.formula || ""}
onChange={(e) => updateCalculationRule(index, { formula: e.target.value })}
placeholder="예: order_qty * unit_price"
className="h-10 text-sm w-full font-mono"
/>
<p className="text-[10px] text-muted-foreground">
: +, -, *, /, (),
</p>
</div>
{/* 현재 설정 표시 */}
{rule.result && rule.formula && (
<div className="p-3 bg-primary/5 border border-primary/20 rounded-md">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-4 bg-primary rounded-full"></div>
<p className="text-xs font-semibold text-primary"> </p>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="font-bold text-primary">
{localConfig.columns?.find((c) => c.field === rule.result)?.label || rule.result}
</span>
<span className="text-muted-foreground">=</span>
<code className="px-2 py-1 bg-muted rounded text-xs font-mono">
{rule.formula}
</code>
</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 border-2 border-dashed rounded-lg">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted flex items-center justify-center">
<Plus className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium mb-1"> </p>
<p className="text-xs text-muted-foreground mb-4">
</p>
<Button size="sm" variant="outline" onClick={addCalculationRule}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
)}
{localConfig.calculationRules && localConfig.calculationRules.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={addCalculationRule}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
)}
</div>
{/* 설정 순서 안내 */}
<div className="p-4 bg-muted rounded-md text-xs text-muted-foreground">
<p className="font-medium mb-2"> :</p>
<ol className="space-y-1 list-decimal list-inside">
<li><strong> </strong> (): </li>
<li><strong> </strong> (): </li>
<li><strong> </strong>: </li>
<li><strong> </strong>: </li>
<li><strong> </strong>: ()</li>
</ol>
<div className="mt-4 pt-4 border-t border-border">
<p className="font-medium mb-2"> :</p>
<ul className="space-y-1.5 text-[10px]">
<li><strong> </strong></li>
<li className="ml-4">- : <code className="bg-background px-1 py-0.5 rounded">item_info</code> ( )</li>
<li className="ml-4">- : <code className="bg-background px-1 py-0.5 rounded">sales_order_mng</code> ( )</li>
<li className="ml-4">- 컬럼: part_name, quantity, unit_price, amount...</li>
<li className="ml-4">- 매핑: item_name part_name ( )</li>
<li className="ml-4">- : <code className="bg-background px-1 py-0.5 rounded">amount = quantity * unit_price</code></li>
</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>
);
}