1248 lines
58 KiB
TypeScript
1248 lines
58 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 타입 import
|
|
import {
|
|
TableColumnConfig,
|
|
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 {
|
|
defaultValueMappingConfig,
|
|
defaultColumnModeConfig,
|
|
generateColumnModeId,
|
|
} from "../config";
|
|
|
|
// 도움말 텍스트 컴포넌트
|
|
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
|
);
|
|
|
|
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; 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 }[]>;
|
|
onLoadTableColumns: (tableName: string) => void;
|
|
}
|
|
|
|
export function TableColumnSettingsModal({
|
|
open,
|
|
onOpenChange,
|
|
column,
|
|
sourceTableName,
|
|
sourceTableColumns,
|
|
formFields,
|
|
sections,
|
|
onSave,
|
|
tables,
|
|
tableColumns,
|
|
onLoadTableColumns,
|
|
}: TableColumnSettingsModalProps) {
|
|
// 로컬 상태
|
|
const [localColumn, setLocalColumn] = useState<TableColumnConfig>({ ...column });
|
|
|
|
// 외부 테이블 검색 상태
|
|
const [externalTableOpen, setExternalTableOpen] = useState(false);
|
|
|
|
// 조회 테이블 검색 상태 (옵션별)
|
|
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
|
|
|
|
// 활성 탭
|
|
const [activeTab, setActiveTab] = useState("basic");
|
|
|
|
// open이 변경될 때마다 데이터 동기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setLocalColumn({ ...column });
|
|
}
|
|
}, [open, column]);
|
|
|
|
// 외부 테이블 컬럼 로드
|
|
const externalTableName = localColumn.valueMapping?.externalRef?.tableName;
|
|
useEffect(() => {
|
|
if (externalTableName) {
|
|
onLoadTableColumns(externalTableName);
|
|
}
|
|
}, [externalTableName, onLoadTableColumns]);
|
|
|
|
// 외부 테이블의 컬럼 목록
|
|
const externalTableColumns = useMemo(() => {
|
|
if (!externalTableName) return [];
|
|
return tableColumns[externalTableName] || [];
|
|
}, [tableColumns, externalTableName]);
|
|
|
|
// 컬럼 업데이트 함수
|
|
const updateColumn = (updates: Partial<TableColumnConfig>) => {
|
|
setLocalColumn((prev) => ({ ...prev, ...updates }));
|
|
};
|
|
|
|
// 값 매핑 업데이트
|
|
const updateValueMapping = (updates: Partial<ValueMappingConfig>) => {
|
|
const current = localColumn.valueMapping || { ...defaultValueMappingConfig };
|
|
updateColumn({
|
|
valueMapping: { ...current, ...updates },
|
|
});
|
|
};
|
|
|
|
// 외부 참조 업데이트
|
|
const updateExternalRef = (updates: Partial<NonNullable<ValueMappingConfig["externalRef"]>>) => {
|
|
const current = localColumn.valueMapping?.externalRef || {
|
|
tableName: "",
|
|
valueColumn: "",
|
|
joinConditions: [],
|
|
};
|
|
updateValueMapping({
|
|
externalRef: { ...current, ...updates },
|
|
});
|
|
};
|
|
|
|
// 조인 조건 추가
|
|
const addJoinCondition = () => {
|
|
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
|
|
const newCondition: TableJoinCondition = {
|
|
sourceType: "row",
|
|
sourceField: "",
|
|
targetColumn: "",
|
|
operator: "=",
|
|
};
|
|
updateExternalRef({
|
|
joinConditions: [...current, newCondition],
|
|
});
|
|
};
|
|
|
|
// 조인 조건 삭제
|
|
const removeJoinCondition = (index: number) => {
|
|
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
|
|
updateExternalRef({
|
|
joinConditions: current.filter((_, i) => i !== index),
|
|
});
|
|
};
|
|
|
|
// 조인 조건 업데이트
|
|
const updateJoinCondition = (index: number, updates: Partial<TableJoinCondition>) => {
|
|
const current = localColumn.valueMapping?.externalRef?.joinConditions || [];
|
|
updateExternalRef({
|
|
joinConditions: current.map((c, i) => (i === index ? { ...c, ...updates } : c)),
|
|
});
|
|
};
|
|
|
|
// 컬럼 모드 추가
|
|
const addColumnMode = () => {
|
|
const newMode: ColumnModeConfig = {
|
|
...defaultColumnModeConfig,
|
|
id: generateColumnModeId(),
|
|
label: `모드 ${(localColumn.columnModes || []).length + 1}`,
|
|
};
|
|
updateColumn({
|
|
columnModes: [...(localColumn.columnModes || []), newMode],
|
|
});
|
|
};
|
|
|
|
// 컬럼 모드 삭제
|
|
const removeColumnMode = (index: number) => {
|
|
updateColumn({
|
|
columnModes: (localColumn.columnModes || []).filter((_, i) => i !== index),
|
|
});
|
|
};
|
|
|
|
// 컬럼 모드 업데이트
|
|
const updateColumnMode = (index: number, updates: Partial<ColumnModeConfig>) => {
|
|
updateColumn({
|
|
columnModes: (localColumn.columnModes || []).map((m, i) =>
|
|
i === index ? { ...m, ...updates } : m
|
|
),
|
|
});
|
|
};
|
|
|
|
// ============================================
|
|
// 조회(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);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
// 값 매핑 타입에 따른 설정 UI 렌더링
|
|
const renderValueMappingConfig = () => {
|
|
const mappingType = localColumn.valueMapping?.type || "source";
|
|
|
|
switch (mappingType) {
|
|
case "source":
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs">소스 컬럼</Label>
|
|
<Select
|
|
value={localColumn.valueMapping?.sourceField || ""}
|
|
onValueChange={(value) => updateValueMapping({ sourceField: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue placeholder="컬럼 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sourceTableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_name}
|
|
{col.comment && ` (${col.comment})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<HelpText>소스 테이블에서 복사할 컬럼을 선택하세요.</HelpText>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case "manual":
|
|
return (
|
|
<div className="text-sm text-muted-foreground p-4 border rounded-lg bg-muted/20">
|
|
사용자가 직접 입력하는 필드입니다.
|
|
<br />
|
|
기본값을 설정하려면 "기본 설정" 탭에서 설정하세요.
|
|
</div>
|
|
);
|
|
|
|
case "internal":
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs">폼 필드 선택</Label>
|
|
<Select
|
|
value={localColumn.valueMapping?.internalField || ""}
|
|
onValueChange={(value) => updateValueMapping({ internalField: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue placeholder="필드 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{formFields.map((field) => (
|
|
<SelectItem key={field.columnName} value={field.columnName}>
|
|
{field.label} ({field.columnName})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<HelpText>같은 모달의 다른 필드 값을 참조합니다.</HelpText>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case "external":
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 외부 테이블 선택 */}
|
|
<div>
|
|
<Label className="text-xs">외부 테이블</Label>
|
|
<Popover open={externalTableOpen} onOpenChange={setExternalTableOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
className="h-8 w-full justify-between text-xs mt-1"
|
|
>
|
|
{externalTableName || "테이블 선택..."}
|
|
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0 w-full min-w-[300px]" 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={() => {
|
|
updateExternalRef({ tableName: table.table_name });
|
|
setExternalTableOpen(false);
|
|
}}
|
|
className="text-xs"
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"mr-2 h-3.5 w-3.5",
|
|
externalTableName === table.table_name ? "opacity-100" : "opacity-0"
|
|
)}
|
|
/>
|
|
{table.table_name}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{/* 가져올 컬럼 선택 */}
|
|
{externalTableName && (
|
|
<div>
|
|
<Label className="text-xs">가져올 컬럼</Label>
|
|
<Select
|
|
value={localColumn.valueMapping?.externalRef?.valueColumn || ""}
|
|
onValueChange={(value) => updateExternalRef({ valueColumn: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue placeholder="컬럼 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{externalTableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
{/* 조인 조건 */}
|
|
{externalTableName && (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<Label className="text-xs">조인 조건</Label>
|
|
<Button size="sm" variant="outline" onClick={addJoinCondition} className="h-7 text-xs">
|
|
<Plus className="h-3 w-3 mr-1" />
|
|
조건 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(localColumn.valueMapping?.externalRef?.joinConditions || []).map((condition, index) => (
|
|
<div key={index} className="flex items-center gap-2 p-2 border rounded-lg bg-muted/30">
|
|
{/* 소스 타입 */}
|
|
<Select
|
|
value={condition.sourceType}
|
|
onValueChange={(value: "row" | "formData") =>
|
|
updateJoinCondition(index, { sourceType: value })
|
|
}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs w-[100px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{JOIN_SOURCE_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 소스 필드 */}
|
|
<Select
|
|
value={condition.sourceField}
|
|
onValueChange={(value) => updateJoinCondition(index, { sourceField: value })}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs w-[120px]">
|
|
<SelectValue placeholder="필드" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{condition.sourceType === "row"
|
|
? sourceTableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_name}
|
|
</SelectItem>
|
|
))
|
|
: formFields.map((field) => (
|
|
<SelectItem key={field.columnName} value={field.columnName}>
|
|
{field.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
|
|
{/* 타겟 컬럼 */}
|
|
<Select
|
|
value={condition.targetColumn}
|
|
onValueChange={(value) => updateJoinCondition(index, { targetColumn: value })}
|
|
>
|
|
<SelectTrigger className="h-7 text-xs flex-1">
|
|
<SelectValue placeholder="대상 컬럼" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{externalTableColumns.map((col) => (
|
|
<SelectItem key={col.column_name} value={col.column_name}>
|
|
{col.column_name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => removeJoinCondition(index)}
|
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{(localColumn.valueMapping?.externalRef?.joinConditions || []).length === 0 && (
|
|
<p className="text-xs text-muted-foreground text-center py-2">
|
|
조인 조건을 추가하세요.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] flex flex-col">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">컬럼 상세 설정</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
"{localColumn.label}" 컬럼의 상세 설정을 구성합니다.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<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-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>
|
|
|
|
{/* 기본 설정 탭 */}
|
|
<TabsContent value="basic" className="mt-4 space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-xs">필드명 (저장 컬럼)</Label>
|
|
<Input
|
|
value={localColumn.field}
|
|
onChange={(e) => updateColumn({ field: e.target.value })}
|
|
placeholder="field_name"
|
|
className="h-8 text-xs mt-1"
|
|
/>
|
|
<HelpText>데이터베이스에 저장될 컬럼명입니다.</HelpText>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">라벨</Label>
|
|
<Input
|
|
value={localColumn.label}
|
|
onChange={(e) => updateColumn({ label: e.target.value })}
|
|
placeholder="표시 라벨"
|
|
className="h-8 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<Label className="text-xs">타입</Label>
|
|
<Select
|
|
value={localColumn.type}
|
|
onValueChange={(value: any) => updateColumn({ type: value })}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TABLE_COLUMN_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">너비</Label>
|
|
<Input
|
|
value={localColumn.width || ""}
|
|
onChange={(e) => updateColumn({ width: e.target.value })}
|
|
placeholder="150px"
|
|
className="h-8 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs">기본값</Label>
|
|
<Input
|
|
value={localColumn.defaultValue?.toString() || ""}
|
|
onChange={(e) => updateColumn({ defaultValue: e.target.value })}
|
|
placeholder="기본값"
|
|
className="h-8 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-medium">옵션</h4>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
|
<Switch
|
|
checked={localColumn.editable ?? true}
|
|
onCheckedChange={(checked) => updateColumn({ editable: checked })}
|
|
className="scale-75"
|
|
/>
|
|
<span>편집 가능</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
|
<Switch
|
|
checked={localColumn.calculated ?? false}
|
|
onCheckedChange={(checked) => updateColumn({ calculated: checked })}
|
|
className="scale-75"
|
|
/>
|
|
<span>계산 필드</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
|
<Switch
|
|
checked={localColumn.required ?? false}
|
|
onCheckedChange={(checked) => updateColumn({ required: checked })}
|
|
className="scale-75"
|
|
/>
|
|
<span>필수</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Select 옵션 (타입이 select일 때) */}
|
|
{localColumn.type === "select" && (
|
|
<>
|
|
<Separator />
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-medium">Select 옵션</h4>
|
|
<div className="space-y-2">
|
|
{(localColumn.selectOptions || []).map((opt, index) => (
|
|
<div key={index} className="flex items-center gap-2">
|
|
<Input
|
|
value={opt.value}
|
|
onChange={(e) => {
|
|
const newOptions = [...(localColumn.selectOptions || [])];
|
|
newOptions[index] = { ...newOptions[index], value: e.target.value };
|
|
updateColumn({ selectOptions: newOptions });
|
|
}}
|
|
placeholder="값"
|
|
className="h-8 text-xs flex-1"
|
|
/>
|
|
<Input
|
|
value={opt.label}
|
|
onChange={(e) => {
|
|
const newOptions = [...(localColumn.selectOptions || [])];
|
|
newOptions[index] = { ...newOptions[index], label: e.target.value };
|
|
updateColumn({ selectOptions: newOptions });
|
|
}}
|
|
placeholder="라벨"
|
|
className="h-8 text-xs flex-1"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
updateColumn({
|
|
selectOptions: (localColumn.selectOptions || []).filter((_, i) => i !== index),
|
|
});
|
|
}}
|
|
className="h-8 w-8 p-0 text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
updateColumn({
|
|
selectOptions: [...(localColumn.selectOptions || []), { value: "", label: "" }],
|
|
});
|
|
}}
|
|
className="h-8 text-xs w-full"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
옵션 추가
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</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>
|
|
<Label className="text-xs">값 매핑 타입</Label>
|
|
<Select
|
|
value={localColumn.valueMapping?.type || "source"}
|
|
onValueChange={(value: any) => {
|
|
// 타입 변경 시 기본 설정 초기화
|
|
updateColumn({
|
|
valueMapping: {
|
|
type: value,
|
|
sourceField: value === "source" ? "" : undefined,
|
|
internalField: value === "internal" ? "" : undefined,
|
|
externalRef: value === "external" ? {
|
|
tableName: "",
|
|
valueColumn: "",
|
|
joinConditions: [],
|
|
} : undefined,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{VALUE_MAPPING_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<HelpText>이 컬럼의 값을 어디서 가져올지 설정합니다.</HelpText>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{renderValueMappingConfig()}
|
|
</TabsContent>
|
|
|
|
{/* 컬럼 모드 탭 */}
|
|
<TabsContent value="modes" className="mt-4 space-y-4">
|
|
<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={addColumnMode} className="h-8 text-xs">
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
모드 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{(localColumn.columnModes || []).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-3">
|
|
{(localColumn.columnModes || []).map((mode, index) => (
|
|
<div key={mode.id} className="border rounded-lg p-3 space-y-3 bg-card">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{mode.label || `모드 ${index + 1}`}</span>
|
|
{mode.isDefault && (
|
|
<Badge variant="secondary" className="text-xs">기본</Badge>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => removeColumnMode(index)}
|
|
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={mode.label}
|
|
onChange={(e) => updateColumnMode(index, { label: e.target.value })}
|
|
placeholder="예: 기준 단가"
|
|
className="h-8 text-xs mt-1"
|
|
/>
|
|
</div>
|
|
<div className="flex items-end pb-1">
|
|
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
|
<Switch
|
|
checked={mode.isDefault ?? false}
|
|
onCheckedChange={(checked) => {
|
|
// 기본 모드는 하나만
|
|
if (checked) {
|
|
updateColumn({
|
|
columnModes: (localColumn.columnModes || []).map((m, i) => ({
|
|
...m,
|
|
isDefault: i === index,
|
|
})),
|
|
});
|
|
} else {
|
|
updateColumnMode(index, { isDefault: false });
|
|
}
|
|
}}
|
|
className="scale-75"
|
|
/>
|
|
<span>기본 모드</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs">값 매핑 타입</Label>
|
|
<Select
|
|
value={mode.valueMapping?.type || "source"}
|
|
onValueChange={(value: any) => {
|
|
updateColumnMode(index, {
|
|
valueMapping: {
|
|
type: value,
|
|
sourceField: value === "source" ? "" : undefined,
|
|
internalField: value === "internal" ? "" : undefined,
|
|
externalRef: value === "external" ? {
|
|
tableName: "",
|
|
valueColumn: "",
|
|
joinConditions: [],
|
|
} : undefined,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<SelectTrigger className="h-8 text-xs mt-1">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{VALUE_MAPPING_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
저장
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|