feat(modal-repeater-table): 동적 데이터 소스 전환 기능 및 UniversalFormModal 저장 버튼 옵션 추가

- ModalRepeaterTable: 컬럼 헤더 클릭으로 데이터 소스 동적 전환
- 단순 조인, 복합 조인(다중 테이블), 전용 API 호출 지원
- DynamicDataSourceConfig, MultiTableJoinStep 타입 추가
- 설정 패널에 동적 데이터 소스 설정 모달 추가
- UniversalFormModal: showSaveButton 옵션 추가
This commit is contained in:
SeongHyun Kim 2025-12-09 14:55:49 +09:00
parent 7ac6bbc2c6
commit d550959cb7
7 changed files with 1234 additions and 88 deletions

View File

@ -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}
/>
{/* 항목 선택 모달 */}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>; // 컬럼명 -> 라벨명 매핑
}

View File

@ -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>

View File

@ -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>
)}

View File

@ -12,6 +12,7 @@ export const defaultConfig: UniversalFormModalConfig = {
size: "lg",
closeOnOutsideClick: false,
showCloseButton: true,
showSaveButton: true,
saveButtonText: "저장",
cancelButtonText: "취소",
showResetButton: false,