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

4344 lines
208 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback } 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,
GripVertical,
ChevronUp,
ChevronDown,
Settings,
Check,
ChevronsUpDown,
Filter,
Table as TableIcon,
Search,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
// 타입 import
import {
FormSectionConfig,
TableSectionConfig,
TableColumnConfig,
TablePreFilter,
TableModalFilter,
TableCalculationRule,
ConditionalCalculationRule,
ConditionalCalculationConfig,
LookupOption,
LookupCondition,
ConditionalTableOption,
TABLE_COLUMN_TYPE_OPTIONS,
FILTER_OPERATOR_OPTIONS,
MODAL_FILTER_TYPE_OPTIONS,
LOOKUP_TYPE_OPTIONS,
LOOKUP_CONDITION_SOURCE_OPTIONS,
CONDITIONAL_TABLE_TRIGGER_OPTIONS,
} from "../types";
import {
defaultTableSectionConfig,
defaultTableColumnConfig,
defaultPreFilterConfig,
defaultModalFilterConfig,
defaultCalculationRuleConfig,
defaultConditionalTableConfig,
generateTableColumnId,
generateFilterId,
generateConditionalOptionId,
} from "../config";
// 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => (
<p className="text-muted-foreground mt-0.5 text-[10px]">{children}</p>
);
// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
interface CalculationRuleEditorProps {
calc: TableCalculationRule;
index: number;
columns: TableColumnConfig[];
sourceTableName?: string; // 소스 테이블명 추가
onUpdate: (updates: Partial<TableCalculationRule>) => void;
onRemove: () => void;
}
const CalculationRuleEditor: React.FC<CalculationRuleEditorProps> = ({
calc,
index,
columns,
sourceTableName,
onUpdate,
onRemove,
}) => {
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [categoryColumns, setCategoryColumns] = useState<Record<string, boolean>>({});
// 조건부 계산 활성화 여부
const isConditionalEnabled = calc.conditionalCalculation?.enabled ?? false;
// 소스 테이블의 카테고리 컬럼 정보 로드
useEffect(() => {
const loadCategoryColumns = async () => {
if (!sourceTableName) {
setCategoryColumns({});
return;
}
try {
const { getCategoryColumns } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryColumns(sourceTableName);
if (result && result.success && Array.isArray(result.data)) {
const categoryMap: Record<string, boolean> = {};
result.data.forEach((col: any) => {
// API 응답은 camelCase (columnName)
const colName = col.columnName || col.column_name;
if (colName) {
categoryMap[colName] = true;
}
});
setCategoryColumns(categoryMap);
}
} catch (error) {
console.error("카테고리 컬럼 조회 실패:", error);
}
};
loadCategoryColumns();
}, [sourceTableName]);
// 조건 필드가 선택되었을 때 옵션 로드 (테이블 타입 관리의 카테고리 기준)
useEffect(() => {
const loadConditionOptions = async () => {
if (!isConditionalEnabled || !calc.conditionalCalculation?.conditionField) {
setCategoryOptions([]);
return;
}
const conditionField = calc.conditionalCalculation.conditionField;
// 소스 필드(sourceField)가 있으면 해당 필드명 사용, 없으면 field명 사용
const selectedColumn = columns.find((col) => col.field === conditionField);
const actualFieldName = selectedColumn?.sourceField || conditionField;
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
try {
setLoadingOptions(true);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(sourceTableName, actualFieldName, false);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label:
item.valueLabel ||
item.displayLabel ||
item.display_label ||
item.label ||
item.valueCode ||
item.value_code ||
item.value,
}));
setCategoryOptions(options);
} else {
setCategoryOptions([]);
}
} catch (error) {
console.error("카테고리 값 로드 실패:", error);
setCategoryOptions([]);
} finally {
setLoadingOptions(false);
}
return;
}
// 카테고리 키가 직접 설정된 경우 (저장된 값)
const categoryKey = calc.conditionalCalculation?.conditionFieldCategoryKey;
if (categoryKey) {
try {
setLoadingOptions(true);
const [tableName, columnName] = categoryKey.split(".");
if (tableName && columnName) {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(tableName, columnName, false);
if (result && result.success && Array.isArray(result.data)) {
setCategoryOptions(
result.data.map((item: any) => ({
// API 응답은 camelCase (valueCode, valueLabel)
value: item.valueCode || item.value_code || item.value,
label:
item.valueLabel ||
item.displayLabel ||
item.display_label ||
item.label ||
item.valueCode ||
item.value_code ||
item.value,
})),
);
}
}
} catch (error) {
console.error("카테고리 옵션 로드 실패:", error);
} finally {
setLoadingOptions(false);
}
return;
}
// 그 외 타입은 옵션 없음 (직접 입력)
setCategoryOptions([]);
};
loadConditionOptions();
}, [
isConditionalEnabled,
calc.conditionalCalculation?.conditionField,
calc.conditionalCalculation?.conditionFieldCategoryKey,
columns,
sourceTableName,
categoryColumns,
]);
// 조건부 계산 토글
const toggleConditionalCalculation = (enabled: boolean) => {
onUpdate({
conditionalCalculation: enabled
? {
enabled: true,
conditionField: "",
conditionFieldType: "static",
rules: [],
defaultFormula: calc.formula || "",
}
: undefined,
});
};
// 조건 필드 변경
const updateConditionField = (field: string) => {
const selectedColumn = columns.find((col) => col.field === field);
const actualFieldName = selectedColumn?.sourceField || field;
// 컬럼의 타입과 옵션 확인 (테이블 타입 관리의 카테고리 기준)
let conditionFieldType: "static" | "code" | "table" = "static";
let conditionFieldCategoryKey = "";
// 소스 테이블에서 해당 컬럼이 카테고리 타입인지 확인
if (sourceTableName && categoryColumns[actualFieldName]) {
conditionFieldType = "code";
conditionFieldCategoryKey = `${sourceTableName}.${actualFieldName}`;
}
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
conditionField: field,
conditionFieldType,
conditionFieldCategoryKey,
rules: [], // 필드 변경 시 규칙 초기화
},
});
};
// 조건 규칙 추가
const addConditionRule = () => {
const newRule: ConditionalCalculationRule = {
conditionValue: "",
formula: calc.formula || "",
};
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: [...(calc.conditionalCalculation?.rules || []), newRule],
},
});
};
// 조건 규칙 업데이트
const updateConditionRule = (ruleIndex: number, updates: Partial<ConditionalCalculationRule>) => {
const newRules = [...(calc.conditionalCalculation?.rules || [])];
newRules[ruleIndex] = { ...newRules[ruleIndex], ...updates };
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: newRules,
},
});
};
// 조건 규칙 삭제
const removeConditionRule = (ruleIndex: number) => {
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
rules: (calc.conditionalCalculation?.rules || []).filter((_, i) => i !== ruleIndex),
},
});
};
// 기본 계산식 업데이트
const updateDefaultFormula = (formula: string) => {
onUpdate({
conditionalCalculation: {
...calc.conditionalCalculation!,
defaultFormula: formula,
},
});
};
// 조건 필드로 사용 가능한 컬럼 (모든 컬럼)
const availableColumns = columns.filter((col) => col.field);
return (
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
{/* 기본 계산 규칙 */}
<div className="flex items-center gap-2">
<Select value={calc.resultField || ""} onValueChange={(value) => onUpdate({ resultField: value })}>
<SelectTrigger className="h-8 w-[150px] text-xs">
<SelectValue placeholder="결과 필드 선택" />
</SelectTrigger>
<SelectContent>
{columns.length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
columns
.filter((col) => col.field)
.map((col, idx) => (
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
{col.label || col.field}
</SelectItem>
))
)}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs">=</span>
<Input
value={calc.formula}
onChange={(e) => onUpdate({ formula: e.target.value })}
placeholder="수식 (예: qty * unit_price)"
className="h-8 flex-1 text-xs"
disabled={isConditionalEnabled}
/>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="text-destructive hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 조건부 계산 토글 */}
<div className="flex items-center gap-2">
<Switch
id={`conditional-calc-${index}`}
checked={isConditionalEnabled}
onCheckedChange={toggleConditionalCalculation}
className="scale-75"
/>
<Label htmlFor={`conditional-calc-${index}`} className="cursor-pointer text-xs">
</Label>
{availableColumns.length === 0 && !isConditionalEnabled && (
<span className="text-muted-foreground ml-2 text-[10px]">( )</span>
)}
</div>
{/* 조건부 계산 설정 */}
{isConditionalEnabled && (
<div className="space-y-3 border-t pt-3">
{/* 조건 필드 선택 */}
<div className="flex items-center gap-2">
<Label className="w-[80px] shrink-0 text-xs"> :</Label>
<Select value={calc.conditionalCalculation?.conditionField || ""} onValueChange={updateConditionField}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="조건 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{availableColumns.length === 0 ? (
<SelectItem value="__no_columns__" disabled>
</SelectItem>
) : (
availableColumns.map((col, idx) => {
// 소스 필드명으로 카테고리 여부 확인
const actualFieldName = col.sourceField || col.field;
const isCategoryColumn = categoryColumns[actualFieldName];
return (
<SelectItem key={col.field || `col_${idx}`} value={col.field}>
{col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
{/* 조건별 계산식 목록 */}
{calc.conditionalCalculation?.conditionField && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> :</Label>
<Button size="sm" variant="outline" onClick={addConditionRule} className="h-6 px-2 text-[10px]">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(calc.conditionalCalculation?.rules || []).map((rule, ruleIndex) => (
<div key={ruleIndex} className="bg-background flex items-center gap-2 rounded p-2">
{/* 조건값 선택 */}
{categoryOptions.length > 0 ? (
<Select
value={rule.conditionValue}
onValueChange={(value) => updateConditionRule(ruleIndex, { conditionValue: value })}
>
<SelectTrigger className="h-7 w-[120px] text-xs">
<SelectValue placeholder="조건값" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((opt, optIdx) => (
<SelectItem key={`${opt.value}_${optIdx}`} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={rule.conditionValue}
onChange={(e) => updateConditionRule(ruleIndex, { conditionValue: e.target.value })}
placeholder="조건값"
className="h-7 w-[120px] text-xs"
/>
)}
<span className="text-muted-foreground text-xs"></span>
<Input
value={rule.formula}
onChange={(e) => updateConditionRule(ruleIndex, { formula: e.target.value })}
placeholder="계산식"
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => removeConditionRule(ruleIndex)}
className="text-destructive hover:text-destructive h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{/* 기본 계산식 */}
<div className="bg-background/50 flex items-center gap-2 rounded border border-dashed p-2">
<span className="text-muted-foreground w-[120px] text-center text-xs">()</span>
<span className="text-muted-foreground text-xs"></span>
<Input
value={calc.conditionalCalculation?.defaultFormula || ""}
onChange={(e) => updateDefaultFormula(e.target.value)}
placeholder="기본 계산식 (조건 미해당 시)"
className="h-7 flex-1 text-xs"
/>
</div>
</div>
)}
{loadingOptions && <p className="text-muted-foreground text-[10px]"> ...</p>}
</div>
)}
</div>
);
};
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
interface OptionSourceConfigProps {
optionSource: {
enabled: boolean;
tableName: string;
valueColumn: string;
labelColumn: string;
filterCondition?: string;
};
tables: { table_name: string; comment?: string }[];
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
onUpdate: (updates: Partial<OptionSourceConfigProps["optionSource"]>) => void;
}
const OptionSourceConfig: React.FC<OptionSourceConfigProps> = ({ optionSource, tables, tableColumns, onUpdate }) => {
const [tableOpen, setTableOpen] = useState(false);
const [valueColumnOpen, setValueColumnOpen] = useState(false);
// 선택된 테이블의 컬럼 목록
const selectedTableColumns = useMemo(() => {
return tableColumns[optionSource.tableName] || [];
}, [tableColumns, optionSource.tableName]);
return (
<div className="grid grid-cols-3 gap-2 pl-6">
{/* 테이블 선택 Combobox */}
<div>
<Label className="text-[10px]"></Label>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableOpen}
className="mt-1 h-7 w-full justify-between text-xs"
>
<span className="truncate">
{optionSource.tableName
? tables.find((t) => t.table_name === optionSource.tableName)?.comment || optionSource.tableName
: "테이블 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색 (영문/한글)..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-3 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={`${table.table_name} ${table.comment || ""}`}
onSelect={() => {
onUpdate({
tableName: table.table_name,
valueColumn: "", // 테이블 변경 시 컬럼 초기화
labelColumn: "",
});
setTableOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
optionSource.tableName === table.table_name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && <span className="text-muted-foreground text-[10px]">{table.comment}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 참조할 값 컬럼 선택 Combobox */}
<div>
<Label className="text-[10px]"> </Label>
<Popover open={valueColumnOpen} onOpenChange={setValueColumnOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={valueColumnOpen}
disabled={!optionSource.tableName}
className="mt-1 h-7 w-full justify-between text-xs"
>
<span className="truncate">
{optionSource.valueColumn
? selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment
? `${optionSource.valueColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment})`
: optionSource.valueColumn
: "컬럼 선택"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색 (영문/한글)..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-3 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{selectedTableColumns.map((column) => (
<CommandItem
key={column.column_name}
value={`${column.column_name} ${column.comment || ""}`}
onSelect={() => {
onUpdate({ valueColumn: column.column_name });
setValueColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
optionSource.valueColumn === column.column_name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.column_name}</span>
{column.comment && <span className="text-muted-foreground text-[10px]">{column.comment}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 출력할 값 컬럼 선택 Combobox */}
<div>
<Label className="text-[10px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={!optionSource.tableName}
className="mt-1 h-7 w-full justify-between text-xs"
>
<span className="truncate">
{optionSource.labelColumn
? selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment
? `${optionSource.labelColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment})`
: optionSource.labelColumn
: "(참조할 값과 동일)"}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색 (영문/한글)..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-3 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{/* 값 컬럼 사용 옵션 */}
<CommandItem
value="__use_value__"
onSelect={() => onUpdate({ labelColumn: "" })}
className="text-muted-foreground text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !optionSource.labelColumn ? "opacity-100" : "opacity-0")} />
( )
</CommandItem>
{selectedTableColumns.map((column) => (
<CommandItem
key={column.column_name}
value={`${column.column_name} ${column.comment || ""}`}
onSelect={() => onUpdate({ labelColumn: column.column_name })}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
optionSource.labelColumn === column.column_name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.column_name}</span>
{column.comment && <span className="text-muted-foreground text-[10px]">{column.comment}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-0.5 text-[9px]"> </p>
</div>
</div>
);
};
// 부모 화면에서 전달 가능한 필드 타입
interface AvailableParentField {
name: string; // 필드명 (columnName)
label: string; // 표시 라벨
sourceComponent?: string; // 출처 컴포넌트
sourceTable?: string; // 출처 테이블명
}
// 컬럼 설정 아이템 컴포넌트
interface ColumnSettingItemProps {
col: TableColumnConfig;
index: number;
totalCount: number;
saveTableColumns: {
column_name: string;
data_type: string;
is_nullable: string;
comment?: string;
input_type?: string;
}[];
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: {
column_name: string;
data_type: string;
is_nullable: string;
comment?: string;
input_type?: string;
}[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
externalTableColumns: {
column_name: string;
data_type: string;
is_nullable: string;
comment?: string;
input_type?: string;
}[]; // 외부 데이터 테이블 컬럼
externalTableName?: string; // 외부 데이터 테이블명
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록
tableColumns: Record<
string,
{ column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]
>; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
availableParentFields?: AvailableParentField[]; // 부모 화면에서 전달 가능한 필드 목록
onLoadTableColumns: (tableName: string) => void;
onUpdate: (updates: Partial<TableColumnConfig>) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}
function ColumnSettingItem({
col,
index,
totalCount,
saveTableColumns,
displayColumns,
sourceTableColumns,
sourceTableName,
externalTableColumns,
externalTableName,
externalDataEnabled,
tables,
tableColumns,
sections,
formFields,
tableConfig,
availableParentFields = [],
onLoadTableColumns,
onUpdate,
onMoveUp,
onMoveDown,
onRemove,
}: ColumnSettingItemProps) {
const [fieldSearchOpen, setFieldSearchOpen] = useState(false);
const [sourceFieldSearchOpen, setSourceFieldSearchOpen] = useState(false);
const [externalFieldSearchOpen, setExternalFieldSearchOpen] = useState(false);
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 소스 필드 기준으로 카테고리 타입인지 확인
const actualSourceField = col.sourceField || col.field;
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
useEffect(() => {
if (isCategoryColumn && col.type !== "category") {
onUpdate({ type: "category" });
}
}, [isCategoryColumn, col.type, onUpdate]);
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
id: `lookup_${Date.now()}`,
label: `조회 옵션 ${(col.lookup?.options || []).length + 1}`,
type: "sameTable",
tableName: sourceTableName,
valueColumn: "",
conditions: [],
isDefault: (col.lookup?.options || []).length === 0,
};
onUpdate({
lookup: {
enabled: true,
options: [...(col.lookup?.options || []), newOption],
},
});
};
// 조회 옵션 삭제
const removeLookupOption = (optIndex: number) => {
const newOptions = (col.lookup?.options || []).filter((_, i) => i !== optIndex);
if (newOptions.length > 0 && !newOptions.some((opt) => opt.isDefault)) {
newOptions[0].isDefault = true;
}
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: newOptions,
},
});
};
// 조회 옵션 업데이트
const updateLookupOption = (optIndex: number, updates: Partial<LookupOption>) => {
onUpdate({
lookup: {
enabled: col.lookup?.enabled ?? false,
options: (col.lookup?.options || []).map((opt, i) => (i === optIndex ? { ...opt, ...updates } : opt)),
},
});
};
// 조회 조건 추가
const addLookupCondition = (optIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
const newCondition: LookupCondition = {
sourceType: "currentRow",
sourceField: "",
targetColumn: "",
};
updateLookupOption(optIndex, {
conditions: [...(option.conditions || []), newCondition],
});
};
// 조회 조건 삭제
const removeLookupCondition = (optIndex: number, condIndex: number) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.filter((_, i) => i !== condIndex),
});
};
// 조회 조건 업데이트
const updateLookupCondition = (optIndex: number, condIndex: number, updates: Partial<LookupCondition>) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.map((c, i) => (i === condIndex ? { ...c, ...updates } : c)),
});
};
return (
<div className="bg-card space-y-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium">{col.label || col.field || `컬럼 ${index + 1}`}</span>
<Badge variant="secondary" className="text-xs">
{TABLE_COLUMN_TYPE_OPTIONS.find((t) => t.value === col.type)?.label || col.type}
</Badge>
{col.calculated && (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={onMoveUp} disabled={index === 0} className="h-7 w-7 p-0">
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onMoveDown}
disabled={index === totalCount - 1}
className="h-7 w-7 p-0"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onRemove}
className="text-destructive hover:text-destructive h-7 w-7 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="grid grid-cols-5 gap-3">
{/* 필드명 - Combobox (저장할 컬럼) */}
<div>
<Label className="text-xs"> ()</Label>
<Popover open={fieldSearchOpen} onOpenChange={setFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={fieldSearchOpen}
className={cn("mt-1 h-8 w-full justify-between text-xs", !col.field && "text-muted-foreground")}
>
<span className="truncate">{col.field || "(저장 안 함)"}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="필드 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{/* 선택 안 함 옵션 */}
<CommandItem
key="__none__"
value="__none__"
onSelect={() => {
onUpdate({ field: "" });
setFieldSearchOpen(false);
}}
className="text-muted-foreground text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !col.field ? "opacity-100" : "opacity-0")} />
<span className="italic">( - )</span>
</CommandItem>
{/* 실제 컬럼 목록 */}
{saveTableColumns.map((column) => (
<CommandItem
key={column.column_name}
value={column.column_name}
onSelect={() => {
onUpdate({
field: column.column_name,
// 라벨이 비어있으면 comment로 자동 설정
...((!col.label || col.label.startsWith("컬럼 ")) && column.comment
? { label: column.comment }
: {}),
});
setFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", col.field === column.column_name ? "opacity-100" : "opacity-0")}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{column.column_name}</span>
<span className="text-muted-foreground truncate text-[10px]">
{column.comment || column.data_type}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 소스 필드 - Combobox (검색 모달에서 가져올 컬럼) */}
<div>
<Label className="text-xs"> </Label>
<Popover open={sourceFieldSearchOpen} onOpenChange={setSourceFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceFieldSearchOpen}
className="mt-1 h-8 w-full justify-between text-xs"
disabled={displayColumns.length === 0}
>
<span className="truncate">{col.sourceField || "(필드명과 동일)"}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="소스 필드 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{/* 필드명과 동일 옵션 */}
<CommandItem
value="__same_as_field__"
onSelect={() => {
onUpdate({ sourceField: undefined });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !col.sourceField ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground">( )</span>
</CommandItem>
{/* 표시 컬럼 목록 */}
{displayColumns.map((colName) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
<CommandItem
key={colName}
value={colName}
onSelect={() => {
onUpdate({ sourceField: colName });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn("mr-2 h-3 w-3", col.sourceField === colName ? "opacity-100" : "opacity-0")}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{colName}</span>
{colInfo?.comment && (
<span className="text-muted-foreground truncate text-[10px]">{colInfo.comment}</span>
)}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 외부 필드 - Combobox (외부 데이터에서 가져올 컬럼) */}
{externalDataEnabled && externalTableName && (
<div>
<Label className="text-xs"> </Label>
<Popover open={externalFieldSearchOpen} onOpenChange={setExternalFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={externalFieldSearchOpen}
className="mt-1 h-8 w-full justify-between text-xs"
disabled={externalTableColumns.length === 0}
>
<span className="truncate">{col.externalField || "(필드명과 동일)"}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="외부 필드 검색..." className="text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{/* 필드명과 동일 옵션 */}
<CommandItem
value="__same_as_field__"
onSelect={() => {
onUpdate({ externalField: undefined });
setExternalFieldSearchOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !col.externalField ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground">( )</span>
</CommandItem>
{/* 외부 테이블 컬럼 목록 */}
{externalTableColumns.map((extCol) => (
<CommandItem
key={extCol.column_name}
value={extCol.column_name}
onSelect={() => {
onUpdate({ externalField: extCol.column_name });
setExternalFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.externalField === extCol.column_name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{extCol.column_name}</span>
{extCol.comment && (
<span className="text-muted-foreground truncate text-[10px]">{extCol.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-0.5 text-[10px]">
({externalTableName})
</p>
</div>
)}
{/* 라벨 */}
<div>
<Label className="text-xs"></Label>
<Input
value={col.label}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="표시 라벨"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 타입 */}
<div>
<Label className="text-xs"></Label>
<Select
value={isCategoryColumn ? "category" : col.type}
onValueChange={(value: any) => onUpdate({ type: value })}
disabled={isCategoryColumn}
>
<SelectTrigger className={cn("mt-1 h-8 text-xs", isCategoryColumn && "cursor-not-allowed opacity-70")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TABLE_COLUMN_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{isCategoryColumn && (
<p className="mt-0.5 text-[10px] text-blue-600"> </p>
)}
</div>
{/* 너비 */}
<div>
<Label className="text-xs"></Label>
<Input
value={col.width || ""}
onChange={(e) => onUpdate({ width: e.target.value })}
placeholder="150px"
className="mt-1 h-8 text-xs"
/>
</div>
</div>
{/* 옵션 스위치 */}
<div className="flex items-center gap-4">
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={col.editable ?? true}
onCheckedChange={(checked) => onUpdate({ editable: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={col.calculated ?? false}
onCheckedChange={(checked) => onUpdate({ calculated: checked })}
className="scale-75"
/>
<span> </span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={col.required ?? false}
onCheckedChange={(checked) => onUpdate({ required: checked })}
className="scale-75"
/>
<span></span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs" title="UI에서 숨기지만 데이터는 유지됩니다">
<Switch
checked={col.hidden ?? false}
onCheckedChange={(checked) => onUpdate({ hidden: checked })}
className="scale-75"
/>
<span></span>
</label>
<label
className="flex cursor-pointer items-center gap-2 text-xs"
title="부모 화면에서 전달받은 값을 모든 행에 적용"
>
<Switch
checked={col.receiveFromParent ?? false}
onCheckedChange={(checked) => onUpdate({ receiveFromParent: checked })}
className="scale-75"
/>
<span className="text-blue-600"></span>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={col.lookup?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ lookup: { enabled: true, options: [] } });
} else {
onUpdate({ lookup: undefined });
}
}}
className="scale-75"
/>
<span className="flex items-center gap-1">
<Search className="h-3 w-3" />
</span>
</label>
{/* 날짜 타입일 때만 일괄 적용 옵션 표시 */}
{col.type === "date" && (
<label
className="flex cursor-pointer items-center gap-2 text-xs"
title="첫 번째 날짜 입력 시 모든 행에 동일하게 적용"
>
<Switch
checked={col.batchApply ?? false}
onCheckedChange={(checked) => onUpdate({ batchApply: checked })}
className="scale-75"
/>
<span> </span>
</label>
)}
</div>
{/* 부모에서 값 받기 설정 (부모값 ON일 때만 표시) */}
{col.receiveFromParent && (
<div className="mt-3 space-y-2 border-t pt-3">
<Label className="text-xs font-medium text-blue-600"> </Label>
<p className="text-muted-foreground text-[10px]">
. .
</p>
{availableParentFields.length > 0 ? (
<Popover open={parentFieldSearchOpen} onOpenChange={setParentFieldSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={parentFieldSearchOpen}
className="h-8 w-full justify-between text-xs"
>
<span className="truncate">
{col.parentFieldName
? availableParentFields.find((f) => f.name === col.parentFieldName)?.label || col.parentFieldName
: `(기본: ${col.field})`}
</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="부모 필드 검색..." className="text-xs" />
<CommandList className="max-h-[250px]">
<CommandEmpty className="py-4 text-center text-xs"> .</CommandEmpty>
<CommandGroup>
{/* 기본값 (필드명과 동일) */}
<CommandItem
value="__same_as_field__"
onSelect={() => {
onUpdate({ parentFieldName: undefined });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", !col.parentFieldName ? "opacity-100" : "opacity-0")} />
<span className="text-muted-foreground">(: {col.field})</span>
</CommandItem>
{/* 부모 필드 목록 */}
{availableParentFields.map((pf) => (
<CommandItem
key={pf.name}
value={`${pf.name} ${pf.label}`}
onSelect={() => {
onUpdate({ parentFieldName: pf.name });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.parentFieldName === pf.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium">{pf.label || pf.name}</span>
{pf.sourceComponent && (
<span className="text-muted-foreground truncate text-[10px]">
{pf.sourceComponent}
{pf.sourceTable && ` (${pf.sourceTable})`}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="space-y-1">
<Input
value={col.parentFieldName || ""}
onChange={(e) => onUpdate({ parentFieldName: e.target.value })}
placeholder={col.field}
className="h-8 text-xs"
/>
<p className="text-muted-foreground text-[10px]"> "{col.field}" .</p>
</div>
)}
</div>
)}
{/* 조회 설정 (조회 ON일 때만 표시) */}
{col.lookup?.enabled && (
<div className="mt-3 space-y-3 border-t pt-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-blue-600"> </Label>
<Button size="sm" variant="outline" onClick={addLookupOption} className="h-6 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(col.lookup?.options || []).length === 0 ? (
<p className="text-muted-foreground bg-muted/20 rounded border border-dashed py-2 text-center text-xs">
"옵션 추가" .
</p>
) : (
<div className="space-y-3">
{(col.lookup?.options || []).map((option, optIndex) => (
<div key={option.id} className="space-y-3 rounded-lg border bg-blue-50/30 p-3">
{/* 옵션 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{option.label || `옵션 ${optIndex + 1}`}</span>
{option.isDefault && (
<Badge variant="secondary" className="h-4 text-[10px]">
</Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupOption(optIndex)}
className="text-destructive hover:text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 기본 설정 - 첫 번째 줄: 옵션명, 표시 라벨 */}
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-[10px]"></Label>
<Input
value={option.label}
onChange={(e) => updateLookupOption(optIndex, { label: e.target.value })}
placeholder="예: 기준단가"
className="mt-0.5 h-7 text-xs"
/>
</div>
<div>
<Label className="text-[10px]"> ( )</Label>
<Input
value={option.displayLabel || ""}
onChange={(e) => updateLookupOption(optIndex, { displayLabel: e.target.value })}
placeholder={`예: 단가 (${option.label || "옵션명"})`}
className="mt-0.5 h-7 text-xs"
/>
<p className="text-muted-foreground mt-0.5 text-[9px]"> </p>
</div>
</div>
{/* 기본 설정 - 두 번째 줄: 조회 유형, 테이블, 가져올 컬럼 */}
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.type}
onValueChange={(value: "sameTable" | "relatedTable" | "combinedLookup") => {
const newTableName = value === "sameTable" ? sourceTableName : "";
updateLookupOption(optIndex, { type: value, tableName: newTableName, conditions: [] });
if (newTableName) onLoadTableColumns(newTableName);
}}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px]"> </Label>
{option.type === "sameTable" ? (
<Input value={sourceTableName} disabled className="bg-muted mt-0.5 h-7 text-xs" />
) : (
<Popover
open={lookupTableOpenMap[option.id]}
onOpenChange={(open) => setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: open }))}
>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-0.5 h-7 w-full justify-between text-xs">
{option.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="py-2 text-center text-xs"></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 w-3",
option.tableName === table.table_name ? "opacity-100" : "opacity-0",
)}
/>
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div>
<Label className="text-[10px]"> </Label>
<Select
value={option.valueColumn}
onValueChange={(value) => updateLookupOption(optIndex, { valueColumn: value })}
>
<SelectTrigger className="mt-0.5 h-7 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 기본 옵션 & 조회 조건 */}
<div className="flex items-center justify-between">
<label className="flex cursor-pointer items-center gap-1.5 text-xs">
<Switch
checked={option.isDefault ?? false}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({
lookup: {
enabled: true,
options: (col.lookup?.options || []).map((o, i) => ({
...o,
isDefault: i === optIndex,
})),
},
});
} else {
updateLookupOption(optIndex, { isDefault: false });
}
}}
className="scale-[0.6]"
/>
<span> </span>
</label>
<Button
size="sm"
variant="ghost"
onClick={() => addLookupCondition(optIndex)}
className="h-6 px-2 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 조회 조건 목록 */}
{(option.conditions || []).length > 0 && (
<div className="space-y-2">
{option.conditions.map((cond, condIndex) => (
<div key={condIndex} className="space-y-2 rounded border bg-white p-2">
{/* 기본 조건 행 */}
<div className="flex items-center gap-1.5">
<Select
value={cond.sourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField" | "externalTable") =>
updateLookupCondition(optIndex, condIndex, {
sourceType: value,
sourceField: "",
sectionId: undefined,
transform: undefined,
externalLookup:
value === "externalTable"
? {
tableName: "",
matchColumn: "",
matchSourceType: "sourceTable",
matchSourceField: "",
resultColumn: "",
}
: undefined,
})
}
>
<SelectTrigger className="h-6 w-[90px] text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-xs">
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 다른 섹션 선택 시 - 섹션 드롭다운 */}
{cond.sourceType === "sectionField" && (
<Select
value={cond.sectionId || ""}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, { sectionId: value, sourceField: "" })
}
>
<SelectTrigger className="h-6 w-[70px] text-[10px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">
{s.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션 - 필드 선택 */}
{cond.sourceType !== "externalTable" && (
<div className="space-y-0.5">
<Select
value={cond.sourceField}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, { sourceField: value })
}
>
<SelectTrigger className="h-6 w-[110px] text-[10px]">
<SelectValue placeholder="필드" />
</SelectTrigger>
<SelectContent>
{cond.sourceType === "currentRow" ? (
// 현재 행: 테이블에 설정된 컬럼 필드 표시
<>
<div className="border-b bg-green-50 px-2 py-1 text-[10px] font-medium text-green-600">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label} ({c.field})
</SelectItem>
))}
</>
) : cond.sourceType === "sourceTable" ? (
// 소스 테이블: 원본 테이블의 컬럼 표시
<>
<div className="border-b bg-orange-50 px-2 py-1 text-[10px] font-medium text-orange-600">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</>
) : (
// 다른 섹션: 폼 필드 표시
<>
{cond.sectionId && (
<div className="border-b bg-purple-50 px-2 py-1 text-[10px] font-medium text-purple-600">
{sections.find((s) => s.id === cond.sectionId)?.title || cond.sectionId}{" "}
</div>
)}
{formFields
.filter((f) => !cond.sectionId || f.sectionId === cond.sectionId)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">
{f.label}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
{cond.sourceField && (
<p className="text-muted-foreground truncate text-[9px]">
{cond.sourceType === "currentRow"
? `rowData.${cond.sourceField}`
: cond.sourceType === "sourceTable"
? `${sourceTableName}.${cond.sourceField}`
: `formData.${cond.sourceField}`}
</p>
)}
</div>
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션일 때 = 기호와 조회 컬럼 */}
{cond.sourceType !== "externalTable" && (
<>
<span className="text-muted-foreground text-[10px]">=</span>
<div className="flex-1 space-y-0.5">
<Select
value={cond.targetColumn}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, { targetColumn: value })
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="조회 컬럼" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="border-b bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-600">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
{cond.targetColumn && option.tableName && (
<p className="text-muted-foreground truncate text-[9px]">
{option.tableName}.{cond.targetColumn}
</p>
)}
</div>
</>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removeLookupCondition(optIndex, condIndex)}
className="text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 외부 테이블 조회 설정 */}
{cond.sourceType === "externalTable" && cond.externalLookup && (
<div className="space-y-2 border-l-2 border-orange-200 pl-2">
<p className="text-[10px] font-medium text-orange-600"> </p>
{/* 1행: 조회 테이블 선택 */}
<div className="grid grid-cols-3 gap-1.5">
<div>
<p className="text-muted-foreground mb-0.5 text-[9px]"> </p>
<Popover
open={lookupTableOpenMap[`ext_${optIndex}_${condIndex}`] || false}
onOpenChange={(open) =>
setLookupTableOpenMap((prev) => ({
...prev,
[`ext_${optIndex}_${condIndex}`]: open,
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-6 w-full justify-between text-[10px]"
>
{cond.externalLookup.tableName || "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
onLoadTableColumns(table.table_name);
updateLookupCondition(optIndex, condIndex, {
externalLookup: {
...cond.externalLookup!,
tableName: table.table_name,
matchColumn: "",
resultColumn: "",
},
});
setLookupTableOpenMap((prev) => ({
...prev,
[`ext_${optIndex}_${condIndex}`]: false,
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
cond.externalLookup?.tableName === table.table_name
? "opacity-100"
: "opacity-0",
)}
/>
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<p className="text-muted-foreground mb-0.5 text-[9px]"> </p>
<Select
value={cond.externalLookup.matchColumn}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchColumn: value },
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<p className="text-muted-foreground mb-0.5 text-[9px]"> </p>
<Select
value={cond.externalLookup.resultColumn}
onValueChange={(value) => {
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, resultColumn: value },
sourceField: value, // sourceField에도 저장
});
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 2행: 비교 값 출처 */}
<div className="rounded bg-orange-50/50 p-1.5">
<p className="text-muted-foreground mb-1 text-[9px]">
( )
</p>
<div className="flex items-center gap-1.5">
<Select
value={cond.externalLookup.matchSourceType}
onValueChange={(value: "currentRow" | "sourceTable" | "sectionField") =>
updateLookupCondition(optIndex, condIndex, {
externalLookup: {
...cond.externalLookup!,
matchSourceType: value,
matchSourceField: "",
matchSectionId: undefined,
},
})
}
>
<SelectTrigger className="h-5 w-[80px] text-[9px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="currentRow" className="text-xs">
</SelectItem>
<SelectItem value="sourceTable" className="text-xs">
</SelectItem>
<SelectItem value="sectionField" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
{cond.externalLookup.matchSourceType === "sectionField" && (
<Select
value={cond.externalLookup.matchSectionId || ""}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
externalLookup: {
...cond.externalLookup!,
matchSectionId: value,
matchSourceField: "",
},
})
}
>
<SelectTrigger className="h-5 w-[60px] text-[9px]">
<SelectValue placeholder="섹션" />
</SelectTrigger>
<SelectContent>
{sections.map((s) => (
<SelectItem key={s.id} value={s.id} className="text-xs">
{s.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select
value={cond.externalLookup.matchSourceField}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceField: value },
})
}
>
<SelectTrigger className="h-5 flex-1 text-[9px]">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{cond.externalLookup.matchSourceType === "currentRow" ? (
<>
<div className="border-b bg-green-50 px-2 py-1 text-[9px] font-medium text-green-600">
</div>
{tableConfig?.columns?.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label} ({c.field})
</SelectItem>
))}
</>
) : cond.externalLookup.matchSourceType === "sourceTable" ? (
<>
<div className="border-b bg-orange-50 px-2 py-1 text-[9px] font-medium text-orange-600">
{sourceTableName}
</div>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</>
) : (
<>
{cond.externalLookup.matchSectionId && (
<div className="border-b bg-purple-50 px-2 py-1 text-[9px] font-medium text-purple-600">
{
sections.find((s) => s.id === cond.externalLookup?.matchSectionId)
?.title
}{" "}
</div>
)}
{formFields
.filter(
(f) =>
!cond.externalLookup?.matchSectionId ||
f.sectionId === cond.externalLookup?.matchSectionId,
)
.map((f) => (
<SelectItem key={f.columnName} value={f.columnName} className="text-xs">
{f.label}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
</div>
{/* 3행: 최종 조회 컬럼 */}
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground text-[9px]"> ( )</span>
<span className="text-muted-foreground text-[10px]">=</span>
<Select
value={cond.targetColumn}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, { targetColumn: value })
}
>
<SelectTrigger className="h-5 flex-1 text-[9px]">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{option.tableName && (
<div className="border-b bg-blue-50 px-2 py-1 text-[9px] font-medium text-blue-600">
{option.tableName}
</div>
)}
{(tableColumns[option.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 설명 텍스트 */}
{cond.externalLookup.tableName &&
cond.externalLookup.matchColumn &&
cond.externalLookup.resultColumn &&
cond.targetColumn && (
<p className="rounded bg-orange-100/50 px-1.5 py-0.5 text-[9px] text-orange-600">
{cond.externalLookup.tableName} {cond.externalLookup.matchColumn} = (
) {cond.externalLookup.resultColumn} {option.tableName}.
{cond.targetColumn}
</p>
)}
</div>
)}
{/* 값 변환 설정 (다른 섹션일 때만 표시) */}
{cond.sourceType === "sectionField" && (
<div className="border-l-2 border-blue-200 pl-2">
<label className="flex cursor-pointer items-center gap-1.5 text-[10px]">
<Switch
checked={cond.transform?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateLookupCondition(optIndex, condIndex, {
transform: { enabled: true, tableName: "", matchColumn: "", resultColumn: "" },
});
} else {
updateLookupCondition(optIndex, condIndex, { transform: undefined });
}
}}
className="scale-[0.5]"
/>
<span className="font-medium text-blue-600"> </span>
<span className="text-muted-foreground">( )</span>
</label>
{cond.transform?.enabled && (
<div className="mt-1.5 space-y-1.5 rounded bg-blue-50/50 p-2">
<div className="grid grid-cols-3 gap-1.5">
<div>
<Label className="text-muted-foreground text-[9px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-6 w-full justify-between text-[10px]">
{cond.transform.tableName || "선택..."}
<ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[180px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-[150px]">
<CommandEmpty className="py-2 text-center text-xs"></CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateLookupCondition(optIndex, condIndex, {
transform: {
...cond.transform!,
tableName: table.table_name,
matchColumn: "",
resultColumn: "",
},
});
onLoadTableColumns(table.table_name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
cond.transform?.tableName === table.table_name
? "opacity-100"
: "opacity-0",
)}
/>
{table.table_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-muted-foreground text-[9px]"> </Label>
<Select
value={cond.transform.matchColumn}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, matchColumn: value },
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-muted-foreground text-[9px]"> </Label>
<Select
value={cond.transform.resultColumn}
onValueChange={(value) =>
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, resultColumn: value },
})
}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{cond.transform.tableName &&
cond.transform.matchColumn &&
cond.transform.resultColumn && (
<p className="rounded bg-blue-100/50 px-1.5 py-0.5 text-[9px] text-blue-600">
{cond.transform.tableName} {cond.transform.matchColumn} = {" "}
{cond.transform.resultColumn}
</p>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* 조회 유형 설명 */}
<p className="text-muted-foreground bg-muted/50 rounded p-1.5 text-[10px]">
{option.type === "sameTable" && "동일 테이블: 검색 모달에서 선택한 행의 다른 컬럼 값"}
{option.type === "relatedTable" && "연관 테이블: 현재 행 데이터로 다른 테이블 조회"}
{option.type === "combinedLookup" && "복합 조건: 다른 섹션 필드 + 현재 행 조합 조회"}
</p>
</div>
))}
</div>
)}
</div>
)}
{/* 동적 Select 옵션 (소스 테이블 필터링이 활성화되고, 타입이 select일 때만 표시) */}
{col.type === "select" && tableConfig.conditionalTable?.sourceFilter?.enabled && (
<div className="mt-3 space-y-3 border-t pt-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs font-medium text-green-600"> </Label>
<p className="text-muted-foreground text-[10px]">
. .
</p>
</div>
<Switch
checked={col.dynamicSelectOptions?.enabled ?? false}
onCheckedChange={(checked) => {
onUpdate({
dynamicSelectOptions: checked
? {
enabled: true,
sourceField: "",
distinct: true,
}
: undefined,
});
}}
className="scale-75"
/>
</div>
{col.dynamicSelectOptions?.enabled && (
<div className="space-y-3 border-l-2 border-green-500/30 pl-2">
{/* 소스 컬럼 선택 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[10px]"> ( )</Label>
<p className="text-muted-foreground mb-1 text-[9px]"> </p>
{sourceTableColumns.length > 0 ? (
<Select
value={col.dynamicSelectOptions.sourceField || ""}
onValueChange={(value) => {
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
sourceField: value,
// 라벨 필드가 비어있으면 소스 필드와 동일하게 설정
labelField: col.dynamicSelectOptions?.labelField || value,
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name} {c.comment && `(${c.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={col.dynamicSelectOptions.sourceField || ""}
onChange={(e) => {
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
sourceField: e.target.value,
},
});
}}
placeholder="inspection_item"
className="h-7 text-xs"
/>
)}
</div>
<div>
<Label className="text-[10px]"> ()</Label>
<p className="text-muted-foreground mb-1 text-[9px]"> ( )</p>
{sourceTableColumns.length > 0 ? (
<Select
value={col.dynamicSelectOptions.labelField || "__same_as_source__"}
onValueChange={(value) => {
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
labelField: value === "__same_as_source__" ? "" : value,
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="(소스 컬럼과 동일)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__same_as_source__" className="text-muted-foreground text-xs">
( )
</SelectItem>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name} {c.comment && `(${c.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={col.dynamicSelectOptions.labelField || ""}
onChange={(e) => {
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
labelField: e.target.value,
},
});
}}
placeholder="(비워두면 소스 컬럼과 동일)"
className="h-7 text-xs"
/>
)}
</div>
</div>
{/* 행 선택 모드 */}
<div className="space-y-2">
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={col.dynamicSelectOptions.rowSelectionMode?.enabled ?? false}
onCheckedChange={(checked) => {
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: checked
? {
enabled: true,
autoFillColumns: [],
}
: undefined,
},
});
}}
className="scale-75"
/>
<span> ( )</span>
</label>
<p className="text-muted-foreground pl-6 text-[9px]">
.
</p>
{col.dynamicSelectOptions.rowSelectionMode?.enabled && (
<div className="space-y-2 pl-6">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentMappings = col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [];
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: [...currentMappings, { sourceColumn: "", targetField: "" }],
},
},
});
}}
className="h-6 px-2 text-[10px]"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length === 0 ? (
<p className="text-muted-foreground bg-muted/20 rounded border border-dashed py-2 text-center text-[10px]">
"매핑 추가" .
</p>
) : (
<div className="space-y-2">
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).map(
(mapping, mappingIndex) => (
<div
key={mappingIndex}
className="flex items-center gap-2 rounded border bg-green-50/30 p-2"
>
{/* 소스 컬럼 */}
<div className="flex-1">
<Label className="text-muted-foreground text-[9px]"> </Label>
{sourceTableColumns.length > 0 ? (
<Select
value={mapping.sourceColumn}
onValueChange={(value) => {
const newMappings = [
...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []),
];
newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: value };
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="소스 컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={mapping.sourceColumn}
onChange={(e) => {
const newMappings = [
...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []),
];
newMappings[mappingIndex] = {
...newMappings[mappingIndex],
sourceColumn: e.target.value,
};
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
placeholder="소스 컬럼"
className="h-6 text-[10px]"
/>
)}
</div>
<span className="text-muted-foreground text-xs"></span>
{/* 타겟 필드 */}
<div className="flex-1">
<Label className="text-muted-foreground text-[9px]"> </Label>
<Select
value={mapping.targetField}
onValueChange={(value) => {
const newMappings = [
...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []),
];
newMappings[mappingIndex] = { ...newMappings[mappingIndex], targetField: value };
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
>
<SelectTrigger className="h-6 text-[10px]">
<SelectValue placeholder="타겟 필드" />
</SelectTrigger>
<SelectContent>
{(tableConfig.columns || [])
.filter((c) => c.field !== col.field) // 현재 컬럼 제외
.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label || c.field}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
onClick={() => {
const newMappings = (
col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []
).filter((_, i) => i !== mappingIndex);
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
className="text-destructive hover:text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
),
)}
</div>
)}
{/* 매핑 설명 */}
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length > 0 && (
<div className="rounded bg-green-100/50 p-1.5 text-[9px] text-green-600">
{col.label || col.field} :{" "}
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || [])
.filter((m) => m.sourceColumn && m.targetField)
.map((m) => {
const targetCol = tableConfig.columns?.find((c) => c.field === m.targetField);
return `${m.sourceColumn}${targetCol?.label || m.targetField}`;
})
.join(", ")}
</div>
)}
</div>
)}
</div>
{/* 설정 요약 */}
{col.dynamicSelectOptions.sourceField && (
<div className="rounded bg-green-100/50 p-1.5 text-[9px] text-green-600">
{sourceTableName}.{col.dynamicSelectOptions.sourceField}
{tableConfig.conditionalTable?.sourceFilter?.filterColumn && (
<> (: {tableConfig.conditionalTable.sourceFilter.filterColumn} = )</>
)}
</div>
)}
</div>
)}
</div>
)}
{/* ============================================ */}
{/* 저장 설정 섹션 */}
{/* ============================================ */}
<div className="space-y-2 border-t pt-3">
<Label className="flex items-center gap-2 text-xs font-semibold"> </Label>
<p className="text-muted-foreground text-[10px]"> DB에 .</p>
{/* 저장 여부 라디오 버튼 */}
<div className="space-y-2 pl-2">
{/* 저장함 옵션 */}
<label className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg border p-2 transition-colors">
<input
type="radio"
name={`saveConfig_${col.field}`}
checked={col.saveConfig?.saveToTarget !== false}
onChange={() => {
onUpdate({
saveConfig: {
saveToTarget: true,
},
});
}}
className="mt-0.5"
/>
<div className="flex-1">
<span className="text-xs font-medium"> ()</span>
<p className="text-muted-foreground mt-0.5 text-[10px]"> / DB에 .</p>
</div>
</label>
{/* 저장 안 함 옵션 */}
<label className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-lg border p-2 transition-colors">
<input
type="radio"
name={`saveConfig_${col.field}`}
checked={col.saveConfig?.saveToTarget === false}
onChange={() => {
onUpdate({
saveConfig: {
saveToTarget: false,
referenceDisplay: {
referenceIdField: "",
sourceColumn: "",
},
},
});
}}
className="mt-0.5"
/>
<div className="flex-1">
<span className="text-xs font-medium"> - </span>
<p className="text-muted-foreground mt-0.5 text-[10px]">
ID로 .
</p>
</div>
</label>
</div>
{/* 참조 설정 패널 (저장 안 함 선택 시) */}
{col.saveConfig?.saveToTarget === false && (
<div className="ml-6 space-y-3 rounded-lg border-2 border-dashed border-amber-300 bg-amber-50/50 p-3">
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-amber-600" />
<span className="text-xs font-semibold text-amber-700"> </span>
</div>
{/* Step 1: ID 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px] font-medium">1. ID ?</Label>
<Select
value={col.saveConfig?.referenceDisplay?.referenceIdField || ""}
onValueChange={(value) => {
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
referenceIdField: value,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="ID 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{(tableConfig.columns || [])
.filter((c) => c.field !== col.field) // 현재 컬럼 제외
.map((c) => (
<SelectItem key={c.field} value={c.field} className="text-xs">
{c.label || c.field}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-[9px]"> ID로 .</p>
</div>
{/* Step 2: 소스 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px] font-medium">2. ?</Label>
{sourceTableColumns.length > 0 ? (
<Select
value={col.saveConfig?.referenceDisplay?.sourceColumn || ""}
onValueChange={(value) => {
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
sourceColumn: value,
},
},
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="소스 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((c) => (
<SelectItem key={c.column_name} value={c.column_name} className="text-xs">
{c.column_name} {c.comment && `(${c.comment})`}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={col.saveConfig?.referenceDisplay?.sourceColumn || ""}
onChange={(e) => {
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
sourceColumn: e.target.value,
},
},
});
}}
placeholder="소스 컬럼명 입력"
className="h-7 text-xs"
/>
)}
<p className="text-muted-foreground text-[9px]"> .</p>
</div>
{/* 설정 요약 */}
{col.saveConfig?.referenceDisplay?.referenceIdField && col.saveConfig?.referenceDisplay?.sourceColumn && (
<div className="mt-2 rounded bg-amber-100 p-2 text-[10px] text-amber-700">
<strong> :</strong>
<br />- ({col.label || col.field}) .
<br />- <strong>{col.saveConfig.referenceDisplay.referenceIdField}</strong>{" "}
<strong>{sourceTableName}</strong> {" "}
<strong>{col.saveConfig.referenceDisplay.sourceColumn}</strong> .
</div>
)}
</div>
)}
</div>
</div>
);
}
interface TableSectionSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: FormSectionConfig;
onSave: (updates: Partial<FormSectionConfig>) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record<
string,
{ column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]
>;
onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (category_values에서 가져옴)
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
onLoadCategoryList?: () => void;
// 전체 섹션 목록 (다른 섹션 필드 참조용)
allSections?: FormSectionConfig[];
// 부모 화면에서 전달 가능한 필드 목록
availableParentFields?: AvailableParentField[];
}
export function TableSectionSettingsModal({
open,
onOpenChange,
section,
onSave,
tables,
tableColumns,
onLoadTableColumns,
categoryList = [],
onLoadCategoryList,
allSections = [],
availableParentFields = [],
}: TableSectionSettingsModalProps) {
// 로컬 상태
const [title, setTitle] = useState(section.title);
const [description, setDescription] = useState(section.description || "");
const [tableConfig, setTableConfig] = useState<TableSectionConfig>(
section.tableConfig || { ...defaultTableSectionConfig },
);
// 테이블 검색 Combobox 상태
const [tableSearchOpen, setTableSearchOpen] = useState(false);
const [saveTableSearchOpen, setSaveTableSearchOpen] = useState(false);
const [externalTableSearchOpen, setExternalTableSearchOpen] = useState(false);
// 활성 탭
const [activeTab, setActiveTab] = useState("source");
// 사전 필터 카테고리 옵션 캐시 (컬럼명 -> 옵션 배열)
const [preFilterCategoryOptions, setPreFilterCategoryOptions] = useState<
Record<string, { value: string; label: string }[]>
>({});
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
setTitle(section.title);
setDescription(section.description || "");
setTableConfig(section.tableConfig || { ...defaultTableSectionConfig });
}
}, [open, section]);
// 소스 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tableConfig.source.tableName) {
onLoadTableColumns(tableConfig.source.tableName);
}
}, [tableConfig.source.tableName, onLoadTableColumns]);
// 저장 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tableConfig.saveConfig?.targetTable) {
onLoadTableColumns(tableConfig.saveConfig.targetTable);
}
}, [tableConfig.saveConfig?.targetTable, onLoadTableColumns]);
// 조회 설정에 있는 테이블들의 컬럼 로드 (모달 열릴 때)
useEffect(() => {
if (open && tableConfig.columns) {
const tablesToLoad = new Set<string>();
// 각 컬럼의 lookup 설정에서 테이블 수집
tableConfig.columns.forEach((col) => {
if (col.lookup?.enabled && col.lookup.options) {
col.lookup.options.forEach((option) => {
// 조회 테이블
if (option.tableName) {
tablesToLoad.add(option.tableName);
}
// 변환 테이블
option.conditions?.forEach((cond) => {
if (cond.transform?.enabled && cond.transform.tableName) {
tablesToLoad.add(cond.transform.tableName);
}
});
});
}
});
// 수집된 테이블들의 컬럼 로드
tablesToLoad.forEach((tableName) => {
if (!tableColumns[tableName]) {
onLoadTableColumns(tableName);
}
});
}
}, [open, tableConfig.columns, tableColumns, onLoadTableColumns]);
// 소스 테이블의 컬럼 목록
const sourceTableColumns = useMemo(() => {
return tableColumns[tableConfig.source.tableName] || [];
}, [tableColumns, tableConfig.source.tableName]);
// 카테고리 옵션 로드 함수
const loadCategoryOptions = useCallback(
async (columnName: string) => {
if (!tableConfig.source.tableName || !columnName) return;
// 이미 로드된 경우 스킵
if (preFilterCategoryOptions[columnName]) return;
try {
const response = await apiClient.get(`/table-categories/${tableConfig.source.tableName}/${columnName}/values`);
if (response.data?.success && response.data?.data) {
const options = response.data.data.map((item: any) => ({
// value는 DB에 저장된 실제 값(valueCode)을 사용해야 필터링이 정상 작동
value: item.valueCode || item.value_code || item.valueLabel || item.value_label || "",
// label은 사용자에게 보여질 라벨
label: item.valueLabel || item.value_label || item.valueCode || item.value_code || "",
}));
setPreFilterCategoryOptions((prev) => ({
...prev,
[columnName]: options,
}));
}
} catch (error) {
console.error(`카테고리 옵션 로드 실패 (${columnName}):`, error);
}
},
[tableConfig.source.tableName, preFilterCategoryOptions],
);
// 사전 필터에서 선택된 카테고리 컬럼들의 옵션 자동 로드
useEffect(() => {
const preFilters = tableConfig.filters?.preFilters || [];
preFilters.forEach((filter) => {
if (filter.column) {
const col = sourceTableColumns.find((c) => c.column_name === filter.column);
if (col && col.input_type === "category") {
loadCategoryOptions(filter.column);
}
}
});
}, [tableConfig.filters?.preFilters, sourceTableColumns, loadCategoryOptions]);
// 저장 테이블의 컬럼 목록
const saveTableColumns = useMemo(() => {
// 저장 테이블이 지정되어 있으면 해당 테이블의 컬럼, 아니면 소스 테이블의 컬럼 사용
const targetTable = tableConfig.saveConfig?.targetTable;
if (targetTable) {
return tableColumns[targetTable] || [];
}
return sourceTableColumns;
}, [tableColumns, tableConfig.saveConfig?.targetTable, sourceTableColumns]);
// 다른 섹션 목록 (현재 섹션 제외, 테이블 타입이 아닌 섹션만)
const otherSections = useMemo(() => {
return allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.map((s) => ({ id: s.id, title: s.title }));
}, [allSections, section.id]);
// 다른 섹션의 필드 목록
const otherSectionFields = useMemo(() => {
const fields: { columnName: string; label: string; sectionId: string }[] = [];
allSections
.filter((s) => s.id !== section.id && s.type !== "table")
.forEach((s) => {
(s.fields || []).forEach((f) => {
fields.push({
columnName: f.columnName,
label: f.label,
sectionId: s.id,
});
});
});
return fields;
}, [allSections, section.id]);
// 설정 업데이트 함수
const updateTableConfig = (updates: Partial<TableSectionConfig>) => {
setTableConfig((prev) => ({ ...prev, ...updates }));
};
const updateSource = (updates: Partial<TableSectionConfig["source"]>) => {
updateTableConfig({
source: { ...tableConfig.source, ...updates },
});
};
const updateFilters = (updates: Partial<TableSectionConfig["filters"]>) => {
updateTableConfig({
filters: { ...tableConfig.filters, ...updates },
});
};
const updateUiConfig = (updates: Partial<NonNullable<TableSectionConfig["uiConfig"]>>) => {
const newUiConfig = { ...tableConfig.uiConfig, ...updates };
// 새 버튼 설정이 사용되면 레거시 addButtonType 제거
if ("showSearchButton" in updates || "showAddRowButton" in updates) {
delete (newUiConfig as any).addButtonType;
}
updateTableConfig({ uiConfig: newUiConfig });
};
const updateSaveConfig = (updates: Partial<NonNullable<TableSectionConfig["saveConfig"]>>) => {
updateTableConfig({
saveConfig: { ...tableConfig.saveConfig, ...updates },
});
};
const updateExternalDataSource = (updates: Partial<NonNullable<TableSectionConfig["externalDataSource"]>>) => {
updateTableConfig({
externalDataSource: { ...tableConfig.externalDataSource, enabled: false, tableName: "", ...updates },
});
};
// 외부 데이터 소스 테이블 컬럼 목록
const externalTableColumns = useMemo(() => {
return tableColumns[tableConfig.externalDataSource?.tableName || ""] || [];
}, [tableColumns, tableConfig.externalDataSource?.tableName]);
// 외부 데이터 소스 테이블 변경 시 컬럼 로드
useEffect(() => {
if (tableConfig.externalDataSource?.enabled && tableConfig.externalDataSource?.tableName) {
onLoadTableColumns(tableConfig.externalDataSource.tableName);
}
}, [tableConfig.externalDataSource?.enabled, tableConfig.externalDataSource?.tableName, onLoadTableColumns]);
// 저장 함수
const handleSave = () => {
onSave({
title,
description,
tableConfig,
});
onOpenChange(false);
};
// 컬럼 추가
const addColumn = () => {
const newColumn: TableColumnConfig = {
...defaultTableColumnConfig,
field: `column_${(tableConfig.columns || []).length + 1}`,
label: `컬럼 ${(tableConfig.columns || []).length + 1}`,
};
updateTableConfig({
columns: [...(tableConfig.columns || []), newColumn],
});
};
// 컬럼 삭제
const removeColumn = (index: number) => {
updateTableConfig({
columns: (tableConfig.columns || []).filter((_, i) => i !== index),
});
};
// 컬럼 업데이트
const updateColumn = (index: number, updates: Partial<TableColumnConfig>) => {
updateTableConfig({
columns: (tableConfig.columns || []).map((col, i) => (i === index ? { ...col, ...updates } : col)),
});
};
// 컬럼 이동
const moveColumn = (index: number, direction: "up" | "down") => {
const columns = [...(tableConfig.columns || [])];
if (direction === "up" && index > 0) {
[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]];
} else if (direction === "down" && index < columns.length - 1) {
[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]];
}
updateTableConfig({ columns });
};
// 사전 필터 추가
const addPreFilter = () => {
const newFilter: TablePreFilter = { ...defaultPreFilterConfig };
updateFilters({
preFilters: [...(tableConfig.filters?.preFilters || []), newFilter],
});
};
// 사전 필터 삭제
const removePreFilter = (index: number) => {
updateFilters({
preFilters: (tableConfig.filters?.preFilters || []).filter((_, i) => i !== index),
});
};
// 사전 필터 업데이트
const updatePreFilter = (index: number, updates: Partial<TablePreFilter>) => {
updateFilters({
preFilters: (tableConfig.filters?.preFilters || []).map((f, i) => (i === index ? { ...f, ...updates } : f)),
});
};
// 모달 필터 추가
const addModalFilter = () => {
const newFilter: TableModalFilter = { ...defaultModalFilterConfig };
updateFilters({
modalFilters: [...(tableConfig.filters?.modalFilters || []), newFilter],
});
};
// 모달 필터 삭제
const removeModalFilter = (index: number) => {
updateFilters({
modalFilters: (tableConfig.filters?.modalFilters || []).filter((_, i) => i !== index),
});
};
// 모달 필터 업데이트
const updateModalFilter = (index: number, updates: Partial<TableModalFilter>) => {
updateFilters({
modalFilters: (tableConfig.filters?.modalFilters || []).map((f, i) => (i === index ? { ...f, ...updates } : f)),
});
};
// 계산 규칙 추가
const addCalculation = () => {
const newCalc: TableCalculationRule = { ...defaultCalculationRuleConfig };
updateTableConfig({
calculations: [...(tableConfig.calculations || []), newCalc],
});
};
// 계산 규칙 삭제
const removeCalculation = (index: number) => {
updateTableConfig({
calculations: (tableConfig.calculations || []).filter((_, i) => i !== index),
});
};
// 계산 규칙 업데이트
const updateCalculation = (index: number, updates: Partial<TableCalculationRule>) => {
updateTableConfig({
calculations: (tableConfig.calculations || []).map((c, i) => (i === index ? { ...c, ...updates } : c)),
});
};
// 표시 컬럼 토글
const toggleDisplayColumn = (columnName: string) => {
const current = tableConfig.source.displayColumns || [];
if (current.includes(columnName)) {
updateSource({
displayColumns: current.filter((c) => c !== columnName),
});
} else {
updateSource({
displayColumns: [...current, columnName],
});
}
};
// 검색 컬럼 토글
const toggleSearchColumn = (columnName: string) => {
const current = tableConfig.source.searchColumns || [];
if (current.includes(columnName)) {
updateSource({
searchColumns: current.filter((c) => c !== columnName),
});
} else {
updateSource({
searchColumns: [...current, columnName],
});
}
};
// 표시 컬럼 순서 변경
const moveDisplayColumn = (index: number, direction: "up" | "down") => {
const columns = [...(tableConfig.source.displayColumns || [])];
if (direction === "up" && index > 0) {
[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]];
} else if (direction === "down" && index < columns.length - 1) {
[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]];
}
updateSource({ displayColumns: columns });
};
// 표시 컬럼 삭제 (순서 편집 영역에서)
const removeDisplayColumn = (columnName: string) => {
updateSource({
displayColumns: (tableConfig.source.displayColumns || []).filter((c) => c !== columnName),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col sm:max-w-[900px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-[calc(90vh-180px)]">
<div className="space-y-4 p-1">
{/* 기본 정보 */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-medium"> </h4>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 품목 목록"
className="h-9 text-sm"
/>
</div>
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="섹션에 대한 설명"
className="h-9 text-sm"
/>
</div>
</div>
</div>
{/* 탭 구성 */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger key="source" value="source" className="text-xs">
</TabsTrigger>
<TabsTrigger key="columns" value="columns" className="text-xs">
</TabsTrigger>
<TabsTrigger key="filters" value="filters" className="text-xs">
</TabsTrigger>
<TabsTrigger key="advanced" value="advanced" className="text-xs">
</TabsTrigger>
</TabsList>
{/* 테이블 설정 탭 */}
<TabsContent key="source-content" value="source" className="mt-4 space-y-4">
{/* 소스 테이블 설정 */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground -mt-1 text-xs"> .</p>
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Popover open={tableSearchOpen} onOpenChange={setTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableSearchOpen}
className="h-9 w-full justify-between text-sm"
>
{tableConfig.source.tableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-sm" />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-6 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateSource({ tableName: table.table_name });
setTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
tableConfig.source.tableName === table.table_name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && (
<span className="text-muted-foreground text-xs">{table.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 저장 테이블 설정 */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground -mt-1 text-xs">
. .
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Popover open={saveTableSearchOpen} onOpenChange={setSaveTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={saveTableSearchOpen}
className="mt-1 h-9 w-full justify-between text-sm"
>
{tableConfig.saveConfig?.targetTable || "(메인 테이블과 동일)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-[300px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-sm" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-4 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
updateSaveConfig({ targetTable: undefined });
setSaveTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!tableConfig.saveConfig?.targetTable ? "opacity-100" : "opacity-0",
)}
/>
<span className="text-muted-foreground">( )</span>
</CommandItem>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateSaveConfig({ targetTable: table.table_name });
// 선택 즉시 컬럼 로드 요청
onLoadTableColumns(table.table_name);
setSaveTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
tableConfig.saveConfig?.targetTable === table.table_name
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && (
<span className="text-muted-foreground text-xs">{table.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.saveConfig?.uniqueField || ""}
onChange={(e) => updateSaveConfig({ uniqueField: e.target.value || undefined })}
placeholder="예: item_id"
className="mt-1 h-9 text-sm"
/>
<HelpText> .</HelpText>
</div>
</div>
</div>
{/* 외부 데이터 소스 설정 */}
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
"데이터 전달 모달열기" .
</p>
</div>
<Switch
checked={tableConfig.externalDataSource?.enabled ?? false}
onCheckedChange={(checked) => {
if (checked) {
updateExternalDataSource({ enabled: true, tableName: "" });
} else {
updateTableConfig({ externalDataSource: undefined });
}
}}
/>
</div>
{tableConfig.externalDataSource?.enabled && (
<div className="space-y-3 pt-2">
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Popover open={externalTableSearchOpen} onOpenChange={setExternalTableSearchOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={externalTableSearchOpen}
className="h-9 w-full justify-between text-sm"
>
{tableConfig.externalDataSource?.tableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-sm" />
<CommandList className="max-h-[300px]">
<CommandEmpty className="py-6 text-center text-sm">
.
</CommandEmpty>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.table_name}
value={table.table_name}
onSelect={() => {
updateExternalDataSource({ enabled: true, tableName: table.table_name });
onLoadTableColumns(table.table_name);
setExternalTableSearchOpen(false);
}}
className="text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
tableConfig.externalDataSource?.tableName === table.table_name
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.table_name}</span>
{table.comment && (
<span className="text-muted-foreground text-xs">{table.comment}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<HelpText>
. (: 수주상세
sales_order_detail)
</HelpText>
</div>
{tableConfig.externalDataSource?.tableName && externalTableColumns.length > 0 && (
<div className="bg-muted/30 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
: {externalTableColumns.length}
</p>
<p className="text-muted-foreground mt-1 text-[10px]">
"컬럼 설정" "외부 필드" .
</p>
</div>
)}
</div>
)}
</div>
</TabsContent>
{/* 컬럼 설정 탭 */}
<TabsContent key="columns-content" value="columns" className="mt-4 space-y-4">
{/* 안내 메시지 */}
{saveTableColumns.length === 0 &&
!tableConfig.saveConfig?.targetTable &&
!tableConfig.source.tableName && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs text-amber-800">
"테이블 설정" .
.
</p>
</div>
)}
{/* 테이블은 선택했지만 컬럼이 아직 로드되지 않은 경우 */}
{saveTableColumns.length === 0 &&
(tableConfig.saveConfig?.targetTable || tableConfig.source.tableName) && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-800">
"{tableConfig.saveConfig?.targetTable || tableConfig.source.tableName}"
...
</p>
</div>
)}
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium"> </Label>
{saveTableColumns.length > 0 && (
<p className="text-muted-foreground text-xs">
: {saveTableColumns.length} (
{tableConfig.saveConfig?.targetTable || tableConfig.source.tableName || "테이블 미선택"})
</p>
)}
</div>
<Button size="sm" variant="outline" onClick={addColumn} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{(tableConfig.columns || []).length === 0 ? (
<div className="bg-muted/20 rounded-lg border border-dashed py-8 text-center">
<TableIcon className="text-muted-foreground/50 mx-auto mb-2 h-8 w-8" />
<p className="text-muted-foreground text-sm"> </p>
<p className="text-muted-foreground mt-1 text-xs">"컬럼 추가" </p>
</div>
) : (
<div className="space-y-2">
{(tableConfig.columns || []).map((col, index) => (
<ColumnSettingItem
key={index}
col={col}
index={index}
totalCount={(tableConfig.columns || []).length}
saveTableColumns={saveTableColumns}
displayColumns={tableConfig.source.displayColumns || []}
sourceTableColumns={sourceTableColumns}
sourceTableName={tableConfig.source.tableName}
externalTableColumns={externalTableColumns}
externalTableName={tableConfig.externalDataSource?.tableName}
externalDataEnabled={tableConfig.externalDataSource?.enabled}
tables={tables}
tableColumns={tableColumns}
sections={otherSections}
formFields={otherSectionFields}
tableConfig={tableConfig}
availableParentFields={availableParentFields}
onLoadTableColumns={onLoadTableColumns}
onUpdate={(updates) => updateColumn(index, updates)}
onMoveUp={() => moveColumn(index, "up")}
onMoveDown={() => moveColumn(index, "down")}
onRemove={() => removeColumn(index)}
/>
))}
</div>
)}
</TabsContent>
{/* 검색 설정 탭 */}
<TabsContent key="filters-content" value="filters" className="mt-4 space-y-4">
{/* 표시 컬럼 / 검색 컬럼 설정 */}
<div className="space-y-4 rounded-lg border p-4">
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground -mt-2 text-xs">
.
</p>
{/* 소스 테이블 미선택 시 안내 */}
{!tableConfig.source.tableName && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs text-amber-800">
"테이블 설정" .
</p>
</div>
)}
{/* 표시 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium">
( )
</Label>
<div className="max-h-[150px] overflow-y-auto rounded-lg border p-3">
<div className="grid grid-cols-2 gap-2">
{sourceTableColumns.map((col) => (
<label
key={col.column_name}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded p-1.5 text-sm"
>
<input
type="checkbox"
checked={(tableConfig.source.displayColumns || []).includes(col.column_name)}
onChange={() => toggleDisplayColumn(col.column_name)}
className="rounded"
/>
<span className="flex-1 truncate">{col.column_name}</span>
{col.comment && (
<span className="text-muted-foreground max-w-[100px] truncate text-xs">
{col.comment}
</span>
)}
</label>
))}
</div>
</div>
<HelpText> : {(tableConfig.source.displayColumns || []).length}</HelpText>
{/* 선택된 컬럼 순서 편집 */}
{(tableConfig.source.displayColumns || []).length > 0 && (
<div className="mt-3">
<Label className="text-muted-foreground mb-1.5 block text-xs font-medium">
</Label>
<div className="bg-muted/30 space-y-1 rounded-lg border p-2">
{(tableConfig.source.displayColumns || []).map((colName, index) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
<div
key={colName}
className="bg-background flex items-center gap-2 rounded px-2 py-1.5"
>
<GripVertical className="text-muted-foreground h-3.5 w-3.5" />
<span className="flex-1 text-xs font-medium">{colName}</span>
{colInfo?.comment && (
<span className="text-muted-foreground max-w-[80px] truncate text-[10px]">
{colInfo.comment}
</span>
)}
<div className="flex items-center gap-0.5">
<Button
size="sm"
variant="ghost"
onClick={() => moveDisplayColumn(index, "up")}
disabled={index === 0}
className="h-6 w-6 p-0"
>
<ChevronUp className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => moveDisplayColumn(index, "down")}
disabled={index === (tableConfig.source.displayColumns || []).length - 1}
className="h-6 w-6 p-0"
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => removeDisplayColumn(colName)}
className="text-destructive hover:text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* 검색 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium">
( )
</Label>
<div className="max-h-[150px] overflow-y-auto rounded-lg border p-3">
<div className="grid grid-cols-2 gap-2">
{sourceTableColumns.map((col) => (
<label
key={col.column_name}
className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded p-1.5 text-sm"
>
<input
type="checkbox"
checked={(tableConfig.source.searchColumns || []).includes(col.column_name)}
onChange={() => toggleSearchColumn(col.column_name)}
className="rounded"
/>
<span className="flex-1 truncate">{col.column_name}</span>
{col.comment && (
<span className="text-muted-foreground max-w-[100px] truncate text-xs">
{col.comment}
</span>
)}
</label>
))}
</div>
</div>
<HelpText> : {(tableConfig.source.searchColumns || []).length}</HelpText>
</div>
)}
</div>
<Separator />
{/* 사전 필터 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addPreFilter} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{(tableConfig.filters?.preFilters || []).map((filter, index) => {
// 선택된 컬럼의 정보 조회
const selectedColumn = filter.column
? sourceTableColumns.find((c) => c.column_name === filter.column)
: null;
const isCategory = selectedColumn?.input_type === "category";
const categoryOptions =
isCategory && filter.column ? preFilterCategoryOptions[filter.column] || [] : [];
return (
<div key={index} className="bg-card flex items-center gap-2 rounded-lg border p-2">
<Select
value={filter.column || undefined}
onValueChange={(value) => {
updatePreFilter(index, { column: value, value: "" }); // 컬럼 변경 시 값 초기화
// 카테고리 컬럼인 경우 옵션 로드
const col = sourceTableColumns.find((c) => c.column_name === value);
if (col && col.input_type === "category") {
loadCategoryOptions(value);
}
}}
>
<SelectTrigger className="h-8 w-[150px] text-xs">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.comment || col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filter.operator || undefined}
onValueChange={(value: any) => updatePreFilter(index, { operator: value })}
>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue placeholder="연산자" />
</SelectTrigger>
<SelectContent>
{FILTER_OPERATOR_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 카테고리 컬럼인 경우 Select Box로 값 선택 */}
{isCategory ? (
<Select
value={filter.value || undefined}
onValueChange={(value) => updatePreFilter(index, { value })}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="값 선택" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={filter.value || ""}
onChange={(e) => updatePreFilter(index, { value: e.target.value })}
placeholder="값"
className="h-8 flex-1 text-xs"
/>
)}
<Button
size="sm"
variant="ghost"
onClick={() => removePreFilter(index)}
className="text-destructive hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
<Separator />
{/* 모달 필터 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium"> </Label>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addModalFilter} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{(tableConfig.filters?.modalFilters || []).map((filter, index) => (
<div key={index} className="bg-card space-y-2 rounded-lg border p-3">
<div className="flex items-center gap-2">
{/* 컬럼 선택 */}
<Select
value={filter.column || undefined}
onValueChange={(value) => {
// 컬럼 선택 시 자동으로 categoryRef 설정
updateModalFilter(index, {
column: value,
categoryRef: tableConfig.source.tableName
? {
tableName: tableConfig.source.tableName,
columnName: value,
}
: undefined,
});
}}
>
<SelectTrigger className="h-8 w-[130px] text-xs">
<SelectValue placeholder="컬럼" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 라벨 */}
<Input
value={filter.label || ""}
onChange={(e) => updateModalFilter(index, { label: e.target.value })}
placeholder="라벨"
className="h-8 w-[100px] text-xs"
/>
{/* 타입 */}
<Select
value={filter.type || undefined}
onValueChange={(value: any) => updateModalFilter(index, { type: value })}
>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue placeholder="타입" />
</SelectTrigger>
<SelectContent>
{MODAL_FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 카테고리 선택 (타입이 category일 때만 표시) */}
{filter.type === "category" && (
<Select
value={
filter.categoryRef
? `${filter.categoryRef.tableName}.${filter.categoryRef.columnName}`
: undefined
}
onValueChange={(value) => {
const [tableName, columnName] = value.split(".");
updateModalFilter(index, {
categoryRef: { tableName, columnName },
});
}}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{/* 현재 소스 테이블의 컬럼 기반 카테고리 */}
{filter.column && tableConfig.source.tableName && (
<SelectItem value={`${tableConfig.source.tableName}.${filter.column}`}>
{tableConfig.source.tableName}.{filter.column}
</SelectItem>
)}
{/* 카테고리 목록에서 추가 */}
{categoryList
.filter(
(cat) =>
// 이미 위에서 추가한 항목 제외
!(
cat.tableName === tableConfig.source.tableName &&
cat.columnName === filter.column
),
)
.map((cat) => (
<SelectItem
key={`${cat.tableName}.${cat.columnName}`}
value={`${cat.tableName}.${cat.columnName}`}
>
{cat.displayName || `${cat.tableName}.${cat.columnName}`}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 삭제 버튼 */}
<Button
size="sm"
variant="ghost"
onClick={() => removeModalFilter(index)}
className="text-destructive hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
</TabsContent>
{/* 고급 설정 탭 */}
<TabsContent key="advanced-content" value="advanced" className="mt-4 space-y-4">
{/* UI 설정 */}
<div className="space-y-3 rounded-lg border p-4">
<h4 className="text-sm font-medium">UI </h4>
{/* 버튼 표시 설정 */}
<div className="bg-muted/30 space-y-2 rounded-lg p-3">
<Label className="text-xs font-medium"> </Label>
<p className="text-muted-foreground mb-2 text-[10px]"> .</p>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showSearchButton ?? true}
onCheckedChange={(checked) => updateUiConfig({ showSearchButton: checked })}
className="scale-75"
/>
<div>
<span className="text-xs font-medium"> </span>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.uiConfig?.showAddRowButton ?? false}
onCheckedChange={(checked) => updateUiConfig({ showAddRowButton: checked })}
className="scale-75"
/>
<div>
<span className="text-xs font-medium"> </span>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 검색 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.searchButtonText || ""}
onChange={(e) => updateUiConfig({ searchButtonText: e.target.value })}
placeholder="품목 검색"
className="mt-1 h-8 text-xs"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
</div>
{/* 행 추가 버튼 텍스트 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.addRowButtonText || ""}
onChange={(e) => updateUiConfig({ addRowButtonText: e.target.value })}
placeholder="직접 입력"
className="mt-1 h-8 text-xs"
disabled={!tableConfig.uiConfig?.showAddRowButton}
/>
</div>
{/* 모달 제목 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.modalTitle || ""}
onChange={(e) => updateUiConfig({ modalTitle: e.target.value })}
placeholder="항목 검색 및 선택"
className="mt-1 h-8 text-xs"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
</div>
{/* 테이블 최대 높이 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={tableConfig.uiConfig?.maxHeight || ""}
onChange={(e) => updateUiConfig({ maxHeight: e.target.value })}
placeholder="400px"
className="mt-1 h-8 text-xs"
/>
</div>
{/* 다중 선택 허용 */}
<div className="flex items-end">
<label className="flex cursor-pointer items-center gap-2 text-xs">
<Switch
checked={tableConfig.uiConfig?.multiSelect ?? true}
onCheckedChange={(checked) => updateUiConfig({ multiSelect: checked })}
className="scale-75"
disabled={!(tableConfig.uiConfig?.showSearchButton ?? true)}
/>
<span> </span>
</label>
</div>
</div>
</div>
{/* 계산 규칙 */}
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs"> .</p>
</div>
<Button size="sm" variant="outline" onClick={addCalculation} className="h-8 text-xs">
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
{(tableConfig.calculations || []).map((calc, index) => (
<CalculationRuleEditor
key={index}
calc={calc}
index={index}
columns={tableConfig.columns || []}
sourceTableName={tableConfig.source?.tableName}
onUpdate={(updates) => updateCalculation(index, updates)}
onRemove={() => removeCalculation(index)}
/>
))}
</div>
{/* 조건부 테이블 설정 */}
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-medium"> </h4>
<p className="text-muted-foreground text-xs">
( ) .
</p>
</div>
<Switch
checked={tableConfig.conditionalTable?.enabled ?? false}
onCheckedChange={(checked) => {
setTableConfig({
...tableConfig,
conditionalTable: checked
? { ...defaultConditionalTableConfig, enabled: true }
: { ...defaultConditionalTableConfig, enabled: false },
});
}}
className="scale-75"
/>
</div>
{tableConfig.conditionalTable?.enabled && (
<div className="space-y-4 pt-2">
{/* 트리거 유형 및 조건 컬럼 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={tableConfig.conditionalTable.triggerType || "checkbox"}
onValueChange={(value: "checkbox" | "dropdown" | "tabs") => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
triggerType: value,
},
});
}}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="트리거 유형 선택" />
</SelectTrigger>
<SelectContent>
{CONDITIONAL_TABLE_TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText>
체크박스: 다중 / 드롭다운: 단일 / : 모든
</HelpText>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={tableConfig.conditionalTable.conditionColumn || ""}
onValueChange={(value) => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
conditionColumn: value,
},
});
}}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{saveTableColumns.map((col) => (
<SelectItem key={col.column_name} value={col.column_name}>
{col.comment || col.column_name}
</SelectItem>
))}
</SelectContent>
</Select>
<HelpText> .</HelpText>
</div>
</div>
{/* 조건 옵션 목록 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
const newOption: ConditionalTableOption = {
id: generateConditionalOptionId(),
value: "",
label: "",
};
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: [...(tableConfig.conditionalTable?.options || []), newOption],
},
});
}}
className="h-7 text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 옵션 목록 */}
<div className="space-y-2">
{(tableConfig.conditionalTable?.options || []).map((option, index) => (
<div
key={option.id}
className="bg-muted/30 flex items-center gap-2 rounded-lg border p-2"
>
<Input
value={option.value}
onChange={(e) => {
const newOptions = [...(tableConfig.conditionalTable?.options || [])];
newOptions[index] = { ...newOptions[index], value: e.target.value };
// label이 비어있으면 value와 동일하게 설정
if (!newOptions[index].label) {
newOptions[index].label = e.target.value;
}
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: newOptions,
},
});
}}
placeholder="저장 값 (예: 입고검사)"
className="h-8 flex-1 text-xs"
/>
<Input
value={option.label}
onChange={(e) => {
const newOptions = [...(tableConfig.conditionalTable?.options || [])];
newOptions[index] = { ...newOptions[index], label: e.target.value };
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: newOptions,
},
});
}}
placeholder="표시 라벨 (예: 입고검사)"
className="h-8 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newOptions = (tableConfig.conditionalTable?.options || []).filter(
(_, i) => i !== index,
);
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: newOptions,
},
});
}}
className="text-destructive hover:text-destructive h-8 w-8 p-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{(tableConfig.conditionalTable?.options || []).length === 0 && (
<div className="text-muted-foreground rounded-lg border border-dashed py-4 text-center text-xs">
. (: 입고검사, , )
</div>
)}
</div>
</div>
{/* 테이블에서 옵션 로드 설정 */}
<div className="space-y-2 border-t pt-3">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.conditionalTable?.optionSource?.enabled ?? false}
onCheckedChange={(checked) => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
optionSource: {
...tableConfig.conditionalTable?.optionSource,
enabled: checked,
tableName: tableConfig.conditionalTable?.optionSource?.tableName || "",
valueColumn: tableConfig.conditionalTable?.optionSource?.valueColumn || "",
labelColumn: tableConfig.conditionalTable?.optionSource?.labelColumn || "",
},
},
});
}}
className="scale-75"
/>
<Label className="text-xs"> </Label>
</div>
{tableConfig.conditionalTable?.optionSource?.enabled && (
<OptionSourceConfig
optionSource={tableConfig.conditionalTable.optionSource}
tables={tables}
tableColumns={tableColumns}
onUpdate={(updates) => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
optionSource: {
...tableConfig.conditionalTable?.optionSource!,
...updates,
},
},
});
}}
/>
)}
</div>
{/* 소스 테이블 필터링 설정 */}
<div className="space-y-2 border-t pt-3">
<div className="flex items-center gap-2">
<Switch
checked={tableConfig.conditionalTable?.sourceFilter?.enabled ?? false}
onCheckedChange={(checked) => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
enabled: checked,
filterColumn: tableConfig.conditionalTable?.sourceFilter?.filterColumn || "",
},
},
});
}}
className="scale-75"
/>
<div>
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-[10px]">
</p>
</div>
</div>
{tableConfig.conditionalTable?.sourceFilter?.enabled && (
<div className="pl-6">
<Label className="text-[10px]"> </Label>
<p className="text-muted-foreground mb-1 text-[10px]">
({tableConfig.source?.tableName || "미설정"})
</p>
{sourceTableColumns.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs font-normal"
>
{tableConfig.conditionalTable.sourceFilter.filterColumn
? (() => {
const col = sourceTableColumns.find(
(c) =>
c.column_name ===
tableConfig.conditionalTable?.sourceFilter?.filterColumn,
);
return col
? `${col.column_name} (${col.comment || col.column_name})`
: tableConfig.conditionalTable.sourceFilter.filterColumn;
})()
: "컬럼 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{sourceTableColumns.map((col) => (
<CommandItem
key={col.column_name}
value={`${col.column_name} ${col.comment || ""}`}
onSelect={() => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
...tableConfig.conditionalTable?.sourceFilter!,
filterColumn: col.column_name,
},
},
});
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
tableConfig.conditionalTable?.sourceFilter?.filterColumn ===
col.column_name
? "opacity-100"
: "opacity-0",
)}
/>
<span className="font-medium">{col.column_name}</span>
{col.comment && (
<span className="text-muted-foreground ml-1">({col.comment})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={tableConfig.conditionalTable.sourceFilter.filterColumn || ""}
onChange={(e) => {
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
...tableConfig.conditionalTable?.sourceFilter!,
filterColumn: e.target.value,
},
},
});
}}
placeholder="inspection_type"
className="h-7 text-xs"
/>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
: 검사유형 "입고검사" inspection_type = '입고검사'
</p>
</div>
)}
{/* 사용 가이드 */}
<div className="mt-4 space-y-2 rounded-lg border border-blue-200/50 bg-blue-50/50 p-3">
<p className="text-xs font-medium text-blue-700"> </p>
<div className="space-y-1.5 text-[10px] text-blue-600">
<p className="font-medium">1. :</p>
<ul className="list-disc space-y-0.5 pl-4">
<li>
<span className="font-medium"> </span>:
</li>
<li>
<span className="font-medium"> </span>:
</li>
</ul>
<p className="mt-2 font-medium">2. :</p>
<ul className="list-disc space-y-0.5 pl-4">
<li>
<span className="font-medium">"선택(드롭다운)"</span>
</li>
<li>
<span className="font-medium">"동적 드롭다운 옵션"</span>
</li>
<li> </li>
<li>
<span className="font-medium">"행 선택 모드"</span>
</li>
</ul>
<p className="mt-2 font-medium">3. ():</p>
<ul className="list-disc space-y-0.5 pl-4">
<li>"입고검사" </li>
<li>"항목 추가" </li>
<li>"검사항목" inspection_type='입고검사' </li>
<li> , ( )</li>
</ul>
</div>
</div>
</div>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</ScrollArea>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
key="cancel"
variant="outline"
onClick={() => onOpenChange(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button key="save" onClick={handleSave} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}