"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 }) => (
{children}
);
// 계산 규칙 편집 컴포넌트 (조건부 계산 지원)
interface CalculationRuleEditorProps {
calc: TableCalculationRule;
index: number;
columns: TableColumnConfig[];
sourceTableName?: string; // 소스 테이블명 추가
onUpdate: (updates: Partial) => void;
onRemove: () => void;
}
const CalculationRuleEditor: React.FC = ({
calc,
index,
columns,
sourceTableName,
onUpdate,
onRemove,
}) => {
const [categoryOptions, setCategoryOptions] = useState<{ value: string; label: string }[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [categoryColumns, setCategoryColumns] = useState>({});
// 조건부 계산 활성화 여부
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 = {};
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) => {
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 (
{/* 기본 계산 규칙 */}
onUpdate({ resultField: value })}
>
{columns.length === 0 ? (
컬럼 설정에서 먼저 컬럼을 추가하세요
) : (
columns
.filter((col) => col.field)
.map((col, idx) => (
{col.label || col.field}
))
)}
=
onUpdate({ formula: e.target.value })}
placeholder="수식 (예: qty * unit_price)"
className="h-8 text-xs flex-1"
disabled={isConditionalEnabled}
/>
{/* 조건부 계산 토글 */}
조건부 계산 활성화
{availableColumns.length === 0 && !isConditionalEnabled && (
(컬럼 설정에서 먼저 컬럼을 추가하세요)
)}
{/* 조건부 계산 설정 */}
{isConditionalEnabled && (
{/* 조건 필드 선택 */}
조건 필드:
{availableColumns.length === 0 ? (
컬럼이 없습니다
) : (
availableColumns.map((col, idx) => {
// 소스 필드명으로 카테고리 여부 확인
const actualFieldName = col.sourceField || col.field;
const isCategoryColumn = categoryColumns[actualFieldName];
return (
{col.label || col.field} {isCategoryColumn ? "(카테고리)" : `(${col.type})`}
);
})
)}
{/* 조건별 계산식 목록 */}
{calc.conditionalCalculation?.conditionField && (
)}
{loadingOptions && (
옵션 로딩 중...
)}
)}
);
};
// 옵션 소스 설정 컴포넌트 (검색 가능한 Combobox)
interface OptionSourceConfigProps {
optionSource: {
enabled: boolean;
tableName: string;
valueColumn: string;
labelColumn: string;
filterCondition?: string;
};
tables: { table_name: string; comment?: string }[];
tableColumns: Record;
onUpdate: (updates: Partial) => void;
}
const OptionSourceConfig: React.FC = ({
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 (
{/* 테이블 선택 Combobox */}
테이블
{optionSource.tableName
? tables.find((t) => t.table_name === optionSource.tableName)?.comment || optionSource.tableName
: "테이블 선택"}
테이블을 찾을 수 없습니다.
{tables.map((table) => (
{
onUpdate({
tableName: table.table_name,
valueColumn: "", // 테이블 변경 시 컬럼 초기화
labelColumn: "",
});
setTableOpen(false);
}}
className="text-xs"
>
{table.table_name}
{table.comment && (
{table.comment}
)}
))}
{/* 참조할 값 컬럼 선택 Combobox */}
참조할 값
{optionSource.valueColumn
? selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment
? `${optionSource.valueColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.valueColumn)?.comment})`
: optionSource.valueColumn
: "컬럼 선택"}
컬럼을 찾을 수 없습니다.
{selectedTableColumns.map((column) => (
{
onUpdate({ valueColumn: column.column_name });
setValueColumnOpen(false);
}}
className="text-xs"
>
{column.column_name}
{column.comment && (
{column.comment}
)}
))}
{/* 출력할 값 컬럼 선택 Combobox */}
출력할 값
{optionSource.labelColumn
? selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment
? `${optionSource.labelColumn} (${selectedTableColumns.find((c) => c.column_name === optionSource.labelColumn)?.comment})`
: optionSource.labelColumn
: "(참조할 값과 동일)"}
컬럼을 찾을 수 없습니다.
{/* 값 컬럼 사용 옵션 */}
onUpdate({ labelColumn: "" })}
className="text-xs text-muted-foreground"
>
(참조할 값과 동일)
{selectedTableColumns.map((column) => (
onUpdate({ labelColumn: column.column_name })}
className="text-xs"
>
{column.column_name}
{column.comment && (
{column.comment}
)}
))}
비워두면 참조할 값을 그대로 표시
);
};
// 부모 화면에서 전달 가능한 필드 타입
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; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
availableParentFields?: AvailableParentField[]; // 부모 화면에서 전달 가능한 필드 목록
onLoadTableColumns: (tableName: string) => void;
onUpdate: (updates: Partial) => 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>({});
// 소스 필드 기준으로 카테고리 타입인지 확인
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) => {
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) => {
const option = col.lookup?.options?.[optIndex];
if (!option) return;
updateLookupOption(optIndex, {
conditions: option.conditions.map((c, i) =>
i === condIndex ? { ...c, ...updates } : c
),
});
};
return (
{col.label || col.field || `컬럼 ${index + 1}`}
{TABLE_COLUMN_TYPE_OPTIONS.find((t) => t.value === col.type)?.label || col.type}
{col.calculated && 계산 }
{/* 필드명 - Combobox (저장할 컬럼) */}
필드명 (저장)
{col.field || "(저장 안 함)"}
필드를 찾을 수 없습니다.
{/* 선택 안 함 옵션 */}
{
onUpdate({ field: "" });
setFieldSearchOpen(false);
}}
className="text-xs text-muted-foreground"
>
(선택 안 함 - 저장하지 않음)
{/* 실제 컬럼 목록 */}
{saveTableColumns.map((column) => (
{
onUpdate({
field: column.column_name,
// 라벨이 비어있으면 comment로 자동 설정
...((!col.label || col.label.startsWith("컬럼 ")) && column.comment ? { label: column.comment } : {})
});
setFieldSearchOpen(false);
}}
className="text-xs"
>
{column.column_name}
{column.comment || column.data_type}
))}
{/* 소스 필드 - Combobox (검색 모달에서 가져올 컬럼) */}
소스 필드
{col.sourceField || "(필드명과 동일)"}
소스 필드를 찾을 수 없습니다.
{/* 필드명과 동일 옵션 */}
{
onUpdate({ sourceField: undefined });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
(필드명과 동일)
{/* 표시 컬럼 목록 */}
{displayColumns.map((colName) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
{
onUpdate({ sourceField: colName });
setSourceFieldSearchOpen(false);
}}
className="text-xs"
>
{colName}
{colInfo?.comment && (
{colInfo.comment}
)}
);
})}
{/* 외부 필드 - Combobox (외부 데이터에서 가져올 컬럼) */}
{externalDataEnabled && externalTableName && (
외부 필드
{col.externalField || "(필드명과 동일)"}
외부 필드를 찾을 수 없습니다.
{/* 필드명과 동일 옵션 */}
{
onUpdate({ externalField: undefined });
setExternalFieldSearchOpen(false);
}}
className="text-xs"
>
(필드명과 동일)
{/* 외부 테이블 컬럼 목록 */}
{externalTableColumns.map((extCol) => (
{
onUpdate({ externalField: extCol.column_name });
setExternalFieldSearchOpen(false);
}}
className="text-xs"
>
{extCol.column_name}
{extCol.comment && (
{extCol.comment}
)}
))}
외부 데이터({externalTableName})에서 이 컬럼에 매핑할 필드
)}
{/* 라벨 */}
라벨
onUpdate({ label: e.target.value })}
placeholder="표시 라벨"
className="h-8 text-xs mt-1"
/>
{/* 타입 */}
타입
onUpdate({ type: value })}
disabled={isCategoryColumn}
>
{TABLE_COLUMN_TYPE_OPTIONS.map((opt) => (
{opt.label}
))}
{isCategoryColumn && (
테이블 타입 관리에서 카테고리로 설정됨
)}
{/* 너비 */}
너비
onUpdate({ width: e.target.value })}
placeholder="150px"
className="h-8 text-xs mt-1"
/>
{/* 옵션 스위치 */}
onUpdate({ editable: checked })}
className="scale-75"
/>
편집 가능
onUpdate({ calculated: checked })}
className="scale-75"
/>
계산 필드
onUpdate({ required: checked })}
className="scale-75"
/>
필수
onUpdate({ hidden: checked })}
className="scale-75"
/>
히든
onUpdate({ receiveFromParent: checked })}
className="scale-75"
/>
부모값
{
if (checked) {
onUpdate({ lookup: { enabled: true, options: [] } });
} else {
onUpdate({ lookup: undefined });
}
}}
className="scale-75"
/>
조회
{/* 날짜 타입일 때만 일괄 적용 옵션 표시 */}
{col.type === "date" && (
onUpdate({ batchApply: checked })}
className="scale-75"
/>
일괄 적용
)}
{/* 부모에서 값 받기 설정 (부모값 ON일 때만 표시) */}
{col.receiveFromParent && (
부모 필드 선택
부모 화면에서 전달받을 필드를 선택하세요. 모든 행에 동일한 값이 적용됩니다.
{availableParentFields.length > 0 ? (
{col.parentFieldName
? availableParentFields.find(f => f.name === col.parentFieldName)?.label || col.parentFieldName
: `(기본: ${col.field})`}
사용 가능한 부모 필드가 없습니다.
{/* 기본값 (필드명과 동일) */}
{
onUpdate({ parentFieldName: undefined });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
(기본: {col.field})
{/* 부모 필드 목록 */}
{availableParentFields.map((pf) => (
{
onUpdate({ parentFieldName: pf.name });
setParentFieldSearchOpen(false);
}}
className="text-xs"
>
{pf.label || pf.name}
{pf.sourceComponent && (
{pf.sourceComponent}{pf.sourceTable && ` (${pf.sourceTable})`}
)}
))}
) : (
)}
)}
{/* 조회 설정 (조회 ON일 때만 표시) */}
{col.lookup?.enabled && (
{(col.lookup?.options || []).length === 0 ? (
"옵션 추가" 버튼을 클릭하여 조회 방식을 추가하세요.
) : (
{(col.lookup?.options || []).map((option, optIndex) => (
{/* 옵션 헤더 */}
{option.label || `옵션 ${optIndex + 1}`}
{option.isDefault && (
기본
)}
removeLookupOption(optIndex)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
{/* 기본 설정 - 첫 번째 줄: 옵션명, 표시 라벨 */}
{/* 기본 설정 - 두 번째 줄: 조회 유형, 테이블, 가져올 컬럼 */}
조회 유형
{
const newTableName = value === "sameTable" ? sourceTableName : "";
updateLookupOption(optIndex, { type: value, tableName: newTableName, conditions: [] });
if (newTableName) onLoadTableColumns(newTableName);
}}
>
{LOOKUP_TYPE_OPTIONS.map((opt) => (
{opt.label}
))}
조회 테이블
{option.type === "sameTable" ? (
) : (
setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: open }))}
>
{option.tableName || "선택..."}
없음
{tables.map((table) => (
{
updateLookupOption(optIndex, { tableName: table.table_name });
onLoadTableColumns(table.table_name);
setLookupTableOpenMap((prev) => ({ ...prev, [option.id]: false }));
}}
className="text-xs"
>
{table.table_name}
))}
)}
가져올 컬럼
updateLookupOption(optIndex, { valueColumn: value })}
>
{(tableColumns[option.tableName] || []).map((c) => (
{c.column_name}
))}
{/* 기본 옵션 & 조회 조건 */}
{
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]"
/>
기본 옵션
addLookupCondition(optIndex)} className="h-6 text-xs px-2">
조건 추가
{/* 조회 조건 목록 */}
{(option.conditions || []).length > 0 && (
{option.conditions.map((cond, condIndex) => (
{/* 기본 조건 행 */}
updateLookupCondition(optIndex, condIndex, {
sourceType: value,
sourceField: "",
sectionId: undefined,
transform: undefined,
externalLookup: value === "externalTable" ? {
tableName: "",
matchColumn: "",
matchSourceType: "sourceTable",
matchSourceField: "",
resultColumn: "",
} : undefined,
})
}
>
{LOOKUP_CONDITION_SOURCE_OPTIONS.map((o) => (
{o.label}
))}
{/* 다른 섹션 선택 시 - 섹션 드롭다운 */}
{cond.sourceType === "sectionField" && (
updateLookupCondition(optIndex, condIndex, { sectionId: value, sourceField: "" })}
>
{sections.map((s) => (
{s.title}
))}
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션 - 필드 선택 */}
{cond.sourceType !== "externalTable" && (
updateLookupCondition(optIndex, condIndex, { sourceField: value })}
>
{cond.sourceType === "currentRow" ? (
// 현재 행: 테이블에 설정된 컬럼 필드 표시
<>
설정된 컬럼
{tableConfig?.columns?.map((c) => (
{c.label} ({c.field})
))}
>
) : cond.sourceType === "sourceTable" ? (
// 소스 테이블: 원본 테이블의 컬럼 표시
<>
{sourceTableName} 컬럼
{sourceTableColumns.map((c) => (
{c.column_name}
))}
>
) : (
// 다른 섹션: 폼 필드 표시
<>
{cond.sectionId && (
{sections.find(s => s.id === cond.sectionId)?.title || cond.sectionId} 필드
)}
{formFields
.filter((f) => !cond.sectionId || f.sectionId === cond.sectionId)
.map((f) => (
{f.label}
))}
>
)}
{cond.sourceField && (
{cond.sourceType === "currentRow"
? `rowData.${cond.sourceField}`
: cond.sourceType === "sourceTable"
? `${sourceTableName}.${cond.sourceField}`
: `formData.${cond.sourceField}`
}
)}
)}
{/* 현재 행 / 소스 테이블 / 다른 섹션일 때 = 기호와 조회 컬럼 */}
{cond.sourceType !== "externalTable" && (
<>
=
updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
{option.tableName && (
{option.tableName} 컬럼
)}
{(tableColumns[option.tableName] || []).map((c) => (
{c.column_name}
))}
{cond.targetColumn && option.tableName && (
{option.tableName}.{cond.targetColumn}
)}
>
)}
removeLookupCondition(optIndex, condIndex)}
className="h-6 w-6 p-0 text-destructive"
>
{/* 외부 테이블 조회 설정 */}
{cond.sourceType === "externalTable" && cond.externalLookup && (
외부 테이블에서 조건 값 조회
{/* 1행: 조회 테이블 선택 */}
조회 테이블
setLookupTableOpenMap(prev => ({ ...prev, [`ext_${optIndex}_${condIndex}`]: open }))}
>
{cond.externalLookup.tableName || "테이블 선택"}
테이블을 찾을 수 없습니다.
{tables.map((table) => (
{
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"
>
{table.table_name}
))}
찾을 컬럼
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchColumn: value }
})}
>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
{c.column_name}
))}
가져올 컬럼
{
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, resultColumn: value },
sourceField: value // sourceField에도 저장
});
}}
>
{(tableColumns[cond.externalLookup.tableName] || []).map((c) => (
{c.column_name}
))}
{/* 2행: 비교 값 출처 */}
비교 값 출처 (찾을 때 사용할 값)
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceType: value, matchSourceField: "", matchSectionId: undefined }
})}
>
현재 행
소스 테이블
다른 섹션
{cond.externalLookup.matchSourceType === "sectionField" && (
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSectionId: value, matchSourceField: "" }
})}
>
{sections.map((s) => (
{s.title}
))}
)}
updateLookupCondition(optIndex, condIndex, {
externalLookup: { ...cond.externalLookup!, matchSourceField: value }
})}
>
{cond.externalLookup.matchSourceType === "currentRow" ? (
<>
설정된 컬럼
{tableConfig?.columns?.map((c) => (
{c.label} ({c.field})
))}
>
) : cond.externalLookup.matchSourceType === "sourceTable" ? (
<>
{sourceTableName} 컬럼
{sourceTableColumns.map((c) => (
{c.column_name}
))}
>
) : (
<>
{cond.externalLookup.matchSectionId && (
{sections.find(s => s.id === cond.externalLookup?.matchSectionId)?.title} 필드
)}
{formFields
.filter((f) => !cond.externalLookup?.matchSectionId || f.sectionId === cond.externalLookup?.matchSectionId)
.map((f) => (
{f.label}
))}
>
)}
{/* 3행: 최종 조회 컬럼 */}
조회된 값 (비교할 컬럼)
=
updateLookupCondition(optIndex, condIndex, { targetColumn: value })}
>
{option.tableName && (
{option.tableName} 컬럼
)}
{(tableColumns[option.tableName] || []).map((c) => (
{c.column_name}
))}
{/* 설명 텍스트 */}
{cond.externalLookup.tableName && cond.externalLookup.matchColumn && cond.externalLookup.resultColumn && cond.targetColumn && (
{cond.externalLookup.tableName}에서 {cond.externalLookup.matchColumn} = 입력값(비교 값 출처)인 행의{" "}
{cond.externalLookup.resultColumn} 값을 가져와 {option.tableName}.{cond.targetColumn}와 비교
)}
)}
{/* 값 변환 설정 (다른 섹션일 때만 표시) */}
{cond.sourceType === "sectionField" && (
{
if (checked) {
updateLookupCondition(optIndex, condIndex, {
transform: { enabled: true, tableName: "", matchColumn: "", resultColumn: "" }
});
} else {
updateLookupCondition(optIndex, condIndex, { transform: undefined });
}
}}
className="scale-[0.5]"
/>
값 변환 필요
(이름 → 코드 등)
{cond.transform?.enabled && (
변환 테이블
{cond.transform.tableName || "선택..."}
없음
{tables.map((table) => (
{
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, tableName: table.table_name, matchColumn: "", resultColumn: "" }
});
onLoadTableColumns(table.table_name);
}}
className="text-xs"
>
{table.table_name}
))}
찾을 컬럼
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, matchColumn: value }
})}
>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
{c.column_name}
))}
가져올 컬럼
updateLookupCondition(optIndex, condIndex, {
transform: { ...cond.transform!, resultColumn: value }
})}
>
{(tableColumns[cond.transform.tableName] || []).map((c) => (
{c.column_name}
))}
{cond.transform.tableName && cond.transform.matchColumn && cond.transform.resultColumn && (
{cond.transform.tableName}에서 {cond.transform.matchColumn} = 입력값 인 행의 {cond.transform.resultColumn} 값으로 변환
)}
)}
)}
))}
)}
{/* 조회 유형 설명 */}
{option.type === "sameTable" && "동일 테이블: 검색 모달에서 선택한 행의 다른 컬럼 값"}
{option.type === "relatedTable" && "연관 테이블: 현재 행 데이터로 다른 테이블 조회"}
{option.type === "combinedLookup" && "복합 조건: 다른 섹션 필드 + 현재 행 조합 조회"}
))}
)}
)}
{/* 동적 Select 옵션 (소스 테이블 필터링이 활성화되고, 타입이 select일 때만 표시) */}
{col.type === "select" && tableConfig.conditionalTable?.sourceFilter?.enabled && (
동적 드롭다운 옵션
소스 테이블에서 옵션을 동적으로 로드합니다. 조건부 테이블 필터가 자동 적용됩니다.
{
onUpdate({
dynamicSelectOptions: checked
? {
enabled: true,
sourceField: "",
distinct: true,
}
: undefined,
});
}}
className="scale-75"
/>
{col.dynamicSelectOptions?.enabled && (
{/* 소스 컬럼 선택 */}
소스 컬럼 (옵션 값)
드롭다운 옵션으로 사용할 컬럼
{sourceTableColumns.length > 0 ? (
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
sourceField: value,
// 라벨 필드가 비어있으면 소스 필드와 동일하게 설정
labelField: col.dynamicSelectOptions?.labelField || value,
},
});
}}
>
{sourceTableColumns.map((c) => (
{c.column_name} {c.comment && `(${c.comment})`}
))}
) : (
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
sourceField: e.target.value,
},
});
}}
placeholder="inspection_item"
className="h-7 text-xs"
/>
)}
라벨 컬럼 (선택)
표시할 라벨 (비워두면 소스 컬럼과 동일)
{sourceTableColumns.length > 0 ? (
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
labelField: value === "__same_as_source__" ? "" : value,
},
});
}}
>
(소스 컬럼과 동일)
{sourceTableColumns.map((c) => (
{c.column_name} {c.comment && `(${c.comment})`}
))}
) : (
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
labelField: e.target.value,
},
});
}}
placeholder="(비워두면 소스 컬럼과 동일)"
className="h-7 text-xs"
/>
)}
{/* 행 선택 모드 */}
{
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: checked
? {
enabled: true,
autoFillColumns: [],
}
: undefined,
},
});
}}
className="scale-75"
/>
행 선택 모드 (자동 채움)
이 컬럼 선택 시 같은 소스 행의 다른 컬럼 값을 자동으로 채웁니다.
{col.dynamicSelectOptions.rowSelectionMode?.enabled && (
자동 채움 매핑
{
const currentMappings = col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [];
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: [...currentMappings, { sourceColumn: "", targetField: "" }],
},
},
});
}}
className="h-6 text-[10px] px-2"
>
매핑 추가
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length === 0 ? (
"매핑 추가" 버튼을 클릭하여 자동 채움 매핑을 추가하세요.
) : (
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).map((mapping, mappingIndex) => (
{/* 소스 컬럼 */}
소스 컬럼
{sourceTableColumns.length > 0 ? (
{
const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])];
newMappings[mappingIndex] = { ...newMappings[mappingIndex], sourceColumn: value };
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
>
{sourceTableColumns.map((c) => (
{c.column_name}
))}
) : (
{
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]"
/>
)}
→
{/* 타겟 필드 */}
타겟 필드
{
const newMappings = [...(col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || [])];
newMappings[mappingIndex] = { ...newMappings[mappingIndex], targetField: value };
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
>
{(tableConfig.columns || [])
.filter((c) => c.field !== col.field) // 현재 컬럼 제외
.map((c) => (
{c.label || c.field}
))}
{/* 삭제 버튼 */}
{
const newMappings = (col.dynamicSelectOptions?.rowSelectionMode?.autoFillColumns || []).filter(
(_, i) => i !== mappingIndex
);
onUpdate({
dynamicSelectOptions: {
...col.dynamicSelectOptions!,
rowSelectionMode: {
...col.dynamicSelectOptions!.rowSelectionMode!,
autoFillColumns: newMappings,
},
},
});
}}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
))}
)}
{/* 매핑 설명 */}
{(col.dynamicSelectOptions.rowSelectionMode?.autoFillColumns || []).length > 0 && (
{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(", ")}
)}
)}
{/* 설정 요약 */}
{col.dynamicSelectOptions.sourceField && (
{sourceTableName}.{col.dynamicSelectOptions.sourceField}
{tableConfig.conditionalTable?.sourceFilter?.filterColumn && (
<> (조건: {tableConfig.conditionalTable.sourceFilter.filterColumn} = 선택된 검사유형)>
)}
)}
)}
)}
{/* ============================================ */}
{/* 저장 설정 섹션 */}
{/* ============================================ */}
저장 설정
이 컬럼의 값을 DB에 저장할지 설정합니다.
{/* 저장 여부 라디오 버튼 */}
{/* 참조 설정 패널 (저장 안 함 선택 시) */}
{col.saveConfig?.saveToTarget === false && (
참조 설정
{/* Step 1: ID 컬럼 선택 */}
1. 어떤 ID 컬럼을 기준으로 조회할까요?
{
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
referenceIdField: value,
},
},
});
}}
>
{(tableConfig.columns || [])
.filter((c) => c.field !== col.field) // 현재 컬럼 제외
.map((c) => (
{c.label || c.field}
))}
이 컬럼에 저장된 ID로 소스 테이블을 조회합니다.
{/* Step 2: 소스 컬럼 선택 */}
2. 소스 테이블의 어떤 컬럼 값을 표시할까요?
{sourceTableColumns.length > 0 ? (
{
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
sourceColumn: value,
},
},
});
}}
>
{sourceTableColumns.map((c) => (
{c.column_name} {c.comment && `(${c.comment})`}
))}
) : (
{
onUpdate({
saveConfig: {
...col.saveConfig!,
referenceDisplay: {
...col.saveConfig!.referenceDisplay!,
sourceColumn: e.target.value,
},
},
});
}}
placeholder="소스 컬럼명 입력"
className="h-7 text-xs"
/>
)}
조회된 행에서 이 컬럼의 값을 화면에 표시합니다.
{/* 설정 요약 */}
{col.saveConfig?.referenceDisplay?.referenceIdField && col.saveConfig?.referenceDisplay?.sourceColumn && (
설정 요약:
- 이 컬럼({col.label || col.field})은 저장되지 않습니다.
- 수정 화면에서 {col.saveConfig.referenceDisplay.referenceIdField} 로{" "}
{sourceTableName} 테이블을 조회하여{" "}
{col.saveConfig.referenceDisplay.sourceColumn} 값을 표시합니다.
)}
)}
);
}
interface TableSectionSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
section: FormSectionConfig;
onSave: (updates: Partial) => void;
tables: { table_name: string; comment?: string }[];
tableColumns: Record;
onLoadTableColumns: (tableName: string) => void;
// 카테고리 목록 (table_column_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(
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
>({});
// 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();
// 각 컬럼의 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) => {
setTableConfig((prev) => ({ ...prev, ...updates }));
};
const updateSource = (updates: Partial) => {
updateTableConfig({
source: { ...tableConfig.source, ...updates },
});
};
const updateFilters = (updates: Partial) => {
updateTableConfig({
filters: { ...tableConfig.filters, ...updates },
});
};
const updateUiConfig = (updates: Partial>) => {
updateTableConfig({
uiConfig: { ...tableConfig.uiConfig, ...updates },
});
};
const updateSaveConfig = (updates: Partial>) => {
updateTableConfig({
saveConfig: { ...tableConfig.saveConfig, ...updates },
});
};
const updateExternalDataSource = (updates: Partial>) => {
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) => {
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) => {
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) => {
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) => {
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 (
테이블 섹션 설정
테이블 형식의 데이터를 표시하고 편집하는 섹션을 설정합니다.
{/* 기본 정보 */}
{/* 탭 구성 */}
테이블 설정
컬럼 설정
검색 설정
고급 설정
{/* 테이블 설정 탭 */}
{/* 소스 테이블 설정 */}
검색용 소스 테이블
검색 모달에서 데이터를 가져올 테이블입니다.
테이블 선택
{tableConfig.source.tableName || "테이블 선택..."}
테이블을 찾을 수 없습니다.
{tables.map((table) => (
{
updateSource({ tableName: table.table_name });
setTableSearchOpen(false);
}}
className="text-sm"
>
{table.table_name}
{table.comment && (
{table.comment}
)}
))}
{/* 저장 테이블 설정 */}
저장용 테이블
테이블 섹션 데이터를 저장할 테이블입니다. 미설정 시 메인 테이블에 저장됩니다.
{/* 외부 데이터 소스 설정 */}
외부 데이터 소스
"데이터 전달 모달열기" 액션으로 전달받은 데이터를 테이블에 표시합니다.
{
if (checked) {
updateExternalDataSource({ enabled: true, tableName: "" });
} else {
updateTableConfig({ externalDataSource: undefined });
}
}}
/>
{tableConfig.externalDataSource?.enabled && (
외부 데이터 테이블
{tableConfig.externalDataSource?.tableName || "테이블 선택..."}
테이블을 찾을 수 없습니다.
{tables.map((table) => (
{
updateExternalDataSource({ enabled: true, tableName: table.table_name });
onLoadTableColumns(table.table_name);
setExternalTableSearchOpen(false);
}}
className="text-sm"
>
{table.table_name}
{table.comment && (
{table.comment}
)}
))}
이전 화면에서 전달받을 데이터의 원본 테이블을 선택하세요. (예: 수주상세 데이터를 전달받는 경우 sales_order_detail)
{tableConfig.externalDataSource?.tableName && externalTableColumns.length > 0 && (
선택한 테이블 컬럼: {externalTableColumns.length}개
"컬럼 설정" 탭에서 각 컬럼의 "외부 필드"를 설정하여 전달받은 데이터의 컬럼을 매핑하세요.
)}
)}
{/* 컬럼 설정 탭 */}
{/* 안내 메시지 */}
{saveTableColumns.length === 0 && !tableConfig.saveConfig?.targetTable && !tableConfig.source.tableName && (
"테이블 설정" 탭에서 저장 테이블을 먼저 선택해주세요. 선택한 테이블의 컬럼을 여기서 설정할 수 있습니다.
)}
{/* 테이블은 선택했지만 컬럼이 아직 로드되지 않은 경우 */}
{saveTableColumns.length === 0 && (tableConfig.saveConfig?.targetTable || tableConfig.source.tableName) && (
테이블 "{tableConfig.saveConfig?.targetTable || tableConfig.source.tableName}" 의 컬럼을 불러오는 중입니다...
)}
테이블 컬럼
{saveTableColumns.length > 0 && (
사용 가능한 컬럼: {saveTableColumns.length}개 ({tableConfig.saveConfig?.targetTable || tableConfig.source.tableName || "테이블 미선택"})
)}
컬럼 추가
{(tableConfig.columns || []).length === 0 ? (
컬럼이 없습니다
"컬럼 추가" 버튼으로 추가하세요
) : (
{(tableConfig.columns || []).map((col, index) => (
updateColumn(index, updates)}
onMoveUp={() => moveColumn(index, "up")}
onMoveDown={() => moveColumn(index, "down")}
onRemove={() => removeColumn(index)}
/>
))}
)}
{/* 검색 설정 탭 */}
{/* 표시 컬럼 / 검색 컬럼 설정 */}
검색 모달 컬럼 설정
검색 모달에서 보여줄 컬럼과 검색 대상 컬럼을 설정합니다.
{/* 소스 테이블 미선택 시 안내 */}
{!tableConfig.source.tableName && (
"테이블 설정" 탭에서 검색용 소스 테이블을 먼저 선택해주세요.
)}
{/* 표시 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
표시 컬럼 (모달 테이블에 보여줄 컬럼)
선택된 컬럼: {(tableConfig.source.displayColumns || []).length}개
{/* 선택된 컬럼 순서 편집 */}
{(tableConfig.source.displayColumns || []).length > 0 && (
컬럼 순서 편집
{(tableConfig.source.displayColumns || []).map((colName, index) => {
const colInfo = sourceTableColumns.find((c) => c.column_name === colName);
return (
{colName}
{colInfo?.comment && (
{colInfo.comment}
)}
moveDisplayColumn(index, "up")}
disabled={index === 0}
className="h-6 w-6 p-0"
>
moveDisplayColumn(index, "down")}
disabled={index === (tableConfig.source.displayColumns || []).length - 1}
className="h-6 w-6 p-0"
>
removeDisplayColumn(colName)}
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
>
);
})}
)}
)}
{/* 검색 컬럼 선택 */}
{sourceTableColumns.length > 0 && (
검색 컬럼 (검색어 입력 시 조회 대상 컬럼)
검색 컬럼: {(tableConfig.source.searchColumns || []).length}개
)}
{/* 사전 필터 */}
{(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 (
{
updatePreFilter(index, { column: value, value: "" }); // 컬럼 변경 시 값 초기화
// 카테고리 컬럼인 경우 옵션 로드
const col = sourceTableColumns.find((c) => c.column_name === value);
if (col && col.input_type === "category") {
loadCategoryOptions(value);
}
}}
>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
{col.comment || col.column_name}
))}
updatePreFilter(index, { operator: value })}
>
{FILTER_OPERATOR_OPTIONS.map((opt) => (
{opt.label}
))}
{/* 카테고리 컬럼인 경우 Select Box로 값 선택 */}
{isCategory ? (
updatePreFilter(index, { value })}
>
{categoryOptions.map((opt) => (
{opt.label}
))}
) : (
updatePreFilter(index, { value: e.target.value })}
placeholder="값"
className="h-8 text-xs flex-1"
/>
)}
removePreFilter(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
);
})}
{/* 모달 필터 */}
모달 필터
사용자가 선택할 수 있는 필터입니다.
추가
{(tableConfig.filters?.modalFilters || []).map((filter, index) => (
{/* 컬럼 선택 */}
{
// 컬럼 선택 시 자동으로 categoryRef 설정
updateModalFilter(index, {
column: value,
categoryRef: tableConfig.source.tableName ? {
tableName: tableConfig.source.tableName,
columnName: value,
} : undefined,
});
}}
>
{sourceTableColumns
.filter((col) => col.column_name)
.map((col) => (
{col.column_name}
))}
{/* 라벨 */}
updateModalFilter(index, { label: e.target.value })}
placeholder="라벨"
className="h-8 text-xs w-[100px]"
/>
{/* 타입 */}
updateModalFilter(index, { type: value })}
>
{MODAL_FILTER_TYPE_OPTIONS.map((opt) => (
{opt.label}
))}
{/* 카테고리 선택 (타입이 category일 때만 표시) */}
{filter.type === "category" && (
{
const [tableName, columnName] = value.split(".");
updateModalFilter(index, {
categoryRef: { tableName, columnName }
});
}}
>
{/* 현재 소스 테이블의 컬럼 기반 카테고리 */}
{filter.column && tableConfig.source.tableName && (
{tableConfig.source.tableName}.{filter.column}
)}
{/* 카테고리 목록에서 추가 */}
{categoryList
.filter((cat) =>
// 이미 위에서 추가한 항목 제외
!(cat.tableName === tableConfig.source.tableName && cat.columnName === filter.column)
)
.map((cat) => (
{cat.displayName || `${cat.tableName}.${cat.columnName}`}
))}
)}
{/* 삭제 버튼 */}
removeModalFilter(index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
))}
{/* 고급 설정 탭 */}
{/* UI 설정 */}
UI 설정
{/* 버튼 표시 설정 */}
표시할 버튼 선택
두 버튼을 동시에 표시할 수 있습니다.
updateUiConfig({ showSearchButton: checked })}
className="scale-75"
/>
updateUiConfig({ showAddRowButton: checked })}
className="scale-75"
/>
{/* 계산 규칙 */}
계산 규칙
다른 컬럼 값을 기반으로 자동 계산합니다.
추가
{(tableConfig.calculations || []).map((calc, index) => (
updateCalculation(index, updates)}
onRemove={() => removeCalculation(index)}
/>
))}
{/* 조건부 테이블 설정 */}
조건부 테이블
조건(검사유형 등)에 따라 다른 데이터를 표시하고 저장합니다.
{
setTableConfig({
...tableConfig,
conditionalTable: checked
? { ...defaultConditionalTableConfig, enabled: true }
: { ...defaultConditionalTableConfig, enabled: false },
});
}}
className="scale-75"
/>
{tableConfig.conditionalTable?.enabled && (
{/* 트리거 유형 및 조건 컬럼 */}
트리거 유형
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
triggerType: value,
},
});
}}
>
{CONDITIONAL_TABLE_TRIGGER_OPTIONS.map((opt) => (
{opt.label}
))}
체크박스: 다중 선택 후 탭으로 표시 / 드롭다운: 단일 선택 / 탭: 모든 옵션 표시
조건 값 저장 컬럼
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
conditionColumn: value,
},
});
}}
>
{saveTableColumns.map((col) => (
{col.comment || col.column_name}
))}
저장 시 각 행에 조건 값이 이 컬럼에 자동 저장됩니다.
{/* 조건 옵션 목록 */}
조건 옵션
{
const newOption: ConditionalTableOption = {
id: generateConditionalOptionId(),
value: "",
label: "",
};
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: [...(tableConfig.conditionalTable?.options || []), newOption],
},
});
}}
className="h-7 text-xs"
>
옵션 추가
{/* 옵션 목록 */}
{(tableConfig.conditionalTable?.options || []).map((option, index) => (
{
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 text-xs flex-1"
/>
{
const newOptions = [...(tableConfig.conditionalTable?.options || [])];
newOptions[index] = { ...newOptions[index], label: e.target.value };
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: newOptions,
},
});
}}
placeholder="표시 라벨 (예: 입고검사)"
className="h-8 text-xs flex-1"
/>
{
const newOptions = (tableConfig.conditionalTable?.options || []).filter(
(_, i) => i !== index
);
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
options: newOptions,
},
});
}}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
))}
{(tableConfig.conditionalTable?.options || []).length === 0 && (
조건 옵션을 추가하세요. (예: 입고검사, 공정검사, 출고검사 등)
)}
{/* 테이블에서 옵션 로드 설정 */}
{
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"
/>
테이블에서 옵션 동적 로드
{tableConfig.conditionalTable?.optionSource?.enabled && (
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
optionSource: {
...tableConfig.conditionalTable?.optionSource!,
...updates,
},
},
});
}}
/>
)}
{/* 소스 테이블 필터링 설정 */}
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
enabled: checked,
filterColumn: tableConfig.conditionalTable?.sourceFilter?.filterColumn || "",
},
},
});
}}
className="scale-75"
/>
소스 테이블 필터링
조건 선택 시 소스 테이블에서 해당 조건으로 필터링합니다
{tableConfig.conditionalTable?.sourceFilter?.enabled && (
필터링할 소스 컬럼
소스 테이블({tableConfig.source?.tableName || "미설정"})에서 조건값으로 필터링할 컬럼
{sourceTableColumns.length > 0 ? (
{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;
})()
: "컬럼 선택..."}
컬럼을 찾을 수 없습니다.
{sourceTableColumns.map((col) => (
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
...tableConfig.conditionalTable?.sourceFilter!,
filterColumn: col.column_name,
},
},
});
}}
className="text-xs"
>
{col.column_name}
{col.comment && (
({col.comment})
)}
))}
) : (
{
setTableConfig({
...tableConfig,
conditionalTable: {
...tableConfig.conditionalTable!,
sourceFilter: {
...tableConfig.conditionalTable?.sourceFilter!,
filterColumn: e.target.value,
},
},
});
}}
placeholder="inspection_type"
className="h-7 text-xs"
/>
)}
예: 검사유형 "입고검사" 선택 시 → inspection_type = '입고검사' 조건 적용
)}
{/* 사용 가이드 */}
사용 가이드
1. 소스 테이블 필터링 활성화 후:
항목 검색 : 검색 모달에서 필터링된 데이터만 표시
빈 행 추가 : 드롭다운 옵션이 필터링된 데이터로 제한
2. 컬럼 설정에서 추가 설정:
컬럼 타입을 "선택(드롭다운)" 으로 변경
"동적 드롭다운 옵션" 섹션이 나타남
소스 컬럼 선택 → 해당 컬럼 값이 드롭다운 옵션으로 표시
"행 선택 모드" 활성화 시 → 선택한 값의 같은 행 데이터를 다른 컬럼에 자동 채움
3. 예시 (품목검사정보):
"입고검사" 체크박스 선택 → 테이블 탭 표시
"항목 추가" 클릭 → 빈 행 생성
"검사항목" 드롭다운 → inspection_type='입고검사'인 항목만 표시
검사항목 선택 시 → 검사기준, 검사방법 자동 채움 (행 선택 모드)
)}
onOpenChange(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
취소
저장
);
}