feat(UniversalFormModal): 테이블 섹션 컬럼 조회(Lookup) 기능 구현
- LookupConfig, LookupOption, LookupCondition 타입 정의 - sourceType 4가지 유형 지원 (currentRow, sourceTable, sectionField, externalTable) - TableColumnSettingsModal에 "조회 설정" 탭 추가 - TableSectionSettingsModal에 간단 조회 설정 UI 추가 - fetchExternalValue, fetchExternalLookupValue 함수 구현 - 헤더 드롭다운에서 조회 옵션 선택 기능
This commit is contained in:
@@ -22,9 +22,14 @@ import {
|
||||
ValueMappingConfig,
|
||||
ColumnModeConfig,
|
||||
TableJoinCondition,
|
||||
LookupConfig,
|
||||
LookupOption,
|
||||
LookupCondition,
|
||||
VALUE_MAPPING_TYPE_OPTIONS,
|
||||
JOIN_SOURCE_TYPE_OPTIONS,
|
||||
TABLE_COLUMN_TYPE_OPTIONS,
|
||||
LOOKUP_TYPE_OPTIONS,
|
||||
LOOKUP_CONDITION_SOURCE_OPTIONS,
|
||||
} from "../types";
|
||||
|
||||
import {
|
||||
@@ -42,8 +47,10 @@ interface TableColumnSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
column: TableColumnConfig;
|
||||
sourceTableName: string; // 소스 테이블명
|
||||
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
|
||||
formFields: { columnName: string; label: string }[]; // formData 필드 목록
|
||||
formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
|
||||
sections: { id: string; title: string }[]; // 섹션 목록
|
||||
onSave: (updatedColumn: TableColumnConfig) => void;
|
||||
tables: { table_name: string; comment?: string }[];
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
|
||||
@@ -54,8 +61,10 @@ export function TableColumnSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
column,
|
||||
sourceTableName,
|
||||
sourceTableColumns,
|
||||
formFields,
|
||||
sections,
|
||||
onSave,
|
||||
tables,
|
||||
tableColumns,
|
||||
@@ -67,6 +76,9 @@ export function TableColumnSettingsModal({
|
||||
// 외부 테이블 검색 상태
|
||||
const [externalTableOpen, setExternalTableOpen] = useState(false);
|
||||
|
||||
// 조회 테이블 검색 상태 (옵션별)
|
||||
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 활성 탭
|
||||
const [activeTab, setActiveTab] = useState("basic");
|
||||
|
||||
@@ -174,6 +186,101 @@ export function TableColumnSettingsModal({
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 조회(Lookup) 관련 함수들
|
||||
// ============================================
|
||||
|
||||
// 조회 설정 업데이트
|
||||
const updateLookup = (updates: Partial<LookupConfig>) => {
|
||||
const current = localColumn.lookup || { enabled: false, options: [] };
|
||||
updateColumn({
|
||||
lookup: { ...current, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 옵션 추가
|
||||
const addLookupOption = () => {
|
||||
const newOption: LookupOption = {
|
||||
id: `lookup_${Date.now()}`,
|
||||
label: `조회 옵션 ${(localColumn.lookup?.options || []).length + 1}`,
|
||||
type: "sameTable",
|
||||
tableName: sourceTableName, // 기본값: 소스 테이블
|
||||
valueColumn: "",
|
||||
conditions: [],
|
||||
isDefault: (localColumn.lookup?.options || []).length === 0, // 첫 번째 옵션은 기본값
|
||||
};
|
||||
updateLookup({
|
||||
options: [...(localColumn.lookup?.options || []), newOption],
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 옵션 삭제
|
||||
const removeLookupOption = (index: number) => {
|
||||
const newOptions = (localColumn.lookup?.options || []).filter((_, i) => i !== index);
|
||||
// 삭제 후 기본 옵션이 없으면 첫 번째를 기본으로
|
||||
if (newOptions.length > 0 && !newOptions.some(opt => opt.isDefault)) {
|
||||
newOptions[0].isDefault = true;
|
||||
}
|
||||
updateLookup({ options: newOptions });
|
||||
};
|
||||
|
||||
// 조회 옵션 업데이트
|
||||
const updateLookupOption = (index: number, updates: Partial<LookupOption>) => {
|
||||
updateLookup({
|
||||
options: (localColumn.lookup?.options || []).map((opt, i) =>
|
||||
i === index ? { ...opt, ...updates } : opt
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 조건 추가
|
||||
const addLookupCondition = (optionIndex: number) => {
|
||||
const option = localColumn.lookup?.options?.[optionIndex];
|
||||
if (!option) return;
|
||||
|
||||
const newCondition: LookupCondition = {
|
||||
sourceType: "currentRow",
|
||||
sourceField: "",
|
||||
targetColumn: "",
|
||||
};
|
||||
updateLookupOption(optionIndex, {
|
||||
conditions: [...(option.conditions || []), newCondition],
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 조건 삭제
|
||||
const removeLookupCondition = (optionIndex: number, conditionIndex: number) => {
|
||||
const option = localColumn.lookup?.options?.[optionIndex];
|
||||
if (!option) return;
|
||||
|
||||
updateLookupOption(optionIndex, {
|
||||
conditions: option.conditions.filter((_, i) => i !== conditionIndex),
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 조건 업데이트
|
||||
const updateLookupCondition = (optionIndex: number, conditionIndex: number, updates: Partial<LookupCondition>) => {
|
||||
const option = localColumn.lookup?.options?.[optionIndex];
|
||||
if (!option) return;
|
||||
|
||||
updateLookupOption(optionIndex, {
|
||||
conditions: option.conditions.map((c, i) =>
|
||||
i === conditionIndex ? { ...c, ...updates } : c
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// 조회 옵션의 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (localColumn.lookup?.enabled) {
|
||||
localColumn.lookup.options?.forEach(option => {
|
||||
if (option.tableName) {
|
||||
onLoadTableColumns(option.tableName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [localColumn.lookup?.enabled, localColumn.lookup?.options, onLoadTableColumns]);
|
||||
|
||||
// 저장 함수
|
||||
const handleSave = () => {
|
||||
onSave(localColumn);
|
||||
@@ -432,8 +539,9 @@ export function TableColumnSettingsModal({
|
||||
<ScrollArea className="h-[calc(90vh-200px)]">
|
||||
<div className="space-y-4 p-1">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="basic" className="text-xs">기본 설정</TabsTrigger>
|
||||
<TabsTrigger value="lookup" className="text-xs">조회 설정</TabsTrigger>
|
||||
<TabsTrigger value="mapping" className="text-xs">값 매핑</TabsTrigger>
|
||||
<TabsTrigger value="modes" className="text-xs">컬럼 모드</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -595,6 +703,376 @@ export function TableColumnSettingsModal({
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 조회 설정 탭 */}
|
||||
<TabsContent value="lookup" className="mt-4 space-y-4">
|
||||
{/* 조회 여부 토글 */}
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/20">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">조회 여부</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
다른 테이블에서 값을 조회하여 가져옵니다.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={localColumn.lookup?.enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
updateLookup({ enabled: true, options: [] });
|
||||
} else {
|
||||
updateColumn({ lookup: undefined });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 조회 설정 (활성화 시) */}
|
||||
{localColumn.lookup?.enabled && (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Label className="text-sm font-medium">조회 옵션</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
헤더에서 선택 가능한 조회 방식을 정의합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={addLookupOption} className="h-8 text-xs">
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(localColumn.lookup?.options || []).length === 0 ? (
|
||||
<div className="text-center py-8 border border-dashed rounded-lg bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground">조회 옵션이 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
"옵션 추가" 버튼을 클릭하여 조회 방식을 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(localColumn.lookup?.options || []).map((option, optIndex) => (
|
||||
<div key={option.id} className="border rounded-lg p-4 space-y-4 bg-card">
|
||||
{/* 옵션 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{option.label || `옵션 ${optIndex + 1}`}</span>
|
||||
{option.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">기본</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeLookupOption(optIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">옵션명</Label>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateLookupOption(optIndex, { label: e.target.value })}
|
||||
placeholder="예: 기준단가"
|
||||
className="h-8 text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">조회 유형</Label>
|
||||
<Select
|
||||
value={option.type}
|
||||
onValueChange={(value: "sameTable" | "relatedTable" | "combinedLookup") => {
|
||||
// 유형 변경 시 테이블 초기화
|
||||
const newTableName = value === "sameTable" ? sourceTableName : "";
|
||||
updateLookupOption(optIndex, {
|
||||
type: value,
|
||||
tableName: newTableName,
|
||||
conditions: [],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOOKUP_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조회 테이블 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">조회 테이블</Label>
|
||||
{option.type === "sameTable" ? (
|
||||
<Input
|
||||
value={sourceTableName}
|
||||
disabled
|
||||
className="h-8 text-xs mt-1 bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Popover
|
||||
open={lookupTableOpenMap[option.id]}
|
||||
onOpenChange={(open) => setLookupTableOpenMap(prev => ({ ...prev, [option.id]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs mt-1"
|
||||
>
|
||||
{option.tableName || "테이블 선택..."}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0 w-full min-w-[250px]" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="text-xs py-4 text-center">
|
||||
테이블을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.table_name}
|
||||
value={table.table_name}
|
||||
onSelect={() => {
|
||||
updateLookupOption(optIndex, { tableName: table.table_name });
|
||||
onLoadTableColumns(table.table_name);
|
||||
setLookupTableOpenMap(prev => ({ ...prev, [option.id]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
option.tableName === table.table_name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{table.table_name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">가져올 컬럼</Label>
|
||||
<Select
|
||||
value={option.valueColumn}
|
||||
onValueChange={(value) => updateLookupOption(optIndex, { valueColumn: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectValue placeholder="컬럼 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[option.tableName] || []).map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
{col.comment && ` (${col.comment})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 옵션 체크박스 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={option.isDefault ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
// 기본 옵션은 하나만
|
||||
updateLookup({
|
||||
options: (localColumn.lookup?.options || []).map((opt, i) => ({
|
||||
...opt,
|
||||
isDefault: i === optIndex,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
updateLookupOption(optIndex, { isDefault: false });
|
||||
}
|
||||
}}
|
||||
className="scale-75"
|
||||
/>
|
||||
<span className="text-xs">기본 옵션으로 설정</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 조회 조건 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-medium">조회 조건</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addLookupCondition(optIndex)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(option.conditions || []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-2 border border-dashed rounded">
|
||||
조회 조건을 추가하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{option.conditions.map((condition, condIndex) => (
|
||||
<div key={condIndex} className="flex items-center gap-2 p-2 border rounded-lg bg-muted/30">
|
||||
{/* 소스 타입 */}
|
||||
<Select
|
||||
value={condition.sourceType}
|
||||
onValueChange={(value: "currentRow" | "sectionField") =>
|
||||
updateLookupCondition(optIndex, condIndex, {
|
||||
sourceType: value,
|
||||
sourceField: "",
|
||||
sectionId: value === "sectionField" ? "" : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 섹션 선택 (sectionField일 때) */}
|
||||
{condition.sourceType === "sectionField" && (
|
||||
<Select
|
||||
value={condition.sectionId || ""}
|
||||
onValueChange={(value) =>
|
||||
updateLookupCondition(optIndex, condIndex, {
|
||||
sectionId: value,
|
||||
sourceField: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-[100px]">
|
||||
<SelectValue placeholder="섹션" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sections.map((section) => (
|
||||
<SelectItem key={section.id} value={section.id}>
|
||||
{section.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 소스 필드 */}
|
||||
<Select
|
||||
value={condition.sourceField}
|
||||
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { sourceField: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-[110px]">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{condition.sourceType === "currentRow"
|
||||
? sourceTableColumns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))
|
||||
: formFields
|
||||
.filter(f => !condition.sectionId || f.sectionId === condition.sectionId)
|
||||
.map((field) => (
|
||||
<SelectItem key={field.columnName} value={field.columnName}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="text-xs text-muted-foreground">=</span>
|
||||
|
||||
{/* 타겟 컬럼 */}
|
||||
<Select
|
||||
value={condition.targetColumn}
|
||||
onValueChange={(value) => updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue placeholder="대상 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(tableColumns[option.tableName] || []).map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeLookupCondition(optIndex, condIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 유형별 설명 */}
|
||||
<div className="text-xs text-muted-foreground p-2 bg-muted/30 rounded">
|
||||
{option.type === "sameTable" && (
|
||||
<>
|
||||
<strong>동일 테이블 조회:</strong> 검색 모달에서 선택한 행의 다른 컬럼 값을 가져옵니다.
|
||||
<br />예: 품목 선택 시 → 품목 테이블의 기준단가
|
||||
</>
|
||||
)}
|
||||
{option.type === "relatedTable" && (
|
||||
<>
|
||||
<strong>연관 테이블 조회:</strong> 현재 행 데이터를 기준으로 다른 테이블에서 값을 조회합니다.
|
||||
<br />예: 품목코드로 → 품목별단가 테이블에서 단가 조회
|
||||
</>
|
||||
)}
|
||||
{option.type === "combinedLookup" && (
|
||||
<>
|
||||
<strong>복합 조건 조회:</strong> 다른 섹션 필드와 현재 행을 조합하여 조회합니다.
|
||||
<br />예: 거래처(섹션1) + 품목(현재행) → 거래처별단가 테이블
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 값 매핑 탭 */}
|
||||
<TabsContent value="mapping" className="mt-4 space-y-4">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user