diff --git a/PLAN.MD b/PLAN.MD new file mode 100644 index 00000000..7c3b1007 --- /dev/null +++ b/PLAN.MD @@ -0,0 +1,27 @@ +# 프로젝트: Digital Twin 에디터 안정화 + +## 개요 + +Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다. + +## 핵심 기능 + +1. `DigitalTwinEditor` 버그 수정 +2. 비동기 함수 입력값 유효성 검증 강화 +3. 외부 DB 연결 상태에 따른 방어 코드 추가 + +## 테스트 계획 + +### 1단계: 긴급 버그 수정 + +- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) +- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 + +### 2단계: 잠재적 문제 점검 + +- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 +- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 + +## 진행 상태 + +- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중 diff --git a/PROJECT_STATUS_2025_11_20.md b/PROJECT_STATUS_2025_11_20.md new file mode 100644 index 00000000..570dd789 --- /dev/null +++ b/PROJECT_STATUS_2025_11_20.md @@ -0,0 +1,57 @@ +# 프로젝트 진행 상황 (2025-11-20) + +## 작업 개요: 디지털 트윈 3D 야드 고도화 (동적 계층 구조) + +### 1. 핵심 변경 사항 +기존의 고정된 `Area` -> `Location` 2단계 구조를 유연한 **N-Level 동적 계층 구조**로 변경하고, 공간적 제약을 강화했습니다. + +### 2. 완료된 작업 + +#### 데이터베이스 +- **마이그레이션 실행**: `db/migrations/042_refactor_digital_twin_hierarchy.sql` +- **스키마 변경**: + - `digital_twin_layout` 테이블에 `hierarchy_config` (JSONB) 컬럼 추가 + - `digital_twin_objects` 테이블에 `hierarchy_level`, `parent_key`, `external_key` 컬럼 추가 + - 기존 하드코딩된 테이블 매핑 컬럼 제거 + +#### 백엔드 (Node.js) +- **API 추가/수정**: + - `POST /api/digital-twin/data/hierarchy`: 계층 설정에 따른 전체 데이터 조회 + - `POST /api/digital-twin/data/children`: 특정 부모의 하위 데이터 조회 + - 기존 레거시 API (`getWarehouses` 등) 호환성 유지 +- **컨트롤러 수정**: + - `digitalTwinDataController.ts`: 동적 쿼리 생성 로직 구현 + - `digitalTwinLayoutController.ts`: 레이아웃 저장/수정 시 `hierarchy_config` 및 객체 계층 정보 처리 + +#### 프론트엔드 (React) +- **신규 컴포넌트**: `HierarchyConfigPanel.tsx` + - 레벨 추가/삭제, 테이블 및 컬럼 매핑 설정 UI +- **유틸리티**: `spatialContainment.ts` + - `validateSpatialContainment`: 자식 객체가 부모 객체 내부에 있는지 검증 (AABB) + - `updateChildrenPositions`: 부모 이동 시 자식 객체 자동 이동 (그룹 이동) +- **에디터 통합 (`DigitalTwinEditor.tsx`)**: + - `HierarchyConfigPanel` 적용 + - 동적 데이터 로드 로직 구현 + - 3D 캔버스 드래그앤드롭 시 공간적 종속성 검증 적용 + - 객체 이동 시 그룹 이동 적용 + +### 3. 현재 상태 +- **백엔드 서버**: 재시작 완료, 정상 동작 중 (PostgreSQL 연결 이슈 해결됨) +- **DB**: 마이그레이션 스크립트 실행 완료 + +### 4. 다음 단계 (테스트 필요) +새로운 세션에서 다음 시나리오를 테스트해야 합니다: +1. **계층 설정**: 에디터에서 창고 -> 구역(Lv1) -> 위치(Lv2) 설정 및 매핑 저장 +2. **배치 검증**: + - 구역 배치 후, 위치를 구역 **내부**에 배치 (성공해야 함) + - 위치를 구역 **외부**에 배치 (실패해야 함) +3. **이동 검증**: 구역 이동 시 내부의 위치들도 같이 따라오는지 확인 + +### 5. 관련 파일 +- `frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/HierarchyConfigPanel.tsx` +- `frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts` +- `backend-node/src/controllers/digitalTwinDataController.ts` +- `backend-node/src/routes/digitalTwinRoutes.ts` +- `db/migrations/042_refactor_digital_twin_hierarchy.sql` + diff --git a/backend-node/src/controllers/digitalTwinDataController.ts b/backend-node/src/controllers/digitalTwinDataController.ts index 51dd85d8..80cb8ccd 100644 --- a/backend-node/src/controllers/digitalTwinDataController.ts +++ b/backend-node/src/controllers/digitalTwinDataController.ts @@ -36,7 +36,138 @@ export async function getExternalDbConnector(connectionId: number) { ); } -// 창고 목록 조회 (사용자 지정 테이블) +// 동적 계층 구조 데이터 조회 (범용) +export const getHierarchyData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig) { + return res.status(400).json({ + success: false, + message: "외부 DB 연결 ID와 계층 구조 설정이 필요합니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + const result: any = { + warehouse: null, + levels: [], + materials: [], + }; + + // 창고 데이터 조회 + if (config.warehouse) { + const warehouseQuery = `SELECT * FROM ${config.warehouse.tableName} LIMIT 100`; + const warehouseResult = await connector.executeQuery(warehouseQuery); + result.warehouse = warehouseResult.rows; + } + + // 각 레벨 데이터 조회 + if (config.levels && Array.isArray(config.levels)) { + for (const level of config.levels) { + const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`; + const levelResult = await connector.executeQuery(levelQuery); + + result.levels.push({ + level: level.level, + name: level.name, + data: levelResult.rows, + }); + } + } + + // 자재 데이터 조회 (개수만) + if (config.material) { + const materialQuery = ` + SELECT + ${config.material.locationKeyColumn} as location_key, + COUNT(*) as count + FROM ${config.material.tableName} + GROUP BY ${config.material.locationKeyColumn} + `; + const materialResult = await connector.executeQuery(materialQuery); + result.materials = materialResult.rows; + } + + logger.info("동적 계층 구조 데이터 조회", { + externalDbConnectionId, + warehouseCount: result.warehouse?.length || 0, + levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })), + }); + + return res.json({ + success: true, + data: result, + }); + } catch (error: any) { + logger.error("동적 계층 구조 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 특정 레벨의 하위 데이터 조회 +export const getChildrenData = async (req: Request, res: Response): Promise => { + try { + const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body; + + if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) { + return res.status(400).json({ + success: false, + message: "필수 파라미터가 누락되었습니다.", + }); + } + + const connector = await getExternalDbConnector(Number(externalDbConnectionId)); + const config = JSON.parse(hierarchyConfig); + + // 다음 레벨 찾기 + const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1); + + if (!nextLevel) { + return res.json({ + success: true, + data: [], + message: "하위 레벨이 없습니다.", + }); + } + + // 하위 데이터 조회 + const query = ` + SELECT * FROM ${nextLevel.tableName} + WHERE ${nextLevel.parentKeyColumn} = '${parentKey}' + LIMIT 1000 + `; + + const result = await connector.executeQuery(query); + + logger.info("하위 데이터 조회", { + externalDbConnectionId, + parentLevel, + parentKey, + count: result.rows.length, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("하위 데이터 조회 실패", error); + return res.status(500).json({ + success: false, + message: "하위 데이터 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +}; + +// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getWarehouses = async (req: Request, res: Response): Promise => { try { const { externalDbConnectionId, tableName } = req.query; @@ -83,32 +214,29 @@ export const getWarehouses = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, warehouseKey } = req.query; + const { externalDbConnectionId, warehouseKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !warehouseKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (warehouseKey) { - query += ` WHERE WAREKEY = '${warehouseKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE WAREKEY = '${warehouseKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Area 목록 조회", { + logger.info("구역 목록 조회", { externalDbConnectionId, tableName, warehouseKey, @@ -120,41 +248,38 @@ export const getAreas = async (req: Request, res: Response): Promise = data: result.rows, }); } catch (error: any) { - logger.error("Area 목록 조회 실패", error); + logger.error("구역 목록 조회 실패", error); return res.status(500).json({ success: false, - message: "Area 목록 조회 중 오류가 발생했습니다.", + message: "구역 목록 조회 중 오류가 발생했습니다.", error: error.message, }); } }; -// Location 목록 조회 (사용자 지정 테이블) +// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지 export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, areaKey } = req.query; + const { externalDbConnectionId, areaKey, tableName } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !areaKey || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (areaKey) { - query += ` WHERE AREAKEY = '${areaKey}'`; - } - - query += ` LIMIT 1000`; + const query = ` + SELECT * FROM ${tableName} + WHERE AREAKEY = '${areaKey}' + LIMIT 1000 + `; const result = await connector.executeQuery(query); - logger.info("Location 목록 조회", { + logger.info("위치 목록 조회", { externalDbConnectionId, tableName, areaKey, @@ -166,37 +291,46 @@ export const getLocations = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKey } = req.query; + const { + externalDbConnectionId, + locaKey, + tableName, + keyColumn, + locationKeyColumn, + layerColumn + } = req.query; - if (!externalDbConnectionId || !tableName) { + if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID와 테이블명이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // 테이블명을 사용하여 모든 컬럼 조회 - let query = `SELECT * FROM ${tableName}`; - - if (locaKey) { - query += ` WHERE LOCAKEY = '${locaKey}'`; - } - - query += ` LIMIT 1000`; + // 동적 쿼리 생성 + const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : ''; + const query = ` + SELECT * FROM ${tableName} + WHERE ${locationKeyColumn} = '${locaKey}' + ${orderByClause} + LIMIT 1000 + `; + + logger.info(`자재 조회 쿼리: ${query}`); const result = await connector.executeQuery(query); @@ -221,31 +355,28 @@ export const getMaterials = async (req: Request, res: Response): Promise => { try { - const { externalDbConnectionId, tableName, locaKeys } = req.query; + const { externalDbConnectionId, locationKeys, tableName } = req.body; - if (!externalDbConnectionId || !tableName || !locaKeys) { + if (!externalDbConnectionId || !locationKeys || !tableName) { return res.status(400).json({ success: false, - message: "외부 DB 연결 ID, 테이블명, Location 키 목록이 필요합니다.", + message: "필수 파라미터가 누락되었습니다.", }); } const connector = await getExternalDbConnector(Number(externalDbConnectionId)); - // locaKeys는 쉼표로 구분된 문자열 - const locaKeyArray = (locaKeys as string).split(","); - const quotedKeys = locaKeyArray.map((key) => `'${key}'`).join(","); + const keysString = locationKeys.map((key: string) => `'${key}'`).join(","); const query = ` SELECT - LOCAKEY, - COUNT(*) as material_count, - MAX(LOLAYER) as max_layer + LOCAKEY as location_key, + COUNT(*) as count FROM ${tableName} - WHERE LOCAKEY IN (${quotedKeys}) + WHERE LOCAKEY IN (${keysString}) GROUP BY LOCAKEY `; @@ -254,7 +385,7 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise 0) { const objectQuery = ` INSERT INTO digital_twin_objects ( @@ -287,12 +306,53 @@ export const updateLayout = async ( rotation, color, area_key, loca_key, loc_type, material_count, material_preview_height, - parent_id, display_order, locked + parent_id, display_order, locked, + hierarchy_level, parent_key, external_key ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING id `; - for (const obj of objects) { + // 임시 ID (음수) → 실제 DB ID 매핑 + const idMapping: { [tempId: number]: number } = {}; + + // 1단계: 부모 객체 먼저 저장 (parentId가 없는 것들) + for (const obj of objects.filter((o) => !o.parentId)) { + const result = await client.query(objectQuery, [ + id, + obj.type, + obj.name, + obj.position.x, + obj.position.y, + obj.position.z, + obj.size.x, + obj.size.y, + obj.size.z, + obj.rotation || 0, + obj.color, + obj.areaKey || null, + obj.locaKey || null, + obj.locType || null, + obj.materialCount || 0, + obj.materialPreview?.height || null, + null, // parent_id + obj.displayOrder || 0, + obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, + ]); + + // 임시 ID와 실제 DB ID 매핑 + if (obj.id) { + idMapping[obj.id] = result.rows[0].id; + } + } + + // 2단계: 자식 객체 저장 (parentId가 있는 것들) + for (const obj of objects.filter((o) => o.parentId)) { + const realParentId = idMapping[obj.parentId!] || null; + await client.query(objectQuery, [ id, obj.type, @@ -310,9 +370,12 @@ export const updateLayout = async ( obj.locType || null, obj.materialCount || 0, obj.materialPreview?.height || null, - obj.parentId || null, + realParentId, // 실제 DB ID 사용 obj.displayOrder || 0, obj.locked || false, + obj.hierarchyLevel || 1, + obj.parentKey || null, + obj.externalKey || null, ]); } } diff --git a/backend-node/src/routes/digitalTwinRoutes.ts b/backend-node/src/routes/digitalTwinRoutes.ts index 3130b470..904096f7 100644 --- a/backend-node/src/routes/digitalTwinRoutes.ts +++ b/backend-node/src/routes/digitalTwinRoutes.ts @@ -12,6 +12,8 @@ import { // 외부 DB 데이터 조회 import { + getHierarchyData, + getChildrenData, getWarehouses, getAreas, getLocations, @@ -32,6 +34,12 @@ router.put("/layouts/:id", updateLayout); // 레이아웃 수정 router.delete("/layouts/:id", deleteLayout); // 레이아웃 삭제 // ========== 외부 DB 데이터 조회 API ========== + +// 동적 계층 구조 API +router.post("/data/hierarchy", getHierarchyData); // 전체 계층 데이터 조회 +router.post("/data/children", getChildrenData); // 특정 부모의 하위 데이터 조회 + +// 테이블 메타데이터 API router.get("/data/tables/:connectionId", async (req, res) => { // 테이블 목록 조회 try { @@ -56,11 +64,12 @@ router.get("/data/table-preview/:connectionId/:tableName", async (req, res) => { } }); +// 레거시 API (호환성 유지) router.get("/data/warehouses", getWarehouses); // 창고 목록 router.get("/data/areas", getAreas); // Area 목록 router.get("/data/locations", getLocations); // Location 목록 router.get("/data/materials", getMaterials); // 자재 목록 (특정 Location) -router.get("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) +router.post("/data/material-counts", getMaterialCounts); // 자재 개수 (여러 Location) - POST로 변경 export default router; diff --git a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx index 082e8661..e7680584 100644 --- a/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx +++ b/frontend/app/(main)/admin/dashboard/DashboardListClient.tsx @@ -18,32 +18,26 @@ import { Pagination, PaginationInfo } from "@/components/common/Pagination"; import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { Plus, Search, Edit, Trash2, Copy, MoreVertical, AlertCircle, RefreshCw } from "lucide-react"; -interface DashboardListClientProps { - initialDashboards: Dashboard[]; - initialPagination: { - total: number; - page: number; - limit: number; - }; -} - /** * 대시보드 목록 클라이언트 컴포넌트 + * - CSR 방식으로 초기 데이터 로드 * - 대시보드 목록 조회 * - 대시보드 생성/수정/삭제/복사 */ -export default function DashboardListClient({ initialDashboards, initialPagination }: DashboardListClientProps) { +export default function DashboardListClient() { const router = useRouter(); const { toast } = useToast(); - const [dashboards, setDashboards] = useState(initialDashboards); - const [loading, setLoading] = useState(false); // 초기 로딩은 서버에서 완료 + + // 상태 관리 + const [dashboards, setDashboards] = useState([]); + const [loading, setLoading] = useState(true); // CSR이므로 초기 로딩 true const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(""); // 페이지네이션 상태 - const [currentPage, setCurrentPage] = useState(initialPagination.page); - const [pageSize, setPageSize] = useState(initialPagination.limit); - const [totalCount, setTotalCount] = useState(initialPagination.total); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + const [totalCount, setTotalCount] = useState(0); // 모달 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -73,17 +67,8 @@ export default function DashboardListClient({ initialDashboards, initialPaginati } }; - // 초기 로드 여부 추적 - const [isInitialLoad, setIsInitialLoad] = useState(true); - + // 검색어/페이지 변경 시 fetch (초기 로딩 포함) useEffect(() => { - // 초기 로드는 건너뛰기 (서버에서 이미 데이터를 가져왔음) - if (isInitialLoad) { - setIsInitialLoad(false); - return; - } - - // 이후 검색어/페이지 변경 시에만 fetch loadDashboards(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchTerm, currentPage, pageSize]); @@ -91,7 +76,7 @@ export default function DashboardListClient({ initialDashboards, initialPaginati // 페이지네이션 정보 계산 const paginationInfo: PaginationInfo = { currentPage, - totalPages: Math.ceil(totalCount / pageSize), + totalPages: Math.ceil(totalCount / pageSize) || 1, totalItems: totalCount, itemsPerPage: pageSize, startItem: totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1, diff --git a/frontend/app/(main)/admin/dashboard/page.tsx b/frontend/app/(main)/admin/dashboard/page.tsx index 8d78600c..7d09bafc 100644 --- a/frontend/app/(main)/admin/dashboard/page.tsx +++ b/frontend/app/(main)/admin/dashboard/page.tsx @@ -1,73 +1,22 @@ import DashboardListClient from "@/app/(main)/admin/dashboard/DashboardListClient"; -import { cookies } from "next/headers"; /** - * 서버에서 초기 대시보드 목록 fetch + * 대시보드 관리 페이지 + * - 클라이언트 컴포넌트를 렌더링하는 래퍼 + * - 초기 로딩부터 CSR로 처리 */ -async function getInitialDashboards() { - try { - // 서버 사이드 전용: 백엔드 API 직접 호출 - // 도커 네트워크 내부에서는 서비스 이름 사용, 로컬에서는 127.0.0.1 - const backendUrl = process.env.SERVER_API_URL || "http://backend:8080"; - - // 쿠키에서 authToken 추출 - const cookieStore = await cookies(); - const authToken = cookieStore.get("authToken")?.value; - - if (!authToken) { - // 토큰이 없으면 빈 데이터 반환 (클라이언트에서 로드) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } - - const response = await fetch(`${backendUrl}/api/dashboards/my?page=1&limit=10`, { - cache: "no-store", // 항상 최신 데이터 - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${authToken}`, // Authorization 헤더로 전달 - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch dashboards: ${response.status}`); - } - - const data = await response.json(); - return { - dashboards: data.data || [], - pagination: data.pagination || { total: 0, page: 1, limit: 10 }, - }; - } catch (error) { - console.error("Server-side fetch error:", error); - // 에러 발생 시 빈 데이터 반환 (클라이언트에서 재시도 가능) - return { - dashboards: [], - pagination: { total: 0, page: 1, limit: 10 }, - }; - } -} - -/** - * 대시보드 관리 페이지 (서버 컴포넌트) - * - 페이지 헤더 + 초기 데이터를 서버에서 렌더링 - * - 클라이언트 컴포넌트로 초기 데이터 전달 - */ -export default async function DashboardListPage() { - const initialData = await getInitialDashboards(); - +export default function DashboardListPage() { return (
- {/* 페이지 헤더 (서버에서 렌더링) */} + {/* 페이지 헤더 */}

대시보드 관리

대시보드를 생성하고 관리할 수 있습니다

- {/* 나머지 컨텐츠 (클라이언트 컴포넌트 + 서버 데이터) */} - + {/* 클라이언트 컴포넌트 */} +
); diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index 9f5d21f3..b8eb31ef 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -199,14 +199,14 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult onValueChange={(value: "current" | "external") => onChange({ connectionType: value })} >
- -
- -
@@ -216,7 +216,7 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult {/* 외부 DB 선택 */} {dataSource.connectionType === "external" && (
-