Files
vexplor/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx
kmh 238a7d1db4 feat: Enhance V2RepeaterConfigPanel with entity join column management
- Updated the toggleEntityJoinColumn function to include an optional columnType parameter for better flexibility in handling join columns.
- Improved the logic for managing entity joins and columns, ensuring that columns are correctly added or removed based on user interactions.
- Introduced a new section in the UI to display entity join columns in a read-only format, providing users with clear visibility of the join configurations.
- Added loading states and messages to enhance user experience during data retrieval for entity joins.

These changes aim to improve the functionality and usability of the V2RepeaterConfigPanel in managing entity relationships.
2026-03-11 23:38:42 +09:00

1927 lines
86 KiB
TypeScript

"use client";
/**
* V2Repeater 설정 패널
*
* 렌더링 모드별 설정:
* - inline: 현재 화면 테이블 컬럼 직접 입력
* - modal: 엔티티 선택 + 추가 입력 (FK 저장 + 추가 컬럼 입력)
*/
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Database,
Link2,
Plus,
Trash2,
GripVertical,
ArrowRight,
Calculator,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
Wand2,
Check,
ChevronsUpDown,
ListTree,
} from "lucide-react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { cn } from "@/lib/utils";
import {
V2RepeaterConfig,
RepeaterColumnConfig,
RepeaterEntityJoin,
DEFAULT_REPEATER_CONFIG,
RENDER_MODE_OPTIONS,
MODAL_SIZE_OPTIONS,
} from "@/types/v2-repeater";
// 테이블 엔티티 관계 정보
interface TableRelation {
tableName: string;
tableLabel: string;
foreignKeyColumn: string; // 저장 테이블의 FK 컬럼
referenceColumn: string; // 마스터 테이블의 PK 컬럼
}
interface V2RepeaterConfigPanelProps {
config: V2RepeaterConfig;
onChange: (config: V2RepeaterConfig) => void;
currentTableName?: string;
screenTableName?: string;
tableColumns?: any[];
menuObjid?: number | string; // 🆕 메뉴 ID (채번 규칙 조회용)
}
interface ColumnOption {
columnName: string;
displayName: string;
inputType?: string;
detailSettings?: {
codeGroup?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
format?: string;
};
}
interface EntityColumnOption {
columnName: string;
displayName: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}
interface CalculationRule {
id: string;
targetColumn: string;
formula: string;
label?: string;
}
export const V2RepeaterConfigPanel: React.FC<V2RepeaterConfigPanelProps> = ({
config: propConfig,
onChange,
currentTableName: propCurrentTableName,
screenTableName,
menuObjid,
}) => {
const currentTableName = screenTableName || propCurrentTableName;
// config 안전하게 초기화
const config: V2RepeaterConfig = useMemo(() => ({
...DEFAULT_REPEATER_CONFIG,
...propConfig,
renderMode: propConfig?.renderMode || DEFAULT_REPEATER_CONFIG.renderMode,
dataSource: {
...DEFAULT_REPEATER_CONFIG.dataSource,
...propConfig?.dataSource,
},
columns: propConfig?.columns || [],
modal: {
...DEFAULT_REPEATER_CONFIG.modal,
...propConfig?.modal,
},
features: {
...DEFAULT_REPEATER_CONFIG.features,
...propConfig?.features,
},
}), [propConfig]);
// 상태 관리
const [currentTableColumns, setCurrentTableColumns] = useState<ColumnOption[]>([]); // 현재 테이블 컬럼
const [entityColumns, setEntityColumns] = useState<EntityColumnOption[]>([]); // 엔티티 타입 컬럼
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnOption[]>([]); // 소스(엔티티) 테이블 컬럼
const [calculationRules, setCalculationRules] = useState<CalculationRule[]>(
config.calculationRules || []
);
const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingSourceColumns, setLoadingSourceColumns] = useState(false);
// 저장 테이블 관련 상태
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [relatedTables, setRelatedTables] = useState<TableRelation[]>([]); // 현재 테이블과 연관된 테이블 목록
const [loadingRelations, setLoadingRelations] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 테이블 Combobox 열림 상태
// Entity 조인 관련 상태
const [entityJoinData, setEntityJoinData] = useState<{
joinTables: Array<{
tableName: string;
currentDisplayColumn: string;
joinConfig?: { sourceColumn?: string };
availableColumns: Array<{
columnName: string;
columnLabel: string;
dataType: string;
inputType?: string;
}>;
}>;
availableColumns: Array<{
tableName: string;
columnName: string;
columnLabel: string;
joinAlias: string;
}>;
}>({ joinTables: [], availableColumns: [] });
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
// 🆕 확장된 컬럼 (상세 설정 표시용)
const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
// 🆕 채번 규칙 목록
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingNumberingRules, setLoadingNumberingRules] = useState(false);
// 🆕 대상 메뉴 목록 (채번 규칙 선택용)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [loadingMenus, setLoadingMenus] = useState(false);
// 🆕 선택된 메뉴 OBJID (컬럼별로 저장, 한 번 선택하면 공유)
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
// 기존 config에서 저장된 값이 있으면 복원
const existingAutoFill = config.columns.find(c => c.autoFill?.type === "numbering" && c.autoFill.selectedMenuObjid);
return existingAutoFill?.autoFill?.selectedMenuObjid || (menuObjid ? Number(menuObjid) : undefined);
});
// 자동 입력 타입 옵션
const autoFillOptions = [
{ value: "none", label: "없음" },
{ value: "currentDate", label: "현재 날짜" },
{ value: "currentDateTime", label: "현재 날짜+시간" },
{ value: "sequence", label: "순번 (1, 2, 3...)" },
{ value: "numbering", label: "채번 규칙" },
{ value: "fromMainForm", label: "메인 폼에서 복사" },
{ value: "fixed", label: "고정값" },
{ value: "parentSequence", label: "부모채번+순번 (예: WO-001-01)" },
];
// 🆕 대상 메뉴 목록 로드 (사용자 메뉴의 레벨 2)
useEffect(() => {
const loadMenus = async () => {
setLoadingMenus(true);
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get("/admin/menus");
if (response.data.success && response.data.data) {
const allMenus = response.data.data;
// 사용자 메뉴(menu_type='1')의 레벨 2만 필터링
const level2UserMenus = allMenus.filter((menu: any) =>
menu.menu_type === '1' && menu.lev === 2
);
setParentMenus(level2UserMenus);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
setLoadingMenus(false);
}
};
loadMenus();
}, []);
// 🆕 채번 규칙 로드 (선택된 메뉴 기준)
useEffect(() => {
const loadNumberingRules = async () => {
// 메뉴가 선택되지 않았으면 로드하지 않음
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
setLoadingNumberingRules(true);
try {
const result = await getAvailableNumberingRules(selectedMenuObjid);
if (result?.success && result.data) {
setNumberingRules(result.data);
}
} catch (error) {
console.error("채번 규칙 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingNumberingRules(false);
}
};
loadNumberingRules();
}, [selectedMenuObjid]);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.displayName || t.table_label || t.tableName || t.table_name,
})));
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 연관 테이블 목록 로드 (엔티티 관계 기반)
// 1. 화면 메인 테이블이 있으면: 그 테이블을 참조하는 테이블 (자식 테이블)
// 2. 저장 테이블이 선택되었으면: 그 테이블을 참조하는 테이블도 추가
useEffect(() => {
const loadRelatedTables = async () => {
// 화면 메인 테이블 또는 저장 테이블 중 하나라도 있어야 함
const baseTable = currentTableName || config.mainTableName;
if (!baseTable) {
setRelatedTables([]);
return;
}
setLoadingRelations(true);
try {
const { apiClient } = await import("@/lib/api/client");
const allRelations: TableRelation[] = [];
// 1. 화면 메인 테이블을 참조하는 테이블 조회 (자식 테이블)
if (currentTableName) {
const response = await apiClient.get(`/table-management/columns/${currentTableName}/referenced-by`);
if (response.data.success && response.data.data) {
const relations: TableRelation[] = response.data.data.map((rel: any) => ({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreignKeyColumn: rel.columnName || rel.column_name, // FK 컬럼 (자식 테이블의)
referenceColumn: rel.referenceColumn || rel.reference_column || "id", // PK 컬럼 (부모 테이블의)
}));
allRelations.push(...relations);
}
}
// 2. 저장 테이블이 화면 메인 테이블과 다르면, 저장 테이블을 참조하는 테이블도 조회
if (config.mainTableName && config.mainTableName !== currentTableName) {
const response2 = await apiClient.get(`/table-management/columns/${config.mainTableName}/referenced-by`);
if (response2.data.success && response2.data.data) {
const relations2: TableRelation[] = response2.data.data.map((rel: any) => ({
tableName: rel.tableName || rel.table_name,
tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name,
foreignKeyColumn: rel.columnName || rel.column_name,
referenceColumn: rel.referenceColumn || rel.reference_column || "id",
}));
// 중복 제거 후 추가
relations2.forEach(rel => {
if (!allRelations.some(r => r.tableName === rel.tableName)) {
allRelations.push(rel);
}
});
}
}
setRelatedTables(allRelations);
} catch (error) {
console.error("연관 테이블 로드 실패:", error);
setRelatedTables([]);
} finally {
setLoadingRelations(false);
}
};
loadRelatedTables();
}, [currentTableName, config.mainTableName]);
// Entity 조인 컬럼 정보 로드 (저장 테이블 기준)
const entityJoinTargetTable = config.useCustomTable && config.mainTableName
? config.mainTableName
: currentTableName;
useEffect(() => {
const fetchEntityJoinColumns = async () => {
if (!entityJoinTargetTable) return;
setLoadingEntityJoins(true);
try {
const result = await entityJoinApi.getEntityJoinColumns(entityJoinTargetTable);
setEntityJoinData({
joinTables: result.joinTables || [],
availableColumns: result.availableColumns || [],
});
} catch (error) {
console.error("Entity 조인 컬럼 조회 오류:", error);
setEntityJoinData({ joinTables: [], availableColumns: [] });
} finally {
setLoadingEntityJoins(false);
}
};
fetchEntityJoinColumns();
}, [entityJoinTargetTable]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback(
(updates: Partial<V2RepeaterConfig>) => {
onChange({ ...config, ...updates });
},
[config, onChange],
);
// Entity 조인 컬럼 토글 (추가/제거)
const toggleEntityJoinColumn = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string, refColumnLabel: string, displayField: string, columnType?: string) => {
const currentJoins = config.entityJoins || [];
const existingJoinIdx = currentJoins.findIndex(
(j) => j.sourceColumn === sourceColumn && j.referenceTable === joinTableName,
);
let newEntityJoins = [...currentJoins];
let newColumns = [...config.columns];
if (existingJoinIdx >= 0) {
const existingJoin = currentJoins[existingJoinIdx];
const existingColIdx = existingJoin.columns.findIndex((c) => c.referenceField === refColumnName);
if (existingColIdx >= 0) {
const updatedColumns = existingJoin.columns.filter((_, i) => i !== existingColIdx);
if (updatedColumns.length === 0) {
newEntityJoins = newEntityJoins.filter((_, i) => i !== existingJoinIdx);
} else {
newEntityJoins[existingJoinIdx] = { ...existingJoin, columns: updatedColumns };
}
// config.columns에서도 제거
newColumns = newColumns.filter(c => !(c.key === displayField && c.isJoinColumn));
} else {
newEntityJoins[existingJoinIdx] = {
...existingJoin,
columns: [...existingJoin.columns, { referenceField: refColumnName, displayField }],
};
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
} else {
newEntityJoins.push({
sourceColumn,
referenceTable: joinTableName,
columns: [{ referenceField: refColumnName, displayField }],
});
// config.columns에 추가
newColumns.push({
key: displayField,
title: refColumnLabel,
width: "auto",
visible: true,
editable: false,
isJoinColumn: true,
inputType: columnType || "text",
});
}
updateConfig({ entityJoins: newEntityJoins, columns: newColumns });
},
[config.entityJoins, config.columns, updateConfig],
);
// Entity 조인에 특정 컬럼이 설정되어 있는지 확인
const isEntityJoinColumnActive = useCallback(
(joinTableName: string, sourceColumn: string, refColumnName: string) => {
return (config.entityJoins || []).some(
(j) =>
j.sourceColumn === sourceColumn &&
j.referenceTable === joinTableName &&
j.columns.some((c) => c.referenceField === refColumnName),
);
},
[config.entityJoins],
);
const updateDataSource = useCallback(
(field: string, value: any) => {
updateConfig({
dataSource: { ...config.dataSource, [field]: value },
});
},
[config.dataSource, updateConfig],
);
const updateModal = useCallback(
(field: string, value: any) => {
updateConfig({
modal: { ...config.modal, [field]: value },
});
},
[config.modal, updateConfig],
);
const updateFeatures = useCallback(
(field: string, value: boolean) => {
updateConfig({
features: { ...config.features, [field]: value },
});
},
[config.features, updateConfig],
);
// 저장 테이블 선택 핸들러 - 엔티티 관계에서 FK/PK 자동 설정
const handleSaveTableSelect = useCallback((tableName: string) => {
// 빈 값 선택 시 (현재 테이블로 복원)
if (!tableName || tableName === currentTableName) {
updateConfig({
useCustomTable: false,
mainTableName: undefined,
foreignKeyColumn: undefined,
foreignKeySourceColumn: undefined,
});
return;
}
// 연관 테이블에서 FK 관계 찾기
const relation = relatedTables.find(r => r.tableName === tableName);
if (relation) {
// 엔티티 관계가 있으면 자동으로 FK/PK 설정
updateConfig({
useCustomTable: true,
mainTableName: tableName,
foreignKeyColumn: relation.foreignKeyColumn,
foreignKeySourceColumn: relation.referenceColumn,
});
} else {
// 엔티티 관계가 없으면 직접 입력 필요
updateConfig({
useCustomTable: true,
mainTableName: tableName,
foreignKeyColumn: undefined,
foreignKeySourceColumn: "id",
});
}
}, [currentTableName, relatedTables, updateConfig]);
// 저장 테이블 컬럼 로드 (저장 테이블이 설정되면 해당 테이블, 아니면 현재 화면 테이블)
// 실제 저장할 테이블의 컬럼을 보여줘야 함
const targetTableForColumns = config.useCustomTable && config.mainTableName
? config.mainTableName
: currentTableName;
useEffect(() => {
const loadCurrentTableColumns = async () => {
if (!targetTableForColumns) {
setCurrentTableColumns([]);
setEntityColumns([]);
return;
}
setLoadingColumns(true);
try {
const columnData = await tableTypeApi.getColumns(targetTableForColumns);
const cols: ColumnOption[] = [];
const entityCols: EntityColumnOption[] = [];
for (const c of columnData) {
// detailSettings 파싱
let detailSettings: any = null;
if (c.detailSettings) {
try {
detailSettings = typeof c.detailSettings === "string"
? JSON.parse(c.detailSettings)
: c.detailSettings;
} catch (e) {
console.warn("detailSettings 파싱 실패:", c.detailSettings);
}
}
const col: ColumnOption = {
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
inputType: c.inputType || c.input_type,
detailSettings: detailSettings ? {
codeGroup: detailSettings.codeGroup,
referenceTable: detailSettings.referenceTable,
referenceColumn: detailSettings.referenceColumn,
displayColumn: detailSettings.displayColumn,
format: detailSettings.format,
} : undefined,
};
cols.push(col);
// 엔티티 타입 컬럼 감지
if (col.inputType === "entity") {
const referenceTable = detailSettings?.referenceTable || c.referenceTable;
const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id";
const displayColumn = detailSettings?.displayColumn || c.displayColumn;
if (referenceTable) {
entityCols.push({
columnName: col.columnName,
displayName: col.displayName,
referenceTable,
referenceColumn,
displayColumn,
});
}
}
}
setCurrentTableColumns(cols);
setEntityColumns(entityCols);
} catch (error) {
console.error("저장 테이블 컬럼 로드 실패:", error);
setCurrentTableColumns([]);
setEntityColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadCurrentTableColumns();
}, [targetTableForColumns]);
// 소스(엔티티) 테이블 컬럼 로드 (모달 모드일 때)
useEffect(() => {
const loadSourceTableColumns = async () => {
const sourceTable = config.dataSource?.sourceTable;
if (!sourceTable) {
setSourceTableColumns([]);
return;
}
setLoadingSourceColumns(true);
try {
const columnData = await tableTypeApi.getColumns(sourceTable);
const cols: ColumnOption[] = columnData.map((c: any) => ({
columnName: c.columnName || c.column_name,
displayName: c.displayName || c.columnLabel || c.columnName || c.column_name,
inputType: c.inputType || c.input_type,
}));
setSourceTableColumns(cols);
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
setSourceTableColumns([]);
} finally {
setLoadingSourceColumns(false);
}
};
if (config.renderMode === "modal") {
loadSourceTableColumns();
}
}, [config.dataSource?.sourceTable, config.renderMode]);
// 컬럼 토글 (현재 테이블 컬럼 - 입력용)
const toggleInputColumn = (column: ColumnOption) => {
const existingIndex = config.columns.findIndex((c) => c.key === column.columnName && !c.isJoinColumn && !c.isSourceDisplay);
if (existingIndex >= 0) {
const newColumns = config.columns.filter((_, i) => i !== existingIndex);
updateConfig({ columns: newColumns });
} else {
// 컬럼의 inputType과 detailSettings 정보 포함
const newColumn: RepeaterColumnConfig = {
key: column.columnName,
title: column.displayName,
width: "auto",
visible: true,
editable: true,
inputType: column.inputType || "text",
detailSettings: column.detailSettings ? {
codeGroup: column.detailSettings.codeGroup,
referenceTable: column.detailSettings.referenceTable,
referenceColumn: column.detailSettings.referenceColumn,
displayColumn: column.detailSettings.displayColumn,
format: column.detailSettings.format,
} : undefined,
};
updateConfig({ columns: [...config.columns, newColumn] });
}
};
// 🆕 소스 컬럼 토글 - columns 배열에 isSourceDisplay: true로 추가
const toggleSourceDisplayColumn = (column: ColumnOption) => {
const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay);
if (exists) {
// 제거
updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) });
} else {
// 추가 (isSourceDisplay: true)
const newColumn: RepeaterColumnConfig = {
key: column.columnName,
title: column.displayName,
width: "auto",
visible: true,
editable: false, // 소스 표시 컬럼은 편집 불가
isSourceDisplay: true,
};
updateConfig({ columns: [...config.columns, newColumn] });
}
};
const isColumnAdded = (columnName: string) => {
return config.columns.some((c) => c.key === columnName && !c.isSourceDisplay && !c.isJoinColumn);
};
const isSourceColumnSelected = (columnName: string) => {
return config.columns.some((c) => c.key === columnName && c.isSourceDisplay);
};
// 컬럼 속성 업데이트
const updateColumnProp = (key: string, field: keyof RepeaterColumnConfig, value: any) => {
const newColumns = config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col));
updateConfig({ columns: newColumns });
};
// 계산 규칙을 config에 반영하는 헬퍼
const syncCalculationRules = (rules: CalculationRule[]) => {
setCalculationRules(rules);
updateConfig({ calculationRules: rules });
};
// 계산 규칙 추가
const addCalculationRule = () => {
const newRules = [
...calculationRules,
{ id: `calc_${Date.now()}`, targetColumn: "", formula: "" }
];
syncCalculationRules(newRules);
};
// 계산 규칙 삭제
const removeCalculationRule = (id: string) => {
syncCalculationRules(calculationRules.filter(r => r.id !== id));
};
// 계산 규칙 업데이트
const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => {
syncCalculationRules(
calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r)
);
};
// 수식 입력 필드에 컬럼명 삽입
const insertColumnToFormula = (ruleId: string, columnKey: string) => {
const rule = calculationRules.find(r => r.id === ruleId);
if (!rule) return;
const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey;
updateCalculationRule(ruleId, "formula", newFormula);
};
// 수식의 영어 컬럼명을 한글 제목으로 변환
const formulaToKorean = (formula: string): string => {
if (!formula) return "";
let result = formula;
const allCols = config.columns || [];
// 긴 컬럼명부터 치환 (부분 매칭 방지)
const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length);
for (const col of sorted) {
if (col.title && col.key) {
result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title);
}
}
return result;
};
// 엔티티 컬럼 선택 시 소스 테이블 자동 설정
const handleEntityColumnSelect = (columnName: string) => {
const selectedEntity = entityColumns.find(c => c.columnName === columnName);
if (selectedEntity) {
console.log("엔티티 컬럼 선택:", selectedEntity);
// 소스 테이블 컬럼에서 라벨 정보 찾기
const displayColInfo = sourceTableColumns.find(c => c.columnName === selectedEntity.displayColumn);
const displayLabel = displayColInfo?.displayName || selectedEntity.displayColumn || "";
updateConfig({
dataSource: {
...config.dataSource,
sourceTable: selectedEntity.referenceTable || "",
foreignKey: selectedEntity.columnName,
referenceKey: selectedEntity.referenceColumn || "id",
displayColumn: selectedEntity.displayColumn,
},
modal: {
...config.modal,
searchFields: selectedEntity.displayColumn ? [selectedEntity.displayColumn] : [],
// 라벨 포함 형식으로 저장
sourceDisplayColumns: selectedEntity.displayColumn
? [{ key: selectedEntity.displayColumn, label: displayLabel }]
: [],
},
});
}
};
// 모드 여부
const isInlineMode = config.renderMode === "inline";
const isModalMode = config.renderMode === "modal";
// 엔티티 컬럼 제외한 입력 가능 컬럼 (FK 컬럼 제외)
const inputableColumns = useMemo(() => {
const fkColumn = config.dataSource?.foreignKey;
return currentTableColumns.filter(col =>
col.columnName !== fkColumn && // FK 컬럼 제외
col.inputType !== "entity" // 다른 엔티티 컬럼도 제외 (필요시)
);
}, [currentTableColumns, config.dataSource?.foreignKey]);
return (
<div className="space-y-4">
<Tabs defaultValue="basic" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="text-xs"></TabsTrigger>
<TabsTrigger value="columns" className="text-xs"></TabsTrigger>
</TabsList>
{/* 기본 설정 탭 */}
<TabsContent value="basic" className="mt-4 space-y-4">
{/* 렌더링 모드 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<Select
value={config.renderMode}
onValueChange={(value) => {
const newMode = value as any;
const currentMode = config.renderMode;
// 모달 → 인라인 모드로 변경 시: isSourceDisplay 컬럼 제거 및 모달 설정 초기화
if (currentMode === "modal" && newMode === "inline") {
const filteredColumns = config.columns.filter((col) => !col.isSourceDisplay);
updateConfig({
renderMode: newMode,
columns: filteredColumns,
dataSource: {
...config.dataSource,
sourceTable: undefined,
foreignKey: undefined,
referenceKey: undefined,
displayColumn: undefined,
},
modal: {
...config.modal,
searchFields: [],
sourceDisplayColumns: [],
},
});
} else {
updateConfig({ renderMode: newMode });
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="모드 선택" />
</SelectTrigger>
<SelectContent>
{RENDER_MODE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div className="flex flex-col">
<span>{opt.label}</span>
<span className="text-[10px] text-muted-foreground/70">
{opt.value === "inline" && "현재 테이블 컬럼 직접 입력"}
{opt.value === "modal" && "엔티티 선택 후 추가 정보 입력"}
{opt.value === "button" && "버튼으로 관련 화면 열기"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* 저장 대상 테이블 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
{/* 현재 선택된 테이블 표시 (기존 테이블 UI와 동일한 스타일) */}
<div className={cn(
"rounded-lg border p-3",
config.useCustomTable && config.mainTableName
? "border-orange-300 bg-amber-50"
: "border-primary/40 bg-primary/10"
)}>
<div className="flex items-center gap-2">
<Database className={cn(
"h-4 w-4",
config.useCustomTable && config.mainTableName
? "text-amber-600"
: "text-primary"
)} />
<div className="flex-1">
<p className={cn(
"text-sm font-medium",
config.useCustomTable && config.mainTableName
? "text-orange-700"
: "text-primary"
)}>
{config.useCustomTable && config.mainTableName
? (allTables.find(t => t.tableName === config.mainTableName)?.displayName || config.mainTableName)
: (currentTableName || "미설정")
}
</p>
{config.useCustomTable && config.mainTableName && config.foreignKeyColumn && (
<p className="text-[10px] text-amber-600 mt-0.5">
FK: {config.foreignKeyColumn} {currentTableName}.{config.foreignKeySourceColumn || "id"}
</p>
)}
{!config.useCustomTable && currentTableName && (
<p className="text-[10px] text-primary mt-0.5"> </p>
)}
</div>
</div>
</div>
{/* 테이블 변경 Combobox */}
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
disabled={loadingTables || loadingRelations}
className="h-8 w-full justify-between text-xs"
>
{loadingTables ? "로딩 중..." : "다른 테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-60">
<CommandEmpty className="text-xs py-3 text-center">
.
</CommandEmpty>
{/* 현재 테이블 (기본) */}
{currentTableName && (
<CommandGroup heading="기본">
<CommandItem
value={currentTableName}
onSelect={() => {
handleSaveTableSelect(currentTableName);
setTableComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable || !config.mainTableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-primary" />
<span>{currentTableName}</span>
<span className="ml-1 text-[10px] text-muted-foreground">()</span>
</CommandItem>
</CommandGroup>
)}
{/* 연관 테이블 (엔티티 관계) */}
{relatedTables.length > 0 && (
<CommandGroup heading="연관 테이블 (FK 자동 설정)">
{relatedTables.map((rel) => (
<CommandItem
key={rel.tableName}
value={`${rel.tableName} ${rel.tableLabel}`}
onSelect={() => {
handleSaveTableSelect(rel.tableName);
setTableComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.mainTableName === rel.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Link2 className="mr-2 h-3 w-3 text-amber-500" />
<span>{rel.tableLabel}</span>
<span className="ml-1 text-[10px] text-muted-foreground">
({rel.foreignKeyColumn})
</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* 전체 테이블 목록 */}
<CommandGroup heading="전체 테이블 (FK 직접 입력)">
{allTables
.filter(t => t.tableName !== currentTableName && !relatedTables.some(r => r.tableName === t.tableName))
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
handleSaveTableSelect(table.tableName);
setTableComboboxOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.mainTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-muted-foreground/70" />
<span>{table.displayName}</span>
</CommandItem>
))
}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* FK 직접 입력 - 화면 메인 테이블이 있고 연관 테이블이 아닌 경우만 표시 */}
{/* 화면 메인 테이블이 없으면 FK 설정 불필요 (독립 저장) */}
{config.useCustomTable && config.mainTableName && currentTableName &&
!relatedTables.some(r => r.tableName === config.mainTableName) && (
<div className="space-y-2 rounded border border-amber-200 bg-amber-50 p-2">
<p className="text-[10px] text-amber-700">
({currentTableName}) . FK .
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]">FK ( )</Label>
<Input
value={config.foreignKeyColumn || ""}
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
placeholder="예: master_id"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]">PK ( )</Label>
<Input
value={config.foreignKeySourceColumn || "id"}
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
placeholder="id"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* 화면 메인 테이블이 없을 때 안내 */}
{config.useCustomTable && config.mainTableName && !currentTableName && (
<div className="rounded border border-primary/20 bg-primary/10 p-2">
<p className="text-[10px] text-primary">
모드: 화면 .
</p>
</div>
)}
</div>
<Separator />
{/* 현재 화면 정보 (메인 테이블이 설정된 경우에만 표시) */}
{currentTableName && (
<div className="space-y-2">
<Label className="text-xs font-medium"> ()</Label>
<div className="rounded border border-border bg-muted p-2">
<p className="text-xs text-foreground font-medium">{currentTableName}</p>
<p className="text-[10px] text-muted-foreground">
{currentTableColumns.length} / {entityColumns.length}
</p>
</div>
</div>
)}
{/* 모달 모드: 엔티티 컬럼 선택 */}
{isModalMode && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground">
(FK만 )
</p>
{entityColumns.length > 0 ? (
<Select
value={config.dataSource?.foreignKey || ""}
onValueChange={handleEntityColumnSelect}
disabled={!targetTableForColumns}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="엔티티 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{entityColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<Link2 className="h-3 w-3 text-primary" />
<span>{col.displayName}</span>
<ArrowRight className="h-3 w-3 text-muted-foreground/70" />
<span className="text-muted-foreground">{col.referenceTable}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="rounded border border-border bg-muted p-2">
<p className="text-[10px] text-muted-foreground">
{loadingColumns
? "로딩 중..."
: !targetTableForColumns
? "저장 테이블을 먼저 선택하세요"
: "엔티티 타입 컬럼이 없습니다"}
</p>
</div>
)}
{/* 선택된 엔티티 정보 */}
{config.dataSource?.sourceTable && (
<div className="rounded border border-emerald-200 bg-emerald-50 p-2 space-y-1">
<p className="text-xs text-emerald-700 font-medium"> </p>
<div className="text-[10px] text-emerald-600">
<p> : {config.dataSource.sourceTable}</p>
<p> : {config.dataSource.foreignKey} (FK)</p>
</div>
</div>
)}
</div>
</>
)}
<Separator />
{/* 소스 디테일 자동 조회 설정 */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="enableSourceDetail"
checked={!!config.sourceDetailConfig}
onCheckedChange={(checked) => {
if (checked) {
updateConfig({
sourceDetailConfig: {
tableName: "",
foreignKey: "",
parentKey: "",
},
});
} else {
updateConfig({ sourceDetailConfig: undefined });
}
}}
/>
<label htmlFor="enableSourceDetail" className="text-xs font-medium flex items-center gap-1">
<ListTree className="h-3 w-3" />
</label>
</div>
<p className="text-[10px] text-muted-foreground">
.
</p>
{config.sourceDetailConfig && (
<div className="space-y-2 rounded border border-violet-200 bg-violet-50 p-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{config.sourceDetailConfig.tableName
? (allTables.find(t => t.tableName === config.sourceDetailConfig!.tableName)?.displayName || config.sourceDetailConfig.tableName)
: "테이블 선택..."
}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList className="max-h-48">
<CommandEmpty className="text-xs py-3 text-center"> .</CommandEmpty>
<CommandGroup>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => {
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
tableName: table.tableName,
},
});
}}
className="text-xs"
>
<Check className={cn("mr-2 h-3 w-3", config.sourceDetailConfig!.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
<span>{table.displayName}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-[10px]"> FK </Label>
<Input
value={config.sourceDetailConfig.foreignKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
foreignKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.sourceDetailConfig.parentKey || ""}
onChange={(e) =>
updateConfig({
sourceDetailConfig: {
...config.sourceDetailConfig!,
parentKey: e.target.value,
},
})
}
placeholder="예: order_no"
className="h-7 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-violet-600">
[{config.sourceDetailConfig.parentKey || "?"}]
{" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"}
</p>
</div>
)}
</div>
<Separator />
{/* 기능 옵션 */}
<div className="space-y-3">
<Label className="text-xs font-medium"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center space-x-2">
<Checkbox
id="showAddButton"
checked={config.features?.showAddButton ?? true}
onCheckedChange={(checked) => updateFeatures("showAddButton", !!checked)}
/>
<label htmlFor="showAddButton" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showDeleteButton"
checked={config.features?.showDeleteButton ?? true}
onCheckedChange={(checked) => updateFeatures("showDeleteButton", !!checked)}
/>
<label htmlFor="showDeleteButton" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="inlineEdit"
checked={config.features?.inlineEdit ?? false}
onCheckedChange={(checked) => updateFeatures("inlineEdit", !!checked)}
/>
<label htmlFor="inlineEdit" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="multiSelect"
checked={config.features?.multiSelect ?? true}
onCheckedChange={(checked) => updateFeatures("multiSelect", !!checked)}
/>
<label htmlFor="multiSelect" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showRowNumber"
checked={config.features?.showRowNumber ?? false}
onCheckedChange={(checked) => updateFeatures("showRowNumber", !!checked)}
/>
<label htmlFor="showRowNumber" className="text-xs"> </label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="selectable"
checked={config.features?.selectable ?? false}
onCheckedChange={(checked) => updateFeatures("selectable", !!checked)}
/>
<label htmlFor="selectable" className="text-xs"> </label>
</div>
</div>
</div>
</TabsContent>
{/* 컬럼 설정 탭 - 🆕 통합 컬럼 선택 */}
<TabsContent value="columns" className="mt-4 space-y-4">
{/* 통합 컬럼 선택 */}
<div className="space-y-2">
<Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground">
{isModalMode
? "표시할 컬럼과 입력 컬럼을 선택하세요. 아이콘으로 표시/입력 구분"
: "입력받을 컬럼을 선택하세요"
}
</p>
{/* 모달 모드: 소스 테이블 컬럼 (표시용) */}
{isModalMode && config.dataSource?.sourceTable && (
<>
<div className="text-[10px] font-medium text-primary mt-2 mb-1 flex items-center gap-1">
<Link2 className="h-3 w-3" />
({config.dataSource.sourceTable}) -
</div>
{loadingSourceColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : sourceTableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs"> </p>
) : (
<div className="max-h-28 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{sourceTableColumns.map((column) => (
<div
key={`source-${column.columnName}`}
className={cn(
"hover:bg-primary/10/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isSourceColumnSelected(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleSourceDisplayColumn(column)}
>
<Checkbox
checked={isSourceColumnSelected(column.columnName)}
onCheckedChange={() => toggleSourceDisplayColumn(column)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="text-primary h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
<span className="text-[10px] text-primary/80 ml-auto"></span>
</div>
))}
</div>
)}
</>
)}
{/* 저장 테이블 컬럼 (입력용) */}
<div className="text-[10px] font-medium text-muted-foreground mt-3 mb-1 flex items-center gap-1">
<Database className="h-3 w-3" />
({targetTableForColumns || "미선택"}) -
</div>
{loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : inputableColumns.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs">
</p>
) : (
<div className="max-h-36 space-y-0.5 overflow-y-auto rounded-md border p-2">
{inputableColumns.map((column) => (
<div
key={`input-${column.columnName}`}
className={cn(
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-2 py-1",
isColumnAdded(column.columnName) && "bg-primary/10",
)}
onClick={() => toggleInputColumn(column)}
>
<Checkbox
checked={isColumnAdded(column.columnName)}
onCheckedChange={() => toggleInputColumn(column)}
className="pointer-events-none h-3.5 w-3.5"
/>
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span className="truncate text-xs">{column.displayName}</span>
<span className="text-[10px] text-muted-foreground/70 ml-auto">{column.inputType}</span>
</div>
))}
</div>
)}
</div>
{/* ===== 🆕 Entity 조인 컬럼 (표시용) ===== */}
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-primary" />
<Label className="text-xs font-medium text-primary">Entity ()</Label>
</div>
<p className="text-[10px] text-muted-foreground">
FK .
</p>
{loadingEntityJoins ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p>
) : entityJoinData.joinTables.length === 0 ? (
<p className="text-muted-foreground py-2 text-xs">
{entityJoinTargetTable
? `${entityJoinTargetTable} 테이블에 Entity 조인 가능한 컬럼이 없습니다`
: "저장 테이블을 먼저 설정해주세요"}
</p>
) : (
<div className="space-y-3">
{entityJoinData.joinTables.map((joinTable, tableIndex) => {
const sourceColumn = (joinTable as any).joinConfig?.sourceColumn || "";
return (
<div key={tableIndex} className="space-y-1">
<div className="mb-1 flex items-center gap-2 text-[10px] font-medium text-primary">
<Link2 className="h-3 w-3" />
<span>{joinTable.tableName}</span>
<span className="text-muted-foreground">({sourceColumn})</span>
</div>
<div className="max-h-40 space-y-0.5 overflow-y-auto rounded-md border border-primary/20 bg-primary/10/30 p-2">
{joinTable.availableColumns.map((column, colIndex) => {
const isActive = isEntityJoinColumnActive(
joinTable.tableName,
sourceColumn,
column.columnName,
);
const matchingCol = config.columns.find((c) => c.key === column.columnName && c.isJoinColumn);
const displayField = matchingCol?.key || column.columnName;
return (
<div
key={colIndex}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-primary/10/50",
isActive && "bg-primary/10",
)}
onClick={() =>
toggleEntityJoinColumn(
joinTable.tableName,
sourceColumn,
column.columnName,
column.columnLabel,
displayField,
column.inputType || column.dataType
)
}
>
<Checkbox
checked={isActive}
className="pointer-events-none h-3.5 w-3.5"
/>
<Link2 className="h-3 w-3 flex-shrink-0 text-primary" />
<span className="truncate text-xs">{column.columnLabel}</span>
<span className="ml-auto text-[10px] text-primary/80">
{column.inputType || column.dataType}
</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 선택된 컬럼 상세 설정 - 🆕 모든 컬럼 통합, 순서 변경 가능 */}
{config.columns.length > 0 && (
<>
<Separator />
<div className="space-y-2">
<Label className="text-xs font-medium">
({config.columns.length})
<span className="text-muted-foreground ml-2 font-normal"> </span>
</Label>
<div className="max-h-48 space-y-1 overflow-y-auto">
{config.columns.map((col, index) => (
<div key={col.key} className="space-y-1">
{/* 컬럼 헤더 (드래그 가능) */}
<div
className={cn(
"flex items-center gap-2 rounded-md border p-2",
(col.isSourceDisplay || col.isJoinColumn) ? "border-primary/20 bg-primary/10/50" : "border-border bg-muted/30",
col.hidden && "opacity-50",
)}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("columnIndex", String(index));
}}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10);
if (fromIndex !== index) {
const newColumns = [...config.columns];
const [movedCol] = newColumns.splice(fromIndex, 1);
newColumns.splice(index, 0, movedCol);
updateConfig({ columns: newColumns });
}
}}
>
<GripVertical className="text-muted-foreground h-3 w-3 cursor-grab flex-shrink-0" />
{/* 확장/축소 버튼 (입력 컬럼만) */}
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => setExpandedColumn(expandedColumn === col.key ? null : col.key)}
className="p-0.5 hover:bg-muted/80 rounded"
>
{expandedColumn === col.key ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
)}
{col.isSourceDisplay ? (
<Link2 className="text-primary h-3 w-3 flex-shrink-0" title="소스 표시 (읽기 전용)" />
) : col.isJoinColumn ? (
<Link2 className="text-amber-500 h-3 w-3 flex-shrink-0" title="Entity 조인 (읽기 전용)" />
) : (
<Database className="text-muted-foreground h-3 w-3 flex-shrink-0" />
)}
<Input
value={col.title}
onChange={(e) => updateColumnProp(col.key, "title", e.target.value)}
placeholder="제목"
className="h-6 flex-1 text-xs"
/>
{/* 히든 토글 (입력 컬럼만) */}
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "hidden", !col.hidden)}
className={cn(
"p-1 rounded hover:bg-muted/80",
col.hidden ? "text-muted-foreground/70" : "text-muted-foreground",
)}
title={col.hidden ? "히든 (저장만 됨)" : "표시됨"}
>
{col.hidden ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</button>
)}
{/* 자동입력 표시 아이콘 */}
{(!col.isSourceDisplay && !col.isJoinColumn) && col.autoFill?.type && col.autoFill.type !== "none" && (
<Wand2 className="h-3 w-3 text-purple-500 flex-shrink-0" title="자동 입력" />
)}
{/* 편집 가능 토글 */}
{(!col.isSourceDisplay && !col.isJoinColumn) && (
<button
type="button"
onClick={() => updateColumnProp(col.key, "editable", !(col.editable ?? true))}
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors",
(col.editable ?? true)
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-muted text-muted-foreground dark:bg-foreground/90 dark:text-muted-foreground/70"
)}
title={(col.editable ?? true) ? "편집 가능 (클릭하여 읽기 전용으로 변경)" : "읽기 전용 (클릭하여 편집 가능으로 변경)"}
>
{(col.editable ?? true) ? "편집" : "읽기"}
</button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
if (col.isSourceDisplay) {
toggleSourceDisplayColumn({ columnName: col.key, displayName: col.title });
} else if (col.isJoinColumn) {
const newColumns = config.columns.filter(c => c.key !== col.key);
const newEntityJoins = config.entityJoins?.map(join => ({
...join,
columns: join.columns.filter(c => c.displayField !== col.key)
})).filter(join => join.columns.length > 0);
updateConfig({ columns: newColumns, entityJoins: newEntityJoins });
} else {
toggleInputColumn({ columnName: col.key, displayName: col.title });
}
}}
className="text-destructive h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 확장된 상세 설정 (입력 컬럼만) */}
{(!col.isSourceDisplay && !col.isJoinColumn) && expandedColumn === col.key && (
<div className="ml-6 space-y-2 rounded-md border border-dashed border-input bg-muted p-2">
{/* 자동 입력 설정 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={col.autoFill?.type || "none"}
onValueChange={(value) => {
const autoFill = value === "none" ? undefined : { type: value as any };
updateColumnProp(col.key, "autoFill", autoFill);
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{autoFillOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 채번 규칙 선택 */}
{col.autoFill?.type === "numbering" && (
<div className="space-y-2">
{/* 대상 메뉴 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => {
const menuObjidNum = parseInt(value);
setSelectedMenuObjid(menuObjidNum);
// 컬럼 설정에도 저장하여 유지
updateColumnProp(col.key, "autoFill", {
...col.autoFill,
selectedMenuObjid: menuObjidNum,
numberingRuleId: undefined, // 메뉴 변경 시 규칙 초기화
});
}}
disabled={loadingMenus}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
) : (
parentMenus.map((menu) => (
<SelectItem key={menu.objid} value={menu.objid.toString()}>
{menu.menu_name_kor}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
( )
</p>
</div>
{/* 채번 규칙 선택 (메뉴 선택 후) */}
{selectedMenuObjid ? (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground">
<span className="text-destructive">*</span>
</Label>
{loadingNumberingRules ? (
<p className="text-[10px] text-muted-foreground/70"> ...</p>
) : numberingRules.length === 0 ? (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-[10px] text-amber-800">
</div>
) : (
<Select
value={col.autoFill?.numberingRuleId || ""}
onValueChange={(value) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
selectedMenuObjid,
numberingRuleId: value,
})}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="채번 규칙 선택" />
</SelectTrigger>
<SelectContent>
{numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId} className="text-xs">
{rule.ruleName}
{rule.description && (
<span className="text-muted-foreground ml-2">
- {rule.description}
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{col.autoFill?.numberingRuleId && (
<p className="text-[10px] text-emerald-600">
API를 .
</p>
)}
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-[10px] text-amber-800">
</div>
)}
</div>
)}
{/* 메인 폼에서 복사 설정 */}
{col.autoFill?.type === "fromMainForm" && (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={col.autoFill?.sourceField || ""}
onChange={(e) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
sourceField: e.target.value,
})}
placeholder="order_no"
className="h-6 text-xs"
/>
</div>
)}
{/* 고정값 설정 */}
{col.autoFill?.type === "fixed" && (
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={String(col.autoFill?.fixedValue || "")}
onChange={(e) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
fixedValue: e.target.value,
})}
placeholder="고정값 입력"
className="h-6 text-xs"
/>
</div>
)}
{/* 부모채번+순번 설정 */}
{col.autoFill?.type === "parentSequence" && (
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={col.autoFill?.parentField || ""}
onChange={(e) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
parentField: e.target.value,
})}
placeholder="work_order_no"
className="h-6 text-xs"
/>
<p className="text-[10px] text-muted-foreground/70"> </p>
</div>
<div className="flex gap-2">
<div className="flex-1 space-y-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
value={col.autoFill?.separator ?? "-"}
onChange={(e) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
separator: e.target.value,
})}
placeholder="-"
className="h-6 text-xs"
/>
</div>
<div className="flex-1 space-y-1">
<Label className="text-[10px] text-muted-foreground"> 릿</Label>
<Input
type="number"
min={1}
max={5}
value={col.autoFill?.sequenceLength ?? 2}
onChange={(e) => updateColumnProp(col.key, "autoFill", {
...col.autoFill,
sequenceLength: parseInt(e.target.value) || 2,
})}
className="h-6 text-xs"
/>
</div>
</div>
<p className="text-[10px] text-emerald-600">
예시: WO-20260223-005{col.autoFill?.separator ?? "-"}{String(1).padStart(col.autoFill?.sequenceLength ?? 2, "0")}
</p>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
</>
)}
{/* 계산 규칙 */}
{(isModalMode || isInlineMode) && config.columns.length > 0 && (
<>
<Separator />
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium"> </Label>
<Button type="button" variant="outline" size="sm" onClick={addCalculationRule} className="h-6 text-xs">
<Calculator className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{calculationRules.map((rule) => (
<div key={rule.id} className="space-y-1 rounded border p-1.5">
<div className="flex items-center gap-1">
<Select
value={rule.targetColumn}
onValueChange={(value) => updateCalculationRule(rule.id, "targetColumn", value)}
>
<SelectTrigger className="h-6 w-20 text-[10px]">
<SelectValue placeholder="결과" />
</SelectTrigger>
<SelectContent>
{config.columns.filter(col => !col.isSourceDisplay).map((col) => (
<SelectItem key={col.key} value={col.key} className="text-xs">
{col.title || col.key}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[10px]">=</span>
<Input
value={rule.formula}
onChange={(e) => updateCalculationRule(rule.id, "formula", e.target.value)}
placeholder="컬럼 클릭 또는 직접 입력"
className="h-6 flex-1 font-mono text-[10px]"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeCalculationRule(rule.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 한글 수식 미리보기 */}
{rule.formula && (
<p className="truncate rounded bg-muted/50 px-1.5 py-0.5 text-[10px] text-muted-foreground">
{config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)}
</p>
)}
{/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */}
<div className="flex flex-wrap gap-0.5">
{config.columns
.filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="secondary"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 px-1 text-[9px]"
>
{col.title || col.key}
</Button>
))}
{config.columns
.filter(col => col.isSourceDisplay)
.map((col) => (
<Button
key={col.key}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, col.key)}
className="h-4 border-dashed px-1 text-[9px] text-primary"
title="품목 정보 컬럼"
>
{col.title || col.key}
</Button>
))}
{["+", "-", "*", "/", "(", ")"].map((op) => (
<Button
key={op}
type="button"
variant="outline"
size="sm"
onClick={() => insertColumnToFormula(rule.id, op)}
className="h-4 w-4 p-0 font-mono text-[9px]"
>
{op}
</Button>
))}
</div>
</div>
))}
{calculationRules.length === 0 && (
<p className="text-muted-foreground py-1 text-center text-[10px]">
</p>
)}
</div>
</div>
</>
)}
</TabsContent>
</Tabs>
</div>
);
};
V2RepeaterConfigPanel.displayName = "V2RepeaterConfigPanel";
export default V2RepeaterConfigPanel;