ERP-node/frontend/lib/registry/components/universal-form-modal/modals/TableColumnSettingsModal.tsx

1599 lines
80 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>
{/* 동적 Select 옵션 (소스 테이블에서 로드) */}
<Separator />
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> ( )</h4>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
<Switch
checked={localColumn.dynamicSelectOptions?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: checked
? {
enabled: true,
sourceField: "",
distinct: true,
}
: undefined,
});
}}
/>
</div>
{localColumn.dynamicSelectOptions?.enabled && (
<div className="space-y-3 pl-2 border-l-2 border-primary/20">
{/* 소스 필드 */}
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground mb-1">
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.sourceField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: value,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<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>
) : (
<Input
value={localColumn.dynamicSelectOptions.sourceField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
sourceField: e.target.value,
},
});
}}
placeholder="inspection_item"
className="h-8 text-xs"
/>
)}
</div>
{/* 라벨 필드 */}
<div>
<Label className="text-xs"> ()</Label>
<p className="text-[10px] text-muted-foreground mb-1">
( )
</p>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.labelField || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: value || undefined,
},
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="(소스 컬럼과 동일)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=""> ( )</SelectItem>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name} {col.comment && `(${col.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.labelField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
labelField: e.target.value || undefined,
},
});
}}
placeholder="(비워두면 소스 컬럼 사용)"
className="h-8 text-xs"
/>
)}
</div>
{/* 행 선택 모드 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center gap-2">
<Switch
checked={localColumn.dynamicSelectOptions.rowSelectionMode?.enabled ?? false}
onCheckedChange={(checked) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: checked
? {
enabled: true,
autoFillMappings: [],
}
: undefined,
},
});
}}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
</div>
{localColumn.dynamicSelectOptions.rowSelectionMode?.enabled && (
<div className="space-y-3 pl-4">
{/* 소스 ID 저장 설정 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"> ID </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onValueChange={(value) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: value || undefined,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs mt-1">
<SelectValue placeholder="id" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.sourceIdColumn || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
sourceIdColumn: e.target.value || undefined,
},
},
});
}}
placeholder="id"
className="h-7 text-xs mt-1"
/>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Input
value={localColumn.dynamicSelectOptions.rowSelectionMode.targetIdField || ""}
onChange={(e) => {
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
targetIdField: e.target.value || undefined,
},
},
});
}}
placeholder="inspection_standard_id"
className="h-7 text-xs mt-1"
/>
</div>
</div>
{/* 자동 채움 매핑 */}
<div>
<div className="flex items-center justify-between mb-2">
<Label className="text-[10px]"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentMappings = localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [];
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: [...currentMappings, { sourceColumn: "", targetField: "" }],
},
},
});
}}
className="h-6 text-[10px] px-2"
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).map((mapping, idx) => (
<div key={idx} className="flex items-center gap-2">
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs flex-1">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.sourceColumn}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], sourceColumn: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="소스 컬럼"
className="h-7 text-xs flex-1"
/>
)}
<span className="text-xs text-muted-foreground"></span>
<Input
value={mapping.targetField}
onChange={(e) => {
const newMappings = [...(localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || [])];
newMappings[idx] = { ...newMappings[idx], targetField: e.target.value };
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
placeholder="타겟 필드"
className="h-7 text-xs flex-1"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = (localColumn.dynamicSelectOptions?.rowSelectionMode?.autoFillMappings || []).filter((_, i) => i !== idx);
updateColumn({
dynamicSelectOptions: {
...localColumn.dynamicSelectOptions!,
rowSelectionMode: {
...localColumn.dynamicSelectOptions!.rowSelectionMode!,
autoFillMappings: newMappings,
},
},
});
}}
className="h-7 w-7 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{(localColumn.dynamicSelectOptions.rowSelectionMode.autoFillMappings || []).length === 0 && (
<p className="text-[10px] text-muted-foreground text-center py-2 border border-dashed rounded">
(: inspection_criteria inspection_standard)
</p>
)}
</div>
</div>
</div>
)}
</div>
</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>
);
}