From 760f9b2d6739436869c84a8f34ca7ab6755523cf Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 15:17:43 +0900 Subject: [PATCH 01/18] =?UTF-8?q?fix(split-panel-layout):=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=ED=91=9C=EC=8B=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - leftPanel.columns 설정을 우선 적용하도록 로직 변경 - 조인 키(leftColumn) 대신 사용자 설정 컬럼이 표시되도록 수정 - 컬럼 라벨 변환 로직 개선 --- .../SplitPanelLayoutComponent.tsx | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index 21a5bb0f..fdaddfc3 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1684,53 +1684,43 @@ export const SplitPanelLayoutComponent: React.FC const isExpanded = expandedItems.has(itemId); const level = item.level || 0; - // 조인에 사용하는 leftColumn을 필수로 표시 - const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + // 🔧 수정: "표시할 컬럼 선택"에서 설정한 컬럼을 우선 사용 + const configuredColumns = componentConfig.leftPanel?.columns || []; let displayFields: { label: string; value: any }[] = []; // 디버그 로그 if (index === 0) { console.log("🔍 좌측 패널 표시 로직:"); - console.log(" - leftColumn (조인 키):", leftColumn); + console.log(" - 설정된 표시 컬럼:", configuredColumns); console.log(" - item keys:", Object.keys(item)); } - if (leftColumn) { - // 조인 모드: leftColumn 값을 첫 번째로 표시 (필수) - displayFields.push({ - label: leftColumn, - value: item[leftColumn], + if (configuredColumns.length > 0) { + // 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용 + displayFields = configuredColumns.slice(0, 2).map((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + const colLabel = typeof col === "object" ? col.label : leftColumnLabels[colName] || colName; + return { + label: colLabel, + value: item[colName], + }; }); - // 추가로 다른 의미있는 필드 1-2개 표시 (동적) - const additionalKeys = Object.keys(item).filter( - (k) => - k !== "id" && - k !== "ID" && - k !== leftColumn && - shouldShowField(k), - ); - - if (additionalKeys.length > 0) { - displayFields.push({ - label: additionalKeys[0], - value: item[additionalKeys[0]], - }); - } - if (index === 0) { - console.log(" ✅ 조인 키 기반 표시:", displayFields); + console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields); } } else { - // 상세 모드 또는 설정 없음: 자동으로 첫 2개 필드 표시 - const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID"); + // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 + const keys = Object.keys(item).filter( + (k) => k !== "id" && k !== "ID" && k !== "children" && k !== "level" && shouldShowField(k) + ); displayFields = keys.slice(0, 2).map((key) => ({ - label: key, + label: leftColumnLabels[key] || key, value: item[key], })); if (index === 0) { - console.log(" ⚠️ 조인 키 없음, 자동 선택:", displayFields); + console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); } } From 700623aa78ae40984933de2e41ccbdfcc053f2a8 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 17:45:22 +0900 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20SplitPanelLayout2=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0-=EB=94=94=ED=85=8C=EC=9D=BC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC=ED=98=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EB=A7=88=EC=8A=A4=ED=84=B0)-?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=8C=A8=EB=84=90(=EB=94=94=ED=85=8C?= =?UTF-8?q?=EC=9D=BC)=20=EB=B6=84=ED=95=A0=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20EditModal=EC=97=90=20isCreateMode=20=ED=94=8C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20INS?= =?UTF-8?q?ERT/UPDATE=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20dataFilt?= =?UTF-8?q?er=20=EA=B8=B0=EB=B0=98=20=EC=A0=95=ED=99=95=ED=95=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=9D=B8=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=ED=95=98=EB=8A=94=20dataTransferFields=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20ConfigPanel=EC=97=90=EC=84=9C=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94,=20=EC=BB=AC=EB=9F=BC,=20=EC=A1=B0=EC=9D=B8?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/EditModal.tsx | 111 ++- frontend/lib/registry/components/index.ts | 1 + .../components/split-panel-layout2/README.md | 102 +++ .../SplitPanelLayout2Component.tsx | 774 ++++++++++++++++++ .../SplitPanelLayout2ConfigPanel.tsx | 684 ++++++++++++++++ .../SplitPanelLayout2Renderer.tsx | 42 + .../components/split-panel-layout2/config.ts | 57 ++ .../components/split-panel-layout2/index.ts | 41 + .../components/split-panel-layout2/types.ts | 102 +++ .../lib/utils/getComponentConfigPanel.tsx | 1 + 10 files changed, 1878 insertions(+), 37 deletions(-) create mode 100644 frontend/lib/registry/components/split-panel-layout2/README.md create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx create mode 100644 frontend/lib/registry/components/split-panel-layout2/config.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/index.ts create mode 100644 frontend/lib/registry/components/split-panel-layout2/types.ts diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 9945a19c..cde9086c 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -118,7 +118,7 @@ export const EditModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail; + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = event.detail; setModalState({ isOpen: true, @@ -134,7 +134,13 @@ export const EditModal: React.FC = ({ className }) => { // 편집 데이터로 폼 데이터 초기화 setFormData(editData || {}); - setOriginalData(editData || {}); + // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) + // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 + setOriginalData(isCreateMode ? {} : (editData || {})); + + if (isCreateMode) { + console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); + } }; const handleCloseEditModal = () => { @@ -567,46 +573,77 @@ export const EditModal: React.FC = ({ className }) => { return; } - // 기존 로직: 단일 레코드 수정 - const changedData: Record = {}; - Object.keys(formData).forEach((key) => { - if (formData[key] !== originalData[key]) { - changedData[key] = formData[key]; - } - }); + // originalData가 비어있으면 INSERT, 있으면 UPDATE + const isCreateMode = Object.keys(originalData).length === 0; + + if (isCreateMode) { + // INSERT 모드 + console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData); + + const response = await dynamicFormApi.saveFormData({ + screenId: modalState.screenId!, + tableName: screenData.screenInfo.tableName, + data: formData, + }); - if (Object.keys(changedData).length === 0) { - toast.info("변경된 내용이 없습니다."); - handleClose(); - return; - } + if (response.success) { + toast.success("데이터가 생성되었습니다."); - // 기본키 확인 (id 또는 첫 번째 키) - const recordId = originalData.id || Object.values(originalData)[0]; - - // UPDATE 액션 실행 - const response = await dynamicFormApi.updateFormDataPartial( - recordId, - originalData, - changedData, - screenData.screenInfo.tableName, - ); - - if (response.success) { - toast.success("데이터가 수정되었습니다."); - - // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) - if (modalState.onSave) { - try { - modalState.onSave(); - } catch (callbackError) { - console.error("⚠️ onSave 콜백 에러:", callbackError); + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } } + + handleClose(); + } else { + throw new Error(response.message || "생성에 실패했습니다."); + } + } else { + // UPDATE 모드 - 기존 로직 + const changedData: Record = {}; + Object.keys(formData).forEach((key) => { + if (formData[key] !== originalData[key]) { + changedData[key] = formData[key]; + } + }); + + if (Object.keys(changedData).length === 0) { + toast.info("변경된 내용이 없습니다."); + handleClose(); + return; } - handleClose(); - } else { - throw new Error(response.message || "수정에 실패했습니다."); + // 기본키 확인 (id 또는 첫 번째 키) + const recordId = originalData.id || Object.values(originalData)[0]; + + // UPDATE 액션 실행 + const response = await dynamicFormApi.updateFormDataPartial( + recordId, + originalData, + changedData, + screenData.screenInfo.tableName, + ); + + if (response.success) { + toast.success("데이터가 수정되었습니다."); + + // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) + if (modalState.onSave) { + try { + modalState.onSave(); + } catch (callbackError) { + console.error("onSave 콜백 에러:", callbackError); + } + } + + handleClose(); + } else { + throw new Error(response.message || "수정에 실패했습니다."); + } } } catch (error: any) { console.error("❌ 수정 실패:", error); diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index fb7cd30b..746e2c2d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -37,6 +37,7 @@ import "./accordion-basic/AccordionBasicRenderer"; import "./table-list/TableListRenderer"; import "./card-display/CardDisplayRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; +import "./split-panel-layout2/SplitPanelLayout2Renderer"; // 분할 패널 레이아웃 v2 import "./map/MapRenderer"; import "./repeater-field-group/RepeaterFieldGroupRenderer"; import "./flow-widget/FlowWidgetRenderer"; diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md new file mode 100644 index 00000000..f1d8544b --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -0,0 +1,102 @@ +# SplitPanelLayout2 컴포넌트 + +마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트 (개선 버전) + +## 개요 + +- **ID**: `split-panel-layout2` +- **카테고리**: layout +- **웹타입**: container +- **버전**: 2.0.0 + +## 주요 기능 + +- 좌측 패널: 마스터 데이터 목록 (예: 부서 목록) +- 우측 패널: 디테일 데이터 목록 (예: 부서원 목록) +- 조인 기반 데이터 연결 +- 검색 기능 (좌측/우측 모두) +- 계층 구조 지원 (트리 형태) +- 데이터 전달 기능 (모달로 선택된 데이터 전달) +- 리사이즈 가능한 분할 바 + +## 사용 예시 + +### 부서-사원 관리 + +1. 좌측 패널: `dept_info` 테이블 (부서 목록) +2. 우측 패널: `user_info` 테이블 (사원 목록) +3. 조인 조건: `dept_code` = `dept_code` +4. 데이터 전달: `dept_code`, `dept_name`, `company_code` + +## 설정 옵션 + +### 좌측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| hierarchyConfig | object | 계층 구조 설정 | + +### 우측 패널 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| title | string | 패널 제목 | +| tableName | string | 테이블명 | +| displayColumns | ColumnConfig[] | 표시할 컬럼 목록 | +| searchColumn | string | 검색 대상 컬럼 | +| showSearch | boolean | 검색 기능 표시 여부 | +| showAddButton | boolean | 추가 버튼 표시 | +| showEditButton | boolean | 수정 버튼 표시 | +| showDeleteButton | boolean | 삭제 버튼 표시 | + +### 조인 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| leftColumn | string | 좌측 테이블의 조인 컬럼 | +| rightColumn | string | 우측 테이블의 조인 컬럼 | + +### 데이터 전달 설정 + +| 속성 | 타입 | 설명 | +|------|------|------| +| sourceColumn | string | 좌측 패널의 소스 컬럼 | +| targetColumn | string | 모달로 전달할 타겟 컬럼명 | + +## 데이터 흐름 + +``` +[좌측 패널 항목 클릭] + ↓ +[selectedLeftItem 상태 저장] + ↓ +[modalDataStore에 테이블명으로 저장] + ↓ +[ScreenContext DataProvider 등록] + ↓ +[우측 패널 데이터 로드 (조인 조건 적용)] +``` + +## 버튼과 연동 + +버튼 컴포넌트에서 이 컴포넌트의 선택된 데이터에 접근하려면: + +1. 버튼의 액션 타입을 `openModalWithData`로 설정 +2. 데이터 소스 ID를 좌측 패널의 테이블명으로 설정 (예: `dept_info`) +3. `modalDataStore`에서 자동으로 데이터를 가져옴 + +## 개발자 정보 + +- **생성일**: 2024 +- **경로**: `lib/registry/components/split-panel-layout2/` + +## 관련 문서 + +- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) +- [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx new file mode 100644 index 00000000..be14038f --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -0,0 +1,774 @@ +"use client"; + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { + SplitPanelLayout2Config, + ColumnConfig, + DataTransferField, +} from "./types"; +import { defaultConfig } from "./config"; +import { cn } from "@/lib/utils"; +import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { toast } from "sonner"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { apiClient } from "@/lib/api/client"; + +export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { + // 추가 props +} + +/** + * SplitPanelLayout2 컴포넌트 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Component: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + isPreview = false, + onClick, + ...props +}) => { + const config = useMemo(() => { + return { + ...defaultConfig, + ...component.componentConfig, + } as SplitPanelLayout2Config; + }, [component.componentConfig]); + + // ScreenContext (데이터 전달용) + const screenContext = useScreenContextOptional(); + + // 상태 관리 + const [leftData, setLeftData] = useState([]); + const [rightData, setRightData] = useState([]); + const [selectedLeftItem, setSelectedLeftItem] = useState(null); + const [leftSearchTerm, setLeftSearchTerm] = useState(""); + const [rightSearchTerm, setRightSearchTerm] = useState(""); + const [leftLoading, setLeftLoading] = useState(false); + const [rightLoading, setRightLoading] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30); + const [isResizing, setIsResizing] = useState(false); + + // 좌측 패널 컬럼 라벨 매핑 + const [leftColumnLabels, setLeftColumnLabels] = useState>({}); + const [rightColumnLabels, setRightColumnLabels] = useState>({}); + + + // 좌측 데이터 로드 + const loadLeftData = useCallback(async () => { + if (!config.leftPanel?.tableName || isDesignMode) return; + + setLeftLoading(true); + try { + const response = await apiClient.post(`/table-management/tables/${config.leftPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + let data = response.data.data?.data || []; + + // 계층 구조 처리 + if (config.leftPanel.hierarchyConfig?.enabled) { + data = buildHierarchy( + data, + config.leftPanel.hierarchyConfig.idColumn, + config.leftPanel.hierarchyConfig.parentColumn + ); + } + + setLeftData(data); + console.log(`[SplitPanelLayout2] 좌측 데이터 로드: ${data.length}건 (company_code 필터 적용)`); + } + } catch (error) { + console.error("[SplitPanelLayout2] 좌측 데이터 로드 실패:", error); + toast.error("좌측 패널 데이터를 불러오는데 실패했습니다."); + } finally { + setLeftLoading(false); + } + }, [config.leftPanel?.tableName, config.leftPanel?.hierarchyConfig, isDesignMode]); + + // 우측 데이터 로드 (좌측 선택 항목 기반) + const loadRightData = useCallback(async (selectedItem: any) => { + if (!config.rightPanel?.tableName || !config.joinConfig?.leftColumn || !config.joinConfig?.rightColumn || !selectedItem) { + setRightData([]); + return; + } + + const joinValue = selectedItem[config.joinConfig.leftColumn]; + if (joinValue === undefined || joinValue === null) { + console.log(`[SplitPanelLayout2] 조인 값이 없음: ${config.joinConfig.leftColumn}`); + setRightData([]); + return; + } + + setRightLoading(true); + try { + console.log(`[SplitPanelLayout2] 우측 데이터 로드 시작: ${config.rightPanel.tableName}, ${config.joinConfig.rightColumn}=${joinValue}`); + + const response = await apiClient.post(`/table-management/tables/${config.rightPanel.tableName}/data`, { + page: 1, + size: 1000, // 전체 데이터 로드 + // dataFilter를 사용하여 정확한 값 매칭 (Entity 타입 검색 문제 회피) + dataFilter: { + enabled: true, + matchType: "all", + filters: [ + { + id: "join_filter", + columnName: config.joinConfig.rightColumn, + operator: "equals", + value: String(joinValue), + valueType: "static", + } + ], + }, + // 멀티테넌시: 자동으로 company_code 필터링 적용 + autoFilter: { + enabled: true, + filterColumn: "company_code", + filterType: "company", + }, + }); + + if (response.data.success) { + // API 응답 구조: { success: true, data: { data: [...], total, page, ... } } + const data = response.data.data?.data || []; + setRightData(data); + console.log(`[SplitPanelLayout2] 우측 데이터 로드 완료: ${data.length}건`); + } else { + console.error("[SplitPanelLayout2] 우측 데이터 로드 실패:", response.data.message); + setRightData([]); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 우측 데이터 로드 에러:", { + message: error?.message, + status: error?.response?.status, + statusText: error?.response?.statusText, + data: error?.response?.data, + config: { + url: error?.config?.url, + method: error?.config?.method, + data: error?.config?.data, + } + }); + setRightData([]); + } finally { + setRightLoading(false); + } + }, [config.rightPanel?.tableName, config.joinConfig]); + + // 좌측 패널 추가 버튼 클릭 + const handleLeftAddClick = useCallback(() => { + if (!config.leftPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.leftPanel.addModalScreenId, + title: config.leftPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: {}, + isCreateMode: true, // 생성 모드 + onSave: () => { + loadLeftData(); + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 좌측 추가 모달 열기:", config.leftPanel.addModalScreenId); + }, [config.leftPanel?.addModalScreenId, config.leftPanel?.addButtonLabel, loadLeftData]); + + // 우측 패널 추가 버튼 클릭 + const handleRightAddClick = useCallback(() => { + if (!config.rightPanel?.addModalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } + } + } + + console.log("[SplitPanelLayout2] 모달로 전달할 데이터:", initialData); + console.log("[SplitPanelLayout2] 모달 screenId:", config.rightPanel?.addModalScreenId); + + // EditModal 열기 이벤트 발생 + const event = new CustomEvent("openEditModal", { + detail: { + screenId: config.rightPanel.addModalScreenId, + title: config.rightPanel?.addButtonLabel || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, // 생성 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 우측 추가 모달 열기"); + }, [config.rightPanel?.addModalScreenId, config.rightPanel?.addButtonLabel, config.dataTransferFields, selectedLeftItem, loadRightData]); + + // 컬럼 라벨 로드 + const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { + if (!tableName) return; + + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + const labels: Record = {}; + // API 응답 구조: { success: true, data: { columns: [...] } } + const columns = response.data.data?.columns || []; + columns.forEach((col: any) => { + const colName = col.column_name || col.columnName; + const colLabel = col.column_label || col.columnLabel || colName; + if (colName) { + labels[colName] = colLabel; + } + }); + setLabels(labels); + } + } catch (error) { + console.error("[SplitPanelLayout2] 컬럼 라벨 로드 실패:", error); + } + }, []); + + // 계층 구조 빌드 + const buildHierarchy = (data: any[], idColumn: string, parentColumn: string): any[] => { + const itemMap = new Map(); + const roots: any[] = []; + + // 모든 항목을 맵에 저장 + data.forEach((item) => { + itemMap.set(item[idColumn], { ...item, children: [] }); + }); + + // 부모-자식 관계 설정 + data.forEach((item) => { + const current = itemMap.get(item[idColumn]); + const parentId = item[parentColumn]; + + if (parentId && itemMap.has(parentId)) { + itemMap.get(parentId).children.push(current); + } else { + roots.push(current); + } + }); + + return roots; + }; + + // 좌측 항목 선택 핸들러 + const handleLeftItemSelect = useCallback((item: any) => { + setSelectedLeftItem(item); + loadRightData(item); + + // ScreenContext DataProvider 등록 (버튼에서 접근 가능하도록) + if (screenContext && !isDesignMode) { + screenContext.registerDataProvider(component.id, { + componentId: component.id, + componentType: "split-panel-layout2", + getSelectedData: () => [item], + getAllData: () => leftData, + clearSelection: () => setSelectedLeftItem(null), + }); + console.log(`[SplitPanelLayout2] DataProvider 등록: ${component.id}`); + } + }, [isDesignMode, screenContext, component.id, leftData, loadRightData]); + + // 항목 확장/축소 토글 + const toggleExpand = useCallback((itemId: string) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }, []); + + // 검색 필터링 + const filteredLeftData = useMemo(() => { + if (!leftSearchTerm) return leftData; + + const searchColumn = config.leftPanel?.searchColumn; + if (!searchColumn) return leftData; + + const filterRecursive = (items: any[]): any[] => { + return items.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + const matches = value.includes(leftSearchTerm.toLowerCase()); + + if (item.children?.length > 0) { + const filteredChildren = filterRecursive(item.children); + if (filteredChildren.length > 0) { + item.children = filteredChildren; + return true; + } + } + + return matches; + }); + }; + + return filterRecursive([...leftData]); + }, [leftData, leftSearchTerm, config.leftPanel?.searchColumn]); + + const filteredRightData = useMemo(() => { + if (!rightSearchTerm) return rightData; + + const searchColumn = config.rightPanel?.searchColumn; + if (!searchColumn) return rightData; + + return rightData.filter((item) => { + const value = String(item[searchColumn] || "").toLowerCase(); + return value.includes(rightSearchTerm.toLowerCase()); + }); + }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); + + // 리사이즈 핸들러 + const handleResizeStart = useCallback((e: React.MouseEvent) => { + if (!config.resizable) return; + e.preventDefault(); + setIsResizing(true); + }, [config.resizable]); + + const handleResizeMove = useCallback((e: MouseEvent) => { + if (!isResizing) return; + + const container = document.getElementById(`split-panel-${component.id}`); + if (!container) return; + + const rect = container.getBoundingClientRect(); + const newPosition = ((e.clientX - rect.left) / rect.width) * 100; + const minLeft = (config.minLeftWidth || 200) / rect.width * 100; + const minRight = (config.minRightWidth || 300) / rect.width * 100; + + setSplitPosition(Math.max(minLeft, Math.min(100 - minRight, newPosition))); + }, [isResizing, component.id, config.minLeftWidth, config.minRightWidth]); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + }, []); + + // 리사이즈 이벤트 리스너 + useEffect(() => { + if (isResizing) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + } + return () => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }; + }, [isResizing, handleResizeMove, handleResizeEnd]); + + // 초기 데이터 로드 + useEffect(() => { + if (config.autoLoad && !isDesignMode) { + loadLeftData(); + loadColumnLabels(config.leftPanel?.tableName || "", setLeftColumnLabels); + loadColumnLabels(config.rightPanel?.tableName || "", setRightColumnLabels); + } + }, [config.autoLoad, isDesignMode, loadLeftData, loadColumnLabels, config.leftPanel?.tableName, config.rightPanel?.tableName]); + + // 컴포넌트 언마운트 시 DataProvider 해제 + useEffect(() => { + return () => { + if (screenContext) { + screenContext.unregisterDataProvider(component.id); + } + }; + }, [screenContext, component.id]); + + // 값 포맷팅 + const formatValue = (value: any, format?: ColumnConfig["format"]): string => { + if (value === null || value === undefined) return "-"; + if (!format) return String(value); + + switch (format.type) { + case "number": + const num = Number(value); + if (isNaN(num)) return String(value); + let formatted = format.decimalPlaces !== undefined + ? num.toFixed(format.decimalPlaces) + : String(num); + if (format.thousandSeparator) { + formatted = formatted.replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + return `${format.prefix || ""}${formatted}${format.suffix || ""}`; + + case "currency": + const currency = Number(value); + if (isNaN(currency)) return String(value); + const currencyFormatted = currency.toLocaleString("ko-KR"); + return `${format.prefix || ""}${currencyFormatted}${format.suffix || "원"}`; + + case "date": + try { + const date = new Date(value); + return date.toLocaleDateString("ko-KR"); + } catch { + return String(value); + } + + default: + return String(value); + } + }; + + // 좌측 패널 항목 렌더링 + const renderLeftItem = (item: any, level: number = 0, index: number = 0) => { + const idColumn = config.leftPanel?.hierarchyConfig?.idColumn || "id"; + const itemId = item[idColumn] ?? `item-${level}-${index}`; + const hasChildren = item.children?.length > 0; + const isExpanded = expandedItems.has(String(itemId)); + const isSelected = selectedLeftItem && selectedLeftItem[idColumn] === item[idColumn]; + + // 표시할 컬럼 결정 + const displayColumns = config.leftPanel?.displayColumns || []; + const primaryColumn = displayColumns[0]; + const secondaryColumn = displayColumns[1]; + + const primaryValue = primaryColumn + ? item[primaryColumn.name] + : Object.values(item).find((v) => typeof v === "string" && v.length > 0); + const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; + + return ( +
+
handleLeftItemSelect(item)} + > + {/* 확장/축소 버튼 */} + {hasChildren ? ( + + ) : ( +
+ )} + + {/* 아이콘 */} + + + {/* 내용 */} +
+
+ {primaryValue || "이름 없음"} +
+ {secondaryValue && ( +
+ {secondaryValue} +
+ )} +
+
+ + {/* 자식 항목 */} + {hasChildren && isExpanded && ( +
+ {item.children.map((child: any, childIndex: number) => renderLeftItem(child, level + 1, childIndex))} +
+ )} +
+ ); + }; + + // 우측 패널 카드 렌더링 + const renderRightCard = (item: any, index: number) => { + const displayColumns = config.rightPanel?.displayColumns || []; + + // 첫 번째 컬럼을 이름으로 사용 + const nameColumn = displayColumns[0]; + const name = nameColumn ? item[nameColumn.name] : "이름 없음"; + + // 나머지 컬럼들 + const otherColumns = displayColumns.slice(1); + + return ( + + +
+
+ {/* 이름 */} +
+ {name} + {otherColumns[0] && ( + + {item[otherColumns[0].name]} + + )} +
+ + {/* 상세 정보 */} +
+ {otherColumns.slice(1).map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + + // 아이콘 결정 + let icon = null; + const colName = col.name.toLowerCase(); + if (colName.includes("tel") || colName.includes("phone")) { + icon = tel; + } else if (colName.includes("email")) { + icon = @; + } else if (colName.includes("sabun") || colName.includes("id")) { + icon = ID; + } + + return ( + + {icon} + {formatValue(value, col.format)} + + ); + })} +
+
+ + {/* 액션 버튼 */} +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+
+
+ ); + }; + + // 디자인 모드 렌더링 + if (isDesignMode) { + return ( +
+ {/* 좌측 패널 미리보기 */} +
+
+ {config.leftPanel?.title || "좌측 패널"} +
+
+ 테이블: {config.leftPanel?.tableName || "미설정"} +
+
+ 좌측 목록 영역 +
+
+ + {/* 우측 패널 미리보기 */} +
+
+ {config.rightPanel?.title || "우측 패널"} +
+
+ 테이블: {config.rightPanel?.tableName || "미설정"} +
+
+ 우측 상세 영역 +
+
+
+ ); + } + + return ( +
+ {/* 좌측 패널 */} +
+ {/* 헤더 */} +
+
+

{config.leftPanel?.title || "목록"}

+ {config.leftPanel?.showAddButton && ( + + )} +
+ + {/* 검색 */} + {config.leftPanel?.showSearch && ( +
+ + setLeftSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 목록 */} +
+ {leftLoading ? ( +
+ 로딩 중... +
+ ) : filteredLeftData.length === 0 ? ( +
+ 데이터가 없습니다 +
+ ) : ( +
+ {filteredLeftData.map((item, index) => renderLeftItem(item, 0, index))} +
+ )} +
+
+ + {/* 리사이저 */} + {config.resizable && ( +
+ )} + + {/* 우측 패널 */} +
+ {/* 헤더 */} +
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

+
+ {selectedLeftItem && ( + + {rightData.length}명 + + )} + {config.rightPanel?.showAddButton && selectedLeftItem && ( + + )} +
+
+ + {/* 검색 */} + {config.rightPanel?.showSearch && selectedLeftItem && ( +
+ + setRightSearchTerm(e.target.value)} + className="pl-9 h-9 text-sm" + /> +
+ )} +
+ + {/* 내용 */} +
+ {!selectedLeftItem ? ( +
+ + {config.rightPanel?.emptyMessage || "좌측에서 항목을 선택해주세요"} +
+ ) : rightLoading ? ( +
+ 로딩 중... +
+ ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} +
+
+
+ ); +}; + +/** + * SplitPanelLayout2 래퍼 컴포넌트 + */ +export const SplitPanelLayout2Wrapper: React.FC = (props) => { + return ; +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx new file mode 100644 index 00000000..878ddb12 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -0,0 +1,684 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Check, ChevronsUpDown, Plus, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import type { SplitPanelLayout2Config, ColumnConfig, DataTransferField } from "./types"; + +// lodash set 대체 함수 +const setPath = (obj: any, path: string, value: any): any => { + const keys = path.split("."); + const result = { ...obj }; + let current = result; + + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + current[key] = current[key] ? { ...current[key] } : {}; + current = current[key]; + } + + current[keys[keys.length - 1]] = value; + return result; +}; + +interface SplitPanelLayout2ConfigPanelProps { + config: SplitPanelLayout2Config; + onChange: (config: SplitPanelLayout2Config) => void; +} + +interface TableInfo { + table_name: string; + table_comment?: string; +} + +interface ColumnInfo { + column_name: string; + data_type: string; + column_comment?: string; +} + +interface ScreenInfo { + screen_id: number; + screen_name: string; + screen_code: string; +} + +export const SplitPanelLayout2ConfigPanel: React.FC = ({ + config, + onChange, +}) => { + // updateConfig 헬퍼 함수: 경로 기반으로 config를 업데이트 + const updateConfig = useCallback((path: string, value: any) => { + console.log(`[SplitPanelLayout2ConfigPanel] updateConfig: ${path} =`, value); + const newConfig = setPath(config, path, value); + console.log("[SplitPanelLayout2ConfigPanel] newConfig:", newConfig); + onChange(newConfig); + }, [config, onChange]); + + // 상태 + const [tables, setTables] = useState([]); + const [leftColumns, setLeftColumns] = useState([]); + const [rightColumns, setRightColumns] = useState([]); + const [screens, setScreens] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [screensLoading, setScreensLoading] = useState(false); + + // Popover 상태 + const [leftTableOpen, setLeftTableOpen] = useState(false); + const [rightTableOpen, setRightTableOpen] = useState(false); + const [leftModalOpen, setLeftModalOpen] = useState(false); + const [rightModalOpen, setRightModalOpen] = useState(false); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + setTablesLoading(true); + try { + const response = await apiClient.get("/table/list?userLang=KR"); + const tableList = response.data?.data || response.data || []; + if (Array.isArray(tableList)) { + setTables(tableList); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setTablesLoading(false); + } + }, []); + + // 화면 목록 로드 + const loadScreens = useCallback(async () => { + setScreensLoading(true); + try { + const response = await apiClient.get("/screen/list"); + console.log("[loadScreens] API 응답:", response.data); + const screenList = response.data?.data || response.data || []; + if (Array.isArray(screenList)) { + const transformedScreens = screenList.map((s: any) => ({ + screen_id: s.screen_id || s.id, + screen_name: s.screen_name || s.name, + screen_code: s.screen_code || s.code || "", + })); + console.log("[loadScreens] 변환된 화면 목록:", transformedScreens); + setScreens(transformedScreens); + } + } catch (error) { + console.error("화면 목록 로드 실패:", error); + } finally { + setScreensLoading(false); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, side: "left" | "right") => { + if (!tableName) return; + try { + const response = await apiClient.get(`/table/${tableName}/columns`); + const columnList = response.data?.data || response.data || []; + if (Array.isArray(columnList)) { + if (side === "left") { + setLeftColumns(columnList); + } else { + setRightColumns(columnList); + } + } + } catch (error) { + console.error(`${side} 컬럼 목록 로드 실패:`, error); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadTables(); + loadScreens(); + }, [loadTables, loadScreens]); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (config.leftPanel?.tableName) { + loadColumns(config.leftPanel.tableName, "left"); + } + }, [config.leftPanel?.tableName, loadColumns]); + + useEffect(() => { + if (config.rightPanel?.tableName) { + loadColumns(config.rightPanel.tableName, "right"); + } + }, [config.rightPanel?.tableName, loadColumns]); + + // 테이블 선택 컴포넌트 + const TableSelect: React.FC<{ + value: string; + onValueChange: (value: string) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 테이블이 없습니다 + + {tables.map((table) => ( + { + onValueChange(selectedValue); + onOpenChange(false); + }} + > + + + {table.table_comment || table.table_name} + {table.table_name} + + + ))} + + + + + + ); + + // 화면 선택 컴포넌트 + const ScreenSelect: React.FC<{ + value: number | undefined; + onValueChange: (value: number | undefined) => void; + placeholder: string; + open: boolean; + onOpenChange: (open: boolean) => void; + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( + + + + + + + + + 화면이 없습니다 + + {screens.map((screen, index) => ( + { + const screenId = parseInt(selectedValue.split("-")[0]); + console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); + onValueChange(screenId); + onOpenChange(false); + }} + className="flex items-center" + > +
+ + + {screen.screen_name} + {screen.screen_code} + +
+
+ ))} +
+
+
+
+
+ ); + + // 컬럼 선택 컴포넌트 + const ColumnSelect: React.FC<{ + columns: ColumnInfo[]; + value: string; + onValueChange: (value: string) => void; + placeholder: string; + }> = ({ columns, value, onValueChange, placeholder }) => ( + + ); + + // 표시 컬럼 추가 + const addDisplayColumn = (side: "left" | "right") => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, [...currentColumns, { name: "", label: "" }]); + }; + + // 표시 컬럼 삭제 + const removeDisplayColumn = (side: "left" | "right", index: number) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? config.leftPanel?.displayColumns || [] + : config.rightPanel?.displayColumns || []; + + updateConfig(path, currentColumns.filter((_, i) => i !== index)); + }; + + // 표시 컬럼 업데이트 + const updateDisplayColumn = (side: "left" | "right", index: number, field: keyof ColumnConfig, value: any) => { + const path = side === "left" ? "leftPanel.displayColumns" : "rightPanel.displayColumns"; + const currentColumns = side === "left" + ? [...(config.leftPanel?.displayColumns || [])] + : [...(config.rightPanel?.displayColumns || [])]; + + if (currentColumns[index]) { + currentColumns[index] = { ...currentColumns[index], [field]: value }; + updateConfig(path, currentColumns); + } + }; + + // 데이터 전달 필드 추가 + const addDataTransferField = () => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", [...currentFields, { sourceColumn: "", targetColumn: "" }]); + }; + + // 데이터 전달 필드 삭제 + const removeDataTransferField = (index: number) => { + const currentFields = config.dataTransferFields || []; + updateConfig("dataTransferFields", currentFields.filter((_, i) => i !== index)); + }; + + // 데이터 전달 필드 업데이트 + const updateDataTransferField = (index: number, field: keyof DataTransferField, value: string) => { + const currentFields = [...(config.dataTransferFields || [])]; + if (currentFields[index]) { + currentFields[index] = { ...currentFields[index], [field]: value }; + updateConfig("dataTransferFields", currentFields); + } + }; + + return ( +
+ {/* 좌측 패널 설정 */} +
+

좌측 패널 설정 (마스터)

+ +
+
+ + updateConfig("leftPanel.title", e.target.value)} + placeholder="부서" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("leftPanel.tableName", value)} + placeholder="테이블 선택" + open={leftTableOpen} + onOpenChange={setLeftTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.leftPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("left", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("leftPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("leftPanel.showAddButton", checked)} + /> +
+ + {config.leftPanel?.showAddButton && ( + <> +
+ + updateConfig("leftPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("leftPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={leftModalOpen} + onOpenChange={setLeftModalOpen} + /> +
+ + )} +
+
+ + {/* 우측 패널 설정 */} +
+

우측 패널 설정 (상세)

+ +
+
+ + updateConfig("rightPanel.title", e.target.value)} + placeholder="사원" + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("rightPanel.tableName", value)} + placeholder="테이블 선택" + open={rightTableOpen} + onOpenChange={setRightTableOpen} + /> +
+ + {/* 표시 컬럼 */} +
+
+ + +
+
+ {(config.rightPanel?.displayColumns || []).map((col, index) => ( +
+ updateDisplayColumn("right", index, "name", value)} + placeholder="컬럼" + /> + updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨" + className="h-9 text-sm flex-1" + /> + +
+ ))} +
+
+ +
+ + updateConfig("rightPanel.showSearch", checked)} + /> +
+ +
+ + updateConfig("rightPanel.showAddButton", checked)} + /> +
+ + {config.rightPanel?.showAddButton && ( + <> +
+ + updateConfig("rightPanel.addButtonLabel", e.target.value)} + placeholder="추가" + className="h-9 text-sm" + /> +
+
+ + updateConfig("rightPanel.addModalScreenId", value)} + placeholder="모달 화면 선택" + open={rightModalOpen} + onOpenChange={setRightModalOpen} + /> +
+ + )} +
+
+ + {/* 연결 설정 */} +
+

연결 설정 (조인)

+ +
+
+ + updateConfig("joinConfig.leftColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+ +
+ + updateConfig("joinConfig.rightColumn", value)} + placeholder="조인 컬럼 선택" + /> +
+
+
+ + {/* 데이터 전달 설정 */} +
+
+

데이터 전달 설정

+ +
+ +
+ {(config.dataTransferFields || []).map((field, index) => ( +
+
+ 필드 {index + 1} + +
+
+ + updateDataTransferField(index, "sourceColumn", value)} + placeholder="소스 컬럼" + /> +
+
+ + updateDataTransferField(index, "targetColumn", e.target.value)} + placeholder="모달에서 사용할 필드명" + className="h-9 text-sm" + /> +
+
+ ))} +
+
+ + {/* 레이아웃 설정 */} +
+

레이아웃 설정

+ +
+
+ + updateConfig("splitRatio", parseInt(e.target.value) || 30)} + min={10} + max={90} + className="h-9 text-sm" + /> +
+ +
+ + updateConfig("resizable", checked)} + /> +
+ +
+ + updateConfig("autoLoad", checked)} + /> +
+
+
+
+ ); +}; + +export default SplitPanelLayout2ConfigPanel; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx new file mode 100644 index 00000000..f582646e --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -0,0 +1,42 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { SplitPanelLayout2Definition } from "./index"; +import { SplitPanelLayout2Component } from "./SplitPanelLayout2Component"; + +/** + * SplitPanelLayout2 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer { + static componentDefinition = SplitPanelLayout2Definition; + + render(): React.ReactElement { + return ; + } + + /** + * 컴포넌트별 특화 메서드들 + */ + + // 좌측 패널 데이터 새로고침 + public refreshLeftPanel() { + // 컴포넌트 내부에서 처리 + } + + // 우측 패널 데이터 새로고침 + public refreshRightPanel() { + // 컴포넌트 내부에서 처리 + } + + // 선택된 좌측 항목 가져오기 + public getSelectedLeftItem(): any { + // 컴포넌트 내부 상태에서 가져옴 + return null; + } +} + +// 자동 등록 실행 +SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/split-panel-layout2/config.ts b/frontend/lib/registry/components/split-panel-layout2/config.ts new file mode 100644 index 00000000..493ddf2c --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/config.ts @@ -0,0 +1,57 @@ +/** + * SplitPanelLayout2 기본 설정 + */ + +import { SplitPanelLayout2Config } from "./types"; + +/** + * 기본 설정값 + */ +export const defaultConfig: Partial = { + leftPanel: { + title: "목록", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: false, + }, + rightPanel: { + title: "상세", + tableName: "", + displayColumns: [], + showSearch: true, + showAddButton: true, + addButtonLabel: "추가", + showEditButton: true, + showDeleteButton: true, + displayMode: "card", + emptyMessage: "좌측에서 항목을 선택해주세요", + }, + joinConfig: { + leftColumn: "", + rightColumn: "", + }, + dataTransferFields: [], + splitRatio: 30, + resizable: true, + minLeftWidth: 250, + minRightWidth: 400, + autoLoad: true, +}; + +/** + * 컴포넌트 메타데이터 + */ +export const componentMeta = { + id: "split-panel-layout2", + name: "분할 패널 레이아웃 v2", + nameEng: "Split Panel Layout v2", + description: "마스터-디테일 패턴의 좌우 분할 레이아웃 (데이터 전달 기능 포함)", + category: "layout", + webType: "container", + icon: "LayoutPanelLeft", + tags: ["레이아웃", "분할", "마스터", "디테일", "패널", "부서", "사원"], + version: "2.0.0", + author: "개발팀", +}; + diff --git a/frontend/lib/registry/components/split-panel-layout2/index.ts b/frontend/lib/registry/components/split-panel-layout2/index.ts new file mode 100644 index 00000000..64a88b11 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/index.ts @@ -0,0 +1,41 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import type { WebType } from "@/types/screen"; +import { SplitPanelLayout2Wrapper } from "./SplitPanelLayout2Component"; +import { SplitPanelLayout2ConfigPanel } from "./SplitPanelLayout2ConfigPanel"; +import { defaultConfig, componentMeta } from "./config"; + +/** + * SplitPanelLayout2 컴포넌트 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ +export const SplitPanelLayout2Definition = createComponentDefinition({ + id: componentMeta.id, + name: componentMeta.name, + nameEng: componentMeta.nameEng, + description: componentMeta.description, + category: ComponentCategory.LAYOUT, + webType: componentMeta.webType as WebType, + component: SplitPanelLayout2Wrapper, + defaultConfig: defaultConfig, + defaultSize: { width: 1200, height: 600 }, + configPanel: SplitPanelLayout2ConfigPanel, + icon: componentMeta.icon, + tags: componentMeta.tags, + version: componentMeta.version, + author: componentMeta.author, + documentation: "https://docs.example.com/components/split-panel-layout2", +}); + +// 타입 내보내기 +export type { + SplitPanelLayout2Config, + LeftPanelConfig, + RightPanelConfig, + JoinConfig, + DataTransferField, + ColumnConfig, +} from "./types"; + diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts new file mode 100644 index 00000000..ec0f61b5 --- /dev/null +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -0,0 +1,102 @@ +/** + * SplitPanelLayout2 컴포넌트 타입 정의 + * 마스터-디테일 패턴의 좌우 분할 레이아웃 (개선 버전) + */ + +/** + * 컬럼 설정 + */ +export interface ColumnConfig { + name: string; // 컬럼명 + label: string; // 표시 라벨 + width?: number; // 너비 (px) + bold?: boolean; // 굵게 표시 + format?: { + type?: "text" | "number" | "currency" | "date"; + thousandSeparator?: boolean; + decimalPlaces?: number; + prefix?: string; + suffix?: string; + dateFormat?: string; + }; +} + +/** + * 데이터 전달 필드 설정 + */ +export interface DataTransferField { + sourceColumn: string; // 좌측 패널의 컬럼명 + targetColumn: string; // 모달로 전달할 컬럼명 + label?: string; // 표시용 라벨 +} + +/** + * 좌측 패널 설정 + */ +export interface LeftPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + // 계층 구조 설정 + hierarchyConfig?: { + enabled: boolean; + parentColumn: string; // 부모 참조 컬럼 (예: parent_dept_code) + idColumn: string; // ID 컬럼 (예: dept_code) + }; +} + +/** + * 우측 패널 설정 + */ +export interface RightPanelConfig { + title?: string; // 패널 제목 + tableName: string; // 테이블명 + displayColumns: ColumnConfig[]; // 표시할 컬럼들 + searchColumn?: string; // 검색 대상 컬럼 + showSearch?: boolean; // 검색 표시 여부 + showAddButton?: boolean; // 추가 버튼 표시 + addButtonLabel?: string; // 추가 버튼 라벨 + addModalScreenId?: number; // 추가 모달 화면 ID + showEditButton?: boolean; // 수정 버튼 표시 + showDeleteButton?: boolean; // 삭제 버튼 표시 + displayMode?: "card" | "list"; // 표시 모드 + emptyMessage?: string; // 데이터 없을 때 메시지 +} + +/** + * 조인 설정 + */ +export interface JoinConfig { + leftColumn: string; // 좌측 테이블의 조인 컬럼 + rightColumn: string; // 우측 테이블의 조인 컬럼 +} + +/** + * 메인 설정 + */ +export interface SplitPanelLayout2Config { + // 패널 설정 + leftPanel: LeftPanelConfig; + rightPanel: RightPanelConfig; + + // 조인 설정 + joinConfig: JoinConfig; + + // 데이터 전달 설정 (모달로 전달할 필드) + dataTransferFields?: DataTransferField[]; + + // 레이아웃 설정 + splitRatio?: number; // 좌우 비율 (0-100, 기본 30) + resizable?: boolean; // 크기 조절 가능 여부 + minLeftWidth?: number; // 좌측 최소 너비 (px) + minRightWidth?: number; // 우측 최소 너비 (px) + + // 동작 설정 + autoLoad?: boolean; // 자동 데이터 로드 +} + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index f921016c..cb2d3f52 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -24,6 +24,7 @@ const CONFIG_PANEL_MAP: Record Promise> = { "table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"), "card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"), "split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"), + "split-panel-layout2": () => import("@/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel"), "repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"), "flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"), // 🆕 수주 등록 관련 컴포넌트들 From 294c61e0e3eec7c26be0fd66fa7ca6a8a594b579 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 3 Dec 2025 18:43:01 +0900 Subject: [PATCH 03/18] =?UTF-8?q?feat(split-panel-layout2):=20=EB=B3=B5?= =?UTF-8?q?=EC=88=98=20=EA=B2=80=EC=83=89=20=EC=BB=AC=EB=9F=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SearchColumnConfig 타입 추가 (types.ts) - 좌측/우측 패널 모두 여러 검색 컬럼 설정 가능 - ConfigPanel에 검색 컬럼 추가/삭제 UI 구현 - 검색 시 OR 조건으로 여러 컬럼 동시 검색 - 기존 searchColumn 단일 설정과 하위 호환성 유지 --- .../SplitPanelLayout2Component.tsx | 185 ++++-- .../SplitPanelLayout2ConfigPanel.tsx | 621 +++++++++++++----- .../components/split-panel-layout2/types.ts | 15 +- 3 files changed, 587 insertions(+), 234 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index be14038f..8a9d73a7 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -317,13 +317,20 @@ export const SplitPanelLayout2Component: React.FC { if (!leftSearchTerm) return leftData; - const searchColumn = config.leftPanel?.searchColumn; - if (!searchColumn) return leftData; + // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) + const searchColumns = config.leftPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; + const legacyColumn = config.leftPanel?.searchColumn; + const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; + + if (columnsToSearch.length === 0) return leftData; const filterRecursive = (items: any[]): any[] => { return items.filter((item) => { - const value = String(item[searchColumn] || "").toLowerCase(); - const matches = value.includes(leftSearchTerm.toLowerCase()); + // 여러 컬럼 중 하나라도 매칭되면 포함 + const matches = columnsToSearch.some((col) => { + const value = String(item[col] || "").toLowerCase(); + return value.includes(leftSearchTerm.toLowerCase()); + }); if (item.children?.length > 0) { const filteredChildren = filterRecursive(item.children); @@ -338,19 +345,26 @@ export const SplitPanelLayout2Component: React.FC { if (!rightSearchTerm) return rightData; - const searchColumn = config.rightPanel?.searchColumn; - if (!searchColumn) return rightData; + // 복수 검색 컬럼 지원 (searchColumns 우선, 없으면 searchColumn 사용) + const searchColumns = config.rightPanel?.searchColumns?.map((c) => c.columnName).filter(Boolean) || []; + const legacyColumn = config.rightPanel?.searchColumn; + const columnsToSearch = searchColumns.length > 0 ? searchColumns : legacyColumn ? [legacyColumn] : []; + + if (columnsToSearch.length === 0) return rightData; return rightData.filter((item) => { - const value = String(item[searchColumn] || "").toLowerCase(); - return value.includes(rightSearchTerm.toLowerCase()); + // 여러 컬럼 중 하나라도 매칭되면 포함 + return columnsToSearch.some((col) => { + const value = String(item[col] || "").toLowerCase(); + return value.includes(rightSearchTerm.toLowerCase()); + }); }); - }, [rightData, rightSearchTerm, config.rightPanel?.searchColumn]); + }, [rightData, rightSearchTerm, config.rightPanel?.searchColumns, config.rightPanel?.searchColumn]); // 리사이즈 핸들러 const handleResizeStart = useCallback((e: React.MouseEvent) => { @@ -451,15 +465,19 @@ export const SplitPanelLayout2Component: React.FC + col.displayRow === "name" || (!col.displayRow && idx === 0) + ); + const infoRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "info" || (!col.displayRow && idx > 0) + ); - const primaryValue = primaryColumn - ? item[primaryColumn.name] + // 이름 행의 첫 번째 값 (주요 표시 값) + const primaryValue = nameRowColumns[0] + ? item[nameRowColumns[0].name] : Object.values(item).find((v) => typeof v === "string" && v.length > 0); - const secondaryValue = secondaryColumn ? item[secondaryColumn.name] : null; return (
@@ -496,12 +514,38 @@ export const SplitPanelLayout2Component: React.FC -
- {primaryValue || "이름 없음"} + {/* 이름 행 (Name Row) */} +
+ + {primaryValue || "이름 없음"} + + {/* 이름 행의 추가 컬럼들 (배지 스타일) */} + {nameRowColumns.slice(1).map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + return ( + + {formatValue(value, col.format)} + + ); + })}
- {secondaryValue && ( -
- {secondaryValue} + {/* 정보 행 (Info Row) */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value) return null; + return ( + + {formatValue(value, col.format)} + + ); + }).filter(Boolean).reduce((acc: React.ReactNode[], curr, idx) => { + if (idx > 0) acc.push(|); + acc.push(curr); + return acc; + }, [])}
)}
@@ -521,53 +565,72 @@ export const SplitPanelLayout2Component: React.FC { const displayColumns = config.rightPanel?.displayColumns || []; - // 첫 번째 컬럼을 이름으로 사용 - const nameColumn = displayColumns[0]; - const name = nameColumn ? item[nameColumn.name] : "이름 없음"; - - // 나머지 컬럼들 - const otherColumns = displayColumns.slice(1); + // displayRow 설정에 따라 컬럼 분류 + // displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info) + const nameRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "name" || (!col.displayRow && idx === 0) + ); + const infoRowColumns = displayColumns.filter((col, idx) => + col.displayRow === "info" || (!col.displayRow && idx > 0) + ); return ( - - + +
- {/* 이름 */} -
- {name} - {otherColumns[0] && ( - - {item[otherColumns[0].name]} - - )} -
+ {/* 이름 행 (Name Row) */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value && idx > 0) return null; + + // 첫 번째 컬럼은 굵게 표시 + if (idx === 0) { + return ( + + {formatValue(value, col.format) || "이름 없음"} + + ); + } + // 나머지는 배지 스타일 + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )} - {/* 상세 정보 */} -
- {otherColumns.slice(1).map((col, idx) => { - const value = item[col.name]; - if (!value) return null; + {/* 정보 행 (Info Row) */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (!value) return null; - // 아이콘 결정 - let icon = null; - const colName = col.name.toLowerCase(); - if (colName.includes("tel") || colName.includes("phone")) { - icon = tel; - } else if (colName.includes("email")) { - icon = @; - } else if (colName.includes("sabun") || colName.includes("id")) { - icon = ID; - } + // 아이콘 결정 + let icon = null; + const colName = col.name.toLowerCase(); + if (colName.includes("tel") || colName.includes("phone")) { + icon = tel; + } else if (colName.includes("email")) { + icon = @; + } else if (colName.includes("sabun") || colName.includes("id")) { + icon = ID; + } - return ( - - {icon} - {formatValue(value, col.format)} - - ); - })} -
+ return ( + + {icon} + {formatValue(value, col.format)} + + ); + })} +
+ )}
{/* 액션 버튼 */} diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index 878ddb12..db3638cb 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -98,13 +98,35 @@ export const SplitPanelLayout2ConfigPanel: React.FC { setTablesLoading(true); try { - const response = await apiClient.get("/table/list?userLang=KR"); - const tableList = response.data?.data || response.data || []; - if (Array.isArray(tableList)) { - setTables(tableList); + const response = await apiClient.get("/table-management/tables"); + console.log("[loadTables] API 응답:", response.data); + + let tableList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + tableList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + tableList = response.data.data; + } else if (Array.isArray(response.data)) { + tableList = response.data; + } + + console.log("[loadTables] 추출된 테이블 목록:", tableList); + + if (tableList.length > 0) { + // 백엔드에서 카멜케이스(tableName)로 반환하므로 둘 다 처리 + const transformedTables = tableList.map((t: any) => ({ + table_name: t.tableName ?? t.table_name ?? t.name ?? "", + table_comment: t.displayName ?? t.table_comment ?? t.description ?? "", + })); + console.log("[loadTables] 변환된 테이블 목록:", transformedTables); + setTables(transformedTables); + } else { + console.warn("[loadTables] 테이블 목록이 비어있습니다"); + setTables([]); } } catch (error) { console.error("테이블 목록 로드 실패:", error); + setTables([]); } finally { setTablesLoading(false); } @@ -114,20 +136,38 @@ export const SplitPanelLayout2ConfigPanel: React.FC { setScreensLoading(true); try { - const response = await apiClient.get("/screen/list"); + // size를 크게 설정하여 모든 화면 가져오기 + const response = await apiClient.get("/screen-management/screens?size=1000"); console.log("[loadScreens] API 응답:", response.data); - const screenList = response.data?.data || response.data || []; - if (Array.isArray(screenList)) { + + // API 응답 구조: { success, data: [...], total, page, size } + let screenList: any[] = []; + if (response.data?.success && Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data?.data)) { + screenList = response.data.data; + } else if (Array.isArray(response.data)) { + screenList = response.data; + } + + console.log("[loadScreens] 추출된 화면 목록:", screenList); + + if (screenList.length > 0) { + // 백엔드에서 카멜케이스(screenId, screenName)로 반환하므로 둘 다 처리 const transformedScreens = screenList.map((s: any) => ({ - screen_id: s.screen_id || s.id, - screen_name: s.screen_name || s.name, - screen_code: s.screen_code || s.code || "", + screen_id: s.screenId ?? s.screen_id ?? s.id, + screen_name: s.screenName ?? s.screen_name ?? s.name ?? `화면 ${s.screenId || s.screen_id || s.id}`, + screen_code: s.screenCode ?? s.screen_code ?? s.code ?? "", })); console.log("[loadScreens] 변환된 화면 목록:", transformedScreens); setScreens(transformedScreens); + } else { + console.warn("[loadScreens] 화면 목록이 비어있습니다"); + setScreens([]); } } catch (error) { console.error("화면 목록 로드 실패:", error); + setScreens([]); } finally { setScreensLoading(false); } @@ -137,17 +177,52 @@ export const SplitPanelLayout2ConfigPanel: React.FC { if (!tableName) return; try { - const response = await apiClient.get(`/table/${tableName}/columns`); - const columnList = response.data?.data || response.data || []; - if (Array.isArray(columnList)) { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=200`); + console.log(`[loadColumns] ${side} API 응답:`, response.data); + + // API 응답 구조: { success, data: { columns: [...], total, page, totalPages } } + let columnList: any[] = []; + if (response.data?.success && response.data?.data?.columns) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data?.columns)) { + columnList = response.data.data.columns; + } else if (Array.isArray(response.data?.data)) { + columnList = response.data.data; + } else if (Array.isArray(response.data)) { + columnList = response.data; + } + + console.log(`[loadColumns] ${side} 추출된 컬럼 목록:`, columnList); + + if (columnList.length > 0) { + // 백엔드에서 카멜케이스(columnName)로 반환하므로 둘 다 처리 + const transformedColumns = columnList.map((c: any) => ({ + column_name: c.columnName ?? c.column_name ?? c.name ?? "", + data_type: c.dataType ?? c.data_type ?? c.type ?? "", + column_comment: c.displayName ?? c.column_comment ?? c.label ?? "", + })); + console.log(`[loadColumns] ${side} 변환된 컬럼 목록:`, transformedColumns); + if (side === "left") { - setLeftColumns(columnList); + setLeftColumns(transformedColumns); } else { - setRightColumns(columnList); + setRightColumns(transformedColumns); + } + } else { + console.warn(`[loadColumns] ${side} 컬럼 목록이 비어있습니다`); + if (side === "left") { + setLeftColumns([]); + } else { + setRightColumns([]); } } } catch (error) { console.error(`${side} 컬럼 목록 로드 실패:`, error); + if (side === "left") { + setLeftColumns([]); + } else { + setRightColumns([]); + } } }, []); @@ -177,59 +252,63 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; - }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( - - - - - - - - - 테이블이 없습니다 - - {tables.map((table) => ( - { - onValueChange(selectedValue); - onOpenChange(false); - }} - > - - - {table.table_comment || table.table_name} - {table.table_name} - - - ))} - - - - - - ); + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => { + const selectedTable = tables.find((t) => t.table_name === value); + + return ( + + + + + + + + + + {tables.length === 0 ? "테이블 목록을 불러오는 중..." : "검색 결과가 없습니다"} + + + {tables.map((table, index) => ( + { + onValueChange(selectedValue); + onOpenChange(false); + }} + > + + + {table.table_comment || table.table_name} + {table.table_name} + + + ))} + + + + + + ); + }; // 화면 선택 컴포넌트 const ScreenSelect: React.FC<{ @@ -238,64 +317,70 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; - }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => ( - - - - - - - - - 화면이 없습니다 - - {screens.map((screen, index) => ( - { - const screenId = parseInt(selectedValue.split("-")[0]); - console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); - onValueChange(screenId); - onOpenChange(false); - }} - className="flex items-center" - > -
- - - {screen.screen_name} - {screen.screen_code} - -
-
- ))} -
-
-
-
-
- ); + }> = ({ value, onValueChange, placeholder, open, onOpenChange }) => { + const selectedScreen = screens.find((s) => s.screen_id === value); + + return ( + + + + + + + + + + {screens.length === 0 ? "화면 목록을 불러오는 중..." : "검색 결과가 없습니다"} + + + {screens.map((screen, index) => ( + { + const screenId = parseInt(selectedValue.split("-")[0]); + console.log("[ScreenSelect] onSelect:", { selectedValue, screenId, screen }); + onValueChange(isNaN(screenId) ? undefined : screenId); + onOpenChange(false); + }} + className="flex items-center" + > +
+ + + {screen.screen_name} + {screen.screen_code} + +
+
+ ))} +
+
+
+
+
+ ); + }; // 컬럼 선택 컴포넌트 const ColumnSelect: React.FC<{ @@ -303,20 +388,36 @@ export const SplitPanelLayout2ConfigPanel: React.FC void; placeholder: string; - }> = ({ columns, value, onValueChange, placeholder }) => ( - - ); + }> = ({ columns, value, onValueChange, placeholder }) => { + // 현재 선택된 값의 라벨 찾기 + const selectedColumn = columns.find((col) => col.column_name === value); + const displayValue = selectedColumn + ? selectedColumn.column_comment || selectedColumn.column_name + : value || ""; + + return ( + + ); + }; // 표시 컬럼 추가 const addDisplayColumn = (side: "left" | "right") => { @@ -405,30 +506,52 @@ export const SplitPanelLayout2ConfigPanel: React.FC
-
+
{(config.leftPanel?.displayColumns || []).map((col, index) => ( -
+
+
+ 컬럼 {index + 1} + +
updateDisplayColumn("left", index, "name", value)} - placeholder="컬럼" + placeholder="컬럼 선택" /> - updateDisplayColumn("left", index, "label", e.target.value)} - placeholder="라벨" - className="h-9 text-sm flex-1" - /> - +
+ + +
))} + {(config.leftPanel?.displayColumns || []).length === 0 && ( +
+ 표시할 컬럼을 추가하세요 +
+ )}
@@ -440,6 +563,61 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {config.leftPanel?.showSearch && ( +
+
+ + +
+
+ {(config.leftPanel?.searchColumns || []).map((searchCol, index) => ( +
+ { + const current = [...(config.leftPanel?.searchColumns || [])]; + current[index] = { ...current[index], columnName: value }; + updateConfig("leftPanel.searchColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(config.leftPanel?.searchColumns || []).length === 0 && ( +
+ 검색할 컬럼을 추가하세요 +
+ )} +
+
+ )} +
-
+
{(config.rightPanel?.displayColumns || []).map((col, index) => ( -
+
+
+ 컬럼 {index + 1} + +
updateDisplayColumn("right", index, "name", value)} - placeholder="컬럼" + placeholder="컬럼 선택" /> - updateDisplayColumn("right", index, "label", e.target.value)} - placeholder="라벨" - className="h-9 text-sm flex-1" - /> - +
+ + +
))} + {(config.rightPanel?.displayColumns || []).length === 0 && ( +
+ 표시할 컬럼을 추가하세요 +
+ )}
@@ -540,6 +740,61 @@ export const SplitPanelLayout2ConfigPanel: React.FC
+ {config.rightPanel?.showSearch && ( +
+
+ + +
+
+ {(config.rightPanel?.searchColumns || []).map((searchCol, index) => ( +
+ { + const current = [...(config.rightPanel?.searchColumns || [])]; + current[index] = { ...current[index], columnName: value }; + updateConfig("rightPanel.searchColumns", current); + }} + placeholder="컬럼 선택" + /> + +
+ ))} + {(config.rightPanel?.searchColumns || []).length === 0 && ( +
+ 검색할 컬럼을 추가하세요 +
+ )} +
+
+ )} +
-

연결 설정 (조인)

+

연결 설정 (조인)

+ + {/* 설명 */} +
+

좌측 패널 선택 시 우측 패널 데이터 필터링

+

좌측에서 항목을 선택하면 좌측 조인 컬럼의 값으로 우측 테이블을 필터링합니다.

+

예: 부서(dept_code) 선택 시 해당 부서의 사원만 표시

+
@@ -604,19 +866,31 @@ export const SplitPanelLayout2ConfigPanel: React.FC
-

데이터 전달 설정

+

데이터 전달 설정

+ {/* 설명 */} +
+

우측 패널 추가 버튼 클릭 시 모달로 데이터 전달

+

좌측에서 선택한 항목의 값을 모달 폼에 자동으로 채워줍니다.

+

예: dept_code를 모달의 dept_code 필드에 자동 입력

+
+
{(config.dataTransferFields || []).map((field, index) => ( -
+
필드 {index + 1} -
@@ -640,6 +914,11 @@ export const SplitPanelLayout2ConfigPanel: React.FC
))} + {(config.dataTransferFields || []).length === 0 && ( +
+ 전달할 필드를 추가하세요 +
+ )}
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index ec0f61b5..a5813600 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -9,6 +9,7 @@ export interface ColumnConfig { name: string; // 컬럼명 label: string; // 표시 라벨 + displayRow?: "name" | "info"; // 표시 위치 (name: 이름 행, info: 정보 행) width?: number; // 너비 (px) bold?: boolean; // 굵게 표시 format?: { @@ -30,6 +31,14 @@ export interface DataTransferField { label?: string; // 표시용 라벨 } +/** + * 검색 컬럼 설정 + */ +export interface SearchColumnConfig { + columnName: string; // 검색 대상 컬럼명 + label?: string; // 표시 라벨 (없으면 컬럼명 사용) +} + /** * 좌측 패널 설정 */ @@ -37,7 +46,8 @@ export interface LeftPanelConfig { title?: string; // 패널 제목 tableName: string; // 테이블명 displayColumns: ColumnConfig[]; // 표시할 컬럼들 - searchColumn?: string; // 검색 대상 컬럼 + searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) + searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 showAddButton?: boolean; // 추가 버튼 표시 addButtonLabel?: string; // 추가 버튼 라벨 @@ -57,7 +67,8 @@ export interface RightPanelConfig { title?: string; // 패널 제목 tableName: string; // 테이블명 displayColumns: ColumnConfig[]; // 표시할 컬럼들 - searchColumn?: string; // 검색 대상 컬럼 + searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) + searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 showAddButton?: boolean; // 추가 버튼 표시 addButtonLabel?: string; // 추가 버튼 라벨 From 40c43bab16ed8490c5bae48a97189ccc9cabc53c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 4 Dec 2025 13:28:13 +0900 Subject: [PATCH 04/18] =?UTF-8?q?feat(numbering-rule):=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=EA=B7=9C=EC=B9=99=20=EA=B5=AC=EB=B6=84=EC=9E=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SeparatorType 타입 및 SEPARATOR_OPTIONS 상수 추가 - 구분자 선택 UI 추가 (없음, -, _, ., /, 직접입력) - 직접 입력 시 최대 2자 제한 - 새 규칙 생성 시 기본값 하이픈(-) - Select 빈 문자열 에러 해결 (value: "" -> "none") --- .../numbering-rule/NumberingRuleDesigner.tsx | 89 ++++++++++++++++++- frontend/types/numbering-rule.ts | 21 +++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index bfdb69c2..b23d85de 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, Trash2 } from "lucide-react"; import { toast } from "sonner"; -import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; +import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; import { @@ -47,6 +47,10 @@ export const NumberingRuleDesigner: React.FC = ({ const [rightTitle, setRightTitle] = useState("규칙 편집"); const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); + + // 구분자 관련 상태 + const [separatorType, setSeparatorType] = useState("-"); + const [customSeparator, setCustomSeparator] = useState(""); useEffect(() => { loadRules(); @@ -87,6 +91,50 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); + // currentRule이 변경될 때 구분자 상태 동기화 + useEffect(() => { + if (currentRule) { + const sep = currentRule.separator ?? "-"; + // 빈 문자열이면 "none" + if (sep === "") { + setSeparatorType("none"); + setCustomSeparator(""); + return; + } + // 미리 정의된 구분자인지 확인 (none, custom 제외) + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + setSeparatorType(predefinedOption.value); + setCustomSeparator(""); + } else { + // 직접 입력된 구분자 + setSeparatorType("custom"); + setCustomSeparator(sep); + } + } + }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + + // 구분자 변경 핸들러 + const handleSeparatorChange = useCallback((type: SeparatorType) => { + setSeparatorType(type); + if (type !== "custom") { + const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); + const newSeparator = option?.displayValue ?? ""; + setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); + setCustomSeparator(""); + } + }, []); + + // 직접 입력 구분자 변경 핸들러 + const handleCustomSeparatorChange = useCallback((value: string) => { + // 최대 2자 제한 + const trimmedValue = value.slice(0, 2); + setCustomSeparator(trimmedValue); + setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + }, []); + const handleAddPart = useCallback(() => { if (!currentRule) return; @@ -372,7 +420,44 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 자동 감지된 테이블 정보 표시 */} + {/* 두 번째 줄: 구분자 설정 */} +
+
+ + +
+ {separatorType === "custom" && ( +
+ + handleCustomSeparatorChange(e.target.value)} + className="h-9" + placeholder="최대 2자" + maxLength={2} + /> +
+ )} +

+ 규칙 사이에 들어갈 문자입니다 +

+
+ + {/* 세 번째 줄: 자동 감지된 테이블 정보 표시 */} {currentTableName && (
diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 9cd81bdb..c44e2354 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -123,3 +123,24 @@ export const RESET_PERIOD_OPTIONS: Array<{ { value: "monthly", label: "월별 초기화" }, { value: "yearly", label: "연별 초기화" }, ]; + +/** + * 구분자 옵션 + * - 규칙과 규칙 사이에 들어가는 문자 + * - "none"은 구분자 없음 + * - "custom"은 직접 입력 (최대 2자) + */ +export type SeparatorType = "none" | "-" | "_" | "." | "/" | "custom"; + +export const SEPARATOR_OPTIONS: Array<{ + value: SeparatorType; + label: string; + displayValue: string; +}> = [ + { value: "none", label: "없음", displayValue: "" }, + { value: "-", label: "하이픈 (-)", displayValue: "-" }, + { value: "_", label: "언더스코어 (_)", displayValue: "_" }, + { value: ".", label: "점 (.)", displayValue: "." }, + { value: "/", label: "슬래시 (/)", displayValue: "/" }, + { value: "custom", label: "직접입력", displayValue: "" }, +]; From dfc83f611445a23f0b1fcc39adae789f5bd0e812 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 4 Dec 2025 14:32:04 +0900 Subject: [PATCH 05/18] =?UTF-8?q?feat(split-panel-layout2):=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=AA=A8=EB=93=9C,=20=EC=88=98=EC=A0=95/?= =?UTF-8?q?=EC=82=AD=EC=A0=9C,=20=EB=B3=B5=EC=88=98=20=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표시 모드 추가 (card/table) - 카드 모드 라벨 표시 옵션 (이름 행/정보 행 가로 배치) - 체크박스 선택 기능 (전체/개별 선택) - 개별 수정/삭제 핸들러 구현 (openEditModal, DELETE API) - 복수 액션 버튼 배열 지원 (add, edit, bulk-delete, custom) - 설정 패널에 표시 라벨 입력 필드 추가 - 기본키 컬럼 설정 옵션 추가 --- .../SplitPanelLayout2Component.tsx | 581 +++++++++++++++--- .../SplitPanelLayout2ConfigPanel.tsx | 266 ++++++++ .../components/split-panel-layout2/types.ts | 29 +- 3 files changed, 796 insertions(+), 80 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index 8a9d73a7..0dd00543 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -6,10 +6,30 @@ import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, + ActionButtonConfig, } from "./types"; import { defaultConfig } from "./config"; import { cn } from "@/lib/utils"; -import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2 } from "lucide-react"; +import { Search, Plus, ChevronRight, ChevronDown, Edit, Trash2, Users, Building2, Check, MoreHorizontal } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -59,6 +79,14 @@ export const SplitPanelLayout2Component: React.FC>({}); const [rightColumnLabels, setRightColumnLabels] = useState>({}); + // 우측 패널 선택 상태 (체크박스용) + const [selectedRightItems, setSelectedRightItems] = useState>(new Set()); + + // 삭제 확인 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + const [isBulkDelete, setIsBulkDelete] = useState(false); + // 좌측 데이터 로드 const loadLeftData = useCallback(async () => { @@ -233,6 +261,178 @@ export const SplitPanelLayout2Component: React.FC { + return config.rightPanel?.primaryKeyColumn || "id"; + }, [config.rightPanel?.primaryKeyColumn]); + + // 우측 패널 수정 버튼 클릭 + const handleEditItem = useCallback((item: any) => { + // 수정용 모달 화면 ID 결정 (editModalScreenId 우선, 없으면 addModalScreenId 사용) + const modalScreenId = config.rightPanel?.editModalScreenId || config.rightPanel?.addModalScreenId; + + if (!modalScreenId) { + toast.error("연결된 모달 화면이 없습니다."); + return; + } + + // EditModal 열기 이벤트 발생 (수정 모드) + const event = new CustomEvent("openEditModal", { + detail: { + screenId: modalScreenId, + title: "수정", + modalSize: "lg", + editData: item, // 기존 데이터 전달 + isCreateMode: false, // 수정 모드 + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + console.log("[SplitPanelLayout2] 수정 모달 열기:", item); + }, [config.rightPanel?.editModalScreenId, config.rightPanel?.addModalScreenId, selectedLeftItem, loadRightData]); + + // 우측 패널 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleDeleteClick = useCallback((item: any) => { + setItemToDelete(item); + setIsBulkDelete(false); + setDeleteDialogOpen(true); + }, []); + + // 일괄 삭제 버튼 클릭 (확인 다이얼로그 표시) + const handleBulkDeleteClick = useCallback(() => { + if (selectedRightItems.size === 0) { + toast.error("삭제할 항목을 선택해주세요."); + return; + } + setIsBulkDelete(true); + setDeleteDialogOpen(true); + }, [selectedRightItems.size]); + + // 실제 삭제 실행 + const executeDelete = useCallback(async () => { + if (!config.rightPanel?.tableName) { + toast.error("테이블 설정이 없습니다."); + return; + } + + const pkColumn = getPrimaryKeyColumn(); + + try { + if (isBulkDelete) { + // 일괄 삭제 + const idsToDelete = Array.from(selectedRightItems); + console.log("[SplitPanelLayout2] 일괄 삭제:", idsToDelete); + + for (const id of idsToDelete) { + await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${id}`); + } + + toast.success(`${idsToDelete.length}개 항목이 삭제되었습니다.`); + setSelectedRightItems(new Set()); + } else if (itemToDelete) { + // 단일 삭제 + const itemId = itemToDelete[pkColumn]; + console.log("[SplitPanelLayout2] 단일 삭제:", itemId); + + await apiClient.delete(`/table-management/tables/${config.rightPanel.tableName}/data/${itemId}`); + toast.success("항목이 삭제되었습니다."); + } + + // 데이터 새로고침 + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + } catch (error: any) { + console.error("[SplitPanelLayout2] 삭제 실패:", error); + toast.error(`삭제 실패: ${error.message}`); + } finally { + setDeleteDialogOpen(false); + setItemToDelete(null); + setIsBulkDelete(false); + } + }, [config.rightPanel?.tableName, getPrimaryKeyColumn, isBulkDelete, selectedRightItems, itemToDelete, selectedLeftItem, loadRightData]); + + // 개별 체크박스 선택/해제 + const handleSelectItem = useCallback((itemId: string | number, checked: boolean) => { + setSelectedRightItems((prev) => { + const newSet = new Set(prev); + if (checked) { + newSet.add(itemId); + } else { + newSet.delete(itemId); + } + return newSet; + }); + }, []); + + // 액션 버튼 클릭 핸들러 + const handleActionButton = useCallback((btn: ActionButtonConfig) => { + switch (btn.action) { + case "add": + if (btn.modalScreenId) { + // 데이터 전달 필드 설정 + const initialData: Record = {}; + if (selectedLeftItem && config.dataTransferFields) { + for (const field of config.dataTransferFields) { + if (field.sourceColumn && field.targetColumn) { + initialData[field.targetColumn] = selectedLeftItem[field.sourceColumn]; + } + } + } + + const event = new CustomEvent("openEditModal", { + detail: { + screenId: btn.modalScreenId, + title: btn.label || "추가", + modalSize: "lg", + editData: initialData, + isCreateMode: true, + onSave: () => { + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(event); + } + break; + + case "edit": + // 선택된 항목이 1개일 때만 수정 + if (selectedRightItems.size === 1) { + const pkColumn = getPrimaryKeyColumn(); + const selectedId = Array.from(selectedRightItems)[0]; + const item = rightData.find((d) => d[pkColumn] === selectedId); + if (item) { + handleEditItem(item); + } + } else if (selectedRightItems.size > 1) { + toast.error("수정할 항목을 1개만 선택해주세요."); + } else { + toast.error("수정할 항목을 선택해주세요."); + } + break; + + case "delete": + case "bulk-delete": + handleBulkDeleteClick(); + break; + + case "custom": + // 커스텀 액션 (추후 확장) + console.log("[SplitPanelLayout2] 커스텀 액션:", btn); + break; + + default: + break; + } + }, [selectedLeftItem, config.dataTransferFields, loadRightData, selectedRightItems, getPrimaryKeyColumn, rightData, handleEditItem, handleBulkDeleteClick]); + // 컬럼 라벨 로드 const loadColumnLabels = useCallback(async (tableName: string, setLabels: (labels: Record) => void) => { if (!tableName) return; @@ -366,6 +566,17 @@ export const SplitPanelLayout2Component: React.FC { + if (checked) { + const pkColumn = getPrimaryKeyColumn(); + const allIds = new Set(filteredRightData.map((item) => item[pkColumn])); + setSelectedRightItems(allIds); + } else { + setSelectedRightItems(new Set()); + } + }, [filteredRightData, getPrimaryKeyColumn]); + // 리사이즈 핸들러 const handleResizeStart = useCallback((e: React.MouseEvent) => { if (!config.resizable) return; @@ -564,6 +775,10 @@ export const SplitPanelLayout2Component: React.FC { const displayColumns = config.rightPanel?.displayColumns || []; + const showLabels = config.rightPanel?.showLabels ?? false; + const showCheckbox = config.rightPanel?.showCheckbox ?? false; + const pkColumn = getPrimaryKeyColumn(); + const itemId = item[pkColumn]; // displayRow 설정에 따라 컬럼 분류 // displayRow가 "name"이면 이름 행, "info"이면 정보 행 (기본값: 첫 번째는 name, 나머지는 info) @@ -577,72 +792,113 @@ export const SplitPanelLayout2Component: React.FC -
+
+ {/* 체크박스 */} + {showCheckbox && ( + handleSelectItem(itemId, !!checked)} + className="mt-1" + /> + )} +
- {/* 이름 행 (Name Row) */} - {nameRowColumns.length > 0 && ( -
- {nameRowColumns.map((col, idx) => { - const value = item[col.name]; - if (!value && idx > 0) return null; - - // 첫 번째 컬럼은 굵게 표시 - if (idx === 0) { - return ( - - {formatValue(value, col.format) || "이름 없음"} - - ); - } - // 나머지는 배지 스타일 - return ( - - {formatValue(value, col.format)} - - ); - })} + {/* showLabels가 true이면 라벨: 값 형식으로 가로 배치 */} + {showLabels ? ( +
+ {/* 이름 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {col.label || col.name}: + {formatValue(value, col.format)} + + ); + })} +
+ )} + {/* 정보 행: 라벨: 값, 라벨: 값 형식으로 가로 배치 */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {col.label || col.name}: + {formatValue(value, col.format)} + + ); + })} +
+ )}
- )} - - {/* 정보 행 (Info Row) */} - {infoRowColumns.length > 0 && ( -
- {infoRowColumns.map((col, idx) => { - const value = item[col.name]; - if (!value) return null; - - // 아이콘 결정 - let icon = null; - const colName = col.name.toLowerCase(); - if (colName.includes("tel") || colName.includes("phone")) { - icon = tel; - } else if (colName.includes("email")) { - icon = @; - } else if (colName.includes("sabun") || colName.includes("id")) { - icon = ID; - } - - return ( - - {icon} - {formatValue(value, col.format)} - - ); - })} + ) : ( + // showLabels가 false일 때 기존 방식 유지 (라벨 없이 값만) +
+ {/* 이름 행 */} + {nameRowColumns.length > 0 && ( +
+ {nameRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + if (idx === 0) { + return ( + + {formatValue(value, col.format)} + + ); + } + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )} + {/* 정보 행 */} + {infoRowColumns.length > 0 && ( +
+ {infoRowColumns.map((col, idx) => { + const value = item[col.name]; + if (value === null || value === undefined) return null; + return ( + + {formatValue(value, col.format)} + + ); + })} +
+ )}
)}
- {/* 액션 버튼 */} + {/* 액션 버튼 (개별 수정/삭제) */}
{config.rightPanel?.showEditButton && ( - )} {config.rightPanel?.showDeleteButton && ( - )}
@@ -652,6 +908,139 @@ export const SplitPanelLayout2Component: React.FC { + const displayColumns = config.rightPanel?.displayColumns || []; + const showCheckbox = config.rightPanel?.showCheckbox ?? true; // 테이블 모드는 기본 체크박스 표시 + const pkColumn = getPrimaryKeyColumn(); + const allSelected = filteredRightData.length > 0 && + filteredRightData.every((item) => selectedRightItems.has(item[pkColumn])); + const someSelected = filteredRightData.some((item) => selectedRightItems.has(item[pkColumn])); + + return ( +
+ + + + {showCheckbox && ( + + { + if (el) { + (el as any).indeterminate = someSelected && !allSelected; + } + }} + onCheckedChange={handleSelectAll} + /> + + )} + {displayColumns.map((col, idx) => ( + + {col.label || col.name} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + 작업 + )} + + + + {filteredRightData.length === 0 ? ( + + + 등록된 항목이 없습니다 + + + ) : ( + filteredRightData.map((item, index) => { + const itemId = item[pkColumn]; + return ( + + {showCheckbox && ( + + handleSelectItem(itemId, !!checked)} + /> + + )} + {displayColumns.map((col, colIdx) => ( + + {formatValue(item[col.name], col.format)} + + ))} + {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && ( + +
+ {config.rightPanel?.showEditButton && ( + + )} + {config.rightPanel?.showDeleteButton && ( + + )} +
+
+ )} +
+ ); + }) + )} +
+
+
+ ); + }; + + // 액션 버튼 렌더링 + const renderActionButtons = () => { + const actionButtons = config.rightPanel?.actionButtons; + if (!actionButtons || actionButtons.length === 0) return null; + + return ( +
+ {actionButtons.map((btn) => ( + + ))} +
+ ); + }; + // 디자인 모드 렌더링 if (isDesignMode) { return ( @@ -765,20 +1154,32 @@ export const SplitPanelLayout2Component: React.FC
-

- {selectedLeftItem - ? config.leftPanel?.displayColumns?.[0] - ? selectedLeftItem[config.leftPanel.displayColumns[0].name] - : config.rightPanel?.title || "상세" - : config.rightPanel?.title || "상세"} -

-
+
+

+ {selectedLeftItem + ? config.leftPanel?.displayColumns?.[0] + ? selectedLeftItem[config.leftPanel.displayColumns[0].name] + : config.rightPanel?.title || "상세" + : config.rightPanel?.title || "상세"} +

{selectedLeftItem && ( - {rightData.length}명 + ({rightData.length}건) )} - {config.rightPanel?.showAddButton && selectedLeftItem && ( + {/* 선택된 항목 수 표시 */} + {selectedRightItems.size > 0 && ( + + {selectedRightItems.size}개 선택됨 + + )} +
+
+ {/* 복수 액션 버튼 (actionButtons 설정 시) */} + {selectedLeftItem && renderActionButtons()} + + {/* 기존 단일 추가 버튼 (하위 호환성) */} + {config.rightPanel?.showAddButton && selectedLeftItem && !config.rightPanel?.actionButtons?.length && (
- ) : filteredRightData.length === 0 ? ( -
- - 등록된 항목이 없습니다 -
) : ( -
- {filteredRightData.map((item, index) => renderRightCard(item, index))} -
+ <> + {/* displayMode에 따라 카드 또는 테이블 렌더링 */} + {config.rightPanel?.displayMode === "table" ? ( + renderRightTable() + ) : filteredRightData.length === 0 ? ( +
+ + 등록된 항목이 없습니다 +
+ ) : ( +
+ {filteredRightData.map((item, index) => renderRightCard(item, index))} +
+ )} + )}
+ + {/* 삭제 확인 다이얼로그 */} + + + + 삭제 확인 + + {isBulkDelete + ? `선택한 ${selectedRightItems.size}개 항목을 삭제하시겠습니까?` + : "이 항목을 삭제하시겠습니까?"} +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
); }; diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx index db3638cb..da520d92 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2ConfigPanel.tsx @@ -530,6 +530,15 @@ export const SplitPanelLayout2ConfigPanel: React.FC updateDisplayColumn("left", index, "name", value)} placeholder="컬럼 선택" /> +
+ + updateDisplayColumn("left", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateDisplayColumn("right", index, "label", e.target.value)} + placeholder="라벨명 (미입력 시 컬럼명 사용)" + className="h-8 text-xs" + /> +
updateConfig("rightPanel.displayMode", value)} + > + + + + + 카드형 + 테이블형 + + +

+ 카드형: 카드 형태로 정보 표시, 테이블형: 표 형태로 정보 표시 +

+
+ + {/* 카드 모드 전용 옵션 */} + {(config.rightPanel?.displayMode || "card") === "card" && ( +
+
+ +

라벨: 값 형식으로 표시

+
+ updateConfig("rightPanel.showLabels", checked)} + /> +
+ )} + + {/* 체크박스 표시 */} +
+
+ +

항목 선택 기능 활성화

+
+ updateConfig("rightPanel.showCheckbox", checked)} + /> +
+ + {/* 수정/삭제 버튼 */} +
+ +
+
+ + updateConfig("rightPanel.showEditButton", checked)} + /> +
+
+ + updateConfig("rightPanel.showDeleteButton", checked)} + /> +
+
+
+ + {/* 수정 모달 화면 (수정 버튼 활성화 시) */} + {config.rightPanel?.showEditButton && ( +
+ + updateConfig("rightPanel.editModalScreenId", value)} + placeholder="수정 모달 화면 선택 (미선택 시 추가 모달 사용)" + open={false} + onOpenChange={() => {}} + /> +

+ 미선택 시 추가 모달 화면을 수정용으로 사용 +

+
+ )} + + {/* 기본키 컬럼 */} +
+ + updateConfig("rightPanel.primaryKeyColumn", value)} + placeholder="기본키 컬럼 선택 (기본: id)" + /> +

+ 수정/삭제 시 사용할 기본키 컬럼 (미선택 시 id 사용) +

+
+ + {/* 복수 액션 버튼 설정 */} +
+
+ + +
+

+ 복수의 버튼을 추가하면 기존 단일 추가 버튼 대신 사용됩니다 +

+
+ {(config.rightPanel?.actionButtons || []).map((btn, index) => ( +
+
+ 버튼 {index + 1} + +
+
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], label: e.target.value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="버튼 라벨" + className="h-8 text-xs" + /> +
+
+ + +
+
+ + +
+
+ + +
+ {btn.action === "add" && ( +
+ + { + const current = [...(config.rightPanel?.actionButtons || [])]; + current[index] = { ...current[index], modalScreenId: value }; + updateConfig("rightPanel.actionButtons", current); + }} + placeholder="모달 화면 선택" + open={false} + onOpenChange={() => {}} + /> +
+ )} +
+ ))} + {(config.rightPanel?.actionButtons || []).length === 0 && ( +
+ 액션 버튼을 추가하세요 (선택사항) +
+ )} +
+
diff --git a/frontend/lib/registry/components/split-panel-layout2/types.ts b/frontend/lib/registry/components/split-panel-layout2/types.ts index a5813600..9d470e7a 100644 --- a/frontend/lib/registry/components/split-panel-layout2/types.ts +++ b/frontend/lib/registry/components/split-panel-layout2/types.ts @@ -22,6 +22,18 @@ export interface ColumnConfig { }; } +/** + * 액션 버튼 설정 + */ +export interface ActionButtonConfig { + id: string; // 고유 ID + label: string; // 버튼 라벨 + variant?: "default" | "outline" | "destructive" | "ghost"; // 버튼 스타일 + icon?: string; // lucide 아이콘명 (예: "Plus", "Edit", "Trash2") + modalScreenId?: number; // 연결할 모달 화면 ID + action?: "add" | "edit" | "delete" | "bulk-delete" | "custom"; // 버튼 동작 유형 +} + /** * 데이터 전달 필드 설정 */ @@ -70,12 +82,17 @@ export interface RightPanelConfig { searchColumn?: string; // 검색 대상 컬럼 (단일, 하위 호환성) searchColumns?: SearchColumnConfig[]; // 검색 대상 컬럼들 (복수) showSearch?: boolean; // 검색 표시 여부 - showAddButton?: boolean; // 추가 버튼 표시 - addButtonLabel?: string; // 추가 버튼 라벨 - addModalScreenId?: number; // 추가 모달 화면 ID - showEditButton?: boolean; // 수정 버튼 표시 - showDeleteButton?: boolean; // 삭제 버튼 표시 - displayMode?: "card" | "list"; // 표시 모드 + showAddButton?: boolean; // 추가 버튼 표시 (하위 호환성) + addButtonLabel?: string; // 추가 버튼 라벨 (하위 호환성) + addModalScreenId?: number; // 추가 모달 화면 ID (하위 호환성) + showEditButton?: boolean; // 수정 버튼 표시 (하위 호환성) + showDeleteButton?: boolean; // 삭제 버튼 표시 (하위 호환성) + editModalScreenId?: number; // 수정 모달 화면 ID + displayMode?: "card" | "table"; // 표시 모드 (card: 카드형, table: 테이블형) + showLabels?: boolean; // 카드 모드에서 라벨 표시 여부 (라벨: 값 형식) + showCheckbox?: boolean; // 체크박스 표시 여부 (테이블 모드에서 일괄 선택용) + actionButtons?: ActionButtonConfig[]; // 복수 액션 버튼 배열 + primaryKeyColumn?: string; // 기본키 컬럼명 (수정/삭제용, 기본: id) emptyMessage?: string; // 데이터 없을 때 메시지 } From ef3b85f343b8dcf5cdcd589c84b45cd6de461b4e Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 4 Dec 2025 17:26:29 +0900 Subject: [PATCH 06/18] =?UTF-8?q?=EB=B0=B0=EC=B9=98=20UPSERT=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B0=8F=20=EA=B3=A0=EC=A0=95=EA=B0=92=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/batchManagementController.ts | 136 +- .../src/routes/batchManagementRoutes.ts | 6 + .../src/services/batchSchedulerService.ts | 88 +- backend-node/src/services/batchService.ts | 147 ++- backend-node/src/types/batchTypes.ts | 49 +- .../admin/batch-management-new/page.tsx | 1132 ++++++++++------- .../(main)/admin/batchmng/edit/[id]/page.tsx | 85 +- frontend/lib/api/batch.ts | 23 + frontend/lib/api/batchManagement.ts | 86 +- 9 files changed, 1176 insertions(+), 576 deletions(-) diff --git a/backend-node/src/controllers/batchManagementController.ts b/backend-node/src/controllers/batchManagementController.ts index 05aece84..134e9177 100644 --- a/backend-node/src/controllers/batchManagementController.ts +++ b/backend-node/src/controllers/batchManagementController.ts @@ -1,7 +1,7 @@ // 배치관리 전용 컨트롤러 (기존 소스와 완전 분리) // 작성일: 2024-12-24 -import { Response } from "express"; +import { Request, Response } from "express"; import { AuthenticatedRequest } from "../types/auth"; import { BatchManagementService, @@ -13,6 +13,7 @@ import { BatchService } from "../services/batchService"; import { BatchSchedulerService } from "../services/batchSchedulerService"; import { BatchExternalDbService } from "../services/batchExternalDbService"; import { CreateBatchConfigRequest, BatchConfig } from "../types/batchTypes"; +import { query } from "../database/db"; export class BatchManagementController { /** @@ -422,6 +423,8 @@ export class BatchManagementController { paramValue, paramSource, requestBody, + authServiceName, // DB에서 토큰 가져올 서비스명 + dataArrayPath, // 데이터 배열 경로 (예: response, data.items) } = req.body; // apiUrl, endpoint는 항상 필수 @@ -432,15 +435,36 @@ export class BatchManagementController { }); } - // GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택) - if ((!method || method === "GET") && !apiKey) { + // 토큰 결정: authServiceName이 있으면 DB에서 조회, 없으면 apiKey 사용 + let finalApiKey = apiKey || ""; + if (authServiceName) { + // DB에서 토큰 조회 + const tokenResult = await query<{ access_token: string }>( + `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`, + [authServiceName] + ); + if (tokenResult.length > 0 && tokenResult[0].access_token) { + finalApiKey = tokenResult[0].access_token; + console.log(`auth_tokens에서 토큰 조회 성공: ${authServiceName}`); + } else { + return res.status(400).json({ + success: false, + message: `서비스 '${authServiceName}'의 토큰을 찾을 수 없습니다. 먼저 토큰 저장 배치를 실행하세요.`, + }); + } + } + + // 토큰이 없으면 에러 (직접 입력도 안 하고 DB 선택도 안 한 경우) + if (!finalApiKey) { return res.status(400).json({ success: false, - message: "GET 메서드에서는 API Key가 필요합니다.", + message: "인증 토큰이 필요합니다. 직접 입력하거나 DB에서 선택하세요.", }); } - console.log("🔍 REST API 미리보기 요청:", { + console.log("REST API 미리보기 요청:", { apiUrl, endpoint, method, @@ -449,6 +473,8 @@ export class BatchManagementController { paramValue, paramSource, requestBody: requestBody ? "Included" : "None", + authServiceName: authServiceName || "직접 입력", + dataArrayPath: dataArrayPath || "전체 응답", }); // RestApiConnector 사용하여 데이터 조회 @@ -456,7 +482,7 @@ export class BatchManagementController { const connector = new RestApiConnector({ baseUrl: apiUrl, - apiKey: apiKey || "", + apiKey: finalApiKey, timeout: 30000, }); @@ -511,8 +537,50 @@ export class BatchManagementController { result.rows && result.rows.length > 0 ? result.rows[0] : "no data", }); - const data = result.rows.slice(0, 5); // 최대 5개 샘플만 - console.log(`[previewRestApiData] 슬라이스된 데이터:`, data); + // 데이터 배열 추출 헬퍼 함수 + const getValueByPath = (obj: any, path: string): any => { + if (!path) return obj; + const keys = path.split("."); + let current = obj; + for (const key of keys) { + if (current === null || current === undefined) return undefined; + current = current[key]; + } + return current; + }; + + // dataArrayPath가 있으면 해당 경로에서 배열 추출 + let extractedData: any[] = []; + if (dataArrayPath) { + // result.rows가 단일 객체일 수 있음 (API 응답 전체) + const rawData = result.rows.length === 1 ? result.rows[0] : result.rows; + const arrayData = getValueByPath(rawData, dataArrayPath); + + if (Array.isArray(arrayData)) { + extractedData = arrayData; + console.log( + `[previewRestApiData] '${dataArrayPath}' 경로에서 ${arrayData.length}개 항목 추출` + ); + } else { + console.warn( + `[previewRestApiData] '${dataArrayPath}' 경로가 배열이 아님:`, + typeof arrayData + ); + // 배열이 아니면 단일 객체로 처리 + if (arrayData) { + extractedData = [arrayData]; + } + } + } else { + // dataArrayPath가 없으면 기존 로직 사용 + extractedData = result.rows; + } + + const data = extractedData.slice(0, 5); // 최대 5개 샘플만 + console.log( + `[previewRestApiData] 슬라이스된 데이터 (${extractedData.length}개 중 ${data.length}개):`, + data + ); if (data.length > 0) { // 첫 번째 객체에서 필드명 추출 @@ -524,9 +592,9 @@ export class BatchManagementController { data: { fields: fields, samples: data, - totalCount: result.rowCount || data.length, + totalCount: extractedData.length, }, - message: `${fields.length}개 필드, ${result.rowCount || data.length}개 레코드를 조회했습니다.`, + message: `${fields.length}개 필드, ${extractedData.length}개 레코드를 조회했습니다.`, }); } else { return res.json({ @@ -554,8 +622,17 @@ export class BatchManagementController { */ static async saveRestApiBatch(req: AuthenticatedRequest, res: Response) { try { - const { batchName, batchType, cronSchedule, description, apiMappings } = - req.body; + const { + batchName, + batchType, + cronSchedule, + description, + apiMappings, + authServiceName, + dataArrayPath, + saveMode, + conflictKey, + } = req.body; if ( !batchName || @@ -576,6 +653,10 @@ export class BatchManagementController { cronSchedule, description, apiMappings, + authServiceName, + dataArrayPath, + saveMode, + conflictKey, }); // 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음) @@ -589,6 +670,10 @@ export class BatchManagementController { cronSchedule: cronSchedule, isActive: "Y", companyCode, + authServiceName: authServiceName || undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode: saveMode || "INSERT", + conflictKey: conflictKey || undefined, mappings: apiMappings, }; @@ -625,4 +710,31 @@ export class BatchManagementController { }); } } + + /** + * 인증 토큰 서비스명 목록 조회 + */ + static async getAuthServiceNames(req: Request, res: Response) { + try { + const result = await query<{ service_name: string }>( + `SELECT DISTINCT service_name + FROM auth_tokens + WHERE service_name IS NOT NULL + ORDER BY service_name` + ); + + const serviceNames = result.map((row) => row.service_name); + + return res.json({ + success: true, + data: serviceNames, + }); + } catch (error) { + console.error("인증 서비스 목록 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "인증 서비스 목록 조회 중 오류가 발생했습니다.", + }); + } + } } diff --git a/backend-node/src/routes/batchManagementRoutes.ts b/backend-node/src/routes/batchManagementRoutes.ts index d6adf4c5..50ee1ea0 100644 --- a/backend-node/src/routes/batchManagementRoutes.ts +++ b/backend-node/src/routes/batchManagementRoutes.ts @@ -79,4 +79,10 @@ router.post("/rest-api/preview", authenticateToken, BatchManagementController.pr */ router.post("/rest-api/save", authenticateToken, BatchManagementController.saveRestApiBatch); +/** + * GET /api/batch-management/auth-services + * 인증 토큰 서비스명 목록 조회 + */ +router.get("/auth-services", authenticateToken, BatchManagementController.getAuthServiceNames); + export default router; diff --git a/backend-node/src/services/batchSchedulerService.ts b/backend-node/src/services/batchSchedulerService.ts index ee849ae2..c425703b 100644 --- a/backend-node/src/services/batchSchedulerService.ts +++ b/backend-node/src/services/batchSchedulerService.ts @@ -2,6 +2,7 @@ import cron, { ScheduledTask } from "node-cron"; import { BatchService } from "./batchService"; import { BatchExecutionLogService } from "./batchExecutionLogService"; import { logger } from "../utils/logger"; +import { query } from "../database/db"; export class BatchSchedulerService { private static scheduledTasks: Map = new Map(); @@ -214,9 +215,16 @@ export class BatchSchedulerService { } // 테이블별로 매핑을 그룹화 + // 고정값 매핑(mapping_type === 'fixed')은 별도 그룹으로 분리하지 않고 나중에 처리 const tableGroups = new Map(); + const fixedMappingsGlobal: typeof config.batch_mappings = []; for (const mapping of config.batch_mappings) { + // 고정값 매핑은 별도로 모아둠 (FROM 소스가 필요 없음) + if (mapping.mapping_type === "fixed") { + fixedMappingsGlobal.push(mapping); + continue; + } const key = `${mapping.from_connection_type}:${mapping.from_connection_id || "internal"}:${mapping.from_table_name}`; if (!tableGroups.has(key)) { tableGroups.set(key, []); @@ -224,6 +232,14 @@ export class BatchSchedulerService { tableGroups.get(key)!.push(mapping); } + // 고정값 매핑만 있고 일반 매핑이 없는 경우 처리 + if (tableGroups.size === 0 && fixedMappingsGlobal.length > 0) { + logger.warn( + `일반 매핑이 없고 고정값 매핑만 있습니다. 고정값만으로는 배치를 실행할 수 없습니다.` + ); + return { totalRecords, successRecords, failedRecords }; + } + // 각 테이블 그룹별로 처리 for (const [tableKey, mappings] of tableGroups) { try { @@ -244,10 +260,31 @@ export class BatchSchedulerService { "./batchExternalDbService" ); + // auth_service_name이 설정된 경우 auth_tokens에서 토큰 조회 + let apiKey = firstMapping.from_api_key || ""; + if (config.auth_service_name) { + const tokenResult = await query<{ access_token: string }>( + `SELECT access_token FROM auth_tokens + WHERE service_name = $1 + ORDER BY created_date DESC LIMIT 1`, + [config.auth_service_name] + ); + if (tokenResult.length > 0 && tokenResult[0].access_token) { + apiKey = tokenResult[0].access_token; + logger.info( + `auth_tokens에서 토큰 조회 성공: ${config.auth_service_name}` + ); + } else { + logger.warn( + `auth_tokens에서 토큰을 찾을 수 없음: ${config.auth_service_name}` + ); + } + } + // 👇 Body 파라미터 추가 (POST 요청 시) const apiResult = await BatchExternalDbService.getDataFromRestApi( firstMapping.from_api_url!, - firstMapping.from_api_key!, + apiKey, firstMapping.from_table_name, (firstMapping.from_api_method as | "GET" @@ -266,7 +303,36 @@ export class BatchSchedulerService { ); if (apiResult.success && apiResult.data) { - fromData = apiResult.data; + // 데이터 배열 경로가 설정되어 있으면 해당 경로에서 배열 추출 + if (config.data_array_path) { + const extractArrayByPath = (obj: any, path: string): any[] => { + if (!path) return Array.isArray(obj) ? obj : [obj]; + const keys = path.split("."); + let current = obj; + for (const key of keys) { + if (current === null || current === undefined) return []; + current = current[key]; + } + return Array.isArray(current) + ? current + : current + ? [current] + : []; + }; + + // apiResult.data가 단일 객체인 경우 (API 응답 전체) + const rawData = + Array.isArray(apiResult.data) && apiResult.data.length === 1 + ? apiResult.data[0] + : apiResult.data; + + fromData = extractArrayByPath(rawData, config.data_array_path); + logger.info( + `데이터 배열 경로 '${config.data_array_path}'에서 ${fromData.length}개 레코드 추출` + ); + } else { + fromData = apiResult.data; + } } else { throw new Error(`REST API 데이터 조회 실패: ${apiResult.message}`); } @@ -298,6 +364,11 @@ export class BatchSchedulerService { const mappedData = fromData.map((row) => { const mappedRow: any = {}; for (const mapping of mappings) { + // 고정값 매핑은 이미 분리되어 있으므로 여기서는 처리하지 않음 + if (mapping.mapping_type === "fixed") { + continue; + } + // DB → REST API 배치인지 확인 if ( firstMapping.to_connection_type === "restapi" && @@ -315,6 +386,13 @@ export class BatchSchedulerService { } } + // 고정값 매핑 적용 (전역으로 분리된 fixedMappingsGlobal 사용) + for (const fixedMapping of fixedMappingsGlobal) { + // from_column_name에 고정값이 저장되어 있음 + mappedRow[fixedMapping.to_column_name] = + fixedMapping.from_column_name; + } + // 멀티테넌시: TO가 DB일 때 company_code 자동 주입 // - 배치 설정에 company_code가 있고 // - 매핑에서 company_code를 명시적으로 다루지 않은 경우만 @@ -384,12 +462,14 @@ export class BatchSchedulerService { insertResult = { successCount: 0, failedCount: 0 }; } } else { - // DB에 데이터 삽입 + // DB에 데이터 삽입 (save_mode, conflict_key 지원) insertResult = await BatchService.insertDataToTable( firstMapping.to_table_name, mappedData, firstMapping.to_connection_type as "internal" | "external", - firstMapping.to_connection_id || undefined + firstMapping.to_connection_id || undefined, + (config.save_mode as "INSERT" | "UPSERT") || "INSERT", + config.conflict_key || undefined ); } diff --git a/backend-node/src/services/batchService.ts b/backend-node/src/services/batchService.ts index 2aefc98b..41b79b29 100644 --- a/backend-node/src/services/batchService.ts +++ b/backend-node/src/services/batchService.ts @@ -176,8 +176,8 @@ export class BatchService { // 배치 설정 생성 const batchConfigResult = await client.query( `INSERT INTO batch_configs - (batch_name, description, cron_schedule, is_active, company_code, created_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + (batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) RETURNING *`, [ data.batchName, @@ -185,6 +185,10 @@ export class BatchService { data.cronSchedule, data.isActive || "Y", data.companyCode, + data.saveMode || "INSERT", + data.conflictKey || null, + data.authServiceName || null, + data.dataArrayPath || null, userId, ] ); @@ -201,37 +205,38 @@ export class BatchService { from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, - to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW()) RETURNING *`, [ - batchConfig.id, - data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 - mapping.from_connection_type, - mapping.from_connection_id, - mapping.from_table_name, - mapping.from_column_name, - mapping.from_column_type, - mapping.from_api_url, - mapping.from_api_key, - mapping.from_api_method, - mapping.from_api_param_type, - mapping.from_api_param_name, - mapping.from_api_param_value, - mapping.from_api_param_source, - mapping.from_api_body, // FROM REST API Body - mapping.to_connection_type, - mapping.to_connection_id, - mapping.to_table_name, - mapping.to_column_name, - mapping.to_column_type, - mapping.to_api_url, - mapping.to_api_key, - mapping.to_api_method, - mapping.to_api_body, - mapping.mapping_order || index + 1, - userId, - ] + batchConfig.id, + data.companyCode, // 멀티테넌시: 배치 설정과 동일한 company_code 사용 + mapping.from_connection_type, + mapping.from_connection_id, + mapping.from_table_name, + mapping.from_column_name, + mapping.from_column_type, + mapping.from_api_url, + mapping.from_api_key, + mapping.from_api_method, + mapping.from_api_param_type, + mapping.from_api_param_name, + mapping.from_api_param_value, + mapping.from_api_param_source, + mapping.from_api_body, // FROM REST API Body + mapping.to_connection_type, + mapping.to_connection_id, + mapping.to_table_name, + mapping.to_column_name, + mapping.to_column_type, + mapping.to_api_url, + mapping.to_api_key, + mapping.to_api_method, + mapping.to_api_body, + mapping.mapping_order || index + 1, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed + userId, + ] ); mappings.push(mappingResult.rows[0]); } @@ -311,6 +316,18 @@ export class BatchService { updateFields.push(`is_active = $${paramIndex++}`); updateValues.push(data.isActive); } + if (data.saveMode !== undefined) { + updateFields.push(`save_mode = $${paramIndex++}`); + updateValues.push(data.saveMode); + } + if (data.conflictKey !== undefined) { + updateFields.push(`conflict_key = $${paramIndex++}`); + updateValues.push(data.conflictKey || null); + } + if (data.authServiceName !== undefined) { + updateFields.push(`auth_service_name = $${paramIndex++}`); + updateValues.push(data.authServiceName || null); + } // 배치 설정 업데이트 const batchConfigResult = await client.query( @@ -339,8 +356,8 @@ export class BatchService { from_column_type, from_api_url, from_api_key, from_api_method, from_api_param_type, from_api_param_name, from_api_param_value, from_api_param_source, from_api_body, to_connection_type, to_connection_id, to_table_name, to_column_name, to_column_type, - to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, created_by, created_date) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, NOW()) + to_api_url, to_api_key, to_api_method, to_api_body, mapping_order, mapping_type, created_by, created_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, NOW()) RETURNING *`, [ id, @@ -368,6 +385,7 @@ export class BatchService { mapping.to_api_method, mapping.to_api_body, mapping.mapping_order || index + 1, + mapping.mapping_type || "direct", // 매핑 타입: direct 또는 fixed userId, ] ); @@ -554,9 +572,7 @@ export class BatchService { try { if (connectionType === "internal") { // 내부 DB 데이터 조회 - const data = await query( - `SELECT * FROM ${tableName} LIMIT 10` - ); + const data = await query(`SELECT * FROM ${tableName} LIMIT 10`); return { success: true, data, @@ -729,19 +745,27 @@ export class BatchService { /** * 테이블에 데이터 삽입 (연결 타입에 따라 내부/외부 DB 구분) + * @param tableName 테이블명 + * @param data 삽입할 데이터 배열 + * @param connectionType 연결 타입 (internal/external) + * @param connectionId 외부 연결 ID + * @param saveMode 저장 모드 (INSERT/UPSERT) + * @param conflictKey UPSERT 시 충돌 기준 컬럼명 */ static async insertDataToTable( tableName: string, data: any[], connectionType: "internal" | "external" = "internal", - connectionId?: number + connectionId?: number, + saveMode: "INSERT" | "UPSERT" = "INSERT", + conflictKey?: string ): Promise<{ successCount: number; failedCount: number; }> { try { console.log( - `[BatchService] 테이블에 데이터 삽입: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드` + `[BatchService] 테이블에 데이터 ${saveMode}: ${tableName} (${connectionType}${connectionId ? `:${connectionId}` : ""}), ${data.length}개 레코드${conflictKey ? `, 충돌키: ${conflictKey}` : ""}` ); if (!data || data.length === 0) { @@ -753,24 +777,45 @@ export class BatchService { let successCount = 0; let failedCount = 0; - // 각 레코드를 개별적으로 삽입 (UPSERT 방식으로 중복 처리) + // 각 레코드를 개별적으로 삽입 for (const record of data) { try { const columns = Object.keys(record); const values = Object.values(record); - const placeholders = values - .map((_, i) => `$${i + 1}`) - .join(", "); + const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); - const queryStr = `INSERT INTO ${tableName} (${columns.join( - ", " - )}) VALUES (${placeholders})`; + let queryStr: string; + + if (saveMode === "UPSERT" && conflictKey) { + // UPSERT 모드: ON CONFLICT DO UPDATE + // 충돌 키를 제외한 컬럼들만 UPDATE + const updateColumns = columns.filter( + (col) => col !== conflictKey + ); + const updateSet = updateColumns + .map((col) => `${col} = EXCLUDED.${col}`) + .join(", "); + + // updated_date 컬럼이 있으면 현재 시간으로 업데이트 + const hasUpdatedDate = columns.includes("updated_date"); + const finalUpdateSet = hasUpdatedDate + ? `${updateSet}, updated_date = NOW()` + : updateSet; + + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) + VALUES (${placeholders}) + ON CONFLICT (${conflictKey}) + DO UPDATE SET ${finalUpdateSet}`; + } else { + // INSERT 모드: 기존 방식 + queryStr = `INSERT INTO ${tableName} (${columns.join(", ")}) VALUES (${placeholders})`; + } await query(queryStr, values); successCount++; } catch (insertError) { console.error( - `내부 DB 데이터 삽입 실패 (${tableName}):`, + `내부 DB 데이터 ${saveMode} 실패 (${tableName}):`, insertError ); failedCount++; @@ -779,7 +824,13 @@ export class BatchService { return { successCount, failedCount }; } else if (connectionType === "external" && connectionId) { - // 외부 DB에 데이터 삽입 + // 외부 DB에 데이터 삽입 (UPSERT는 내부 DB만 지원) + if (saveMode === "UPSERT") { + console.warn( + `[BatchService] 외부 DB는 UPSERT를 지원하지 않습니다. INSERT로 실행합니다.` + ); + } + const result = await BatchExternalDbService.insertDataToTable( connectionId, tableName, @@ -799,7 +850,7 @@ export class BatchService { ); } } catch (error) { - console.error(`데이터 삽입 오류 (${tableName}):`, error); + console.error(`데이터 ${saveMode} 오류 (${tableName}):`, error); return { successCount: 0, failedCount: data ? data.length : 0 }; } } diff --git a/backend-node/src/types/batchTypes.ts b/backend-node/src/types/batchTypes.ts index 15efd003..a6404036 100644 --- a/backend-node/src/types/batchTypes.ts +++ b/backend-node/src/types/batchTypes.ts @@ -32,7 +32,7 @@ export interface TableInfo { // 연결 정보 타입 export interface ConnectionInfo { - type: 'internal' | 'external'; + type: "internal" | "external"; id?: number; name: string; db_type?: string; @@ -52,27 +52,27 @@ export interface BatchMapping { id?: number; batch_config_id?: number; company_code?: string; - from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_type: "internal" | "external" | "restapi"; from_connection_id?: number; from_table_name: string; from_column_name: string; from_column_type?: string; from_api_url?: string; from_api_key?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - from_api_param_type?: 'url' | 'query'; + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + from_api_param_type?: "url" | "query"; from_api_param_name?: string; from_api_param_value?: string; - from_api_param_source?: 'static' | 'dynamic'; + from_api_param_source?: "static" | "dynamic"; from_api_body?: string; - to_connection_type: 'internal' | 'external' | 'restapi'; + to_connection_type: "internal" | "external" | "restapi"; to_connection_id?: number; to_table_name: string; to_column_name: string; to_column_type?: string; to_api_url?: string; to_api_key?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; to_api_body?: string; mapping_order?: number; created_by?: string; @@ -85,8 +85,12 @@ export interface BatchConfig { batch_name: string; description?: string; cron_schedule: string; - is_active: 'Y' | 'N'; + is_active: "Y" | "N"; company_code?: string; + save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT) + conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명 + auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명 + data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items) created_by?: string; created_date?: Date; updated_by?: string; @@ -95,7 +99,7 @@ export interface BatchConfig { } export interface BatchConnectionInfo { - type: 'internal' | 'external'; + type: "internal" | "external"; id?: number; name: string; db_type?: string; @@ -109,38 +113,43 @@ export interface BatchColumnInfo { } export interface BatchMappingRequest { - from_connection_type: 'internal' | 'external' | 'restapi'; + from_connection_type: "internal" | "external" | "restapi" | "fixed"; from_connection_id?: number; from_table_name: string; from_column_name: string; from_column_type?: string; from_api_url?: string; from_api_key?: string; - from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - from_api_param_type?: 'url' | 'query'; // API 파라미터 타입 + from_api_method?: "GET" | "POST" | "PUT" | "DELETE"; + from_api_param_type?: "url" | "query"; // API 파라미터 타입 from_api_param_name?: string; // API 파라미터명 from_api_param_value?: string; // API 파라미터 값 또는 템플릿 - from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 + from_api_param_source?: "static" | "dynamic"; // 파라미터 소스 타입 // REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요) - from_api_body?: string; - to_connection_type: 'internal' | 'external' | 'restapi'; + from_api_body?: string; + to_connection_type: "internal" | "external" | "restapi"; to_connection_id?: number; to_table_name: string; to_column_name: string; to_column_type?: string; to_api_url?: string; to_api_key?: string; - to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + to_api_method?: "GET" | "POST" | "PUT" | "DELETE"; to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용) mapping_order?: number; + mapping_type?: "direct" | "fixed"; // 매핑 타입: direct (API 필드) 또는 fixed (고정값) } export interface CreateBatchConfigRequest { batchName: string; description?: string; cronSchedule: string; - isActive: 'Y' | 'N'; + isActive: "Y" | "N"; companyCode: string; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 mappings: BatchMappingRequest[]; } @@ -148,7 +157,11 @@ export interface UpdateBatchConfigRequest { batchName?: string; description?: string; cronSchedule?: string; - isActive?: 'Y' | 'N'; + isActive?: "Y" | "N"; + saveMode?: "INSERT" | "UPSERT"; + conflictKey?: string; + authServiceName?: string; + dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로 mappings?: BatchMappingRequest[]; } diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 2046ed3e..29f36270 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -8,12 +8,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Plus, ArrowRight, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; +import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { toast } from "sonner"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; // 타입 정의 -type BatchType = 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi'; +type BatchType = "db-to-restapi" | "restapi-to-db" | "restapi-to-restapi"; interface BatchTypeOption { value: BatchType; @@ -33,18 +33,21 @@ interface BatchColumnInfo { is_nullable: string; } +// 통합 매핑 아이템 타입 +interface MappingItem { + id: string; + dbColumn: string; + sourceType: "api" | "fixed"; + apiField: string; + fixedValue: string; +} + interface RestApiToDbMappingCardProps { fromApiFields: string[]; toColumns: BatchColumnInfo[]; fromApiData: any[]; - apiFieldMappings: Record; - setApiFieldMappings: React.Dispatch< - React.SetStateAction> - >; - apiFieldPathOverrides: Record; - setApiFieldPathOverrides: React.Dispatch< - React.SetStateAction> - >; + mappingList: MappingItem[]; + setMappingList: React.Dispatch>; } interface DbToRestApiMappingCardProps { @@ -52,20 +55,23 @@ interface DbToRestApiMappingCardProps { selectedColumns: string[]; toApiFields: string[]; dbToApiFieldMapping: Record; - setDbToApiFieldMapping: React.Dispatch< - React.SetStateAction> - >; + setDbToApiFieldMapping: React.Dispatch>>; setToApiBody: (body: string) => void; } export default function BatchManagementNewPage() { const router = useRouter(); - + // 기본 상태 const [batchName, setBatchName] = useState(""); const [cronSchedule, setCronSchedule] = useState("0 12 * * *"); const [description, setDescription] = useState(""); + // 인증 토큰 설정 + const [authTokenMode, setAuthTokenMode] = useState<"direct" | "db">("direct"); // 직접입력 / DB에서 선택 + const [authServiceName, setAuthServiceName] = useState(""); + const [authServiceNames, setAuthServiceNames] = useState([]); + // 연결 정보 const [connections, setConnections] = useState([]); const [toConnection, setToConnection] = useState(null); @@ -77,14 +83,15 @@ export default function BatchManagementNewPage() { const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiKey, setFromApiKey] = useState(""); const [fromEndpoint, setFromEndpoint] = useState(""); - const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET'); + const [fromApiMethod, setFromApiMethod] = useState<"GET" | "POST" | "PUT" | "DELETE">("GET"); const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON) - + const [dataArrayPath, setDataArrayPath] = useState(""); // 데이터 배열 경로 (예: response, data.items) + // REST API 파라미터 설정 - const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); + const [apiParamType, setApiParamType] = useState<"none" | "url" | "query">("none"); const [apiParamName, setApiParamName] = useState(""); // 파라미터명 (예: userId, id) const [apiParamValue, setApiParamValue] = useState(""); // 파라미터 값 또는 템플릿 - const [apiParamSource, setApiParamSource] = useState<'static' | 'dynamic'>('static'); // 정적 값 또는 동적 값 + const [apiParamSource, setApiParamSource] = useState<"static" | "dynamic">("static"); // 정적 값 또는 동적 값 // DB → REST API용 상태 const [fromConnection, setFromConnection] = useState(null); @@ -93,13 +100,13 @@ export default function BatchManagementNewPage() { const [fromColumns, setFromColumns] = useState([]); const [selectedColumns, setSelectedColumns] = useState([]); // 선택된 컬럼들 const [dbToApiFieldMapping, setDbToApiFieldMapping] = useState>({}); // DB 컬럼 → API 필드 매핑 - + // REST API 대상 설정 (DB → REST API용) const [toApiUrl, setToApiUrl] = useState(""); const [toApiKey, setToApiKey] = useState(""); const [toEndpoint, setToEndpoint] = useState(""); - const [toApiMethod, setToApiMethod] = useState<'POST' | 'PUT' | 'DELETE'>('POST'); - const [toApiBody, setToApiBody] = useState(''); // Request Body 템플릿 + const [toApiMethod, setToApiMethod] = useState<"POST" | "PUT" | "DELETE">("POST"); + const [toApiBody, setToApiBody] = useState(""); // Request Body 템플릿 const [toApiFields, setToApiFields] = useState([]); // TO API 필드 목록 const [urlPathColumn, setUrlPathColumn] = useState(""); // URL 경로에 사용할 컬럼 (PUT/DELETE용) @@ -107,38 +114,51 @@ export default function BatchManagementNewPage() { const [fromApiData, setFromApiData] = useState([]); const [fromApiFields, setFromApiFields] = useState([]); - // API 필드 → DB 컬럼 매핑 - const [apiFieldMappings, setApiFieldMappings] = useState>({}); - // API 필드별 JSON 경로 오버라이드 (예: "response.access_token") - const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState>({}); + // 통합 매핑 리스트 + const [mappingList, setMappingList] = useState([]); + + // INSERT/UPSERT 설정 + const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT"); + const [conflictKey, setConflictKey] = useState(""); // 배치 타입 상태 - const [batchType, setBatchType] = useState('restapi-to-db'); + const [batchType, setBatchType] = useState("restapi-to-db"); // 배치 타입 옵션 const batchTypeOptions: BatchTypeOption[] = [ { - value: 'restapi-to-db', - label: 'REST API → DB', - description: 'REST API에서 데이터베이스로 데이터 수집' + value: "restapi-to-db", + label: "REST API → DB", + description: "REST API에서 데이터베이스로 데이터 수집", }, { - value: 'db-to-restapi', - label: 'DB → REST API', - description: '데이터베이스에서 REST API로 데이터 전송' - } + value: "db-to-restapi", + label: "DB → REST API", + description: "데이터베이스에서 REST API로 데이터 전송", + }, ]; // 초기 데이터 로드 useEffect(() => { loadConnections(); + loadAuthServiceNames(); }, []); + // 인증 서비스명 목록 로드 + const loadAuthServiceNames = async () => { + try { + const serviceNames = await BatchManagementAPI.getAuthServiceNames(); + setAuthServiceNames(serviceNames); + } catch (error) { + console.error("인증 서비스 목록 로드 실패:", error); + } + }; + // 배치 타입 변경 시 상태 초기화 useEffect(() => { // 공통 초기화 - setApiFieldMappings({}); - + setMappingList([]); + // REST API → DB 관련 초기화 setToConnection(null); setToTables([]); @@ -149,7 +169,7 @@ export default function BatchManagementNewPage() { setFromEndpoint(""); setFromApiData([]); setFromApiFields([]); - + // DB → REST API 관련 초기화 setFromConnection(null); setFromTables([]); @@ -164,7 +184,6 @@ export default function BatchManagementNewPage() { setToApiFields([]); }, [batchType]); - // 연결 목록 로드 const loadConnections = async () => { try { @@ -179,26 +198,26 @@ export default function BatchManagementNewPage() { // TO 연결 변경 핸들러 const handleToConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - - if (connectionValue === 'internal') { + + if (connectionValue === "internal") { // 내부 데이터베이스 선택 - connection = connections.find(conn => conn.type === 'internal') || null; + connection = connections.find((conn) => conn.type === "internal") || null; } else { // 외부 데이터베이스 선택 const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } - + setToConnection(connection); setToTable(""); setToColumns([]); if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); - const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + const tableNames = Array.isArray(result) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setToTables(tableNames); } catch (error) { @@ -215,7 +234,7 @@ export default function BatchManagementNewPage() { if (toConnection && tableName) { try { - const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; + const connectionType = toConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); if (result && result.length > 0) { setToColumns(result); @@ -233,11 +252,11 @@ export default function BatchManagementNewPage() { // FROM 연결 변경 핸들러 (DB → REST API용) const handleFromConnectionChange = async (connectionValue: string) => { let connection: BatchConnectionInfo | null = null; - if (connectionValue === 'internal') { - connection = connections.find(conn => conn.type === 'internal') || null; + if (connectionValue === "internal") { + connection = connections.find((conn) => conn.type === "internal") || null; } else { const connectionId = parseInt(connectionValue); - connection = connections.find(conn => conn.id === connectionId) || null; + connection = connections.find((conn) => conn.id === connectionId) || null; } setFromConnection(connection); setFromTable(""); @@ -245,10 +264,10 @@ export default function BatchManagementNewPage() { if (connection) { try { - const connectionType = connection.type === 'internal' ? 'internal' : 'external'; + const connectionType = connection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTablesFromConnection(connectionType, connection.id); const tableNames = Array.isArray(result) - ? result.map((table: any) => typeof table === 'string' ? table : table.table_name || String(table)) + ? result.map((table: any) => (typeof table === "string" ? table : table.table_name || String(table))) : []; setFromTables(tableNames); } catch (error) { @@ -267,7 +286,7 @@ export default function BatchManagementNewPage() { if (fromConnection && tableName) { try { - const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; + const connectionType = fromConnection.type === "internal" ? "internal" : "external"; const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); if (result && result.length > 0) { setFromColumns(result); @@ -294,7 +313,7 @@ export default function BatchManagementNewPage() { toApiUrl, toApiKey, toEndpoint, - 'GET' // 미리보기는 항상 GET으로 + "GET", // 미리보기는 항상 GET으로 ); if (result.fields && result.fields.length > 0) { @@ -319,27 +338,39 @@ export default function BatchManagementNewPage() { return; } - // GET 메서드일 때만 API 키 필수 - if (fromApiMethod === "GET" && !fromApiKey) { - toast.error("GET 메서드에서는 API 키를 입력해주세요."); + // 직접 입력 모드일 때만 토큰 검증 + if (authTokenMode === "direct" && !fromApiKey) { + toast.error("인증 토큰을 입력해주세요."); + return; + } + + // DB 선택 모드일 때 서비스명 검증 + if (authTokenMode === "db" && !authServiceName) { + toast.error("인증 토큰 서비스를 선택해주세요."); return; } try { const result = await BatchManagementAPI.previewRestApiData( fromApiUrl, - fromApiKey || "", + authTokenMode === "direct" ? fromApiKey : "", // 직접 입력일 때만 API 키 전달 fromEndpoint, fromApiMethod, // 파라미터 정보 추가 - apiParamType !== 'none' ? { - paramType: apiParamType, - paramName: apiParamName, - paramValue: apiParamValue, - paramSource: apiParamSource - } : undefined, + apiParamType !== "none" + ? { + paramType: apiParamType, + paramName: apiParamName, + paramValue: apiParamValue, + paramSource: apiParamSource, + } + : undefined, // Request Body 추가 (POST/PUT/DELETE) - (fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + // DB 선택 모드일 때 서비스명 전달 + authTokenMode === "db" ? authServiceName : undefined, + // 데이터 배열 경로 전달 + dataArrayPath || undefined, ); if (result.fields && result.fields.length > 0) { @@ -351,7 +382,7 @@ export default function BatchManagementNewPage() { const extractedFields = Object.keys(result.samples[0]); setFromApiFields(extractedFields); setFromApiData(result.samples); - + toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); } else { setFromApiFields([]); @@ -374,55 +405,45 @@ export default function BatchManagementNewPage() { } // 배치 타입별 검증 및 저장 - if (batchType === 'restapi-to-db') { - const mappedFields = Object.keys(apiFieldMappings).filter( - (field) => apiFieldMappings[field] + if (batchType === "restapi-to-db") { + // 유효한 매핑만 필터링 (DB 컬럼이 선택되고, API 필드 또는 고정값이 있는 것) + const validMappings = mappingList.filter( + (m) => m.dbColumn && (m.sourceType === "api" ? m.apiField : m.fixedValue), ); - if (mappedFields.length === 0) { - toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); + + if (validMappings.length === 0) { + toast.error("최소 하나의 매핑을 설정해주세요."); return; } - - // API 필드 매핑을 배치 매핑 형태로 변환 - const apiMappings = mappedFields.map((apiField) => { - const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token) - // 기본은 상위 필드 그대로 사용하되, - // 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용 - let fromColumnName = apiField; - const overridePath = apiFieldPathOverrides[apiField]; - if (overridePath && overridePath.trim().length > 0) { - fromColumnName = overridePath.trim(); - } + // UPSERT 모드일 때 conflict key 검증 + if (saveMode === "UPSERT" && !conflictKey) { + toast.error("UPSERT 모드에서는 충돌 기준 컬럼을 선택해주세요."); + return; + } - return { - from_connection_type: "restapi" as const, - from_table_name: fromEndpoint, // API 엔드포인트 - from_column_name: fromColumnName, // API 필드명 또는 중첩 경로 - from_api_url: fromApiUrl, - from_api_key: fromApiKey, - from_api_method: fromApiMethod, - from_api_body: - fromApiMethod === "POST" || - fromApiMethod === "PUT" || - fromApiMethod === "DELETE" - ? fromApiBody - : undefined, - // API 파라미터 정보 추가 - from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, - from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, - from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, - from_api_param_source: - apiParamType !== "none" ? apiParamSource : undefined, - to_connection_type: - toConnection?.type === "internal" ? "internal" : "external", - to_connection_id: - toConnection?.type === "internal" ? undefined : toConnection?.id, - to_table_name: toTable, - to_column_name: toColumnName, // 매핑된 DB 컬럼 - mapping_type: "direct" as const, - }; - }); + // 통합 매핑 리스트를 배치 매핑 형태로 변환 + // 고정값 매핑도 동일한 from_connection_type을 사용해야 같은 그룹으로 처리됨 + const apiMappings = validMappings.map((mapping) => ({ + from_connection_type: "restapi" as const, // 고정값도 동일한 소스 타입 사용 + from_table_name: fromEndpoint, + from_column_name: mapping.sourceType === "api" ? mapping.apiField : mapping.fixedValue, + from_api_url: fromApiUrl, + from_api_key: authTokenMode === "direct" ? fromApiKey : "", + from_api_method: fromApiMethod, + from_api_body: + fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE" ? fromApiBody : undefined, + from_api_param_type: apiParamType !== "none" ? apiParamType : undefined, + from_api_param_name: apiParamType !== "none" ? apiParamName : undefined, + from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined, + from_api_param_source: apiParamType !== "none" ? apiParamSource : undefined, + to_connection_type: toConnection?.type === "internal" ? "internal" : "external", + to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id, + to_table_name: toTable, + to_column_name: mapping.dbColumn, + mapping_type: mapping.sourceType === "fixed" ? ("fixed" as const) : ("direct" as const), + fixed_value: mapping.sourceType === "fixed" ? mapping.fixedValue : undefined, + })); // 실제 API 호출 try { @@ -431,13 +452,17 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings + apiMappings, + authServiceName: authTokenMode === "db" ? authServiceName : undefined, + dataArrayPath: dataArrayPath || undefined, + saveMode, + conflictKey: saveMode === "UPSERT" ? conflictKey : undefined, }); if (result.success) { toast.success(result.message || "REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -447,67 +472,67 @@ export default function BatchManagementNewPage() { toast.error("배치 저장 중 오류가 발생했습니다."); } return; - } else if (batchType === 'db-to-restapi') { + } else if (batchType === "db-to-restapi") { // DB → REST API 배치 검증 if (!fromConnection || !fromTable || selectedColumns.length === 0) { toast.error("소스 데이터베이스, 테이블, 컬럼을 선택해주세요."); return; } - + if (!toApiUrl || !toApiKey || !toEndpoint) { toast.error("대상 API URL, API Key, 엔드포인트를 입력해주세요."); return; } - if ((toApiMethod === 'POST' || toApiMethod === 'PUT') && !toApiBody) { + if ((toApiMethod === "POST" || toApiMethod === "PUT") && !toApiBody) { toast.error("POST/PUT 메서드의 경우 Request Body 템플릿을 입력해주세요."); return; } // DELETE의 경우 빈 Request Body라도 템플릿 로직을 위해 "{}" 설정 let finalToApiBody = toApiBody; - if (toApiMethod === 'DELETE' && !finalToApiBody.trim()) { - finalToApiBody = '{}'; + if (toApiMethod === "DELETE" && !finalToApiBody.trim()) { + finalToApiBody = "{}"; } // DB → REST API 매핑 생성 (선택된 컬럼만) - const selectedColumnObjects = fromColumns.filter(column => selectedColumns.includes(column.column_name)); + const selectedColumnObjects = fromColumns.filter((column) => selectedColumns.includes(column.column_name)); const dbMappings = selectedColumnObjects.map((column, index) => ({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: column.column_name, from_column_type: column.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, // API 엔드포인트 to_column_name: dbToApiFieldMapping[column.column_name] || column.column_name, // 매핑된 API 필드명 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, // Request Body 템플릿 - mapping_type: 'template' as const, - mapping_order: index + 1 + mapping_type: "template" as const, + mapping_order: index + 1, })); // URL 경로 파라미터 매핑 추가 (PUT/DELETE용) - if ((toApiMethod === 'PUT' || toApiMethod === 'DELETE') && urlPathColumn) { - const urlPathColumnObject = fromColumns.find(col => col.column_name === urlPathColumn); + if ((toApiMethod === "PUT" || toApiMethod === "DELETE") && urlPathColumn) { + const urlPathColumnObject = fromColumns.find((col) => col.column_name === urlPathColumn); if (urlPathColumnObject) { dbMappings.push({ - from_connection_type: fromConnection.type === 'internal' ? 'internal' : 'external', - from_connection_id: fromConnection.type === 'internal' ? undefined : fromConnection.id, + from_connection_type: fromConnection.type === "internal" ? "internal" : "external", + from_connection_id: fromConnection.type === "internal" ? undefined : fromConnection.id, from_table_name: fromTable, from_column_name: urlPathColumn, from_column_type: urlPathColumnObject.data_type, - to_connection_type: 'restapi' as const, + to_connection_type: "restapi" as const, to_table_name: toEndpoint, - to_column_name: 'URL_PATH_PARAM', // 특별한 식별자 + to_column_name: "URL_PATH_PARAM", // 특별한 식별자 to_api_url: toApiUrl, to_api_key: toApiKey, to_api_method: toApiMethod, to_api_body: finalToApiBody, - mapping_type: 'url_path' as const, - mapping_order: 999 // 마지막 순서 + mapping_type: "url_path" as const, + mapping_order: 999, // 마지막 순서 }); } } @@ -519,13 +544,14 @@ export default function BatchManagementNewPage() { batchType, cronSchedule, description, - apiMappings: dbMappings + apiMappings: dbMappings, + authServiceName: authServiceName || undefined, }); if (result.success) { toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다."); setTimeout(() => { - router.push('/admin/batchmng'); + router.push("/admin/batchmng"); }, 1000); } else { toast.error(result.message || "배치 저장에 실패했습니다."); @@ -541,16 +567,16 @@ export default function BatchManagementNewPage() { }; return ( -
+

고급 배치 생성

@@ -565,26 +591,24 @@ export default function BatchManagementNewPage() { {/* 배치 타입 선택 */}
-
+
{batchTypeOptions.map((option) => (
setBatchType(option.value)} >
- {option.value === 'restapi-to-db' ? ( - + {option.value === "restapi-to-db" ? ( + ) : ( - + )}
-
{option.label}
-
{option.description}
+
{option.label}
+
{option.description}
@@ -592,7 +616,7 @@ export default function BatchManagementNewPage() {
-
+
- {batchType === 'restapi-to-db' ? ( + {batchType === "restapi-to-db" ? ( <> - + FROM: REST API (소스) ) : ( <> - + FROM: 데이터베이스 (소스) )} @@ -644,9 +668,9 @@ export default function BatchManagementNewPage() { {/* REST API 설정 (REST API → DB) */} - {batchType === 'restapi-to-db' && ( + {batchType === "restapi-to-db" && (
-
+
- - setFromApiKey(e.target.value)} - placeholder="ak_your_api_key_here" - /> -

- GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다. + + {/* 토큰 설정 방식 선택 */} +

+ + +
+ {/* 직접 입력 모드 */} + {authTokenMode === "direct" && ( + setFromApiKey(e.target.value)} + placeholder="Bearer eyJhbGciOiJIUzI1NiIs..." + className="mt-2" + /> + )} + {/* DB 선택 모드 */} + {authTokenMode === "db" && ( + + )} +

+ {authTokenMode === "direct" + ? "API 호출 시 Authorization 헤더에 사용할 토큰을 입력하세요." + : "auth_tokens 테이블에서 선택한 서비스의 최신 토큰을 사용합니다."}

-
+
+
+ {/* 데이터 배열 경로 */} +
+ + setDataArrayPath(e.target.value)} + placeholder="response (예: data.items, results)" + /> +

+ API 응답에서 배열 데이터가 있는 경로를 입력하세요. 비워두면 응답 전체를 사용합니다. +
+ 예시: response, data.items, result.list +

{/* Request Body (POST/PUT/DELETE용) */} - {(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && ( + {(fromApiMethod === "POST" || fromApiMethod === "PUT" || fromApiMethod === "DELETE") && (