feat(modal-repeater-table): 동적 데이터 소스 전환 기능 및 UniversalFormModal 저장 버튼 옵션 추가
- ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환 - 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원 - DynamicDataSourceConfig, MultiTableJoinStep 타입 추가 - 설정 패널에 동적 데이터 소스 설정 모달 추가 - UniversalFormModal: showSaveButton 옵션 추가
This commit is contained in:
parent
7ac6bbc2c6
commit
d550959cb7
|
|
@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Plus } from "lucide-react";
|
||||
import { ItemSelectionModal } from "./ItemSelectionModal";
|
||||
import { RepeaterTable } from "./RepeaterTable";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition } from "./types";
|
||||
import { ModalRepeaterTableProps, RepeaterColumnConfig, JoinCondition, DynamicDataSourceOption } from "./types";
|
||||
import { useCalculation } from "./useCalculation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -293,6 +293,9 @@ export function ModalRepeaterTableComponent({
|
|||
|
||||
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
|
||||
|
||||
// 🆕 동적 데이터 소스 활성화 상태 (컬럼별로 현재 선택된 옵션 ID)
|
||||
const [activeDataSources, setActiveDataSources] = useState<Record<string, string>>({});
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||
|
|
@ -409,6 +412,193 @@ export function ModalRepeaterTableComponent({
|
|||
}, [localValue, columnName, component?.id, onFormDataChange, targetTable]);
|
||||
|
||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||
|
||||
/**
|
||||
* 동적 데이터 소스 변경 시 호출
|
||||
* 해당 컬럼의 모든 행 데이터를 새로운 소스에서 다시 조회
|
||||
*/
|
||||
const handleDataSourceChange = async (columnField: string, optionId: string) => {
|
||||
console.log(`🔄 데이터 소스 변경: ${columnField} → ${optionId}`);
|
||||
|
||||
// 활성화 상태 업데이트
|
||||
setActiveDataSources((prev) => ({
|
||||
...prev,
|
||||
[columnField]: optionId,
|
||||
}));
|
||||
|
||||
// 해당 컬럼 찾기
|
||||
const column = columns.find((col) => col.field === columnField);
|
||||
if (!column?.dynamicDataSource?.enabled) {
|
||||
console.warn(`⚠️ 컬럼 "${columnField}"에 동적 데이터 소스가 설정되지 않음`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택된 옵션 찾기
|
||||
const option = column.dynamicDataSource.options.find((opt) => opt.id === optionId);
|
||||
if (!option) {
|
||||
console.warn(`⚠️ 옵션 "${optionId}"을 찾을 수 없음`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 모든 행에 대해 새 값 조회
|
||||
const updatedData = await Promise.all(
|
||||
localValue.map(async (row, index) => {
|
||||
try {
|
||||
const newValue = await fetchDynamicValue(option, row);
|
||||
console.log(` ✅ 행 ${index}: ${columnField} = ${newValue}`);
|
||||
return {
|
||||
...row,
|
||||
[columnField]: newValue,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(` ❌ 행 ${index} 조회 실패:`, error);
|
||||
return row;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 계산 필드 업데이트 후 데이터 반영
|
||||
const calculatedData = calculateAll(updatedData);
|
||||
handleChange(calculatedData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 동적 데이터 소스 옵션에 따라 값 조회
|
||||
*/
|
||||
async function fetchDynamicValue(
|
||||
option: DynamicDataSourceOption,
|
||||
rowData: any
|
||||
): Promise<any> {
|
||||
if (option.sourceType === "table" && option.tableConfig) {
|
||||
// 테이블 직접 조회 (단순 조인)
|
||||
const { tableName, valueColumn, joinConditions } = option.tableConfig;
|
||||
|
||||
const whereConditions: Record<string, any> = {};
|
||||
for (const cond of joinConditions) {
|
||||
const value = rowData[cond.sourceField];
|
||||
if (value === undefined || value === null) {
|
||||
console.warn(`⚠️ 조인 조건의 소스 필드 "${cond.sourceField}" 값이 없음`);
|
||||
return undefined;
|
||||
}
|
||||
whereConditions[cond.targetField] = value;
|
||||
}
|
||||
|
||||
console.log(`🔍 테이블 조회: ${tableName}`, whereConditions);
|
||||
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{ search: whereConditions, size: 1, page: 1 }
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||
return response.data.data.data[0][valueColumn];
|
||||
}
|
||||
return undefined;
|
||||
|
||||
} else if (option.sourceType === "multiTable" && option.multiTableConfig) {
|
||||
// 테이블 복합 조인 (2개 이상 테이블 순차 조인)
|
||||
const { joinChain, valueColumn } = option.multiTableConfig;
|
||||
|
||||
if (!joinChain || joinChain.length === 0) {
|
||||
console.warn("⚠️ 조인 체인이 비어있습니다.");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.log(`🔗 복합 조인 시작: ${joinChain.length}단계`);
|
||||
|
||||
// 현재 값을 추적 (첫 단계는 현재 행에서 시작)
|
||||
let currentValue: any = null;
|
||||
let currentRow: any = null;
|
||||
|
||||
for (let i = 0; i < joinChain.length; i++) {
|
||||
const step = joinChain[i];
|
||||
const { tableName, joinCondition, outputField } = step;
|
||||
|
||||
// 조인 조건 값 가져오기
|
||||
let fromValue: any;
|
||||
if (i === 0) {
|
||||
// 첫 번째 단계: 현재 행에서 값 가져오기
|
||||
fromValue = rowData[joinCondition.fromField];
|
||||
console.log(` 📍 단계 ${i + 1}: 현재행.${joinCondition.fromField} = ${fromValue}`);
|
||||
} else {
|
||||
// 이후 단계: 이전 조회 결과에서 값 가져오기
|
||||
fromValue = currentRow?.[joinCondition.fromField] || currentValue;
|
||||
console.log(` 📍 단계 ${i + 1}: 이전결과.${joinCondition.fromField} = ${fromValue}`);
|
||||
}
|
||||
|
||||
if (fromValue === undefined || fromValue === null) {
|
||||
console.warn(`⚠️ 단계 ${i + 1}: 조인 조건 값이 없습니다. (${joinCondition.fromField})`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 테이블 조회
|
||||
const whereConditions: Record<string, any> = {
|
||||
[joinCondition.toField]: fromValue
|
||||
};
|
||||
|
||||
console.log(` 🔍 단계 ${i + 1}: ${tableName} 조회`, whereConditions);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{ search: whereConditions, size: 1, page: 1 }
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data?.length > 0) {
|
||||
currentRow = response.data.data.data[0];
|
||||
currentValue = outputField ? currentRow[outputField] : currentRow;
|
||||
console.log(` ✅ 단계 ${i + 1} 성공:`, { outputField, value: currentValue });
|
||||
} else {
|
||||
console.warn(` ⚠️ 단계 ${i + 1}: 조회 결과 없음`);
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(` ❌ 단계 ${i + 1} 조회 실패:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 값 반환 (마지막 테이블에서 valueColumn 가져오기)
|
||||
const finalValue = currentRow?.[valueColumn];
|
||||
console.log(`🎯 복합 조인 완료: ${valueColumn} = ${finalValue}`);
|
||||
return finalValue;
|
||||
|
||||
} else if (option.sourceType === "api" && option.apiConfig) {
|
||||
// 전용 API 호출 (복잡한 다중 조인)
|
||||
const { endpoint, method = "GET", parameterMappings, responseValueField } = option.apiConfig;
|
||||
|
||||
// 파라미터 빌드
|
||||
const params: Record<string, any> = {};
|
||||
for (const mapping of parameterMappings) {
|
||||
const value = rowData[mapping.sourceField];
|
||||
if (value !== undefined && value !== null) {
|
||||
params[mapping.paramName] = value;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 API 호출: ${method} ${endpoint}`, params);
|
||||
|
||||
let response;
|
||||
if (method === "POST") {
|
||||
response = await apiClient.post(endpoint, params);
|
||||
} else {
|
||||
response = await apiClient.get(endpoint, { params });
|
||||
}
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
// responseValueField로 값 추출 (중첩 경로 지원: "data.price")
|
||||
const keys = responseValueField.split(".");
|
||||
let value = response.data.data;
|
||||
for (const key of keys) {
|
||||
value = value?.[key];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
useEffect(() => {
|
||||
|
|
@ -579,6 +769,8 @@ export function ModalRepeaterTableComponent({
|
|||
onDataChange={handleChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
activeDataSources={activeDataSources}
|
||||
onDataSourceChange={handleDataSourceChange}
|
||||
/>
|
||||
|
||||
{/* 항목 선택 모달 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import React, { useState, useEffect } from "react";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Trash2, ChevronDown, Check } from "lucide-react";
|
||||
import { RepeaterColumnConfig } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -14,6 +15,9 @@ interface RepeaterTableProps {
|
|||
onDataChange: (newData: any[]) => void;
|
||||
onRowChange: (index: number, newRow: any) => void;
|
||||
onRowDelete: (index: number) => void;
|
||||
// 동적 데이터 소스 관련
|
||||
activeDataSources?: Record<string, string>; // 컬럼별 현재 활성화된 데이터 소스 ID
|
||||
onDataSourceChange?: (columnField: string, optionId: string) => void; // 데이터 소스 변경 콜백
|
||||
}
|
||||
|
||||
export function RepeaterTable({
|
||||
|
|
@ -22,11 +26,16 @@ export function RepeaterTable({
|
|||
onDataChange,
|
||||
onRowChange,
|
||||
onRowDelete,
|
||||
activeDataSources = {},
|
||||
onDataSourceChange,
|
||||
}: RepeaterTableProps) {
|
||||
const [editingCell, setEditingCell] = useState<{
|
||||
rowIndex: number;
|
||||
field: string;
|
||||
} | null>(null);
|
||||
|
||||
// 동적 데이터 소스 Popover 열림 상태
|
||||
const [openPopover, setOpenPopover] = useState<string | null>(null);
|
||||
|
||||
// 데이터 변경 감지 (필요시 활성화)
|
||||
// useEffect(() => {
|
||||
|
|
@ -144,16 +153,79 @@ export function RepeaterTable({
|
|||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</th>
|
||||
))}
|
||||
{columns.map((col) => {
|
||||
const hasDynamicSource = col.dynamicDataSource?.enabled && col.dynamicDataSource.options.length > 0;
|
||||
const activeOptionId = activeDataSources[col.field] || col.dynamicDataSource?.defaultOptionId;
|
||||
const activeOption = hasDynamicSource
|
||||
? col.dynamicDataSource!.options.find(opt => opt.id === activeOptionId) || col.dynamicDataSource!.options[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{hasDynamicSource ? (
|
||||
<Popover
|
||||
open={openPopover === col.field}
|
||||
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 hover:text-primary transition-colors",
|
||||
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
|
||||
)}
|
||||
>
|
||||
<span>{col.label}</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-auto min-w-[160px] p-1"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
|
||||
데이터 소스 선택
|
||||
</div>
|
||||
{col.dynamicDataSource!.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onDataSourceChange?.(col.field, option.id);
|
||||
setOpenPopover(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
"focus:outline-none focus-visible:bg-accent",
|
||||
activeOption?.id === option.id && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||
삭제
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export interface ModalRepeaterTableProps {
|
|||
sourceColumnLabels?: Record<string, string>; // 모달 컬럼 라벨 (컬럼명 -> 표시 라벨)
|
||||
sourceSearchFields?: string[]; // 검색 가능한 필드들
|
||||
|
||||
// 🆕 저장 대상 테이블 설정
|
||||
// 저장 대상 테이블 설정
|
||||
targetTable?: string; // 저장할 테이블 (예: "sales_order_mng")
|
||||
|
||||
// 모달 설정
|
||||
|
|
@ -25,14 +25,14 @@ export interface ModalRepeaterTableProps {
|
|||
calculationRules?: CalculationRule[]; // 자동 계산 규칙
|
||||
|
||||
// 데이터
|
||||
value: any[]; // 현재 추가된 항목들
|
||||
onChange: (newData: any[]) => void; // 데이터 변경 콜백
|
||||
value: Record<string, unknown>[]; // 현재 추가된 항목들
|
||||
onChange: (newData: Record<string, unknown>[]) => void; // 데이터 변경 콜백
|
||||
|
||||
// 중복 체크
|
||||
uniqueField?: string; // 중복 체크할 필드 (예: "item_code")
|
||||
|
||||
// 필터링
|
||||
filterCondition?: Record<string, any>;
|
||||
filterCondition?: Record<string, unknown>;
|
||||
companyCode?: string;
|
||||
|
||||
// 스타일
|
||||
|
|
@ -47,11 +47,92 @@ export interface RepeaterColumnConfig {
|
|||
calculated?: boolean; // 계산 필드 여부
|
||||
width?: string; // 컬럼 너비
|
||||
required?: boolean; // 필수 입력 여부
|
||||
defaultValue?: any; // 기본값
|
||||
defaultValue?: string | number | boolean; // 기본값
|
||||
selectOptions?: { value: string; label: string }[]; // select일 때 옵션
|
||||
|
||||
// 🆕 컬럼 매핑 설정
|
||||
|
||||
// 컬럼 매핑 설정
|
||||
mapping?: ColumnMapping; // 이 컬럼의 데이터를 어디서 가져올지 설정
|
||||
|
||||
// 동적 데이터 소스 (컬럼 헤더 클릭으로 데이터 소스 전환)
|
||||
dynamicDataSource?: DynamicDataSourceConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 데이터 소스 설정
|
||||
* 컬럼 헤더를 클릭하여 데이터 소스를 전환할 수 있는 기능
|
||||
* 예: 거래처별 단가, 품목별 단가, 기준 단가 등을 선택
|
||||
*/
|
||||
export interface DynamicDataSourceConfig {
|
||||
enabled: boolean;
|
||||
options: DynamicDataSourceOption[];
|
||||
defaultOptionId?: string; // 기본 선택 옵션 ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 데이터 소스 옵션
|
||||
* 각 옵션은 다른 테이블/API에서 데이터를 가져오는 방법을 정의
|
||||
*/
|
||||
export interface DynamicDataSourceOption {
|
||||
id: string;
|
||||
label: string; // 표시 라벨 (예: "거래처별 단가")
|
||||
|
||||
// 조회 방식
|
||||
sourceType: "table" | "multiTable" | "api";
|
||||
|
||||
// 테이블 직접 조회 (단순 조인 - 1개 테이블)
|
||||
tableConfig?: {
|
||||
tableName: string; // 참조 테이블명
|
||||
valueColumn: string; // 가져올 값 컬럼
|
||||
joinConditions: {
|
||||
sourceField: string; // 현재 행의 필드
|
||||
targetField: string; // 참조 테이블의 필드
|
||||
}[];
|
||||
};
|
||||
|
||||
// 테이블 복합 조인 (2개 이상 테이블 조인)
|
||||
multiTableConfig?: {
|
||||
// 조인 체인 정의 (순서대로 조인)
|
||||
joinChain: MultiTableJoinStep[];
|
||||
// 최종적으로 가져올 값 컬럼 (마지막 테이블에서)
|
||||
valueColumn: string;
|
||||
};
|
||||
|
||||
// 전용 API 호출 (복잡한 다중 조인)
|
||||
apiConfig?: {
|
||||
endpoint: string; // API 엔드포인트 (예: "/api/price/customer")
|
||||
method?: "GET" | "POST"; // HTTP 메서드 (기본: GET)
|
||||
parameterMappings: {
|
||||
paramName: string; // API 파라미터명
|
||||
sourceField: string; // 현재 행의 필드
|
||||
}[];
|
||||
responseValueField: string; // 응답에서 값을 가져올 필드
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 복합 조인 단계 정의
|
||||
* 예: item_info.item_number → customer_item.item_code → customer_item.id → customer_item_price.customer_item_id
|
||||
*/
|
||||
export interface MultiTableJoinStep {
|
||||
// 조인할 테이블
|
||||
tableName: string;
|
||||
// 조인 조건
|
||||
joinCondition: {
|
||||
// 이전 단계의 필드 (첫 번째 단계는 현재 행의 필드)
|
||||
fromField: string;
|
||||
// 이 테이블의 필드
|
||||
toField: string;
|
||||
};
|
||||
// 다음 단계로 전달할 필드 (다음 조인에 사용)
|
||||
outputField?: string;
|
||||
// 추가 필터 조건 (선택사항)
|
||||
additionalFilters?: {
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
value: string | number | boolean;
|
||||
// 값이 현재 행에서 오는 경우
|
||||
valueFromField?: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -61,16 +142,16 @@ export interface RepeaterColumnConfig {
|
|||
export interface ColumnMapping {
|
||||
/** 매핑 타입 */
|
||||
type: "source" | "reference" | "manual";
|
||||
|
||||
|
||||
/** 매핑 타입별 설정 */
|
||||
// type: "source" - 소스 테이블 (모달에서 선택한 항목)의 컬럼에서 가져오기
|
||||
sourceField?: string; // 소스 테이블의 컬럼명 (예: "item_name")
|
||||
|
||||
|
||||
// type: "reference" - 외부 테이블 참조 (조인)
|
||||
referenceTable?: string; // 참조 테이블명 (예: "customer_item_mapping")
|
||||
referenceField?: string; // 참조 테이블에서 가져올 컬럼 (예: "basic_price")
|
||||
joinCondition?: JoinCondition[]; // 조인 조건
|
||||
|
||||
|
||||
// type: "manual" - 사용자가 직접 입력
|
||||
}
|
||||
|
||||
|
|
@ -101,11 +182,10 @@ export interface ItemSelectionModalProps {
|
|||
sourceColumns: string[];
|
||||
sourceSearchFields?: string[];
|
||||
multiSelect?: boolean;
|
||||
filterCondition?: Record<string, any>;
|
||||
filterCondition?: Record<string, unknown>;
|
||||
modalTitle: string;
|
||||
alreadySelected: any[]; // 이미 선택된 항목들 (중복 방지용)
|
||||
alreadySelected: Record<string, unknown>[]; // 이미 선택된 항목들 (중복 방지용)
|
||||
uniqueField?: string;
|
||||
onSelect: (items: any[]) => void;
|
||||
onSelect: (items: Record<string, unknown>[]) => void;
|
||||
columnLabels?: Record<string, string>; // 컬럼명 -> 라벨명 매핑
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1027,7 +1027,7 @@ export function UniversalFormModalComponent({
|
|||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<SelectTrigger id={fieldKey} className="w-full">
|
||||
<SelectTrigger id={fieldKey} className="w-full" size="default">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1409,47 +1409,37 @@ export function UniversalFormModalComponent({
|
|||
{/* 섹션들 */}
|
||||
<div className="space-y-4">{config.sections.map((section) => renderSection(section))}</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
{config.modal.showResetButton && (
|
||||
{/* 버튼 영역 - 저장 버튼이 표시될 때만 렌더링 */}
|
||||
{config.modal.showSaveButton !== false && (
|
||||
<div className="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
{config.modal.showResetButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{config.modal.resetButtonText || "초기화"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
handleSave();
|
||||
}}
|
||||
disabled={saving}
|
||||
disabled={saving || !config.saveConfig.tableName}
|
||||
>
|
||||
<RefreshCw className="mr-1 h-4 w-4" />
|
||||
{config.modal.resetButtonText || "초기화"}
|
||||
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel?.();
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
{config.modal.cancelButtonText || "취소"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}}
|
||||
disabled={saving || !config.saveConfig.tableName}
|
||||
>
|
||||
{saving ? "저장 중..." : config.modal.saveButtonText || "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog
|
||||
|
|
@ -1502,7 +1492,7 @@ function SelectField({ fieldId, value, onChange, optionConfig, placeholder, disa
|
|||
|
||||
return (
|
||||
<Select value={value || ""} onValueChange={onChange} disabled={disabled || loading}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger size="default">
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
|
|||
|
|
@ -402,21 +402,26 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px]">저장 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">취소 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.cancelButtonText || "취소"}
|
||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<div className="border rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">저장 버튼 표시</span>
|
||||
<Switch
|
||||
checked={config.modal.showSaveButton !== false}
|
||||
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked })}
|
||||
/>
|
||||
</div>
|
||||
<HelpText>ButtonPrimary 컴포넌트로 저장 버튼을 별도 구성할 경우 끄세요</HelpText>
|
||||
|
||||
{config.modal.showSaveButton !== false && (
|
||||
<div>
|
||||
<Label className="text-[10px]">저장 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
|
@ -1896,7 +1901,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
<div className="space-y-2 pt-2 border-t">
|
||||
<HelpText>테이블 참조: DB 테이블에서 옵션 목록을 가져옵니다.</HelpText>
|
||||
<div>
|
||||
<Label className="text-[10px]">참조 테이블 (옵션을 가져올 테이블)</Label>
|
||||
<Label className="text-[10px]">참조 테이블</Label>
|
||||
<Select
|
||||
value={selectedField.selectOptions?.tableName || ""}
|
||||
onValueChange={(value) =>
|
||||
|
|
@ -1908,7 +1913,7 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-[10px] mt-1">
|
||||
<SelectTrigger className="h-7 text-xs mt-1">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1919,10 +1924,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>예: dept_info (부서 테이블)</HelpText>
|
||||
<HelpText>드롭다운 목록을 가져올 테이블을 선택하세요</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">값 컬럼 (저장될 값)</Label>
|
||||
<Label className="text-[10px]">조인할 컬럼</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.valueColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1933,13 +1938,17 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="dept_code"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
placeholder="customer_code"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>선택 시 실제 저장되는 값 (예: D001)</HelpText>
|
||||
<HelpText>
|
||||
참조 테이블에서 조인할 컬럼을 선택하세요
|
||||
<br />
|
||||
예: customer_code, customer_id
|
||||
</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[10px]">라벨 컬럼 (화면에 표시될 텍스트)</Label>
|
||||
<Label className="text-[10px]">표시할 컬럼</Label>
|
||||
<Input
|
||||
value={selectedField.selectOptions?.labelColumn || ""}
|
||||
onChange={(e) =>
|
||||
|
|
@ -1950,10 +1959,14 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor
|
|||
},
|
||||
})
|
||||
}
|
||||
placeholder="dept_name"
|
||||
className="h-6 text-[10px] mt-1"
|
||||
placeholder="customer_name"
|
||||
className="h-7 text-xs mt-1"
|
||||
/>
|
||||
<HelpText>드롭다운에 보여질 텍스트 (예: 영업부)</HelpText>
|
||||
<HelpText>
|
||||
드롭다운에 표시할 컬럼을 선택하세요
|
||||
<br />
|
||||
예: customer_name, company_name
|
||||
</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = {
|
|||
size: "lg",
|
||||
closeOnOutsideClick: false,
|
||||
showCloseButton: true,
|
||||
showSaveButton: true,
|
||||
saveButtonText: "저장",
|
||||
cancelButtonText: "취소",
|
||||
showResetButton: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue