feat(UniversalFormModal): 테이블 섹션 컬럼 조회(Lookup) 기능 구현

- LookupConfig, LookupOption, LookupCondition 타입 정의
- sourceType 4가지 유형 지원 (currentRow, sourceTable, sectionField, externalTable)
- TableColumnSettingsModal에 "조회 설정" 탭 추가
- TableSectionSettingsModal에 간단 조회 설정 UI 추가
- fetchExternalValue, fetchExternalLookupValue 함수 구현
- 헤더 드롭다운에서 조회 옵션 선택 기능
This commit is contained in:
SeongHyun Kim
2025-12-19 11:48:46 +09:00
parent fdb9ef9167
commit c86140fad3
5 changed files with 1816 additions and 25 deletions

View File

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