From a4862c45f44810416820c8aa7bdd6a4b40140308 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 27 Jan 2026 12:04:16 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=ED=99=94=EB=A9=B4=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EA=B0=9C=EB=B0=9C=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 화면 컴포넌트 개발 가이드를 개선하여 핵심 원칙과 패턴을 명확히 설명하였습니다. - 목차를 재구성하고, V2 컴포넌트 규칙, 표준 Props 인터페이스, 멀티테넌시 관련 내용을 추가하였습니다. - 각 컴포넌트의 역할과 사용법을 상세히 설명하여 개발자들이 이해하기 쉽게 하였습니다. - API 호출 시 autoFilter 패턴과 렌더링 최적화 패턴을 포함하여 가이드의 실용성을 높였습니다. --- .cursor/rules/component-development-guide.mdc | 2038 ++++++++++------- .../screen/InteractiveScreenViewer.tsx | 16 +- .../components/screen/widgets/TabsWidget.tsx | 2 +- 3 files changed, 1256 insertions(+), 800 deletions(-) diff --git a/.cursor/rules/component-development-guide.mdc b/.cursor/rules/component-development-guide.mdc index f3262b30..4a18e80a 100644 --- a/.cursor/rules/component-development-guide.mdc +++ b/.cursor/rules/component-development-guide.mdc @@ -5,326 +5,588 @@ alwaysApply: false # 화면 컴포넌트 개발 가이드 -새로운 화면 컴포넌트를 개발할 때 반드시 따라야 하는 핵심 원칙과 패턴을 설명합니다. -이 가이드는 컴포넌트가 시스템의 핵심 기능(엔티티 조인, 다국어, 폼 데이터 관리 등)과 -올바르게 통합되도록 하는 방법을 설명합니다. +새로운 화면 컴포넌트를 개발할 때 반드시 따라야 하는 핵심 원칙과 패턴입니다. --- ## 목차 -0. [V2 컴포넌트 규칙 (최우선)](#0-v2-컴포넌트-규칙-최우선) -1. [컴포넌트별 테이블 설정 (핵심 원칙)](#1-컴포넌트별-테이블-설정-핵심-원칙) -2. [엔티티 조인 컬럼 활용 (필수)](#2-엔티티-조인-컬럼-활용-필수) -3. [폼 데이터 관리](#3-폼-데이터-관리) -4. [다국어 지원](#4-다국어-지원) -5. [컬럼 설정 패널 구현](#5-컬럼-설정-패널-구현) -6. [체크리스트](#6-체크리스트) +1. [V2 컴포넌트 규칙 (최우선)](#1-v2-컴포넌트-규칙-최우선) +2. [표준 Props 인터페이스](#2-표준-props-인터페이스) +3. [멀티테넌시 (company_code)](#3-멀티테넌시-company_code) +4. [디자인 모드 vs 인터랙티브 모드](#4-디자인-모드-vs-인터랙티브-모드) +5. [로딩 및 에러 처리](#5-로딩-및-에러-처리) +6. [테이블 컬럼 기반 입력 위젯](#6-테이블-컬럼-기반-입력-위젯) +7. [컴포넌트별 테이블 설정](#7-컴포넌트별-테이블-설정) +8. [엔티티 조인 컬럼 활용](#8-엔티티-조인-컬럼-활용) +9. [폼 데이터 관리](#9-폼-데이터-관리) +10. [다국어 지원](#10-다국어-지원) +11. [저장 버튼 및 플로우 연동](#11-저장-버튼-및-플로우-연동) +12. [표준 코드 스타일 가이드](#12-표준-코드-스타일-가이드) +13. [성능 최적화](#13-성능-최적화) +14. [체크리스트](#14-체크리스트) --- -## 0. V2 컴포넌트 규칙 (최우선) +## 1. V2 컴포넌트 규칙 (최우선) ### 핵심 원칙 **화면관리 시스템에서는 반드시 V2 컴포넌트만 사용하고 수정합니다.** -원본 컴포넌트(v2 접두사 없는 것)는 더 이상 사용하지 않으며, 모든 수정/개발은 V2 폴더에서 진행합니다. +- 원본 컴포넌트(v2 접두사 없는 것)는 더 이상 사용하지 않음 +- 모든 수정/개발은 V2 폴더에서 진행 ### V2 컴포넌트 목록 (18개) -| 컴포넌트 ID | 이름 | 경로 | -|------------|------|------| -| `v2-button-primary` | 기본 버튼 | `v2-button-primary/` | -| `v2-text-display` | 텍스트 표시 | `v2-text-display/` | -| `v2-divider-line` | 구분선 | `v2-divider-line/` | -| `v2-table-list` | 테이블 리스트 | `v2-table-list/` | -| `v2-card-display` | 카드 디스플레이 | `v2-card-display/` | -| `v2-split-panel-layout` | 분할 패널 | `v2-split-panel-layout/` | -| `v2-numbering-rule` | 채번 규칙 | `v2-numbering-rule/` | -| `v2-table-search-widget` | 검색 필터 | `v2-table-search-widget/` | -| `v2-repeat-screen-modal` | 반복 화면 모달 | `v2-repeat-screen-modal/` | -| `v2-section-paper` | 섹션 페이퍼 | `v2-section-paper/` | -| `v2-section-card` | 섹션 카드 | `v2-section-card/` | -| `v2-tabs-widget` | 탭 위젯 | `v2-tabs-widget/` | -| `v2-location-swap-selector` | 출발지/도착지 선택 | `v2-location-swap-selector/` | -| `v2-rack-structure` | 렉 구조 | `v2-rack-structure/` | -| `v2-unified-repeater` | 통합 리피터 | `v2-unified-repeater/` | -| `v2-pivot-grid` | 피벗 그리드 | `v2-pivot-grid/` | -| `v2-aggregation-widget` | 집계 위젯 | `v2-aggregation-widget/` | -| `v2-repeat-container` | 리피터 컨테이너 | `v2-repeat-container/` | +| 컴포넌트 ID | 이름 | 용도 | +|-------------|------|------| +| `v2-button-primary` | 기본 버튼 | 저장, 삭제, 조회 등 액션 | +| `v2-text-display` | 텍스트 표시 | 읽기 전용 텍스트 | +| `v2-divider-line` | 구분선 | 섹션 구분 | +| `v2-table-list` | 테이블 리스트 | 데이터 목록 조회 | +| `v2-table-search-widget` | 검색 필터 | 테이블 검색 조건 | +| `v2-card-display` | 카드 디스플레이 | 카드 형태 표시 | +| `v2-split-panel-layout` | 분할 패널 | 좌우/상하 분할 | +| `v2-numbering-rule` | 채번 규칙 | 자동 채번 생성 | +| `v2-tabs-widget` | 탭 위젯 | 탭 레이아웃 | +| `v2-unified-repeater` | 통합 리피터 | 행 단위 입력/저장 | +| `v2-rack-structure` | 렉 구조 | 창고 렉 위치 생성 | +| `v2-section-paper` | 섹션 페이퍼 | 섹션 컨테이너 | +| `v2-section-card` | 섹션 카드 | 카드 컨테이너 | +| `v2-repeat-screen-modal` | 반복 화면 모달 | 반복 팝업 | +| `v2-location-swap-selector` | 위치 선택 | 출발지/도착지 | +| `v2-pivot-grid` | 피벗 그리드 | 피벗 테이블 | +| `v2-aggregation-widget` | 집계 위젯 | 데이터 집계 | +| `v2-repeat-container` | 리피터 컨테이너 | 반복 컨테이너 | ### 파일 경로 ``` frontend/lib/registry/components/ -├── v2-button-primary/ ← V2 컴포넌트 (수정 대상) -├── v2-table-list/ ← V2 컴포넌트 (수정 대상) -├── v2-split-panel-layout/ ← V2 컴포넌트 (수정 대상) -├── ... -├── button-primary/ ← 원본 (수정 금지) -├── table-list/ ← 원본 (수정 금지) -├── split-panel-layout/ ← 원본 (수정 금지) +├── v2-button-primary/ ← V2 컴포넌트 (수정 대상) +├── v2-table-list/ ← V2 컴포넌트 (수정 대상) +├── button-primary/ ← 원본 (수정 금지) +├── table-list/ ← 원본 (수정 금지) └── ... ``` ### 수정/개발 시 규칙 -1. **버그 수정**: V2 폴더의 파일만 수정 -2. **기능 추가**: V2 폴더에만 추가 -3. **새 컴포넌트 생성**: `v2-` 접두사로 폴더 생성, ID도 `v2-` 접두사 사용 -4. **원본 폴더는 절대 수정하지 않음** - -### 컴포넌트 등록 - -V2 컴포넌트는 `frontend/lib/registry/components/index.ts`에서 등록됩니다: - -```typescript -// V2 컴포넌트들 (화면관리 전용) -import "./v2-unified-repeater/UnifiedRepeaterRenderer"; -import "./v2-button-primary/ButtonPrimaryRenderer"; -import "./v2-split-panel-layout/SplitPanelLayoutRenderer"; -// ... 기타 v2 컴포넌트들 -``` +1. **버그 수정/기능 추가**: V2 폴더의 파일만 수정 +2. **새 컴포넌트 생성**: `v2-` 접두사로 폴더 및 ID 생성 +3. **원본 폴더는 절대 수정하지 않음** ### Definition 네이밍 규칙 -V2 컴포넌트의 Definition은 `V2` 접두사를 사용합니다: - ```typescript -// index.ts +// V2 접두사 사용 export const V2TableListDefinition = createComponentDefinition({ id: "v2-table-list", name: "테이블 리스트", // ... }); +``` -// Renderer.tsx -import { V2TableListDefinition } from "./index"; +--- -export class TableListRenderer extends AutoRegisteringComponentRenderer { - static componentDefinition = V2TableListDefinition; - // ... +## 2. 표준 Props 인터페이스 + +### 컴포넌트가 받아야 하는 표준 Props + +모든 화면 컴포넌트는 다음 Props를 지원해야 합니다: + +```typescript +interface StandardComponentProps { + // 필수 + component: ComponentData; // 컴포넌트 설정 데이터 + isDesignMode?: boolean; // 디자인 모드 여부 (기본: false) + isSelected?: boolean; // 선택 상태 (디자인 모드용) + isPreview?: boolean; // 미리보기 모드 + + // 폼 데이터 관련 + formData?: Record; // 현재 폼 데이터 + onFormDataChange?: (fieldName: string, value: any) => void; + + // 선택 관련 (테이블/리스트 컴포넌트) + selectedRows?: any[]; // 선택된 행 ID 목록 + selectedRowsData?: any[]; // 선택된 행 전체 데이터 + onSelectedRowsChange?: (rows: any[], data: any[]) => void; + + // 사용자 정보 (멀티테넌시용) + userId?: string; + userName?: string; + companyCode?: string; // 회사 코드 (멀티테넌시 필수) + + // 화면 정보 + screenId?: number; + tableName?: string; // 화면 메인 테이블 + menuObjid?: number; + + // 새로고침 제어 + refreshKey?: number; // 변경 시 데이터 새로고침 + onRefresh?: () => void; + + // 기타 + className?: string; + style?: React.CSSProperties; +} +``` + +### Props 사용 예시 + +```typescript +export const MyComponent: React.FC = ({ + component, + isDesignMode = false, + formData = {}, + onFormDataChange, + companyCode, + refreshKey, +}) => { + // 컴포넌트 구현 +}; +``` + +--- + +## 3. 멀티테넌시 (company_code) + +### 핵심 원칙 + +**모든 데이터 조회/저장 API 호출 시 `autoFilter`를 사용하여 회사별 데이터 격리를 적용합니다.** + +### API 호출 시 autoFilter 패턴 (필수) + +```typescript +// 데이터 조회 시 +const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 100, + // 멀티테넌시: company_code 자동 필터링 + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, +}); + +// entityJoinApi 사용 시 +const response = await entityJoinApi.getTableDataWithJoins(tableName, { + page: 1, + size: 100, + enableEntityJoin: true, + companyCodeOverride: companyCode, // 프리뷰용 회사 코드 오버라이드 +}); +``` + +### 데이터 저장 시 + +```typescript +// 저장 시에는 백엔드에서 자동으로 company_code 추가 +// 프론트엔드에서 명시적으로 company_code를 전달할 필요 없음 +const response = await apiClient.post(`/table-management/tables/${tableName}/add`, { + ...formData, + // company_code는 백엔드에서 세션 정보로 자동 추가 +}); +``` + +### 주의사항 + +1. **모든 테이블 조회에 autoFilter 적용** (예외: 시스템 설정 테이블) +2. **company_code = "*"는 최고 관리자 전용** (일반 사용자에게 노출 금지) +3. **JOIN 시에도 company_code 조건 확인** (entityJoinApi가 자동 처리) + +--- + +## 4. 디자인 모드 vs 인터랙티브 모드 + +### 모드 구분 + +| 모드 | 설명 | API 호출 | 이벤트 처리 | +|------|------|---------|------------| +| 디자인 모드 (`isDesignMode=true`) | 화면 편집기에서 레이아웃 설계 시 | 스킵 | 비활성화 | +| 인터랙티브 모드 (`isDesignMode=false`) | 실제 화면 실행 시 | 정상 실행 | 활성화 | +| 미리보기 모드 (`isPreview=true`) | 편집기 내 미리보기 | 제한적 실행 | 활성화 | + +### 디자인 모드 처리 패턴 + +```typescript +export const MyComponent: React.FC = ({ isDesignMode = false }) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + + // 1. 디자인 모드에서 API 호출 스킵 + useEffect(() => { + if (isDesignMode) return; + fetchData(); + }, [isDesignMode]); + + // 2. 디자인 모드에서 더미 데이터 표시 + if (isDesignMode) { + return ( +
+

+ 테이블 리스트 (3개 컬럼) +

+
+ ); + } + + // 3. 인터랙티브 모드에서 실제 데이터 표시 + return ( + + {data.map((row) => ( + ... + ))} +
+ ); +}; +``` + +### 이벤트 핸들러 비활성화 + +```typescript +const handleClick = useCallback(() => { + if (isDesignMode) return; // 디자인 모드에서 클릭 무시 + + // 실제 액션 수행 + executeAction(); +}, [isDesignMode]); +``` + +--- + +## 5. 로딩 및 에러 처리 + +### 로딩 상태 관리 + +```typescript +const [loading, setLoading] = useState(false); +const [error, setError] = useState(null); + +const fetchData = useCallback(async () => { + if (isDesignMode) return; + + setLoading(true); + setError(null); + + try { + const response = await apiClient.get(`/api/data`); + if (response.data.success) { + setData(response.data.data); + } + } catch (err: any) { + setError(err.message || "데이터 조회 실패"); + } finally { + setLoading(false); + } +}, [isDesignMode]); +``` + +### 로딩 UI 표시 + +```tsx +if (loading) { + return ( +
+ + 로딩 중... +
+ ); +} + +if (error) { + return ( +
+ {error} +
+ ); +} +``` + +### 토스트 알림 패턴 + +```typescript +import { toast } from "sonner"; + +// 성공 +toast.success("저장되었습니다."); + +// 에러 +toast.error("저장 중 오류가 발생했습니다."); + +// 로딩 (장시간 작업) +const toastId = toast.loading("저장 중..."); +// 완료 후 +toast.dismiss(toastId); +toast.success("저장 완료"); +``` + +### DB 에러 유형별 처리 + +```typescript +const handleApiError = (error: any): string => { + const responseData = error?.response?.data; + + if (!responseData?.error) { + return error.message || "오류가 발생했습니다."; + } + + const errorMsg = responseData.error; + + // 중복 키 에러 + if (errorMsg.includes("duplicate key")) { + return "이미 존재하는 값입니다. 다른 값을 입력해주세요."; + } + + // NOT NULL 제약조건 에러 + if (errorMsg.includes("null value")) { + const match = errorMsg.match(/column "(\w+)"/); + const columnName = match ? match[1] : "필수"; + return `${columnName} 필드는 필수 입력 항목입니다.`; + } + + // 외래키 제약조건 에러 + if (errorMsg.includes("foreign key")) { + return "참조하는 데이터가 존재하지 않습니다."; + } + + return responseData.message || errorMsg; +}; + +// 사용 +try { + await apiClient.post("/api/save", data); + toast.success("저장되었습니다."); +} catch (error) { + toast.error(handleApiError(error)); +} +``` + +### 조용히 처리해야 하는 액션 + +다음 액션은 성공/실패 토스트를 표시하지 않습니다: + +```typescript +const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan"]; + +if (!silentActions.includes(actionType)) { + toast.success("처리 완료"); } ``` --- -## 1. 컴포넌트별 테이블 설정 (핵심 원칙) +## 6. 테이블 컬럼 기반 입력 위젯 + +### 드래그 방식으로 입력 폼 생성 + +좌측 패널에서 테이블 컬럼을 드래그하면 **테이블 타입관리에서 설정된 `inputType`**에 따라 자동으로 적절한 입력 위젯이 생성됩니다. + +``` +┌─────────────────────┐ ┌─────────────────────┐ +│ 좌측 테이블 패널 │ 드래그 │ 캔버스 │ +├─────────────────────┤ ───▶ ├─────────────────────┤ +│ 창고코드 (text) │ │ [창고코드] [____] │ +│ 층 (category) │ │ [층] [▼] │ +│ 열 (number) │ │ [열] [____] │ +└─────────────────────┘ └─────────────────────┘ +``` + +### inputType → 위젯 타입 매핑 + +| inputType | 생성 위젯 | 설명 | +|-----------|----------|------| +| `text`, `textarea` | UnifiedInput | 텍스트 입력 | +| `number` | UnifiedInput | 숫자 입력 | +| `date`, `datetime` | UnifiedDate | 날짜/시간 선택 | +| `code`, `category`, `entity` | UnifiedSelect | 선택박스 | +| `checkbox`, `radio` | 체크박스/라디오 | 선택 | +| `image`, `file` | UnifiedMedia | 파일 업로드 | + +### 핵심 Unified 컴포넌트 (3개) + +| 컴포넌트 | 담당 inputType | 파일 경로 | +|----------|---------------|-----------| +| `UnifiedInput` | text, textarea, number, password | `components/unified/UnifiedInput.tsx` | +| `UnifiedSelect` | code, category, entity, select | `components/unified/UnifiedSelect.tsx` | +| `UnifiedDate` | date, datetime, time, daterange | `components/unified/UnifiedDate.tsx` | + +### 컴포넌트 패널에서 직접 드래그 가능한 컴포넌트 + +| 컴포넌트 ID | 이름 | 설명 | +|-------------|------|------| +| `v2-unified-repeater` | 리피터 그리드 | 행 단위 데이터 추가/수정/삭제 | +| `v2-table-list` | 테이블 리스트 | 데이터 목록 조회/필터/정렬 | +| `v2-table-search-widget` | 검색 필터 | 테이블 검색 조건 입력 | +| `v2-button-primary` | 버튼 | 저장, 삭제, 조회 등 액션 | +| `v2-split-panel-layout` | 분할 패널 | 좌우/상하 분할 레이아웃 | +| `v2-tabs-widget` | 탭 위젯 | 탭으로 화면 분리 | + +### 입력 폼 필수 설정 (중요) + +입력 폼 컴포넌트 개발 시 **반드시** 다음 설정을 지원해야 합니다: + +#### 필수 항목 설정 (`required`) + +```typescript +interface InputWidgetConfig { + required?: boolean; // 필수 입력 여부 + requiredMessage?: string; // 에러 메시지 (기본: "필수 입력 항목입니다") +} +``` + +- 우측 속성 패널에서 **"필수 입력"** 체크박스 제공 +- 필수 필드는 라벨 옆에 **빨간색 `*`** 표시 +- 테이블 타입관리의 `isNullable = 'NO'`인 경우 기본값 `required: true` + +#### 숨김 설정 (`hidden`) + +```typescript +interface InputWidgetConfig { + hidden?: boolean; // 화면에서 숨김 여부 + hiddenOnNew?: boolean; // 신규 모드에서만 숨김 + hiddenOnEdit?: boolean; // 수정 모드에서만 숨김 +} +``` + +#### 저장 시 필수 항목 검증 (필수 구현) + +```typescript +// buttonActions.ts - 저장 액션 +const validateRequiredFields = ( + formData: Record, + allComponents: ComponentData[], +): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + allComponents.forEach((comp) => { + if (comp.type === "widget" && comp.required) { + const value = formData[comp.columnName]; + const isEmpty = value === null || value === undefined || value === "" || + (Array.isArray(value) && value.length === 0); + + if (isEmpty) { + const label = comp.label || comp.columnName; + errors.push(`${label}은(는) 필수 입력 항목입니다.`); + } + } + }); + + return { isValid: errors.length === 0, errors }; +}; + +// 저장 전 검증 +const handleSave = async (context: ButtonActionContext) => { + const validation = validateRequiredFields(context.formData, context.allComponents); + + if (!validation.isValid) { + toast.error(validation.errors.join("\n")); + return { success: false, errors: validation.errors }; + } + + return await saveFormData(context.formData); +}; +``` + +--- + +## 7. 컴포넌트별 테이블 설정 ### 핵심 원칙 **하나의 화면에서 여러 테이블을 다룰 수 있습니다.** -화면 생성 시 "메인 테이블"을 필수로 지정하지 않으며, 컴포넌트별로 사용할 테이블을 지정할 수 있습니다. - -### 왜 필요한가? - -일반적인 ERP 화면에서는 여러 테이블이 동시에 필요합니다: - -| 예시: 입고 화면 | 테이블 | 용도 | -| --------------- | ----------------------- | ------------------------------- | -| 메인 폼 | `receiving_mng` | 입고 마스터 정보 입력/저장 | -| 조회 리스트 | `purchase_order_detail` | 발주 상세 목록 조회 (읽기 전용) | -| 입력 리피터 | `receiving_detail` | 입고 상세 항목 입력/저장 | +| 예시: 입고 화면 | 테이블 | 용도 | +|----------------|--------|------| +| 메인 폼 | `receiving_mng` | 입고 마스터 정보 | +| 조회 리스트 | `purchase_order_detail` | 발주 상세 조회 (읽기 전용) | +| 입력 리피터 | `receiving_detail` | 입고 상세 입력/저장 | ### 컴포넌트 설정 패턴 -#### 1. 테이블 리스트 (조회용) +#### 조회용 (테이블 리스트) ```typescript interface TableListConfig { - // 조회용 테이블 (화면 메인 테이블과 다를 수 있음) - customTableName?: string; // 사용할 테이블명 - useCustomTable?: boolean; // true: customTableName 사용 - isReadOnly?: boolean; // true: 조회만, 저장 안 함 + customTableName?: string; // 사용할 테이블명 + useCustomTable?: boolean; // true: customTableName 사용 + isReadOnly?: boolean; // true: 조회만 } ``` -#### 2. 리피터 (입력/저장용) +#### 저장용 (리피터) ```typescript interface UnifiedRepeaterConfig { - // 저장 대상 테이블 (화면 메인 테이블과 다를 수 있음) - mainTableName?: string; // 저장할 테이블명 - useCustomTable?: boolean; // true: mainTableName 사용 - - // FK 자동 연결 (마스터-디테일 관계) - foreignKeyColumn?: string; // 이 테이블의 FK 컬럼 (예: receiving_id) - foreignKeySourceColumn?: string; // 마스터 테이블의 PK 컬럼 (예: id) + mainTableName?: string; // 저장할 테이블명 + useCustomTable?: boolean; // true: mainTableName 사용 + foreignKeyColumn?: string; // FK 컬럼 (예: receiving_id) + foreignKeySourceColumn?: string; // PK 컬럼 (예: id) } ``` -### 조회 테이블 설정 UI 표준 (테이블 리스트) - -테이블 리스트 등 조회용 컴포넌트의 ConfigPanel에서: +### 테이블 선택 UI 표준 (Combobox 그룹별) ```tsx -// 현재 선택된 테이블 카드 형태로 표시 -
- -
-
- {config.customTableName || screenTableName || "테이블 미선택"} -
-
- {config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"} -
-
-
+ + + + {/* 그룹 1: 기본 (화면 테이블) */} + + + + {screenTableName} + + -// 테이블 선택 Combobox (기본/전체 그룹) - - - - - - - - - {/* 그룹 1: 화면 기본 테이블 */} - {screenTableName && ( - - { - handleChange("useCustomTable", false); - handleChange("customTableName", undefined); - handleChange("selectedTable", screenTableName); - handleChange("columns", []); // 테이블 변경 시 컬럼 초기화 - }} - > - - {screenTableName} - - - )} + {/* 그룹 2: 연관 테이블 (FK 자동 설정) */} + + {relatedTables.map((table) => ( + + + {table.tableName} + + FK: {table.foreignKeyColumn} + + + ))} + - {/* 그룹 2: 전체 테이블 */} - - {availableTables - .filter((table) => table.tableName !== screenTableName) - .map((table) => ( - { - handleChange("useCustomTable", true); - handleChange("customTableName", table.tableName); - handleChange("selectedTable", table.tableName); - handleChange("columns", []); // 테이블 변경 시 컬럼 초기화 - }} - > - - {table.displayName || table.tableName} - - ))} - - - - - - -// 읽기전용 설정 -
- handleChange("isReadOnly", checked)} - /> - -
+ {/* 그룹 3: 전체 테이블 */} + + {allTables.map((table) => ( + + {table.displayName || table.tableName} + + ))} + +
+
``` -### 저장 테이블 설정 UI 표준 (리피터) - -리피터 등 저장 기능이 있는 컴포넌트의 ConfigPanel에서: - -```tsx -// 1. 테이블 선택 Combobox - - - - - - - - - {/* 그룹 1: 현재 화면 테이블 (기본) */} - - - - {currentTableName} - - - - {/* 그룹 2: 연관 테이블 (FK 자동 설정) */} - {relatedTables.length > 0 && ( - - {relatedTables.map((table) => ( - - - {table.tableName} - - FK: {table.foreignKeyColumn} - - - ))} - - )} - - {/* 그룹 3: 전체 테이블 */} - - {allTables.map((table) => ( - - {table.displayName || table.tableName} - - ))} - - - - -; - -// 2. 연관 테이블 선택 시 FK/PK 자동 설정 -const handleSaveTableSelect = (tableName: string) => { - const relation = relatedTables.find((r) => r.tableName === tableName); +### 연관 테이블 선택 시 FK 자동 설정 +```typescript +const handleTableSelect = (tableName: string) => { + const relation = relatedTables.find(r => r.tableName === tableName); + if (relation) { - // 엔티티 관계에서 자동으로 FK/PK 가져옴 + // 연관 테이블: FK/PK 자동 설정 updateConfig({ useCustomTable: true, mainTableName: tableName, foreignKeyColumn: relation.foreignKeyColumn, foreignKeySourceColumn: relation.referenceColumn, }); - } else { - // 연관 테이블이 아니면 수동 입력 필요 - updateConfig({ - useCustomTable: true, - mainTableName: tableName, - foreignKeyColumn: undefined, - foreignKeySourceColumn: undefined, - }); } }; ``` ### 연관 테이블 조회 API -엔티티 관계에서 현재 테이블을 참조하는 테이블 목록을 조회합니다: - ```typescript -// API 호출 const response = await apiClient.get( `/api/table-management/columns/${currentTableName}/referenced-by` ); @@ -334,149 +596,32 @@ const response = await apiClient.get( success: true, data: [ { - tableName: "receiving_detail", // 참조하는 테이블 - columnName: "receiving_id", // FK 컬럼 - referenceColumn: "id", // 참조되는 컬럼 (PK) - }, - // ... + tableName: "receiving_detail", + columnName: "receiving_id", + referenceColumn: "id", + } ] } ``` -### FK 자동 연결 동작 - -마스터 저장 후 디테일 저장 시 FK가 자동으로 설정됩니다: - -```typescript -// 1. 마스터 저장 이벤트 발생 (ButtonConfigPanel에서) -window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - masterRecordId: savedId, // 마스터 테이블에 저장된 ID - tableName: "receiving_mng", - mainFormData: formData, - }, - }) -); - -// 2. 리피터에서 이벤트 수신 및 FK 설정 -useEffect(() => { - const handleSaveEvent = (event: CustomEvent) => { - const { masterRecordId } = event.detail; - - if (config.foreignKeyColumn && masterRecordId) { - // 모든 행에 FK 값 자동 설정 - const updatedRows = rows.map((row) => ({ - ...row, - [config.foreignKeyColumn]: masterRecordId, - })); - - // 저장 실행 - saveRows(updatedRows); - } - }; - - window.addEventListener("repeaterSave", handleSaveEvent); - return () => window.removeEventListener("repeaterSave", handleSaveEvent); -}, [config.foreignKeyColumn, rows]); -``` - -### 저장 테이블 변경 시 컬럼 자동 로드 - -저장 테이블이 변경되면 해당 테이블의 컬럼이 자동으로 로드됩니다: - -```typescript -// 저장 테이블 또는 화면 테이블 기준으로 컬럼 로드 -const targetTableForColumns = - config.useCustomTable && config.mainTableName - ? config.mainTableName - : currentTableName; - -useEffect(() => { - const loadColumns = async () => { - if (!targetTableForColumns) return; - - const columnData = await tableTypeApi.getColumns(targetTableForColumns); - setCurrentTableColumns(columnData); - }; - - loadColumns(); -}, [targetTableForColumns]); -``` - -### 요약 - -| 상황 | 처리 방법 | -| ------------------------------------- | ----------------------------------- | -| 화면과 같은 테이블에 저장 | `useCustomTable: false` (기본값) | -| 다른 테이블에 저장 + 엔티티 관계 있음 | 연관 테이블 선택 → FK/PK 자동 설정 | -| 다른 테이블에 저장 + 엔티티 관계 없음 | 전체 테이블에서 선택 → FK 수동 입력 | -| 조회만 (저장 안 함) | `isReadOnly: true` 설정 | - --- -## 2. 엔티티 조인 컬럼 활용 (필수) +## 8. 엔티티 조인 컬럼 활용 ### 핵심 원칙 -**화면을 새로 만들어서 화면 안에 넣는 방식을 사용하지 않습니다.** - -대신, 현재 화면의 메인 테이블을 기준으로 테이블 타입관리의 엔티티 관계를 불러와서 -조인되어 있는 컬럼들을 모두 사용 가능하게 해야 합니다. +**테이블 타입관리의 엔티티 관계를 불러와서 조인된 컬럼들을 모두 사용 가능하게 합니다.** ### API 사용법 ```typescript import { entityJoinApi } from "@/lib/api/entityJoin"; -// 테이블의 엔티티 조인 컬럼 정보 가져오기 const result = await entityJoinApi.getEntityJoinColumns(tableName); // 응답 구조 { tableName: string; - joinTables: Array<{ - tableName: string; // 조인 테이블명 (예: item_info) - currentDisplayColumn: string; // 현재 표시 컬럼 - availableColumns: Array<{ - // 사용 가능한 컬럼들 - columnName: string; - columnLabel: string; - dataType: string; - description?: string; - }>; - }>; - availableColumns: Array<{ - // 플랫한 구조의 전체 사용 가능 컬럼 - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; // 예: item_code_item_name - suggestedLabel: string; // 예: 품목명 - }>; - summary: { - totalJoinTables: number; - totalAvailableColumns: number; - } -} -``` - -### 컬럼 선택 UI 구현 - -ConfigPanel에서 엔티티 조인 컬럼을 표시하는 표준 패턴입니다. - -```typescript -// 상태 정의 -const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: Array<{ - tableName: string; - columnName: string; - columnLabel: string; - dataType: string; - joinAlias: string; - suggestedLabel: string; - }>; joinTables: Array<{ tableName: string; currentDisplayColumn: string; @@ -484,125 +629,55 @@ const [entityJoinColumns, setEntityJoinColumns] = useState<{ columnName: string; columnLabel: string; dataType: string; - description?: string; }>; }>; -}>({ availableColumns: [], joinTables: [] }); + availableColumns: Array<{ + tableName: string; + columnName: string; + columnLabel: string; + joinAlias: string; // 예: item_code_item_name + suggestedLabel: string; // 예: 품목명 + }>; +} +``` -const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); +### 설정 패널에 엔티티 조인 컬럼 섹션 추가 (필수) + +```typescript +// 상태 정의 +const [entityJoinColumns, setEntityJoinColumns] = useState<{ + availableColumns: any[]; + joinTables: any[]; +}>({ availableColumns: [], joinTables: [] }); // 엔티티 조인 컬럼 로드 useEffect(() => { const fetchEntityJoinColumns = async () => { const tableName = config.selectedTable || screenTableName; - if (!tableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } + if (!tableName) return; - setLoadingEntityJoins(true); - try { - const result = await entityJoinApi.getEntityJoinColumns(tableName); - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], - }); - } catch (error) { - console.error("엔티티 조인 컬럼 조회 오류:", error); - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); - } + const result = await entityJoinApi.getEntityJoinColumns(tableName); + setEntityJoinColumns({ + availableColumns: result.availableColumns || [], + joinTables: result.joinTables || [], + }); }; fetchEntityJoinColumns(); }, [config.selectedTable, screenTableName]); ``` -### 컬럼 선택 UI 렌더링 - -```tsx -{ - /* 엔티티 조인 컬럼 섹션 */ -} -{ - entityJoinColumns.joinTables.length > 0 && ( -
- - - {entityJoinColumns.joinTables.map((joinTable) => ( -
-
- - {joinTable.tableName} - - - ({joinTable.availableColumns.length}) - -
- -
- {joinTable.availableColumns.map((col) => { - // "테이블명.컬럼명" 형식으로 컬럼 이름 생성 - const fullColumnName = `${joinTable.tableName}.${col.columnName}`; - const isSelected = config.columns?.some( - (c) => c.columnName === fullColumnName - ); - - return ( -
{ - if (isSelected) { - removeColumn(fullColumnName); - } else { - addEntityJoinColumn(joinTable.tableName, col); - } - }} - > - -
-
{col.columnLabel}
-
- {col.columnName} -
-
-
- ); - })} -
-
- ))} -
- ); -} -``` - ### 엔티티 조인 컬럼 추가 함수 ```typescript const addEntityJoinColumn = (tableName: string, column: any) => { + // "테이블명.컬럼명" 형식으로 저장 const fullColumnName = `${tableName}.${column.columnName}`; - const newColumn: ColumnConfig = { + const newColumn = { columnName: fullColumnName, - displayName: column.columnLabel || column.columnName, - visible: true, - sortable: true, - searchable: true, - align: "left", - format: "text", - order: config.columns?.length || 0, - isEntityJoin: true, // 엔티티 조인 컬럼 표시 + displayName: column.columnLabel, + isEntityJoin: true, entityJoinTable: tableName, entityJoinColumn: column.columnName, }; @@ -614,61 +689,21 @@ const addEntityJoinColumn = (tableName: string, column: any) => { }; ``` -### 데이터 조회 시 엔티티 조인 활용 - -```typescript -// 엔티티 조인이 포함된 데이터 조회 -const response = await entityJoinApi.getTableDataWithJoins(tableName, { - page: 1, - size: 10, - enableEntityJoin: true, - // 추가 조인 컬럼 지정 (화면 설정에서 선택한 컬럼들) - additionalJoinColumns: config.columns - ?.filter((col) => col.isEntityJoin) - ?.map((col) => ({ - sourceTable: col.entityJoinTable!, - sourceColumn: col.entityJoinColumn!, - joinAlias: col.columnName, - })), -}); -``` - ### 셀 값 추출 헬퍼 -엔티티 조인 컬럼의 값을 데이터에서 추출하는 헬퍼 함수입니다. - ```typescript const getEntityJoinValue = (item: any, columnName: string): any => { - // 직접 매칭 시도 - if (item[columnName] !== undefined) { - return item[columnName]; - } + // 직접 매칭 + if (item[columnName] !== undefined) return item[columnName]; - // "테이블명.컬럼명" 형식인 경우 + // "테이블명.컬럼명" 형식 if (columnName.includes(".")) { const [tableName, fieldName] = columnName.split("."); - - // 1. 소스 컬럼 추론 (item_info → item_code) - const inferredSourceColumn = tableName - .replace("_info", "_code") - .replace("_mng", "_id"); - - // 2. 정확한 키 매핑: 소스컬럼_필드명 + const inferredSourceColumn = tableName.replace("_info", "_code"); const exactKey = `${inferredSourceColumn}_${fieldName}`; - if (item[exactKey] !== undefined) { - return item[exactKey]; - } - - // 3. item_id 패턴 시도 - const idPatternKey = `${tableName.replace("_info", "_id")}_${fieldName}`; - if (item[idPatternKey] !== undefined) { - return item[idPatternKey]; - } - - // 4. 단순 필드명으로 시도 - if (item[fieldName] !== undefined) { - return item[fieldName]; - } + + if (item[exactKey] !== undefined) return item[exactKey]; + if (item[fieldName] !== undefined) return item[fieldName]; } return undefined; @@ -677,17 +712,14 @@ const getEntityJoinValue = (item: any, columnName: string): any => { --- -## 3. 폼 데이터 관리 +## 9. 폼 데이터 관리 ### 통합 폼 시스템 (UnifiedFormContext) -새 컴포넌트는 통합 폼 시스템을 사용해야 합니다. - ```typescript import { useFormCompatibility } from "@/hooks/useFormCompatibility"; -const MyComponent = ({ onFormDataChange, formData, ...props }) => { - // 호환성 브릿지 사용 +const MyComponent = ({ onFormDataChange, formData }) => { const { getValue, setValue, submit } = useFormCompatibility({ legacyOnFormDataChange: onFormDataChange, }); @@ -695,87 +727,45 @@ const MyComponent = ({ onFormDataChange, formData, ...props }) => { // 값 읽기 const currentValue = getValue("fieldName"); - // 값 설정 (모든 시스템에 전파됨) + // 값 설정 const handleChange = (value: any) => { setValue("fieldName", value); }; // 저장 const handleSave = async () => { - const result = await submit({ - tableName: "my_table", - mode: "insert", - }); + const result = await submit({ tableName: "my_table", mode: "insert" }); }; }; ``` -### 레거시 컴포넌트와의 호환성 - -기존 `beforeFormSave` 이벤트를 사용하는 컴포넌트(리피터 등)와 호환됩니다. - -```typescript -import { useBeforeFormSave } from "@/hooks/useFormCompatibility"; - -const MyRepeaterComponent = ({ value, columnName }) => { - // beforeFormSave 이벤트에서 데이터 수집 - useEffect(() => { - const handleSaveRequest = (event: CustomEvent) => { - if (event.detail && columnName) { - event.detail.formData[columnName] = value; - } - }; - - window.addEventListener("beforeFormSave", handleSaveRequest); - return () => - window.removeEventListener("beforeFormSave", handleSaveRequest); - }, [value, columnName]); -}; -``` - ### onChange 핸들러 패턴 -컴포넌트에서 값이 변경될 때 사용하는 표준 패턴입니다. - ```typescript -// 기본 패턴 (권장) -const handleChange = useCallback( - (value: any) => { - // 1. UnifiedFormContext가 있으면 사용 - if (unifiedContext) { - unifiedContext.setValue(fieldName, value); - } +const handleChange = useCallback((value: any) => { + // 1. UnifiedFormContext + unifiedContext?.setValue(fieldName, value); - // 2. ScreenContext가 있으면 사용 - if (screenContext?.updateFormData) { - screenContext.updateFormData(fieldName, value); - } + // 2. ScreenContext + screenContext?.updateFormData?.(fieldName, value); - // 3. 레거시 콜백이 있으면 호출 - if (onFormDataChange) { - onFormDataChange(fieldName, value); - } - }, - [fieldName, unifiedContext, screenContext, onFormDataChange] -); + // 3. 레거시 콜백 + onFormDataChange?.(fieldName, value); +}, [fieldName, unifiedContext, screenContext, onFormDataChange]); ``` --- -## 4. 다국어 지원 +## 10. 다국어 지원 ### 타입 정의 시 다국어 필드 추가 -텍스트가 표시되는 **모든 속성**에 `langKeyId`와 `langKey` 필드를 추가합니다. - ```typescript interface MyComponentConfig { - // 기본 텍스트 title?: string; titleLangKeyId?: number; titleLangKey?: string; - // 컬럼 배열 columns?: Array<{ name: string; label: string; @@ -792,75 +782,25 @@ interface MyComponentConfig { ```typescript // extractMultilangLabels 함수에 추가 if (comp.componentType === "my-new-component") { - const config = comp.componentConfig as MyComponentConfig; + const config = comp.componentConfig; - // 제목 추출 if (config?.title) { addLabel({ id: `${comp.id}_title`, - componentId: `${comp.id}_title`, label: config.title, - type: "title", - parentType: "my-new-component", - parentLabel: config.title, langKeyId: config.titleLangKeyId, langKey: config.titleLangKey, }); } - // 컬럼 추출 - if (config?.columns && Array.isArray(config.columns)) { - config.columns.forEach((col, index) => { - addLabel({ - id: `${comp.id}_col_${index}`, - componentId: `${comp.id}_col_${index}`, - label: col.label || col.name, - type: "column", - parentType: "my-new-component", - parentLabel: config.title || "컴포넌트", - langKeyId: col.langKeyId, - langKey: col.langKey, - }); + config?.columns?.forEach((col, index) => { + addLabel({ + id: `${comp.id}_col_${index}`, + label: col.label, + langKeyId: col.langKeyId, + langKey: col.langKey, }); - } -} -``` - -### 매핑 적용 로직 등록 - -```typescript -// applyMultilangMappings 함수에 추가 -if (comp.componentType === "my-new-component") { - const config = comp.componentConfig as MyComponentConfig; - - // 제목 매핑 - const titleMapping = mappingMap.get(`${comp.id}_title`); - if (titleMapping) { - updated.componentConfig = { - ...updated.componentConfig, - titleLangKeyId: titleMapping.keyId, - titleLangKey: titleMapping.langKey, - }; - } - - // 컬럼 매핑 - if (config?.columns && Array.isArray(config.columns)) { - const updatedColumns = config.columns.map((col, index) => { - const colMapping = mappingMap.get(`${comp.id}_col_${index}`); - if (colMapping) { - return { - ...col, - langKeyId: colMapping.keyId, - langKey: colMapping.langKey, - }; - } - return col; - }); - updated.componentConfig = { - ...updated.componentConfig, - columns: updatedColumns, - }; - } + }); } ``` @@ -873,238 +813,760 @@ const MyComponent = ({ component }) => { const { getTranslatedText } = useScreenMultiLang(); const config = component.componentConfig; - // 제목 번역 const displayTitle = config?.titleLangKey ? getTranslatedText(config.titleLangKey, config.title || "") : config?.title || ""; - // 컬럼 헤더 번역 - const translatedColumns = config?.columns?.map((col) => ({ - ...col, - displayLabel: col.langKey - ? getTranslatedText(col.langKey, col.label) - : col.label, - })); - - return ( -
-

{displayTitle}

- - - - {translatedColumns?.map((col, idx) => ( - - ))} - - -
{col.displayLabel}
-
- ); + return

{displayTitle}

; }; ``` -### ScreenMultiLangContext에 키 수집 로직 추가 - -파일: `frontend/contexts/ScreenMultiLangContext.tsx` - -```typescript -// collectLangKeys 함수에 추가 -if (comp.componentType === "my-new-component") { - const config = comp.componentConfig; - - if (config?.titleLangKey) { - keys.add(config.titleLangKey); - } - - if (config?.columns && Array.isArray(config.columns)) { - config.columns.forEach((col: any) => { - if (col.langKey) { - keys.add(col.langKey); - } - }); - } -} -``` --- -## 5. 컬럼 설정 패널 구현 +## 11. 저장 버튼 및 플로우 연동 -### 필수 구조 +### beforeFormSave 이벤트 처리 (필수) -모든 테이블/목록 기반 컴포넌트의 설정 패널은 다음 구조를 따릅니다: +저장 버튼 클릭 시 `beforeFormSave` 이벤트가 발생하며, 각 컴포넌트는 자신의 데이터를 제공해야 합니다: ```typescript -interface ConfigPanelProps { - config: MyComponentConfig; - onChange: (config: Partial) => void; - screenTableName?: string; // 화면에 연결된 테이블명 - tableColumns?: any[]; // 테이블 컬럼 정보 -} +useEffect(() => { + const handleSaveRequest = (event: CustomEvent) => { + const componentKey = columnName || component?.id; + if (event.detail && componentKey) { + event.detail.formData[componentKey] = currentValue; + } + }; + + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); + return () => window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); +}, [currentValue, columnName, component?.id]); +``` -export const MyComponentConfigPanel: React.FC = ({ - config, - onChange, - screenTableName, - tableColumns, -}) => { - // 1. 기본 테이블 컬럼 상태 - const [availableColumns, setAvailableColumns] = useState>([]); +### 배열 데이터 처리 (리피터, 테이블) - // 2. 엔티티 조인 컬럼 상태 (필수!) - const [entityJoinColumns, setEntityJoinColumns] = useState<{ - availableColumns: Array<{...}>; - joinTables: Array<{...}>; - }>({ availableColumns: [], joinTables: [] }); +```typescript +useEffect(() => { + const handleSaveRequest = (event: CustomEvent) => { + const componentKey = columnName || component?.id || "repeater_data"; + + // 메타데이터 필드(_로 시작) 제외 + const filteredData = localValue.map((item: any) => { + const filtered: Record = {}; + Object.keys(item).forEach((key) => { + if (!key.startsWith("_")) { + filtered[key] = item[key]; + } + }); + return filtered; + }); + + if (event.detail) { + event.detail.formData[componentKey] = filteredData; + } + }; + + window.addEventListener("beforeFormSave", handleSaveRequest as EventListener); + return () => window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener); +}, [localValue, columnName, component?.id]); +``` - // 3. 로딩 상태 - const [loadingEntityJoins, setLoadingEntityJoins] = useState(false); +### 플로우(제어관리)와의 연동 - // 4. 화면 테이블명이 있으면 자동 설정 +```typescript +import { useFlowContext } from "@/contexts/FlowContext"; + +const MyFormComponent = ({ formData, onFormDataChange }) => { + const flowContext = useFlowContext(); + + // 스텝에서 선택된 데이터로 폼 초기화 useEffect(() => { - if (screenTableName && !config.selectedTable) { - onChange({ - ...config, - selectedTable: screenTableName, - columns: config.columns || [], + if (flowContext?.selectedData?.length > 0) { + const firstItem = flowContext.selectedData[0]; + Object.keys(firstItem).forEach((key) => { + onFormDataChange?.(key, firstItem[key]); }); } - }, [screenTableName]); + }, [flowContext?.selectedData]); +}; +``` - // 5. 기본 컬럼 로드 +### 저장 버튼 액션 타입 + +| 액션 타입 | 설명 | 플로우 연동 | +|----------|------|------------| +| `save` | 단순 저장 | X | +| `saveAndClose` | 저장 후 모달/화면 닫기 | X | +| `saveAndMove` | 저장 후 다음 스텝으로 이동 | O | +| `saveAndRefresh` | 저장 후 화면 새로고침 | X | + +### 주의사항 + +1. **컴포넌트 독립성**: 플로우 없이도 단독으로 작동해야 함 +2. **옵셔널 처리**: `flowContext`가 없을 수 있으므로 항상 옵셔널 체이닝 사용 +3. **데이터 키 일관성**: `columnName`이 있으면 우선 사용, 없으면 `component.id` 사용 +4. **메타데이터 제외**: 저장 시 `_`로 시작하는 메타데이터 필드는 제외 + + + +--- + +## 12. 표준 코드 스타일 가이드 + +**`v2-unified-repeater`** 컴포넌트를 표준으로 삼아 동일한 구조로 작성합니다. + +### 핵심 원칙: 느슨한 결합도 (Loose Coupling) + +**컴포넌트 간 직접 참조를 피하고, 이벤트 기반 통신을 사용합니다.** + +``` +┌─────────────┐ 이벤트 ┌─────────────┐ 이벤트 ┌─────────────┐ +│ 저장 버튼 │ ──────────▶ │ 리피터 │ ◀────────── │ 플로우 │ +│ │ beforeFormSave │ │ flowStepChange │ │ +└─────────────┘ └─────────────┘ └─────────────┘ + │ + ▼ + ┌─────────────────────────────────────────────────┐ + │ Window Event Bus │ + │ beforeFormSave, repeaterSave, flowStepChange │ + └─────────────────────────────────────────────────┘ +``` + +### 파일 구조 표준 + +``` +frontend/lib/registry/components/v2-[component-name]/ +├── index.ts # V2ComponentDefinition (V2 접두사) +├── [Name]Renderer.tsx # 렌더러 (자동 등록) +├── [Name]Component.tsx # 메인 컴포넌트 +├── [Name]ConfigPanel.tsx # 설정 패널 +└── types.ts # 타입 정의 (선택적) +``` + +### index.ts 표준 구조 + +```typescript +import { ComponentCategory } from "@/types/component"; +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { MyComponentConfigPanel } from "./MyComponentConfigPanel"; +import { MyComponent } from "./MyComponent"; + +export const V2MyComponentDefinition = createComponentDefinition({ + id: "v2-my-component", // 반드시 v2- 접두사 + name: "마이 컴포넌트", + category: ComponentCategory.INPUT, + component: MyComponent, + configPanel: MyComponentConfigPanel, + defaultProps: { config: {} }, + events: ["onDataChange", "onRowClick"], +}); + +export default V2MyComponentDefinition; +``` + +### 메인 컴포넌트 표준 구조 + +```typescript +"use client"; + +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; + +interface MyComponentProps { + config: MyComponentConfig; + parentId?: string | number; + onDataChange?: (data: any[]) => void; +} + +export const MyComponent: React.FC = ({ + config: propConfig, + parentId, + onDataChange, +}) => { + // 1. 설정 병합 (useMemo) + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...propConfig, + }), [propConfig]); + + // 2. 상태 정의 + const [data, setData] = useState([]); + const dataRef = useRef(data); + dataRef.current = data; + + // 3. 이벤트 리스너 등록 (느슨한 결합) useEffect(() => { - // tableColumns prop 또는 API에서 로드 - }, [config.selectedTable, screenTableName, tableColumns]); + const handleSaveEvent = async (event: CustomEvent) => { + const { masterRecordId } = event.detail || {}; + await saveData(dataRef.current, masterRecordId); + }; - // 6. 엔티티 조인 컬럼 로드 (필수!) + // V2 이벤트 버스 구독 + const unsubscribe = v2EventBus.subscribe( + V2_EVENTS.FORM.REPEATER_SAVE, + handleSaveEvent + ); + + // 레거시 이벤트 지원 + window.addEventListener("repeaterSave", handleSaveEvent as any); + + return () => { + unsubscribe(); + window.removeEventListener("repeaterSave", handleSaveEvent as any); + }; + }, [config]); + + // 4. beforeFormSave 이벤트 리스너 useEffect(() => { - const fetchEntityJoinColumns = async () => { - const tableName = config.selectedTable || screenTableName; - if (!tableName) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - return; - } - - setLoadingEntityJoins(true); - try { - const result = await entityJoinApi.getEntityJoinColumns(tableName); - setEntityJoinColumns({ - availableColumns: result.availableColumns || [], - joinTables: result.joinTables || [], + const handleBeforeFormSave = (event: CustomEvent) => { + const fieldName = config.fieldName || config.columnName; + if (event.detail && fieldName) { + // 메타데이터 제외 후 데이터 제공 + const cleanData = dataRef.current.map(item => { + const clean: Record = {}; + Object.keys(item).forEach(key => { + if (!key.startsWith("_")) clean[key] = item[key]; + }); + return clean; }); - } catch (error) { - setEntityJoinColumns({ availableColumns: [], joinTables: [] }); - } finally { - setLoadingEntityJoins(false); + event.detail.formData[fieldName] = cleanData; } }; - fetchEntityJoinColumns(); - }, [config.selectedTable, screenTableName]); + const unsubscribe = v2EventBus.subscribe( + V2_EVENTS.FORM.BEFORE_SAVE, + handleBeforeFormSave + ); + window.addEventListener("beforeFormSave", handleBeforeFormSave as any); + + return () => { + unsubscribe(); + window.removeEventListener("beforeFormSave", handleBeforeFormSave as any); + }; + }, [config.fieldName, config.columnName]); - // 7. UI 렌더링 + // 5. 렌더링 return ( -
- {/* 기본 테이블 컬럼 */} -
- - {/* 기본 컬럼 체크박스들 */} + +
+ {/* 컴포넌트 내용 */} +
+
+ ); +}; +``` + +### 설정 패널 표준 구조 + +```typescript +"use client"; + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Database, Link2, ChevronsUpDown, Check } from "lucide-react"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +interface MyComponentConfigPanelProps { + config: MyComponentConfig; + onChange: (config: MyComponentConfig) => void; + screenTableName?: string; +} + +export const MyComponentConfigPanel: React.FC = ({ + config: propConfig, + onChange, + screenTableName, +}) => { + // 1. config 안전하게 초기화 (useMemo) + const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...propConfig, + }), [propConfig]); + + // 2. 상태 정의 + const [allTables, setAllTables] = useState([]); + const [relatedTables, setRelatedTables] = useState([]); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + + // 3. 데이터 로딩 + useEffect(() => { + const loadTables = async () => { + const response = await tableManagementApi.getTableList(); + if (response.success) setAllTables(response.data); + }; + loadTables(); + }, []); + + useEffect(() => { + if (!screenTableName) return; + const loadRelatedTables = async () => { + const response = await tableManagementApi.getReferencedByTables(screenTableName); + if (response.success) setRelatedTables(response.data); + }; + loadRelatedTables(); + }, [screenTableName]); + + // 4. 핸들러 + const handleChange = useCallback(( + key: K, value: MyComponentConfig[K] + ) => { + onChange({ ...config, [key]: value }); + }, [config, onChange]); + + const handleTableSelect = useCallback((tableName: string) => { + const relation = relatedTables.find(r => r.tableName === tableName); + + if (relation) { + onChange({ + ...config, + useCustomTable: true, + mainTableName: tableName, + foreignKeyColumn: relation.foreignKeyColumn, + foreignKeySourceColumn: relation.referenceColumn, + }); + } else { + onChange({ + ...config, + useCustomTable: tableName !== screenTableName, + mainTableName: tableName !== screenTableName ? tableName : undefined, + }); + } + setTableComboboxOpen(false); + }, [config, onChange, screenTableName, relatedTables]); + + // 5. 렌더링 + return ( +
+ {/* 테이블 선택 (그룹별 Combobox) */} +
+ + + + + + + + + + {/* 기본 */} + {screenTableName && ( + + handleTableSelect(screenTableName)}> + + {screenTableName} + + + )} + + {/* 연관 테이블 */} + {relatedTables.length > 0 && ( + + {relatedTables.map((table) => ( + handleTableSelect(table.tableName)}> + + {table.tableName} + + FK: {table.foreignKeyColumn} + + + ))} + + )} + + {/* 전체 */} + + {allTables.filter(t => + t.tableName !== screenTableName && + !relatedTables.some(r => r.tableName === t.tableName) + ).map((table) => ( + handleTableSelect(table.tableName)}> + {table.displayName || table.tableName} + + ))} + + + + +
- {/* 엔티티 조인 컬럼 (필수!) */} - {entityJoinColumns.joinTables.length > 0 && ( -
- - {/* 조인 테이블별 컬럼 선택 UI */} + {/* 필수/숨김 설정 */} +
+ +
+ handleChange("required", checked as boolean)} + /> +
- )} +
+ handleChange("hidden", checked as boolean)} + /> + +
+
); }; ``` +### 이벤트 패턴 + +#### 이벤트 구독 (소비자) + +```typescript +useEffect(() => { + const handleEvent = (event: CustomEvent) => { /* 처리 */ }; + + // V2 이벤트 버스 (권장) + const unsubscribe = v2EventBus.subscribe(V2_EVENTS.FORM.BEFORE_SAVE, handleEvent); + + // 레거시 지원 + window.addEventListener("beforeFormSave", handleEvent as any); + + // Cleanup 필수! + return () => { + unsubscribe(); + window.removeEventListener("beforeFormSave", handleEvent as any); + }; +}, [dependencies]); +``` + +#### 이벤트 발행 (생산자) + +```typescript +// V2 이벤트 버스 +v2EventBus.emit(V2_EVENTS.FORM.REPEATER_SAVE, { + tableName: "my_table", + masterRecordId: savedId, +}); + +// 레거시 이벤트 +window.dispatchEvent(new CustomEvent("repeaterSave", { + detail: { tableName: "my_table", masterRecordId: savedId } +})); +``` + +### 옵셔널 체이닝 필수 사용 + +```typescript +// 컨텍스트가 없을 수 있음 +const flowContext = useFlowContext?.(); +const currentStepId = flowContext?.currentStepId; + +// 콜백이 없을 수 있음 +onDataChange?.(newData); + +// 중첩 속성 접근 +const tableName = config?.dataSource?.tableName; +``` + --- -## 6. 체크리스트 +## 13. 성능 최적화 -새 컴포넌트 개발 시 다음 항목을 확인하세요: +### useMemo로 계산 비용 줄이기 -### V2 컴포넌트 규칙 (최우선) +```typescript +// 설정 병합 (매 렌더링마다 새 객체 생성 방지) +const config = useMemo(() => ({ + ...DEFAULT_CONFIG, + ...propConfig, +}), [propConfig]); -- [ ] V2 폴더(`v2-*/`)에서 작업 중인지 확인 -- [ ] 원본 폴더는 수정하지 않음 +// 필터링된 데이터 (데이터나 필터가 변경될 때만 재계산) +const filteredData = useMemo(() => { + return data.filter(item => item.status === selectedStatus); +}, [data, selectedStatus]); + +// 컬럼 정의 (설정이 변경될 때만 재생성) +const columns = useMemo(() => { + return config.columns?.map(col => ({ + ...col, + render: (value: any) => formatValue(value, col.type), + })); +}, [config.columns]); +``` + +### useCallback으로 핸들러 안정화 + +```typescript +// 자식 컴포넌트에 전달되는 콜백 안정화 +const handleRowClick = useCallback((row: any) => { + setSelectedRow(row); + onRowClick?.(row); +}, [onRowClick]); + +const handleChange = useCallback((value: any) => { + onFormDataChange?.(fieldName, value); +}, [fieldName, onFormDataChange]); + +// 의존성이 많은 핸들러는 useRef 활용 +const dataRef = useRef(data); +dataRef.current = data; + +const handleSave = useCallback(async () => { + // dataRef.current로 최신 값 참조 (의존성 배열에 data 불필요) + await saveData(dataRef.current); +}, []); +``` + +### useRef로 이벤트 핸들러 최적화 + +```typescript +// 이벤트 핸들러에서 최신 상태 참조 +const [data, setData] = useState([]); +const dataRef = useRef(data); + +// 상태 변경 시 ref도 업데이트 +useEffect(() => { + dataRef.current = data; +}, [data]); + +// 이벤트 핸들러에서 ref 사용 (의존성 배열 최소화) +useEffect(() => { + const handleSaveEvent = (event: CustomEvent) => { + // data 대신 dataRef.current 사용 + event.detail.formData[fieldName] = dataRef.current; + }; + + window.addEventListener("beforeFormSave", handleSaveEvent as any); + return () => window.removeEventListener("beforeFormSave", handleSaveEvent as any); +}, [fieldName]); // data 의존성 제거 → 리스너 재등록 방지 +``` + +### React.memo로 불필요한 리렌더링 방지 + +```typescript +// 자주 리렌더링되는 자식 컴포넌트 +const TableRow = React.memo(({ row, onClick }: TableRowProps) => { + return ( + onClick(row)}> + {/* 셀 렌더링 */} + + ); +}); + +// 커스텀 비교 함수 (필요 시) +const TableRow = React.memo( + ({ row, onClick }: TableRowProps) => { /* ... */ }, + (prevProps, nextProps) => prevProps.row.id === nextProps.row.id +); +``` + +### 대량 데이터 처리 패턴 + +```typescript +// 1. 페이지네이션 필수 (전체 데이터 로드 금지) +const fetchData = async (page: number, size: number) => { + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page, + size, // 기본값 50~100 + }); +}; + +// 2. 가상화 (1000개 이상 행 표시 시) +import { useVirtualizer } from "@tanstack/react-virtual"; + +const rowVirtualizer = useVirtualizer({ + count: data.length, + getScrollElement: () => containerRef.current, + estimateSize: () => 40, // 행 높이 +}); + +// 3. 디바운싱 (검색/필터 입력) +import { useDebouncedCallback } from "use-debounce"; + +const debouncedSearch = useDebouncedCallback((value: string) => { + setSearchTerm(value); + fetchData(1, pageSize); +}, 300); +``` + +### 렌더링 최적화 패턴 + +```typescript +// 조건부 렌더링 최적화 +const MyComponent = ({ isVisible, data }) => { + // 숨겨진 컴포넌트는 early return + if (!isVisible) return null; + + // 데이터 없으면 빈 상태만 표시 + if (!data?.length) { + return ; + } + + return ; +}; + +// 무거운 컴포넌트 지연 로드 +const HeavyChart = React.lazy(() => import("./HeavyChart")); + +const Dashboard = () => ( + }> + + +); +``` + +### 피해야 할 패턴 + +```typescript +// ❌ 렌더링마다 새 객체/배열 생성 + + + +// ✅ useMemo 또는 상수로 분리 +const style = useMemo(() => ({ color: "red" }), []); +const options = useMemo(() => [1, 2, 3], []); + + +// ❌ 인라인 함수로 핸들러 전달 + handleClick(id)} /> + +// ✅ useCallback으로 안정화 +const handleItemClick = useCallback(() => handleClick(id), [id]); + + +// ❌ 불필요한 상태 업데이트 +useEffect(() => { + setDerivedValue(data.map(x => x.value)); // 매번 새 배열 생성 +}, [data]); + +// ✅ useMemo로 파생 값 계산 +const derivedValue = useMemo(() => data.map(x => x.value), [data]); +``` + +--- + +## 14. 체크리스트 + +### V2 컴포넌트 규칙 + +- [ ] V2 폴더(`v2-*/`)에서 작업 - [ ] 컴포넌트 ID에 `v2-` 접두사 사용 -- [ ] Definition 이름에 `V2` 접두사 사용 (예: `V2TableListDefinition`) -- [ ] Renderer에서 올바른 V2 Definition 참조 확인 +- [ ] Definition 이름에 `V2` 접두사 사용 +- [ ] 원본 폴더 수정하지 않음 -### 컴포넌트별 테이블 설정 (핵심) +### 표준 Props -- [ ] 화면 메인 테이블과 다른 테이블을 사용할 수 있는지 확인 -- [ ] `useCustomTable`, `mainTableName` (또는 `customTableName`) 설정 지원 -- [ ] 연관 테이블 선택 시 FK/PK 자동 설정 (`/api/table-management/columns/:tableName/referenced-by` API 활용) -- [ ] 저장 테이블 변경 시 해당 테이블의 컬럼 자동 로드 -- [ ] 테이블 선택 UI는 Combobox 형태로 그룹별 표시 (기본/연관/전체) -- [ ] FK 자동 연결: `repeaterSave` 이벤트에서 `masterRecordId` 수신 및 적용 +- [ ] `component`, `isDesignMode` props 지원 +- [ ] `formData`, `onFormDataChange` props 지원 +- [ ] `companyCode` props 지원 (멀티테넌시) +- [ ] `refreshKey`, `onRefresh` props 지원 -### 엔티티 조인 (필수) +### 멀티테넌시 -- [ ] `entityJoinApi.getEntityJoinColumns()` 호출하여 조인 컬럼 로드 +- [ ] 모든 데이터 조회 API에 `autoFilter` 적용 +- [ ] `company_code` 필터링 확인 +- [ ] 최고 관리자(`*`) 데이터 일반 사용자에게 노출 안함 + +### 디자인/인터랙티브 모드 + +- [ ] `isDesignMode` 체크하여 API 호출 스킵 +- [ ] 디자인 모드에서 더미 UI 표시 +- [ ] 디자인 모드에서 이벤트 핸들러 비활성화 + +### 로딩 및 에러 처리 + +- [ ] `loading` 상태 관리 +- [ ] `error` 상태 관리 +- [ ] 로딩 중 UI 표시 (Spinner/Skeleton) +- [ ] 에러 메시지 토스트 표시 +- [ ] DB 에러 유형별 메시지 처리 + +### 입력 폼 필수 설정 + +- [ ] `required` 속성 지원 +- [ ] `hidden` 속성 지원 +- [ ] 필수 필드에 빨간색 `*` 표시 +- [ ] 속성 패널에 "필수 입력", "숨김" 체크박스 제공 +- [ ] 저장 시 필수 항목 검증 로직 구현 + +### 테이블 설정 + +- [ ] `useCustomTable`, `mainTableName` 설정 지원 +- [ ] 연관 테이블 선택 시 FK/PK 자동 설정 +- [ ] 테이블 선택 UI는 Combobox 그룹별 표시 + +### 엔티티 조인 + +- [ ] `entityJoinApi.getEntityJoinColumns()` 호출 - [ ] 설정 패널에 "엔티티 조인 컬럼" 섹션 추가 -- [ ] 조인 컬럼 선택 시 `tableName.columnName` 형식으로 저장 -- [ ] 데이터 조회 시 `getTableDataWithJoins()` 사용 -- [ ] 셀 값 추출 시 `getEntityJoinValue()` 헬퍼 사용 +- [ ] 컬럼명 `tableName.columnName` 형식으로 저장 -### 폼 데이터 관리 +### 폼 데이터 - [ ] `useFormCompatibility` 훅 사용 -- [ ] 값 변경 시 `setValue()` 호출 - [ ] 리피터 컴포넌트는 `beforeFormSave` 이벤트 처리 ### 다국어 지원 -- [ ] 타입 정의에 `langKeyId`, `langKey` 필드 추가 -- [ ] `extractMultilangLabels` 함수에 라벨 추출 로직 추가 -- [ ] `applyMultilangMappings` 함수에 매핑 적용 로직 추가 -- [ ] `collectLangKeys` 함수에 키 수집 로직 추가 -- [ ] 컴포넌트에서 `useScreenMultiLang` 훅으로 번역 표시 +- [ ] 타입에 `langKeyId`, `langKey` 필드 추가 +- [ ] `extractMultilangLabels` 함수에 추출 로직 추가 +- [ ] `applyMultilangMappings` 함수에 매핑 로직 추가 +- [ ] `collectLangKeys` 함수에 수집 로직 추가 -### 설정 패널 +### 저장 버튼 연동 -- [ ] `screenTableName` prop 처리 -- [ ] `tableColumns` prop 처리 -- [ ] 엔티티 조인 컬럼 로드 및 표시 -- [ ] 컬럼 추가/제거/순서변경 기능 +- [ ] `beforeFormSave` 이벤트에서 데이터 제공 +- [ ] 배열 데이터는 메타데이터(`_` 접두사) 제외 +- [ ] 이벤트 리스너 cleanup 처리 + +### 플로우 연동 + +- [ ] `FlowContext` 있을 때 `selectedData`로 초기화 +- [ ] 플로우 없이도 단독 작동 가능 (옵셔널 체이닝) + +### 코드 스타일 + +- [ ] `v2-unified-repeater` 구조 참고 +- [ ] 느슨한 결합도 유지 (이벤트 기반 통신) + +### 성능 최적화 + +- [ ] `useMemo`로 설정/데이터 병합 +- [ ] `useCallback`으로 핸들러 안정화 +- [ ] `useRef`로 이벤트 핸들러에서 최신 값 참조 +- [ ] 렌더링마다 새 객체/배열 생성 방지 +- [ ] 인라인 함수 콜백 방지 (자식 컴포넌트 리렌더링 유발) +- [ ] 대량 데이터는 페이지네이션 필수 --- ## 관련 파일 목록 -| 파일 | 역할 | -| ---------------------------------------------------- | --------------------- | -| `frontend/lib/api/entityJoin.ts` | 엔티티 조인 API | -| `frontend/hooks/useFormCompatibility.ts` | 폼 호환성 브릿지 | -| `frontend/components/unified/UnifiedFormContext.tsx` | 통합 폼 Context | -| `frontend/lib/utils/multilangLabelExtractor.ts` | 다국어 라벨 추출/매핑 | -| `frontend/contexts/ScreenMultiLangContext.tsx` | 다국어 번역 Context | +### 핵심 파일 ---- +| 파일 | 역할 | +|------|------| +| `components/unified/UnifiedInput.tsx` | text, number 입력 | +| `components/unified/UnifiedSelect.tsx` | code, entity 선택 | +| `components/unified/UnifiedDate.tsx` | date, datetime 선택 | +| `lib/registry/components/v2-*/` | V2 컴포넌트 폴더 | +| `lib/api/entityJoin.ts` | 엔티티 조인 API | +| `hooks/useFormCompatibility.ts` | 폼 호환성 브릿지 | +| `lib/utils/multilangLabelExtractor.ts` | 다국어 라벨 추출/매핑 | +| `lib/utils/buttonActions.ts` | 저장 버튼 액션 처리 | +| `contexts/FlowContext.tsx` | 플로우 컨텍스트 | -## 참고: TableListConfigPanel 예시 +### 참고 컴포넌트 -`frontend/lib/registry/components/table-list/TableListConfigPanel.tsx` 파일에서 -엔티티 조인 컬럼을 어떻게 표시하는지 참고하세요. - -주요 패턴: - -1. `entityJoinApi.getEntityJoinColumns(tableName)` 호출 -2. `joinTables` 배열을 순회하며 각 조인 테이블의 컬럼 표시 -3. `tableName.columnName` 형식으로 컬럼명 생성 -4. `isEntityJoin: true` 플래그로 일반 컬럼과 구분 +| 컴포넌트 | 경로 | 참고 사항 | +|----------|------|-----------| +| `v2-unified-repeater` | `lib/registry/components/v2-unified-repeater/` | **표준 참조 컴포넌트** | +| `v2-table-list` | `lib/registry/components/v2-table-list/` | 조회 컴포넌트 참조 | +| `v2-table-search-widget` | `lib/registry/components/v2-table-search-widget/` | 검색 필터 참조 | diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 30399409..837ad250 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -499,9 +499,9 @@ export const InteractiveScreenViewer: React.FC = ( ); } - // 탭 컴포넌트 처리 + // 탭 컴포넌트 처리 (v1, v2 모두 지원) const componentType = (comp as any).componentType || (comp as any).componentId; - if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) { + if (comp.type === "tabs" || (comp.type === "component" && (componentType === "tabs-widget" || componentType === "v2-tabs-widget"))) { const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; // componentConfig에서 탭 정보 추출 @@ -517,19 +517,13 @@ export const InteractiveScreenViewer: React.FC = ( persistSelection: tabsConfig.persistSelection || false, }; - console.log("🔍 탭 컴포넌트 렌더링:", { - originalType: comp.type, - componentType, - componentId: (comp as any).componentId, - tabs: tabsComponent.tabs, - tabsConfig, - }); - return (
); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 72dca523..1f165df2 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -15,7 +15,7 @@ interface TabsWidgetProps { style?: React.CSSProperties; menuObjid?: number; formData?: Record; - onFormDataChange?: (data: Record) => void; + onFormDataChange?: (fieldName: string, value: any) => void; // DynamicComponentRenderer와 동일한 시그니처 isDesignMode?: boolean; // 디자인 모드 여부 onComponentSelect?: (tabId: string, componentId: string) => void; // 컴포넌트 선택 콜백 selectedComponentId?: string; // 선택된 컴포넌트 ID