리피터 케이블 설정 구현

This commit is contained in:
kjs 2026-01-15 15:17:52 +09:00
parent bed7f5f5c4
commit e168753d87
8 changed files with 893 additions and 116 deletions

View File

@ -13,15 +13,238 @@ alwaysApply: false
## 목차 ## 목차
1. [엔티티 조인 컬럼 활용 (필수)](#1-엔티티-조인-컬럼-활용-필수) 1. [컴포넌트별 테이블 설정 (핵심 원칙)](#1-컴포넌트별-테이블-설정-핵심-원칙)
2. [폼 데이터 관리](#2-폼-데이터-관리) 2. [엔티티 조인 컬럼 활용 (필수)](#2-엔티티-조인-컬럼-활용-필수)
3. [다국어 지원](#3-다국어-지원) 3. [폼 데이터 관리](#3-폼-데이터-관리)
4. [컬럼 설정 패널 구현](#4-컬럼-설정-패널-구현) 4. [다국어 지원](#4-다국어-지원)
5. [체크리스트](#5-체크리스트) 5. [컬럼 설정 패널 구현](#5-컬럼-설정-패널-구현)
6. [체크리스트](#6-체크리스트)
--- ---
## 1. 엔티티 조인 컬럼 활용 (필수) ## 1. 컴포넌트별 테이블 설정 (핵심 원칙)
### 핵심 원칙
**하나의 화면에서 여러 테이블을 다룰 수 있습니다.**
화면 생성 시 "메인 테이블"을 필수로 지정하지 않으며, 컴포넌트별로 사용할 테이블을 지정할 수 있습니다.
### 왜 필요한가?
일반적인 ERP 화면에서는 여러 테이블이 동시에 필요합니다:
| 예시: 입고 화면 | 테이블 | 용도 |
| --------------- | ----------------------- | ------------------------------- |
| 메인 폼 | `receiving_mng` | 입고 마스터 정보 입력/저장 |
| 조회 리스트 | `purchase_order_detail` | 발주 상세 목록 조회 (읽기 전용) |
| 입력 리피터 | `receiving_detail` | 입고 상세 항목 입력/저장 |
### 컴포넌트 설정 패턴
#### 1. 테이블 리스트 (조회용)
```typescript
interface TableListConfig {
// 조회용 테이블 (화면 메인 테이블과 다를 수 있음)
customTableName?: string; // 사용할 테이블명
useCustomTable?: boolean; // true: customTableName 사용
isReadOnly?: boolean; // true: 조회만, 저장 안 함
}
```
#### 2. 리피터 (입력/저장용)
```typescript
interface UnifiedRepeaterConfig {
// 저장 대상 테이블 (화면 메인 테이블과 다를 수 있음)
mainTableName?: string; // 저장할 테이블명
useCustomTable?: boolean; // true: mainTableName 사용
// FK 자동 연결 (마스터-디테일 관계)
foreignKeyColumn?: string; // 이 테이블의 FK 컬럼 (예: receiving_id)
foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼 (예: id)
}
```
### 저장 테이블 설정 UI 표준
리피터 등 저장 기능이 있는 컴포넌트의 ConfigPanel에서:
```tsx
// 1. 테이블 선택 Combobox
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{selectedTableName || "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandList>
{/* 그룹 1: 현재 화면 테이블 (기본) */}
<CommandGroup heading="기본">
<CommandItem value={currentTableName}>
<Database className="h-3 w-3 text-blue-500" />
{currentTableName}
</CommandItem>
</CommandGroup>
{/* 그룹 2: 연관 테이블 (FK 자동 설정) */}
{relatedTables.length > 0 && (
<CommandGroup heading="연관 테이블 (FK 자동 설정)">
{relatedTables.map((table) => (
<CommandItem key={table.tableName} value={table.tableName}>
<Link2 className="h-3 w-3 text-green-500" />
{table.tableName}
<span className="text-xs text-muted-foreground ml-auto">
FK: {table.foreignKeyColumn}
</span>
</CommandItem>
))}
</CommandGroup>
)}
{/* 그룹 3: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{allTables.map((table) => (
<CommandItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>;
// 2. 연관 테이블 선택 시 FK/PK 자동 설정
const handleSaveTableSelect = (tableName: string) => {
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: undefined,
});
}
};
```
### 연관 테이블 조회 API
엔티티 관계에서 현재 테이블을 참조하는 테이블 목록을 조회합니다:
```typescript
// API 호출
const response = await apiClient.get(
`/api/table-management/columns/${currentTableName}/referenced-by`
);
// 응답
{
success: true,
data: [
{
tableName: "receiving_detail", // 참조하는 테이블
columnName: "receiving_id", // FK 컬럼
referenceColumn: "id", // 참조되는 컬럼 (PK)
},
// ...
]
}
```
### FK 자동 연결 동작
마스터 저장 후 디테일 저장 시 FK가 자동으로 설정됩니다:
```typescript
// 1. 마스터 저장 이벤트 발생 (ButtonConfigPanel에서)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
masterRecordId: savedId, // 마스터 테이블에 저장된 ID
tableName: "receiving_mng",
mainFormData: formData,
},
})
);
// 2. 리피터에서 이벤트 수신 및 FK 설정
useEffect(() => {
const handleSaveEvent = (event: CustomEvent) => {
const { masterRecordId } = event.detail;
if (config.foreignKeyColumn && masterRecordId) {
// 모든 행에 FK 값 자동 설정
const updatedRows = rows.map((row) => ({
...row,
[config.foreignKeyColumn]: masterRecordId,
}));
// 저장 실행
saveRows(updatedRows);
}
};
window.addEventListener("repeaterSave", handleSaveEvent);
return () => window.removeEventListener("repeaterSave", handleSaveEvent);
}, [config.foreignKeyColumn, rows]);
```
### 저장 테이블 변경 시 컬럼 자동 로드
저장 테이블이 변경되면 해당 테이블의 컬럼이 자동으로 로드됩니다:
```typescript
// 저장 테이블 또는 화면 테이블 기준으로 컬럼 로드
const targetTableForColumns =
config.useCustomTable && config.mainTableName
? config.mainTableName
: currentTableName;
useEffect(() => {
const loadColumns = async () => {
if (!targetTableForColumns) return;
const columnData = await tableTypeApi.getColumns(targetTableForColumns);
setCurrentTableColumns(columnData);
};
loadColumns();
}, [targetTableForColumns]);
```
### 요약
| 상황 | 처리 방법 |
| ------------------------------------- | ----------------------------------- |
| 화면과 같은 테이블에 저장 | `useCustomTable: false` (기본값) |
| 다른 테이블에 저장 + 엔티티 관계 있음 | 연관 테이블 선택 → FK/PK 자동 설정 |
| 다른 테이블에 저장 + 엔티티 관계 없음 | 전체 테이블에서 선택 → FK 수동 입력 |
| 조회만 (저장 안 함) | `isReadOnly: true` 설정 |
---
## 2. 엔티티 조인 컬럼 활용 (필수)
### 핵심 원칙 ### 핵심 원칙
@ -283,7 +506,7 @@ const getEntityJoinValue = (item: any, columnName: string): any => {
--- ---
## 2. 폼 데이터 관리 ## 3. 폼 데이터 관리
### 통합 폼 시스템 (UnifiedFormContext) ### 통합 폼 시스템 (UnifiedFormContext)
@ -368,7 +591,7 @@ const handleChange = useCallback(
--- ---
## 3. 다국어 지원 ## 4. 다국어 지원
### 타입 정의 시 다국어 필드 추가 ### 타입 정의 시 다국어 필드 추가
@ -534,7 +757,7 @@ if (comp.componentType === "my-new-component") {
--- ---
## 4. 컬럼 설정 패널 구현 ## 5. 컬럼 설정 패널 구현
### 필수 구조 ### 필수 구조
@ -639,10 +862,19 @@ export const MyComponentConfigPanel: React.FC<ConfigPanelProps> = ({
--- ---
## 5. 체크리스트 ## 6. 체크리스트
새 컴포넌트 개발 시 다음 항목을 확인하세요: 새 컴포넌트 개발 시 다음 항목을 확인하세요:
### 컴포넌트별 테이블 설정 (핵심)
- [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인
- [ ] `useCustomTable`, `mainTableName` (또는 `customTableName`) 설정 지원
- [ ] 연관 테이블 선택 시 FK/PK 자동 설정 (`/api/table-management/columns/:tableName/referenced-by` API 활용)
- [ ] 저장 테이블 변경 시 해당 테이블의 컬럼 자동 로드
- [ ] 테이블 선택 UI는 Combobox 형태로 그룹별 표시 (기본/연관/전체)
- [ ] FK 자동 연결: `repeaterSave` 이벤트에서 `masterRecordId` 수신 및 적용
### 엔티티 조인 (필수) ### 엔티티 조인 (필수)
- [ ] `entityJoinApi.getEntityJoinColumns()` 호출하여 조인 컬럼 로드 - [ ] `entityJoinApi.getEntityJoinColumns()` 호출하여 조인 컬럼 로드

View File

@ -97,10 +97,10 @@ export async function getColumnList(
} }
const tableManagementService = new TableManagementService(); const tableManagementService = new TableManagementService();
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시 // 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
const bustCache = !!req.query._t; const bustCache = !!req.query._t;
const result = await tableManagementService.getColumnList( const result = await tableManagementService.getColumnList(
tableName, tableName,
parseInt(page as string), parseInt(page as string),
@ -2376,3 +2376,90 @@ export async function getTableEntityRelations(
res.status(500).json(response); res.status(500).json(response);
} }
} }
/**
* (FK로 )
* GET /api/table-management/columns/:tableName/referenced-by
*
* column_labels에서 reference_table이
* FK .
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "tableName 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "tableName 경로 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
// column_labels에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
const sqlQuery = `
SELECT DISTINCT
cl.table_name,
cl.column_name,
cl.column_label,
cl.reference_table,
cl.reference_column,
cl.display_column,
cl.table_name as table_label
FROM column_labels cl
WHERE cl.reference_table = $1
AND cl.input_type = 'entity'
ORDER BY cl.table_name, cl.column_name
`;
const result = await query(sqlQuery, [tableName]);
const referencedByTables = result.map((row: any) => ({
tableName: row.table_name,
tableLabel: row.table_label,
columnName: row.column_name,
columnLabel: row.column_label,
referenceTable: row.reference_table,
referenceColumn: row.reference_column || "id",
displayColumn: row.display_column,
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견`
);
const response: ApiResponse<any> = {
success: true,
message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`,
data: referencedByTables,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 참조 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 참조 관계 조회 중 오류가 발생했습니다.",
error: {
code: "REFERENCED_BY_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -26,6 +26,7 @@ import {
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -54,6 +55,14 @@ router.get("/tables/entity-relations", getTableEntityRelations);
*/ */
router.get("/tables/:tableName/columns", getColumnList); router.get("/tables/:tableName/columns", getColumnList);
/**
*
* GET /api/table-management/columns/:tableName/referenced-by
*
* FK
*/
router.get("/columns/:tableName/referenced-by", getReferencedByTables);
/** /**
* *
* PUT /api/table-management/tables/:tableName/label * PUT /api/table-management/tables/:tableName/label

View File

@ -185,16 +185,18 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
} }
}, [open, screenCode]); }, [open, screenCode]);
// 테이블 선택은 선택 사항 - 컴포넌트별로 테이블을 설정할 수 있음
const isValid = useMemo(() => { const isValid = useMemo(() => {
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0; const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
if (dataSourceType === "database") { if (dataSourceType === "database") {
return baseValid && tableName.trim().length > 0; // 테이블 선택은 선택 사항 (비워두면 컴포넌트별로 테이블 설정)
return baseValid;
} else { } else {
// REST API: 연결 선택 필수 // REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null; return baseValid && selectedRestApiId !== null;
} }
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]); }, [screenName, screenCode, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용) // 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => { const filteredTables = useMemo(() => {
@ -230,8 +232,8 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}; };
if (dataSourceType === "database") { if (dataSourceType === "database") {
// 데이터베이스 소스 // 데이터베이스 소스 - 테이블 선택은 선택 사항
createData.tableName = tableName.trim(); createData.tableName = tableName.trim() || null; // 비어있으면 null
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external"; createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource); createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else { } else {
@ -507,7 +509,10 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
{/* 테이블 선택 (데이터베이스 모드일 때만) */} {/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && ( {dataSourceType === "database" && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tableName"> *</Label> <Label htmlFor="tableName"> ()</Label>
<p className="text-muted-foreground text-xs">
.
</p>
<Select <Select
value={tableName} value={tableName}
onValueChange={setTableName} onValueChange={setTableName}
@ -521,7 +526,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "테이블 선택하세요"} /> <SelectValue placeholder={loadingExternalTables ? "로딩 중..." : "(선택 사항) 기본 테이블 선택"} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="max-h-80"> <SelectContent className="max-h-80">
{/* 검색 입력 필드 */} {/* 검색 입력 필드 */}

View File

@ -1011,6 +1011,94 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
loadScreenDataSource(); loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]); }, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
// 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출
const handleTableSelect = useCallback(async (tableName: string) => {
console.log("📊 테이블 선택:", tableName);
try {
// 테이블 라벨 조회
const tableListResponse = await tableManagementApi.getTableList();
const currentTable =
tableListResponse.success && tableListResponse.data
? tableListResponse.data.find((t: any) => (t.tableName || t.table_name) === tableName)
: null;
const tableLabel = currentTable?.displayName || currentTable?.table_label || tableName;
// 테이블 컬럼 정보 조회
const columnsResponse = await tableTypeApi.getColumns(tableName, true);
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const inputType = col.inputType || col.input_type;
const widgetType = inputType || col.widgetType || col.widget_type || col.webType || col.web_type;
let detailSettings = col.detailSettings || col.detail_settings;
if (typeof detailSettings === "string") {
try {
detailSettings = JSON.parse(detailSettings);
} catch (e) {
detailSettings = {};
}
}
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type || col.dbType,
webType: col.webType || col.web_type,
input_type: inputType,
inputType: inputType,
widgetType,
isNullable: col.isNullable || col.is_nullable,
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
referenceTable: detailSettings?.referenceTable || col.referenceTable || col.reference_table,
referenceColumn: detailSettings?.referenceColumn || col.referenceColumn || col.reference_column,
displayColumn: detailSettings?.displayColumn || col.displayColumn || col.display_column,
detailSettings,
};
});
const tableInfo: TableInfo = {
tableName,
tableLabel,
columns,
};
setTables([tableInfo]);
toast.success(`테이블 "${tableLabel}" 선택됨`);
// 기존 테이블과 다른 테이블 선택 시, 기존 컴포넌트 중 다른 테이블 컬럼은 제거
if (tables.length > 0 && tables[0].tableName !== tableName) {
setLayout((prev) => {
const newComponents = prev.components.filter((comp) => {
// 테이블 컬럼 기반 컴포넌트인지 확인
if (comp.tableName && comp.tableName !== tableName) {
console.log("🗑️ 다른 테이블 컴포넌트 제거:", comp.tableName, comp.columnName);
return false;
}
return true;
});
if (newComponents.length < prev.components.length) {
toast.info(`이전 테이블(${tables[0].tableName})의 컴포넌트가 ${prev.components.length - newComponents.length}개 제거되었습니다.`);
}
return {
...prev,
components: newComponents,
};
});
}
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
}
}, [tables]);
// 화면 레이아웃 로드 // 화면 레이아웃 로드
useEffect(() => { useEffect(() => {
if (selectedScreen?.screenId) { if (selectedScreen?.screenId) {
@ -4418,8 +4506,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
e.dataTransfer.setData("application/json", JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}} }}
selectedTableName={selectedScreen.tableName} selectedTableName={selectedScreen?.tableName}
placedColumns={placedColumns} placedColumns={placedColumns}
onTableSelect={handleTableSelect}
showTableSelector={true}
/> />
</TabsContent> </TabsContent>

View File

@ -18,6 +18,9 @@ interface ComponentsPanelProps {
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void; onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
selectedTableName?: string; selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부 (기본: 테이블 없으면 표시)
} }
export function ComponentsPanel({ export function ComponentsPanel({
@ -28,6 +31,8 @@ export function ComponentsPanel({
onTableDragStart, onTableDragStart,
selectedTableName, selectedTableName,
placedColumns, placedColumns,
onTableSelect,
showTableSelector = true,
}: ComponentsPanelProps) { }: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@ -272,24 +277,16 @@ export function ComponentsPanel({
{/* 테이블 컬럼 탭 */} {/* 테이블 컬럼 탭 */}
<TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto"> <TabsContent value="tables" className="mt-0 flex-1 overflow-y-auto">
{tables.length > 0 && onTableDragStart ? ( <TablesPanel
<TablesPanel tables={tables}
tables={tables} searchTerm={searchTerm}
searchTerm={searchTerm} onSearchChange={onSearchChange || (() => {})}
onSearchChange={onSearchChange || (() => {})} onDragStart={onTableDragStart || (() => {})}
onDragStart={onTableDragStart} selectedTableName={selectedTableName}
selectedTableName={selectedTableName} placedColumns={placedColumns}
placedColumns={placedColumns} onTableSelect={onTableSelect}
/> showTableSelector={showTableSelector}
) : ( />
<div className="flex h-32 items-center justify-center text-center">
<div className="p-6">
<Database className="text-muted-foreground/40 mx-auto mb-2 h-10 w-10" />
<p className="text-muted-foreground text-xs font-medium"> </p>
<p className="text-muted-foreground/60 mt-1 text-xs"> </p>
</div>
</div>
)}
</TabsContent> </TabsContent>
{/* 컴포넌트 탭 */} {/* 컴포넌트 탭 */}

View File

@ -1,7 +1,15 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Database, Database,
Type, Type,
@ -16,9 +24,13 @@ import {
Link2, Link2,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Plus,
Search,
X,
} from "lucide-react"; } from "lucide-react";
import { TableInfo, WebType } from "@/types/screen"; import { TableInfo, WebType } from "@/types/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableManagementApi } from "@/lib/api/tableManagement";
interface EntityJoinColumn { interface EntityJoinColumn {
columnName: string; columnName: string;
@ -41,6 +53,9 @@ interface TablesPanelProps {
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void; onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
selectedTableName?: string; selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식) placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합 (tableName.columnName 형식)
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부
} }
// 위젯 타입별 아이콘 // 위젯 타입별 아이콘
@ -81,12 +96,20 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
searchTerm, searchTerm,
onDragStart, onDragStart,
placedColumns = new Set(), placedColumns = new Set(),
onTableSelect,
showTableSelector = false,
}) => { }) => {
// 엔티티 조인 컬럼 상태 // 엔티티 조인 컬럼 상태
const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]); const [entityJoinTables, setEntityJoinTables] = useState<EntityJoinTable[]>([]);
const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); const [loadingEntityJoins, setLoadingEntityJoins] = useState(false);
const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set()); const [expandedJoinTables, setExpandedJoinTables] = useState<Set<string>>(new Set());
// 전체 테이블 목록 (테이블 선택용)
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingAllTables, setLoadingAllTables] = useState(false);
const [tableSearchTerm, setTableSearchTerm] = useState("");
const [showTableSelectDropdown, setShowTableSelectDropdown] = useState(false);
// 시스템 컬럼 목록 (숨김 처리) // 시스템 컬럼 목록 (숨김 처리)
const systemColumns = new Set([ const systemColumns = new Set([
"id", "id",
@ -96,6 +119,42 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
"company_code", "company_code",
]); ]);
// 전체 테이블 목록 로드
const loadAllTables = useCallback(async () => {
if (allTables.length > 0) return; // 이미 로드됨
setLoadingAllTables(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 {
setLoadingAllTables(false);
}
}, [allTables.length]);
// 테이블 선택 시 호출
const handleTableSelect = (tableName: string) => {
setShowTableSelectDropdown(false);
setTableSearchTerm("");
onTableSelect?.(tableName);
};
// 필터링된 테이블 목록
const filteredAllTables = tableSearchTerm
? allTables.filter(
(t) =>
t.tableName.toLowerCase().includes(tableSearchTerm.toLowerCase()) ||
t.displayName.toLowerCase().includes(tableSearchTerm.toLowerCase())
)
: allTables;
// 메인 테이블명 추출 // 메인 테이블명 추출
const mainTableName = tables[0]?.tableName; const mainTableName = tables[0]?.tableName;
@ -209,6 +268,91 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 테이블 선택 버튼 (메인 테이블이 없을 때 또는 showTableSelector가 true일 때) */}
{(showTableSelector || tables.length === 0) && (
<div className="border-b p-3">
<div className="relative">
<Button
variant="outline"
size="sm"
className="w-full justify-between"
onClick={() => {
setShowTableSelectDropdown(!showTableSelectDropdown);
if (!showTableSelectDropdown) {
loadAllTables();
}
}}
>
<span className="flex items-center gap-2">
<Plus className="h-3.5 w-3.5" />
{tables.length > 0 ? "테이블 추가/변경" : "테이블 선택"}
</span>
<ChevronDown className="h-4 w-4" />
</Button>
{/* 드롭다운 */}
{showTableSelectDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-lg">
{/* 검색 */}
<div className="border-b p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="테이블 검색..."
value={tableSearchTerm}
onChange={(e) => setTableSearchTerm(e.target.value)}
autoFocus
className="w-full rounded-md border px-8 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{tableSearchTerm && (
<button
onClick={() => setTableSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="h-3.5 w-3.5 text-gray-400" />
</button>
)}
</div>
</div>
{/* 테이블 목록 */}
<div className="max-h-60 overflow-y-auto">
{loadingAllTables ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : filteredAllTables.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
{tableSearchTerm ? "검색 결과 없음" : "테이블 없음"}
</div>
) : (
filteredAllTables.map((t) => (
<button
key={t.tableName}
onClick={() => handleTableSelect(t.tableName)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-100"
>
<Database className="h-3.5 w-3.5 text-blue-600" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{t.displayName}</div>
<div className="truncate text-xs text-gray-500">{t.tableName}</div>
</div>
</button>
))
)}
</div>
</div>
)}
</div>
{/* 현재 테이블 정보 */}
{tables.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
: {tables[0]?.tableLabel || tables[0]?.tableName}
</div>
)}
</div>
)}
{/* 테이블과 컬럼 평면 목록 */} {/* 테이블과 컬럼 평면 목록 */}
<div className="flex-1 overflow-y-auto p-3"> <div className="flex-1 overflow-y-auto p-3">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -29,8 +29,24 @@ import {
Eye, Eye,
EyeOff, EyeOff,
Wand2, Wand2,
Check,
ChevronsUpDown,
} from "lucide-react"; } 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 { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule"; import { getAvailableNumberingRules, getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule"; import { NumberingRuleConfig } from "@/types/numbering-rule";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -42,6 +58,14 @@ import {
MODAL_SIZE_OPTIONS, MODAL_SIZE_OPTIONS,
} from "@/types/unified-repeater"; } from "@/types/unified-repeater";
// 테이블 엔티티 관계 정보
interface TableRelation {
tableName: string;
tableLabel: string;
foreignKeyColumn: string; // 저장 테이블의 FK 컬럼
referenceColumn: string; // 마스터 테이블의 PK 컬럼
}
interface UnifiedRepeaterConfigPanelProps { interface UnifiedRepeaterConfigPanelProps {
config: UnifiedRepeaterConfig; config: UnifiedRepeaterConfig;
onChange: (config: UnifiedRepeaterConfig) => void; onChange: (config: UnifiedRepeaterConfig) => void;
@ -117,6 +141,13 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
const [loadingColumns, setLoadingColumns] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false);
const [loadingSourceColumns, setLoadingSourceColumns] = 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 열림 상태
// 🆕 확장된 컬럼 (상세 설정 표시용) // 🆕 확장된 컬럼 (상세 설정 표시용)
const [expandedColumn, setExpandedColumn] = useState<string | null>(null); const [expandedColumn, setExpandedColumn] = useState<string | null>(null);
@ -199,6 +230,60 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
loadNumberingRules(); loadNumberingRules();
}, [selectedMenuObjid]); }, [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();
}, []);
// 현재 테이블과 연관된 테이블 목록 로드 (엔티티 관계 기반)
useEffect(() => {
const loadRelatedTables = async () => {
if (!currentTableName) {
setRelatedTables([]);
return;
}
setLoadingRelations(true);
try {
// column_labels에서 현재 테이블을 reference_table로 참조하는 테이블 찾기
const { apiClient } = await import("@/lib/api/client");
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 컬럼
}));
setRelatedTables(relations);
}
} catch (error) {
console.error("연관 테이블 로드 실패:", error);
setRelatedTables([]);
} finally {
setLoadingRelations(false);
}
};
loadRelatedTables();
}, [currentTableName]);
// 설정 업데이트 헬퍼 // 설정 업데이트 헬퍼
const updateConfig = useCallback( const updateConfig = useCallback(
(updates: Partial<UnifiedRepeaterConfig>) => { (updates: Partial<UnifiedRepeaterConfig>) => {
@ -234,10 +319,50 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
[config.features, updateConfig], [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(() => { useEffect(() => {
const loadCurrentTableColumns = async () => { const loadCurrentTableColumns = async () => {
if (!currentTableName) { if (!targetTableForColumns) {
setCurrentTableColumns([]); setCurrentTableColumns([]);
setEntityColumns([]); setEntityColumns([]);
return; return;
@ -245,7 +370,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
setLoadingColumns(true); setLoadingColumns(true);
try { try {
const columnData = await tableTypeApi.getColumns(currentTableName); const columnData = await tableTypeApi.getColumns(targetTableForColumns);
const cols: ColumnOption[] = []; const cols: ColumnOption[] = [];
const entityCols: EntityColumnOption[] = []; const entityCols: EntityColumnOption[] = [];
@ -297,7 +422,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
setCurrentTableColumns(cols); setCurrentTableColumns(cols);
setEntityColumns(entityCols); setEntityColumns(entityCols);
} catch (error) { } catch (error) {
console.error("현재 테이블 컬럼 로드 실패:", error); console.error("저장 테이블 컬럼 로드 실패:", error);
setCurrentTableColumns([]); setCurrentTableColumns([]);
setEntityColumns([]); setEntityColumns([]);
} finally { } finally {
@ -305,7 +430,7 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
} }
}; };
loadCurrentTableColumns(); loadCurrentTableColumns();
}, [currentTableName]); }, [targetTableForColumns]);
// 소스(엔티티) 테이블 컬럼 로드 (모달 모드일 때) // 소스(엔티티) 테이블 컬럼 로드 (모달 모드일 때)
useEffect(() => { useEffect(() => {
@ -529,97 +654,185 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
<Separator /> <Separator />
{/* 저장 대상 테이블 설정 */} {/* 저장 대상 테이블 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs font-medium"> </Label> <Label className="text-xs font-medium"> </Label>
<p className="text-[10px] text-muted-foreground">
</p>
<div className="flex items-center space-x-2"> {/* 현재 선택된 테이블 표시 (기존 테이블 UI와 동일한 스타일) */}
<Checkbox <div className={cn(
id="useCustomTable" "rounded-lg border p-3",
checked={config.useCustomTable || false} config.useCustomTable && config.mainTableName
onCheckedChange={(checked) => { ? "border-orange-300 bg-orange-50"
if (!checked) { : "border-blue-300 bg-blue-50"
updateConfig({ )}>
useCustomTable: false, <div className="flex items-center gap-2">
mainTableName: undefined, <Database className={cn(
foreignKeyColumn: undefined, "h-4 w-4",
foreignKeySourceColumn: undefined, config.useCustomTable && config.mainTableName
}); ? "text-orange-600"
} else { : "text-blue-600"
updateConfig({ useCustomTable: true }); )} />
} <div className="flex-1">
}} <p className={cn(
/> "text-sm font-medium",
<label htmlFor="useCustomTable" className="text-xs"> </label> config.useCustomTable && config.mainTableName
? "text-orange-700"
: "text-blue-700"
)}>
{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-orange-600 mt-0.5">
FK: {config.foreignKeyColumn} {currentTableName}.{config.foreignKeySourceColumn || "id"}
</p>
)}
{!config.useCustomTable && currentTableName && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div>
</div>
</div> </div>
{config.useCustomTable && ( {/* 테이블 변경 Combobox */}
<div className="space-y-2 rounded border border-orange-200 bg-orange-50 p-2"> <Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
{/* 저장 테이블 선택 */} <PopoverTrigger asChild>
<div className="space-y-1"> <Button
<Label className="text-[10px]"> *</Label> variant="outline"
<Select role="combobox"
value={config.mainTableName || ""} aria-expanded={tableComboboxOpen}
onValueChange={(value) => updateConfig({ mainTableName: value })} disabled={loadingTables || loadingRelations}
> className="h-8 w-full justify-between text-xs"
<SelectTrigger className="h-7 text-xs"> >
<SelectValue placeholder="테이블 선택..." /> {loadingTables ? "로딩 중..." : "다른 테이블 선택..."}
</SelectTrigger> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
<SelectContent> </Button>
{currentTableColumns.length === 0 ? ( </PopoverTrigger>
<div className="p-2 text-xs text-gray-500"> ...</div> <PopoverContent
) : ( className="p-0"
<SelectItem value={currentTableName || ""} disabled> style={{ width: "var(--radix-popover-trigger-width)" }}
<span className="text-gray-400">( - )</span> align="start"
</SelectItem> >
)} <Command>
</SelectContent> <CommandInput placeholder="테이블 검색..." className="text-xs" />
</Select> <CommandList className="max-h-60">
<Input <CommandEmpty className="text-xs py-3 text-center">
value={config.mainTableName || ""} .
onChange={(e) => updateConfig({ mainTableName: e.target.value })} </CommandEmpty>
placeholder="테이블명 직접 입력 (예: receiving_detail)"
className="h-7 text-xs" {/* 현재 테이블 (기본) */}
/> {currentTableName && (
</div> <CommandGroup heading="기본">
<CommandItem
{/* FK 컬럼 설정 */} 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-blue-500" />
<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-orange-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-gray-400" />
<span>{table.displayName}</span>
</CommandItem>
))
}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* FK 직접 입력 (연관 테이블이 아닌 경우만) */}
{config.useCustomTable && config.mainTableName &&
!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">
. FK .
</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px]">FK ( )</Label> <Label className="text-[10px]">FK </Label>
<Input <Input
value={config.foreignKeyColumn || ""} value={config.foreignKeyColumn || ""}
onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })} onChange={(e) => updateConfig({ foreignKeyColumn: e.target.value })}
placeholder="예: receiving_id" placeholder="예: master_id"
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-[10px]">PK ( )</Label> <Label className="text-[10px]">PK </Label>
<Input <Input
value={config.foreignKeySourceColumn || "id"} value={config.foreignKeySourceColumn || "id"}
onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })} onChange={(e) => updateConfig({ foreignKeySourceColumn: e.target.value })}
placeholder="예: id" placeholder="id"
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
</div> </div>
{config.mainTableName && config.foreignKeyColumn && (
<div className="text-[10px] text-orange-700">
<strong> :</strong> {config.mainTableName}.{config.foreignKeyColumn}
{currentTableName}.{config.foreignKeySourceColumn || "id"} ( )
</div>
)}
</div>
)}
{!config.useCustomTable && (
<div className="text-[10px] text-muted-foreground">
현재: 화면 ({currentTableName || "미설정"})
</div> </div>
)} )}
</div> </div>
@ -809,10 +1022,10 @@ export const UnifiedRepeaterConfigPanel: React.FC<UnifiedRepeaterConfigPanelProp
</> </>
)} )}
{/* 현재 테이블 컬럼 (입력용) */} {/* 저장 테이블 컬럼 (입력용) */}
<div className="text-[10px] font-medium text-gray-600 mt-3 mb-1 flex items-center gap-1"> <div className="text-[10px] font-medium text-gray-600 mt-3 mb-1 flex items-center gap-1">
<Database className="h-3 w-3" /> <Database className="h-3 w-3" />
({currentTableName || "미선택"}) - ({targetTableForColumns || "미선택"}) -
</div> </div>
{loadingColumns ? ( {loadingColumns ? (
<p className="text-muted-foreground py-2 text-xs"> ...</p> <p className="text-muted-foreground py-2 text-xs"> ...</p>