diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 46459bd1..237d714a 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -25,6 +25,7 @@ import screenStandardRoutes from "./routes/screenStandardRoutes"; import templateStandardRoutes from "./routes/templateStandardRoutes"; import componentStandardRoutes from "./routes/componentStandardRoutes"; import layoutRoutes from "./routes/layoutRoutes"; +import dataRoutes from "./routes/dataRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -113,6 +114,7 @@ app.use("/api/admin/template-standards", templateStandardRoutes); app.use("/api/admin/component-standards", componentStandardRoutes); app.use("/api/layouts", layoutRoutes); app.use("/api/screen", screenStandardRoutes); +app.use("/api/data", dataRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts new file mode 100644 index 00000000..4ad42561 --- /dev/null +++ b/backend-node/src/routes/dataRoutes.ts @@ -0,0 +1,130 @@ +import express from "express"; +import { dataService } from "../services/dataService"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { AuthenticatedRequest } from "../types/auth"; + +const router = express.Router(); + +/** + * 동적 테이블 데이터 조회 API + * GET /api/data/{tableName} + */ +router.get( + "/:tableName", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + const { limit = "10", offset = "0", orderBy, ...filters } = req.query; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + // SQL 인젝션 방지를 위한 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`📊 데이터 조회 요청: ${tableName}`, { + limit: parseInt(limit as string), + offset: parseInt(offset as string), + orderBy: orderBy as string, + filters, + user: req.user?.userId, + }); + + // 데이터 조회 + const result = await dataService.getTableData({ + tableName, + limit: parseInt(limit as string), + offset: parseInt(offset as string), + orderBy: orderBy as string, + filters: filters as Record, + userCompany: req.user?.companyCode, + }); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목` + ); + + return res.json(result.data); + } catch (error) { + console.error("데이터 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +/** + * 테이블 컬럼 정보 조회 API + * GET /api/data/{tableName}/columns + */ +router.get( + "/:tableName/columns", + authenticateToken, + async (req: AuthenticatedRequest, res) => { + try { + const { tableName } = req.params; + + // 입력값 검증 + if (!tableName || typeof tableName !== "string") { + return res.status(400).json({ + success: false, + message: "테이블명이 필요합니다.", + error: "INVALID_TABLE_NAME", + }); + } + + // SQL 인젝션 방지를 위한 테이블명 검증 + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) { + return res.status(400).json({ + success: false, + message: "유효하지 않은 테이블명입니다.", + error: "INVALID_TABLE_NAME", + }); + } + + console.log(`📋 컬럼 정보 조회: ${tableName}`); + + // 컬럼 정보 조회 + const result = await dataService.getTableColumns(tableName); + + if (!result.success) { + return res.status(400).json(result); + } + + console.log( + `✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼` + ); + + return res.json(result); + } catch (error) { + console.error("컬럼 정보 조회 오류:", error); + return res.status(500).json({ + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } +); + +export default router; diff --git a/backend-node/src/services/dataService.ts b/backend-node/src/services/dataService.ts new file mode 100644 index 00000000..fc5935a8 --- /dev/null +++ b/backend-node/src/services/dataService.ts @@ -0,0 +1,328 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +interface GetTableDataParams { + tableName: string; + limit?: number; + offset?: number; + orderBy?: string; + filters?: Record; + userCompany?: string; +} + +interface ServiceResponse { + success: boolean; + data?: T; + message?: string; + error?: string; +} + +/** + * 안전한 테이블명 목록 (화이트리스트) + * SQL 인젝션 방지를 위해 허용된 테이블만 접근 가능 + */ +const ALLOWED_TABLES = [ + "company_mng", + "user_info", + "dept_info", + "code_info", + "code_category", + "menu_info", + "approval", + "approval_kind", + "board", + "comm_code", + "product_mng", + "part_mng", + "material_mng", + "order_mng_master", + "inventory_mng", + "contract_mgmt", + "project_mgmt", + "screen_definitions", + "screen_layouts", + "layout_standards", + "component_standards", + "web_type_standards", + "button_action_standards", + "template_standards", + "grid_standards", + "style_templates", + "multi_lang_key_master", + "multi_lang_text", + "language_master", + "table_labels", + "column_labels", + "dynamic_form_data", +]; + +/** + * 회사별 필터링이 필요한 테이블 목록 + */ +const COMPANY_FILTERED_TABLES = [ + "company_mng", + "user_info", + "dept_info", + "approval", + "board", + "product_mng", + "part_mng", + "material_mng", + "order_mng_master", + "inventory_mng", + "contract_mgmt", + "project_mgmt", +]; + +class DataService { + /** + * 테이블 데이터 조회 + */ + async getTableData( + params: GetTableDataParams + ): Promise> { + const { + tableName, + limit = 10, + offset = 0, + orderBy, + filters = {}, + userCompany, + } = params; + + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + // 테이블 존재 여부 확인 + const tableExists = await this.checkTableExists(tableName); + if (!tableExists) { + return { + success: false, + message: `테이블을 찾을 수 없습니다: ${tableName}`, + error: "TABLE_NOT_FOUND", + }; + } + + // 동적 SQL 쿼리 생성 + let query = `SELECT * FROM "${tableName}"`; + const queryParams: any[] = []; + let paramIndex = 1; + + // WHERE 조건 생성 + const whereConditions: string[] = []; + + // 회사별 필터링 추가 + if (COMPANY_FILTERED_TABLES.includes(tableName) && userCompany) { + // 슈퍼관리자(*)가 아닌 경우에만 회사 필터 적용 + if (userCompany !== "*") { + whereConditions.push(`company_code = $${paramIndex}`); + queryParams.push(userCompany); + paramIndex++; + } + } + + // 사용자 정의 필터 추가 + for (const [key, value] of Object.entries(filters)) { + if ( + value && + key !== "limit" && + key !== "offset" && + key !== "orderBy" && + key !== "userLang" + ) { + // 컬럼명 검증 (SQL 인젝션 방지) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) { + continue; // 유효하지 않은 컬럼명은 무시 + } + + whereConditions.push(`"${key}" ILIKE $${paramIndex}`); + queryParams.push(`%${value}%`); + paramIndex++; + } + } + + // WHERE 절 추가 + if (whereConditions.length > 0) { + query += ` WHERE ${whereConditions.join(" AND ")}`; + } + + // ORDER BY 절 추가 + if (orderBy) { + // ORDER BY 검증 (SQL 인젝션 방지) + const orderParts = orderBy.split(" "); + const columnName = orderParts[0]; + const direction = orderParts[1]?.toUpperCase(); + + if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) { + const validDirection = direction === "DESC" ? "DESC" : "ASC"; + query += ` ORDER BY "${columnName}" ${validDirection}`; + } + } else { + // 기본 정렬: 최신순 (가능한 컬럼 시도) + const dateColumns = [ + "created_date", + "regdate", + "reg_date", + "updated_date", + "upd_date", + ]; + const tableColumns = await this.getTableColumnsSimple(tableName); + const availableDateColumn = dateColumns.find((col) => + tableColumns.some((tableCol) => tableCol.column_name === col) + ); + + if (availableDateColumn) { + query += ` ORDER BY "${availableDateColumn}" DESC`; + } + } + + // LIMIT과 OFFSET 추가 + query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + queryParams.push(limit, offset); + + console.log("🔍 실행할 쿼리:", query); + console.log("📊 쿼리 파라미터:", queryParams); + + // 쿼리 실행 + const result = await prisma.$queryRawUnsafe(query, ...queryParams); + + return { + success: true, + data: result as any[], + }; + } catch (error) { + console.error(`데이터 조회 오류 (${tableName}):`, error); + return { + success: false, + message: "데이터 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 테이블 컬럼 정보 조회 + */ + async getTableColumns(tableName: string): Promise> { + try { + // 테이블명 화이트리스트 검증 + if (!ALLOWED_TABLES.includes(tableName)) { + return { + success: false, + message: `접근이 허용되지 않은 테이블입니다: ${tableName}`, + error: "TABLE_NOT_ALLOWED", + }; + } + + const columns = await this.getTableColumnsSimple(tableName); + + // 컬럼 라벨 정보 추가 + const columnsWithLabels = await Promise.all( + columns.map(async (column) => { + const label = await this.getColumnLabel( + tableName, + column.column_name + ); + return { + columnName: column.column_name, + columnLabel: label || column.column_name, + dataType: column.data_type, + isNullable: column.is_nullable === "YES", + defaultValue: column.column_default, + }; + }) + ); + + return { + success: true, + data: columnsWithLabels, + }; + } catch (error) { + console.error(`컬럼 정보 조회 오류 (${tableName}):`, error); + return { + success: false, + message: "컬럼 정보 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }; + } + } + + /** + * 테이블 존재 여부 확인 + */ + private async checkTableExists(tableName: string): Promise { + try { + const result = await prisma.$queryRawUnsafe( + ` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + ); + `, + tableName + ); + + return (result as any)[0]?.exists || false; + } catch (error) { + console.error("테이블 존재 확인 오류:", error); + return false; + } + } + + /** + * 테이블 컬럼 정보 조회 (간단 버전) + */ + private async getTableColumnsSimple(tableName: string): Promise { + const result = await prisma.$queryRawUnsafe( + ` + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = $1 + AND table_schema = 'public' + ORDER BY ordinal_position; + `, + tableName + ); + + return result as any[]; + } + + /** + * 컬럼 라벨 조회 + */ + private async getColumnLabel( + tableName: string, + columnName: string + ): Promise { + try { + // column_labels 테이블에서 라벨 조회 + const result = await prisma.$queryRawUnsafe( + ` + SELECT label_ko + FROM column_labels + WHERE table_name = $1 AND column_name = $2 + LIMIT 1; + `, + tableName, + columnName + ); + + const labelResult = result as any[]; + return labelResult[0]?.label_ko || null; + } catch (error) { + // column_labels 테이블이 없거나 오류가 발생하면 null 반환 + return null; + } + } +} + +export const dataService = new DataService(); diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index daa14ebb..2bb8a595 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1529,7 +1529,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, webTypeConfig: getDefaultWebTypeConfig(component.webType), style: { - labelDisplay: component.id === "text-display" ? false : true, // 텍스트 표시 컴포넌트는 기본적으로 라벨 숨김 + labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelFontSize: "14px", labelColor: "#374151", labelFontWeight: "500", @@ -1804,11 +1804,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD required: column.required, readonly: false, parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 + componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: defaultWidth, height: 40 }, gridColumns: 1, style: { - labelDisplay: true, + labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelFontSize: "12px", labelColor: "#374151", labelFontWeight: "500", @@ -1836,11 +1837,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD columnName: column.columnName, required: column.required, readonly: false, + componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입 position: { x, y, z: 1 } as Position, size: { width: defaultWidth, height: 40 }, gridColumns: 1, style: { - labelDisplay: true, + labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelFontSize: "12px", labelColor: "#374151", labelFontWeight: "500", diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 02e5dedc..d6daef4e 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -23,18 +23,21 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) { return ComponentRegistry.getAllComponents(); }, []); - // 카테고리별 분류 + // 카테고리별 분류 (input 카테고리 제외) const componentsByCategory = useMemo(() => { + // input 카테고리 컴포넌트들을 제외한 컴포넌트만 필터링 + const filteredComponents = allComponents.filter((component) => component.category !== "input"); + const categories: Record = { - all: allComponents, - input: [], + all: filteredComponents, // input 카테고리 제외된 컴포넌트들만 포함 + input: [], // 빈 배열로 유지 (사용되지 않음) display: [], action: [], layout: [], utility: [], }; - allComponents.forEach((component) => { + filteredComponents.forEach((component) => { if (categories[component.category]) { categories[component.category].push(component); } @@ -104,7 +107,7 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
- 컴포넌트 ({allComponents.length}) + 컴포넌트 ({componentsByCategory.all.length})
+ ); +} + +interface AccordionContentProps { + className?: string; + children: React.ReactNode; +} + +function AccordionContent({ className, children, ...props }: AccordionContentProps) { + const context = React.useContext(AccordionContext); + const parent = React.useContext(AccordionItemContext); + + if (!context || !parent) { + throw new Error("AccordionContent must be used within AccordionItem"); + } + + const isOpen = + context.type === "multiple" + ? Array.isArray(context.value) && context.value.includes(parent.value) + : context.value === parent.value; + + if (!isOpen) return null; + + return ( +
+ {children} +
+ ); +} + +// AccordionItem을 래핑하여 컨텍스트 제공 +const AccordionItemWithContext = React.forwardRef( + ({ value, children, ...props }, ref) => { + return ( + + + {children} + + + ); + }, +); + +AccordionItemWithContext.displayName = "AccordionItem"; + +export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/frontend/docs/CLI_컴포넌트_생성_가이드.md b/frontend/docs/CLI_컴포넌트_생성_가이드.md new file mode 100644 index 00000000..ad395edd --- /dev/null +++ b/frontend/docs/CLI_컴포넌트_생성_가이드.md @@ -0,0 +1,216 @@ +# 컴포넌트 자동 생성 CLI 가이드 + +화면 관리 시스템의 컴포넌트를 자동으로 생성하는 CLI 도구 사용법입니다. + +## 사용법 + +```bash +node scripts/create-component.js <컴포넌트이름> <표시이름> <설명> <카테고리> [웹타입] +``` + +### 파라미터 + +| 파라미터 | 필수 | 설명 | 예시 | +|---------|-----|------|------| +| 컴포넌트이름 | ✅ | kebab-case 형식의 컴포넌트 ID | `text-input`, `date-picker` | +| 표시이름 | ✅ | 한글 표시명 | `텍스트 입력`, `날짜 선택` | +| 설명 | ✅ | 컴포넌트 설명 | `텍스트를 입력하는 컴포넌트` | +| 카테고리 | ✅ | 컴포넌트 카테고리 | `input`, `display`, `action` | +| 웹타입 | ⭕ | 기본 웹타입 (기본값: text) | `text`, `number`, `button` | + +### 카테고리 옵션 + +| 카테고리 | 설명 | 아이콘 | +|---------|-----|-------| +| `input` | 입력 컴포넌트 | Edit | +| `display` | 표시 컴포넌트 | Eye | +| `action` | 액션/버튼 컴포넌트 | MousePointer | +| `layout` | 레이아웃 컴포넌트 | Layout | +| `form` | 폼 관련 컴포넌트 | FormInput | +| `chart` | 차트 컴포넌트 | BarChart | +| `media` | 미디어 컴포넌트 | Image | +| `navigation` | 네비게이션 컴포넌트 | Menu | +| `feedback` | 피드백 컴포넌트 | Bell | +| `utility` | 유틸리티 컴포넌트 | Settings | + +### 웹타입 옵션 + +| 웹타입 | 설명 | 적용 대상 | +|-------|-----|----------| +| `text` | 텍스트 입력 | 기본 텍스트 필드 | +| `number` | 숫자 입력 | 숫자 전용 필드 | +| `email` | 이메일 입력 | 이메일 검증 필드 | +| `password` | 비밀번호 입력 | 패스워드 필드 | +| `textarea` | 다중행 텍스트 | 텍스트 영역 | +| `select` | 선택박스 | 드롭다운 선택 | +| `button` | 버튼 | 클릭 액션 | +| `checkbox` | 체크박스 | 불린 값 선택 | +| `radio` | 라디오 버튼 | 단일 선택 | +| `date` | 날짜 선택 | 날짜 피커 | +| `file` | 파일 업로드 | 파일 선택 | + +## 사용 예시 + +### 1. 기본 텍스트 입력 컴포넌트 + +```bash +node scripts/create-component.js text-input "텍스트 입력" "기본 텍스트 입력 컴포넌트" input text +``` + +### 2. 숫자 입력 컴포넌트 + +```bash +node scripts/create-component.js number-input "숫자 입력" "숫자만 입력 가능한 컴포넌트" input number +``` + +### 3. 버튼 컴포넌트 + +```bash +node scripts/create-component.js action-button "액션 버튼" "사용자 액션을 처리하는 버튼" action button +``` + +### 4. 차트 컴포넌트 + +```bash +node scripts/create-component.js bar-chart "막대 차트" "데이터를 막대 그래프로 표시" chart +``` + +### 5. 이미지 표시 컴포넌트 + +```bash +node scripts/create-component.js image-viewer "이미지 뷰어" "이미지를 표시하는 컴포넌트" media +``` + +## 생성되는 파일들 + +CLI를 실행하면 다음 파일들이 자동으로 생성됩니다: + +``` +lib/registry/components/[컴포넌트이름]/ +├── index.ts # 컴포넌트 정의 및 메타데이터 +├── [컴포넌트이름]Component.tsx # 메인 컴포넌트 파일 +├── [컴포넌트이름]Renderer.tsx # 자동 등록 렌더러 +├── [컴포넌트이름]ConfigPanel.tsx # 설정 패널 UI +├── types.ts # TypeScript 타입 정의 +└── README.md # 컴포넌트 문서 +``` + +## 자동 처리되는 작업들 + +### ✅ 자동 등록 + +- `lib/registry/components/index.ts`에 import 구문 자동 추가 +- 컴포넌트 레지스트리에 자동 등록 +- 브라우저에서 즉시 사용 가능 + +### ✅ 타입 안전성 + +- TypeScript 인터페이스 자동 생성 +- 컴포넌트 설정 타입 정의 +- Props 타입 안전성 보장 + +### ✅ 설정 패널 + +- 웹타입별 맞춤 설정 UI 자동 생성 +- 공통 설정 (disabled, required, readonly) 포함 +- 실시간 설정 값 업데이트 + +### ✅ 문서화 + +- 자동 생성된 README.md +- 사용법 및 설정 옵션 문서 +- 개발자 정보 및 CLI 명령어 기록 + +## CLI 실행 후 확인사항 + +### 1. 브라우저에서 확인 + +```javascript +// 개발자 도구에서 확인 +__COMPONENT_REGISTRY__.get("컴포넌트이름") +``` + +### 2. 컴포넌트 패널에서 테스트 + +1. 화면 디자이너 열기 +2. 컴포넌트 패널에서 새 컴포넌트 확인 +3. 드래그앤드롭으로 캔버스에 추가 +4. 속성 편집 패널에서 설정 테스트 + +### 3. 설정 패널 동작 확인 + +- 속성 변경 시 실시간 반영 여부 +- 필수/선택 설정들의 정상 동작 +- 웹타입별 특화 설정 확인 + +## 트러블슈팅 + +### import 자동 추가 실패 + +만약 index.ts에 import가 자동 추가되지 않았다면: + +```typescript +// lib/registry/components/index.ts에 수동 추가 +import "./컴포넌트이름/컴포넌트이름Renderer"; +``` + +### 컴포넌트가 패널에 나타나지 않는 경우 + +1. 브라우저 새로고침 +2. 개발자 도구에서 오류 확인 +3. import 구문 확인 +4. TypeScript 컴파일 오류 확인 + +### 설정 패널이 제대로 작동하지 않는 경우 + +1. 타입 정의 확인 (`types.ts`) +2. ConfigPanel 컴포넌트 확인 +3. 웹타입별 설정 로직 확인 + +## 고급 사용법 + +### 사용자 정의 옵션 + +```bash +# 크기 지정 +node scripts/create-component.js my-component "내 컴포넌트" "설명" display --size=300x50 + +# 태그 추가 +node scripts/create-component.js my-component "내 컴포넌트" "설명" display --tags=tag1,tag2,tag3 + +# 작성자 지정 +node scripts/create-component.js my-component "내 컴포넌트" "설명" display --author="개발자명" +``` + +### 생성 후 커스터마이징 + +1. **컴포넌트 로직 수정**: `[컴포넌트이름]Component.tsx` +2. **설정 패널 확장**: `[컴포넌트이름]ConfigPanel.tsx` +3. **타입 정의 확장**: `types.ts` +4. **렌더러 로직 수정**: `[컴포넌트이름]Renderer.tsx` + +## 베스트 프랙티스 + +### 네이밍 규칙 + +- **컴포넌트이름**: kebab-case (예: `text-input`, `date-picker`) +- **표시이름**: 명확한 한글명 (예: "텍스트 입력", "날짜 선택") +- **설명**: 구체적이고 명확한 설명 + +### 카테고리 선택 + +- 컴포넌트의 주된 용도에 맞는 카테고리 선택 +- 일관성 있는 카테고리 분류 +- 사용자가 찾기 쉬운 카테고리 구조 + +### 웹타입 선택 + +- 컴포넌트의 데이터 타입에 맞는 웹타입 선택 +- 기본 동작과 검증 로직 고려 +- 확장 가능성 고려 + +## 결론 + +이 CLI 도구를 사용하면 화면 관리 시스템에 새로운 컴포넌트를 빠르고 일관성 있게 추가할 수 있습니다. 자동 생성된 템플릿을 기반으로 비즈니스 로직에 집중하여 개발할 수 있습니다. + +더 자세한 정보는 [컴포넌트 시스템 가이드](./컴포넌트_시스템_가이드.md)를 참조하세요. diff --git a/frontend/docs/컴포넌트_생성_가이드.md b/frontend/docs/컴포넌트_생성_가이드.md deleted file mode 100644 index 25e0a54a..00000000 --- a/frontend/docs/컴포넌트_생성_가이드.md +++ /dev/null @@ -1,496 +0,0 @@ -# 컴포넌트 생성 가이드 - -## 📋 개요 - -화면관리 시스템에서 새로운 컴포넌트를 생성할 때 반드시 준수해야 하는 규칙과 가이드입니다. -특히 **위치 스타일 이중 적용 문제**를 방지하기 위한 핵심 원칙들을 포함합니다. - -## 🚫 절대 금지 사항 - -### ❌ 컴포넌트에서 위치 스타일 직접 적용 금지 - -**절대로 하면 안 되는 것:** - -```typescript -// ❌ 절대 금지! 이중 위치 적용으로 인한 버그 발생 -const componentStyle: React.CSSProperties = { - position: "absolute", // 🚫 금지 - left: `${component.position?.x || 0}px`, // 🚫 금지 - top: `${component.position?.y || 0}px`, // 🚫 금지 - zIndex: component.position?.z || 1, // 🚫 금지 - width: `${component.size?.width || 120}px`, // 🚫 금지 - height: `${component.size?.height || 36}px`, // 🚫 금지 - ...component.style, - ...style, -}; -``` - -**이유**: `RealtimePreviewDynamic`에서 이미 위치를 관리하므로 이중 적용됨 - -### ✅ 올바른 방법 - -```typescript -// ✅ 올바른 방법: 위치는 부모가 관리, 컴포넌트는 100% 크기만 -const componentStyle: React.CSSProperties = { - width: "100%", // ✅ 부모 컨테이너에 맞춤 - height: "100%", // ✅ 부모 컨테이너에 맞춤 - ...component.style, - ...style, -}; -``` - -## 📝 컴포넌트 생성 단계별 가이드 - -### 1. CLI 도구 사용 - -```bash -# 새 컴포넌트 생성 (대화형으로 한글 이름/설명 입력) -node scripts/create-component.js <컴포넌트-이름> - -# 예시 -node scripts/create-component.js password-input -node scripts/create-component.js user-avatar -node scripts/create-component.js progress-bar -``` - -### 🌐 대화형 한글 입력 - -CLI 도구는 대화형으로 다음 정보를 입력받습니다: - -**1. 한글 이름 입력:** - -``` -한글 이름 (예: 기본 버튼): 비밀번호 입력 -``` - -**2. 설명 입력:** - -``` -설명 (예: 일반적인 액션을 위한 기본 버튼 컴포넌트): 비밀번호 입력을 위한 보안 입력 컴포넌트 -``` - -**3. 카테고리 선택 (옵션에서 제공하지 않은 경우):** - -``` -📂 카테고리를 선택해주세요: -1. input - 입력 컴포넌트 -2. display - 표시 컴포넌트 -3. layout - 레이아웃 컴포넌트 -4. action - 액션 컴포넌트 -5. admin - 관리자 컴포넌트 -카테고리 번호 (1-5): 1 -``` - -**4. 웹타입 입력 (옵션에서 제공하지 않은 경우):** - -``` -🎯 웹타입을 입력해주세요: -예시: text, number, email, password, date, select, checkbox, radio, boolean, file, button -웹타입 (기본: text): password -``` - -### 📋 명령행 옵션 사용 - -옵션을 미리 제공하면 해당 단계를 건너뜁니다: - -```bash -# 카테고리와 웹타입을 미리 지정 -node scripts/create-component.js color-picker --category=input --webType=text - -# 이 경우 한글 이름과 설명만 입력하면 됩니다 -``` - -### 📁 카테고리 종류 - -- `input` - 입력 컴포넌트 -- `display` - 표시 컴포넌트 -- `layout` - 레이아웃 컴포넌트 -- `action` - 액션 컴포넌트 -- `admin` - 관리자 컴포넌트 - -### 2. 생성된 컴포넌트 파일 수정 - -#### A. 스타일 계산 부분 확인 - -**템플릿에서 생성되는 기본 코드:** - -```typescript -// 스타일 계산 -const componentStyle: React.CSSProperties = { - position: "absolute", // ⚠️ 이 부분을 수정해야 함 - left: `${component.position?.x || 0}px`, - top: `${component.position?.y || 0}px`, - // ... 기타 위치 관련 스타일 -}; -``` - -**반드시 다음과 같이 수정:** - -```typescript -// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) -const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - ...component.style, - ...style, -}; -``` - -#### B. 디자인 모드 스타일 유지 - -```typescript -// 디자인 모드 스타일 (이 부분은 유지) -if (isDesignMode) { - componentStyle.border = "1px dashed #cbd5e1"; - componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; -} -``` - -#### C. React Props 필터링 - -```typescript -// DOM에 전달하면 안 되는 React-specific props 필터링 -const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - ...domProps -} = props; -``` - -### 3. 컴포넌트 렌더링 구조 - -```typescript -return ( -
- {/* 라벨 렌더링 (필요한 경우) */} - {component.label && ( - - )} - - {/* 실제 입력 요소 */} - -
-); -``` - -## 🔧 CLI 템플릿 수정 완료 ✅ - -CLI 도구(`frontend/scripts/create-component.js`)가 이미 올바른 코드를 생성하도록 수정되었습니다. - -### 수정된 내용 - -1. **위치 스타일 제거** - - ```typescript - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) - const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - ...component.style, - ...style, - }; - ``` - -2. **React Props 필터링 추가** - - ```typescript - // DOM에 전달하면 안 되는 React-specific props 필터링 - const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - ...domProps - } = props; - ``` - -3. **JSX에서 domProps 사용** - ```typescript - return ( -
- {/* 컴포넌트 내용 */} -
- ); - ``` - -## 📋 체크리스트 - -새 컴포넌트 생성 시 반드시 확인해야 할 사항들: - -### ✅ 필수 확인 사항 - -- [ ] `position: "absolute"` 제거됨 -- [ ] `left`, `top` 스타일 제거됨 -- [ ] `zIndex` 직접 설정 제거됨 -- [ ] `width: "100%"`, `height: "100%"` 설정됨 -- [ ] React-specific props 필터링됨 -- [ ] 디자인 모드 스타일 유지됨 -- [ ] 라벨 렌더링 로직 구현됨 (필요한 경우) - -### ✅ 테스트 확인 사항 - -- [ ] 드래그앤드롭 시 위치가 정확함 -- [ ] 컴포넌트 경계와 실제 요소가 일치함 -- [ ] 속성 편집이 정상 작동함 -- [ ] 라벨이 올바른 위치에 표시됨 -- [ ] 콘솔에 React prop 경고가 없음 - -## 🚨 문제 해결 - -### 자주 발생하는 문제 - -1. **컴포넌트가 잘못된 위치에 표시됨** - - 원인: 위치 스타일 이중 적용 - - 해결: 컴포넌트에서 위치 관련 스타일 모두 제거 - -2. **컴포넌트 크기가 올바르지 않음** - - 원인: 고정 크기 설정 - - 해결: `width: "100%"`, `height: "100%"` 사용 - -3. **React prop 경고** - - 원인: React-specific props가 DOM으로 전달됨 - - 해결: props 필터링 로직 추가 - -## 💡 모범 사례 - -### 컴포넌트 구조 예시 - -```typescript -export const ExampleComponent: React.FC = ({ - component, - isDesignMode = false, - isSelected = false, - onClick, - onDragStart, - onDragEnd, - config, - className, - style, - ...props -}) => { - // 1. 설정 병합 - const componentConfig = { - ...config, - ...component.config, - } as ExampleConfig; - - // 2. 스타일 계산 (위치 제외) - const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - ...component.style, - ...style, - }; - - // 3. 디자인 모드 스타일 - if (isDesignMode) { - componentStyle.border = "1px dashed #cbd5e1"; - componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; - } - - // 4. 이벤트 핸들러 - const handleClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onClick?.(); - }; - - // 5. Props 필터링 - const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - ...domProps - } = props; - - // 6. 렌더링 - return ( -
- {/* 컴포넌트 내용 */} -
- ); -}; -``` - -## 📚 참고 자료 - -- **기존 컴포넌트 예시**: `frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx` -- **렌더링 로직**: `frontend/components/screen/RealtimePreviewDynamic.tsx` -- **CLI 도구**: `scripts/create-component.js` - -## 🆕 새로운 기능 (v2.0) - -### ✅ 한글 이름/설명 자동 생성 완료 - -CLI 도구가 다음과 같이 개선되었습니다: - -**이전:** - -``` -name: "button-primary", -description: "button-primary 컴포넌트입니다", -``` - -**개선 후:** - -``` -name: "기본 버튼", -description: "일반적인 액션을 위한 버튼 컴포넌트", -``` - -### ✅ React Props 필터링 자동 적용 - -모든 CLI 생성 컴포넌트에 자동으로 적용됩니다: - -```typescript -// DOM에 전달하면 안 되는 React-specific props 필터링 -const { - selectedScreen, - onZoneComponentDrop, - onZoneClick, - componentConfig: _componentConfig, - component: _component, - isSelected: _isSelected, - onClick: _onClick, - onDragStart: _onDragStart, - onDragEnd: _onDragEnd, - size: _size, - position: _position, - style: _style, - ...domProps -} = props; - -return ( -
- {/* 컴포넌트 내용 */} -
-); -``` - -### ✅ 위치 스타일 자동 제거 - -CLI 생성 컴포넌트는 자동으로 올바른 스타일 구조를 사용합니다: - -```typescript -// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) -const componentStyle: React.CSSProperties = { - width: "100%", - height: "100%", - ...component.style, - ...style, -}; -``` - -## 📈 현재 컴포넌트 현황 - -### 완성된 컴포넌트 (14개) - -**📝 폼 입력 컴포넌트 (8개):** - -- 텍스트 입력 (`text-input`) -- 텍스트 영역 (`textarea-basic`) -- 숫자 입력 (`number-input`) -- 날짜 선택 (`date-input`) -- 선택상자 (`select-basic`) -- 체크박스 (`checkbox-basic`) -- 라디오 버튼 (`radio-basic`) -- 파일 업로드 (`file-upload`) - -**🎛️ 인터페이스 컴포넌트 (3개):** - -- 기본 버튼 (`button-primary`) -- 슬라이더 (`slider-basic`) -- 토글 스위치 (`toggle-switch`) - -**🖼️ 표시 컴포넌트 (2개):** - -- 라벨 텍스트 (`label-basic`) -- 이미지 표시 (`image-display`) - -**📐 레이아웃 컴포넌트 (1개):** - -- 구분선 (`divider-line`) - -## 🚀 다음 단계 - -### 우선순위 1: 고급 입력 컴포넌트 - -```bash -node scripts/create-component.js color-picker --category=input --webType=text -node scripts/create-component.js rich-editor --category=input --webType=textarea -node scripts/create-component.js autocomplete --category=input --webType=text -``` - -### 우선순위 2: 표시 컴포넌트 - -```bash -node scripts/create-component.js user-avatar --category=display --webType=file -node scripts/create-component.js status-badge --category=display --webType=text -node scripts/create-component.js tooltip-help --category=display --webType=text -``` - -### 우선순위 3: 액션 컴포넌트 - -```bash -node scripts/create-component.js icon-button --category=action --webType=button -node scripts/create-component.js floating-button --category=action --webType=button -``` - ---- - -**⚠️ 중요**: 이 가이드의 규칙을 지키지 않으면 컴포넌트 위치 오류가 발생합니다. -새 컴포넌트 생성 시 반드시 이 체크리스트를 확인하세요! diff --git a/frontend/docs/컴포넌트_시스템_전환_완료.md b/frontend/docs/컴포넌트_시스템_전환_완료.md deleted file mode 100644 index ef7d7b71..00000000 --- a/frontend/docs/컴포넌트_시스템_전환_완료.md +++ /dev/null @@ -1,278 +0,0 @@ -# ✅ 컴포넌트 시스템 전환 완료 - -## 🎉 전환 성공 - -기존의 데이터베이스 기반 컴포넌트 관리 시스템을 **레지스트리 기반 시스템**으로 완전히 전환 완료했습니다! - -## 📊 전환 결과 - -### ✅ 완료된 작업들 - -#### **Phase 1: 기반 구축** ✅ - -- [x] `ComponentRegistry` 클래스 구현 -- [x] `AutoRegisteringComponentRenderer` 기반 클래스 구현 -- [x] TypeScript 타입 정의 (`ComponentDefinition`, `ComponentCategory`) -- [x] CLI 도구 (`create-component.js`) 구현 -- [x] 10개 핵심 컴포넌트 생성 - -#### **Phase 2: 개발 도구** ✅ - -- [x] Hot Reload 시스템 구현 -- [x] 브라우저 개발자 도구 통합 -- [x] 성능 최적화 시스템 (`PerformanceOptimizer`) -- [x] 자동 컴포넌트 발견 및 등록 - -#### **Phase 3: 마이그레이션 시스템** ✅ - -- [x] 마이그레이션 분석기 구현 -- [x] 자동 변환 도구 구현 -- [x] 호환성 계층 구현 -- [x] 실시간 모니터링 시스템 - -#### **Phase 4: 시스템 정리** ✅ - -- [x] DB 기반 컴포넌트 시스템 완전 제거 -- [x] 하이브리드 패널 제거 -- [x] 마이그레이션 시스템 정리 -- [x] 순수한 레지스트리 기반 시스템 구축 - -## 🛠️ 새로운 시스템 구조 - -### 📁 디렉토리 구조 - -``` -frontend/lib/registry/components/ -├── index.ts # 컴포넌트 자동 등록 -├── ComponentRegistry.ts # 중앙 레지스트리 -├── AutoRegisteringComponentRenderer.ts # 기반 클래스 -├── button-primary/ # 개별 컴포넌트 폴더 -│ ├── index.ts # 컴포넌트 정의 -│ ├── ButtonPrimaryRenderer.tsx -│ ├── ButtonPrimaryConfigPanel.tsx -│ └── types.ts -├── text-input/ -├── textarea-basic/ -├── number-input/ -├── select-basic/ -├── checkbox-basic/ -├── radio-basic/ -├── date-input/ -├── label-basic/ -└── file-upload/ -``` - -### 🔧 컴포넌트 생성 방법 - -**CLI를 사용한 자동 생성:** - -```bash -cd frontend -node scripts/create-component.js -``` - -**대화형 프롬프트:** - -- 컴포넌트 이름 입력 -- 카테고리 선택 (input/display/action/layout/utility) -- 웹타입 선택 (text/button/select 등) -- 기본 크기 설정 -- 작성자 정보 - -**자동 생성되는 파일들:** - -- `index.ts` - 컴포넌트 정의 -- `ComponentRenderer.tsx` - 렌더링 로직 -- `ConfigPanel.tsx` - 속성 설정 패널 -- `types.ts` - TypeScript 타입 정의 -- `config.ts` - 기본 설정 -- `README.md` - 사용법 문서 - -### 🎯 사용법 - -#### 1. 컴포넌트 패널에서 사용 - -화면 편집기의 컴포넌트 패널에서 자동으로 표시되며: - -- **카테고리별 분류**: 입력/표시/액션/레이아웃/유틸 -- **검색 기능**: 이름, 설명, 태그로 검색 -- **드래그앤드롭**: 캔버스에 직접 배치 -- **실시간 새로고침**: 개발 중 자동 업데이트 - -#### 2. 브라우저 개발자 도구 - -F12를 눌러 콘솔에서 다음 명령어 사용 가능: - -```javascript -// 컴포넌트 레지스트리 조회 -__COMPONENT_REGISTRY__.list(); // 모든 컴포넌트 목록 -__COMPONENT_REGISTRY__.stats(); // 통계 정보 -__COMPONENT_REGISTRY__.search("버튼"); // 검색 -__COMPONENT_REGISTRY__.help(); // 도움말 - -// 성능 최적화 (필요시) -__PERFORMANCE_OPTIMIZER__.getMetrics(); // 성능 메트릭 -__PERFORMANCE_OPTIMIZER__.optimizeMemory(); // 메모리 최적화 -``` - -#### 3. Hot Reload - -파일 저장 시 자동으로 컴포넌트가 업데이트됩니다: - -- 컴포넌트 코드 수정 → 즉시 반영 -- 새 컴포넌트 추가 → 자동 등록 -- TypeScript 타입 안전성 보장 - -## 🚀 혁신적 개선 사항 - -### 📈 성능 지표 - -| 지표 | 기존 시스템 | 새 시스템 | 개선율 | -| --------------- | -------------- | ------------ | ------------- | -| **개발 속도** | 1시간/컴포넌트 | 4분/컴포넌트 | **15배 향상** | -| **타입 안전성** | 50% | 95% | **90% 향상** | -| **Hot Reload** | 미지원 | 즉시 반영 | **무한대** | -| **메모리 효율** | 기준 | 50% 절약 | **50% 개선** | -| **빌드 시간** | 기준 | 30% 단축 | **30% 개선** | - -### 🛡️ 타입 안전성 - -```typescript -// 완전한 TypeScript 지원 -interface ComponentDefinition { - id: string; - name: string; - description: string; - category: ComponentCategory; // enum으로 타입 안전 - webType: WebType; // union type으로 제한 - defaultSize: { width: number; height: number }; - // ... 모든 속성이 타입 안전 -} -``` - -### ⚡ Hot Reload - -```typescript -// 개발 중 자동 업데이트 -if (process.env.NODE_ENV === "development") { - // 파일 변경 감지 → 자동 리로드 - initializeHotReload(); -} -``` - -### 🔍 자동 발견 - -```typescript -// 컴포넌트 자동 등록 -import "./button-primary"; // 파일 import만으로 자동 등록 -import "./text-input"; -import "./select-basic"; -// ... 모든 컴포넌트 자동 발견 -``` - -## 🎯 개발자 가이드 - -### 새로운 컴포넌트 만들기 - -1. **CLI 실행** - - ```bash - node scripts/create-component.js - ``` - -2. **정보 입력** - - 컴포넌트 이름: "고급 버튼" - - 카테고리: action - - 웹타입: button - - 기본 크기: 120x40 - -3. **자동 생성됨** - - ``` - components/advanced-button/ - ├── index.ts # 자동 등록 - ├── AdvancedButtonRenderer.tsx # 렌더링 로직 - ├── AdvancedButtonConfigPanel.tsx # 설정 패널 - └── ... 기타 파일들 - ``` - -4. **바로 사용 가능** - - 컴포넌트 패널에 자동 표시 - - 드래그앤드롭으로 배치 - - 속성 편집 가능 - -### 커스터마이징 - -```typescript -// index.ts - 컴포넌트 정의 -export const advancedButtonDefinition = createComponentDefinition({ - name: "고급 버튼", - category: ComponentCategory.ACTION, - webType: "button", - defaultSize: { width: 120, height: 40 }, - // 자동 등록됨 -}); - -// AdvancedButtonRenderer.tsx - 렌더링 -export class AdvancedButtonRenderer extends AutoRegisteringComponentRenderer { - render() { - return ( - - ); - } -} -``` - -## 📋 제거된 레거시 시스템 - -### 🗑️ 삭제된 파일들 - -- `frontend/hooks/admin/useComponents.ts` -- `frontend/lib/api/componentApi.ts` -- `frontend/components/screen/panels/ComponentsPanelHybrid.tsx` -- `frontend/lib/registry/utils/migrationAnalyzer.ts` -- `frontend/lib/registry/utils/migrationTool.ts` -- `frontend/lib/registry/utils/migrationMonitor.ts` -- `frontend/lib/registry/utils/compatibilityLayer.ts` -- `frontend/components/admin/migration/MigrationPanel.tsx` -- `frontend/app/(main)/admin/migration/page.tsx` - -### 🧹 정리된 기능들 - -- ❌ 데이터베이스 기반 컴포넌트 관리 -- ❌ React Query 의존성 -- ❌ 하이브리드 호환성 시스템 -- ❌ 마이그레이션 도구들 -- ❌ 복잡한 API 호출 - -### ✅ 남겨진 필수 도구들 - -- ✅ `PerformanceOptimizer` - 성능 최적화 (필요시 사용) -- ✅ `ComponentRegistry` - 중앙 레지스트리 -- ✅ CLI 도구 - 컴포넌트 자동 생성 -- ✅ Hot Reload - 개발 편의성 - -## 🎉 결론 - -**완전히 새로운 컴포넌트 시스템이 구축되었습니다!** - -- 🚀 **15배 빠른 개발 속도** -- 🛡️ **95% 타입 안전성** -- ⚡ **즉시 Hot Reload** -- 💚 **50% 메모리 절약** -- 🔧 **CLI 기반 자동화** - -### 다음 단계 - -1. **새 컴포넌트 개발**: CLI를 사용하여 필요한 컴포넌트들 추가 -2. **커스터마이징**: 프로젝트별 특수 컴포넌트 개발 -3. **성능 모니터링**: `PerformanceOptimizer`로 지속적 최적화 -4. **팀 교육**: 새로운 개발 방식 공유 - -**🎊 축하합니다! 차세대 컴포넌트 시스템이 완성되었습니다!** ✨ diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index fc6927b8..54da5d76 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -91,13 +91,14 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { - // component_config에서 실제 컴포넌트 타입 추출 - const componentType = component.componentConfig?.type || component.type; + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 + const componentType = (component as any).componentType || component.type; console.log("🔍 컴포넌트 타입 추출:", { componentId: component.id, componentConfigType: component.componentConfig?.type, componentType: component.type, + componentTypeProp: (component as any).componentType, finalComponentType: componentType, componentConfig: component.componentConfig, propsScreenId: props.screenId, diff --git a/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx new file mode 100644 index 00000000..76ced3ae --- /dev/null +++ b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx @@ -0,0 +1,717 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { ComponentRendererProps } from "../../types"; +import { AccordionBasicConfig, AccordionItem, DataSourceConfig, ContentFieldConfig } from "./types"; +import { apiClient } from "@/lib/api/client"; + +// 커스텀 아코디언 컴포넌트 +interface CustomAccordionProps { + items: AccordionItem[]; + type: "single" | "multiple"; + collapsible?: boolean; + defaultValue?: string | string[]; + onValueChange?: (value: string | string[]) => void; + className?: string; + style?: React.CSSProperties; + onClick?: (e: React.MouseEvent) => void; + onDragStart?: (e: React.DragEvent) => void; + onDragEnd?: (e: React.DragEvent) => void; +} + +const CustomAccordion: React.FC = ({ + items, + type, + collapsible = true, + defaultValue, + onValueChange, + className = "", + style, + onClick, + onDragStart, + onDragEnd, +}) => { + const [openItems, setOpenItems] = useState>(() => { + if (type === "single") { + return new Set(defaultValue ? [defaultValue as string] : []); + } else { + return new Set(defaultValue ? (defaultValue as string[]) : []); + } + }); + + const toggleItem = (itemId: string) => { + const newOpenItems = new Set(openItems); + + if (type === "single") { + if (openItems.has(itemId)) { + if (collapsible) { + newOpenItems.clear(); + } + } else { + newOpenItems.clear(); + newOpenItems.add(itemId); + } + } else { + if (openItems.has(itemId)) { + newOpenItems.delete(itemId); + } else { + newOpenItems.add(itemId); + } + } + + setOpenItems(newOpenItems); + + if (onValueChange) { + if (type === "single") { + onValueChange(newOpenItems.size > 0 ? Array.from(newOpenItems)[0] : ""); + } else { + onValueChange(Array.from(newOpenItems)); + } + } + }; + + return ( +
+ {items.map((item, index) => ( +
+ + +
+
+ {/* 내용 필드가 배열이거나 복잡한 객체인 경우 처리 */} + { + typeof item.content === "string" + ? item.content + : Array.isArray(item.content) + ? item.content.join("\n") // 배열인 경우 줄바꿈으로 연결 + : typeof item.content === "object" + ? Object.entries(item.content) + .map(([key, value]) => `${key}: ${value}`) + .join("\n") // 객체인 경우 키:값 형태로 줄바꿈 + : String(item.content) // 기타 타입은 문자열로 변환 + } +
+
+
+ ))} +
+ ); +}; + +export interface AccordionBasicComponentProps extends ComponentRendererProps { + // 추가 props가 필요한 경우 여기에 정의 +} + +/** + * AccordionBasic 컴포넌트 + * accordion-basic 컴포넌트입니다 + */ +/** + * 더미 테이블 데이터 생성 + */ +const generateDummyTableData = (dataSource: DataSourceConfig, tableColumns?: any[]): AccordionItem[] => { + const limit = dataSource.limit || 5; + const items: AccordionItem[] = []; + + for (let i = 0; i < limit; i++) { + // 더미 데이터 행 생성 + const dummyRow: any = {}; + + // 테이블 컬럼을 기반으로 더미 데이터 생성 + if (tableColumns && tableColumns.length > 0) { + tableColumns.forEach((column) => { + const fieldName = column.columnName; + + // 필드 타입에 따른 더미 데이터 생성 + if (fieldName.includes("name") || fieldName.includes("title")) { + dummyRow[fieldName] = `샘플 ${column.columnLabel || fieldName} ${i + 1}`; + } else if (fieldName.includes("price") || fieldName.includes("amount")) { + dummyRow[fieldName] = (Math.random() * 100000).toFixed(0); + } else if (fieldName.includes("date")) { + dummyRow[fieldName] = new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000) + .toISOString() + .split("T")[0]; + } else if (fieldName.includes("description") || fieldName.includes("content")) { + dummyRow[fieldName] = + `이것은 ${column.columnLabel || fieldName}에 대한 샘플 설명입니다. 항목 ${i + 1}의 상세 정보가 여기에 표시됩니다.`; + } else if (fieldName.includes("id")) { + dummyRow[fieldName] = `sample_${i + 1}`; + } else if (fieldName.includes("status")) { + dummyRow[fieldName] = ["활성", "비활성", "대기", "완료"][Math.floor(Math.random() * 4)]; + } else { + dummyRow[fieldName] = `샘플 데이터 ${i + 1}`; + } + }); + } else { + // 기본 더미 데이터 + dummyRow.id = `sample_${i + 1}`; + dummyRow.title = `샘플 항목 ${i + 1}`; + dummyRow.description = `이것은 샘플 항목 ${i + 1}에 대한 설명입니다.`; + dummyRow.price = (Math.random() * 50000).toFixed(0); + dummyRow.category = ["전자제품", "의류", "도서", "식품"][Math.floor(Math.random() * 4)]; + dummyRow.status = ["판매중", "품절", "대기"][Math.floor(Math.random() * 3)]; + dummyRow.created_at = new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; + } + + // 제목 생성 + const titleFieldName = dataSource.titleField || "title"; + const title = getFieldLabel(dummyRow, titleFieldName, tableColumns) || `샘플 항목 ${i + 1}`; + + // 내용 생성 + const content = buildContentFromFields(dummyRow, dataSource.contentFields); + + // ID 생성 + const idFieldName = dataSource.idField || "id"; + const id = dummyRow[idFieldName] || `sample_${i + 1}`; + + items.push({ + id: String(id), + title, + content: content || `샘플 항목 ${i + 1}의 내용입니다.`, + defaultOpen: i === 0, + }); + } + + return items; +}; + +/** + * 여러 필드를 조합하여 내용 생성 + */ +const buildContentFromFields = (row: any, contentFields?: ContentFieldConfig[]): string => { + if (!contentFields || contentFields.length === 0) { + return row.content || row.description || "내용이 없습니다."; + } + + return contentFields + .map((field) => { + const value = row[field.fieldName]; + if (!value) return ""; + + // 라벨이 있으면 "라벨: 값" 형식으로, 없으면 값만 + return field.label ? `${field.label}: ${value}` : value; + }) + .filter(Boolean) // 빈 값 제거 + .join(contentFields[0]?.separator || "\n"); // 구분자로 연결 (기본값: 줄바꿈) +}; + +/** + * 필드명에서 라벨 추출 (라벨이 있으면 라벨, 없으면 필드명) + */ +const getFieldLabel = (row: any, fieldName: string, tableColumns?: any[]): string => { + // 테이블 컬럼 정보에서 라벨 찾기 + if (tableColumns) { + const column = tableColumns.find((col) => col.columnName === fieldName); + if (column && column.columnLabel) { + return column.columnLabel; + } + } + + // 데이터에서 라벨 찾기 (예: title_label, name_label 등) + const labelField = `${fieldName}_label`; + if (row[labelField]) { + return row[labelField]; + } + + // 기본값: 필드명 그대로 또는 데이터 값 + return row[fieldName] || fieldName; +}; + +/** + * 데이터 소스에서 아코디언 아이템 가져오기 + */ +const useAccordionData = ( + dataSource?: DataSourceConfig, + isDesignMode: boolean = false, + screenTableName?: string, + tableColumns?: any[], +) => { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!dataSource || dataSource.sourceType === "static") { + // 정적 데이터 소스인 경우 items 사용 + return; + } + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + if (dataSource.sourceType === "table") { + // 테이블 이름 결정: 화면 테이블 또는 직접 입력한 테이블 + const targetTableName = dataSource.useScreenTable ? screenTableName : dataSource.tableName; + + console.log("🔍 아코디언 테이블 디버깅:", { + sourceType: dataSource.sourceType, + useScreenTable: dataSource.useScreenTable, + screenTableName, + manualTableName: dataSource.tableName, + targetTableName, + isDesignMode, + tableColumns: tableColumns?.length || 0, + }); + + if (!targetTableName) { + console.warn("⚠️ 테이블이 지정되지 않음"); + console.log("- screenTableName:", screenTableName); + console.log("- dataSource.tableName:", dataSource.tableName); + console.log("- useScreenTable:", dataSource.useScreenTable); + + // 실제 화면에서는 에러 메시지 표시, 개발 모드에서만 더미 데이터 + if (isDesignMode || process.env.NODE_ENV === "development") { + console.log("🔧 개발 환경: 더미 데이터로 대체"); + const dummyData = generateDummyTableData(dataSource, tableColumns); + setItems(dummyData); + } else { + setError("테이블이 설정되지 않았습니다. 설정 패널에서 테이블을 지정해주세요."); + } + return; + } + + // 개발 모드이거나 API가 없을 때 더미 데이터 사용 + if (isDesignMode) { + console.log("🎨 디자인 모드: 더미 데이터 사용"); + const dummyData = generateDummyTableData(dataSource, tableColumns); + setItems(dummyData); + return; + } + + console.log(`🌐 실제 API 호출 시도: /api/data/${targetTableName}`); + + try { + // 테이블에서 전체 데이터 가져오기 (limit 제거하여 모든 데이터 표시) + const params = new URLSearchParams({ + limit: "1000", // 충분히 큰 값으로 설정하여 모든 데이터 가져오기 + ...(dataSource.orderBy && { orderBy: dataSource.orderBy }), + ...(dataSource.filters && + Object.entries(dataSource.filters).reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record, + )), + }); + + const response = await apiClient.get(`/data/${targetTableName}?${params}`); + const data = response.data; + + if (data && Array.isArray(data)) { + const accordionItems: AccordionItem[] = data.map((row: any, index: number) => { + // 제목: 라벨이 있으면 라벨 우선, 없으면 필드값 + const titleFieldName = dataSource.titleField || "title"; + const title = getFieldLabel(row, titleFieldName, tableColumns) || `아이템 ${index + 1}`; + + // 내용: 여러 필드 조합 가능 + const content = buildContentFromFields(row, dataSource.contentFields); + + // ID: 지정된 필드 또는 기본값 + const idFieldName = dataSource.idField || "id"; + const id = row[idFieldName] || `item-${index}`; + + return { + id: String(id), + title, + content, + defaultOpen: index === 0, // 첫 번째 아이템만 기본으로 열림 + }; + }); + setItems(accordionItems); + } + } catch (apiError) { + console.warn("⚠️ 테이블 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError); + console.log("📊 테이블 API 오류 상세:", { + targetTableName, + error: apiError.message, + dataSource, + timestamp: new Date().toISOString(), + }); + + // 실제 화면에서도 API 오류 시 더미 데이터로 대체 + const dummyData = generateDummyTableData(dataSource, tableColumns); + setItems(dummyData); + + // 사용자에게 알림 (에러는 콘솔에만 표시) + console.info("💡 임시로 샘플 데이터를 표시합니다. 백엔드 API 연결을 확인해주세요."); + } + } else if (dataSource.sourceType === "api" && dataSource.apiEndpoint) { + // 개발 모드이거나 API가 없을 때 더미 데이터 사용 + if (isDesignMode) { + console.log("🎨 디자인 모드: API 더미 데이터 사용"); + const dummyData = generateDummyTableData(dataSource, tableColumns); + setItems(dummyData); + return; + } + + try { + // API에서 데이터 가져오기 + const response = await fetch(dataSource.apiEndpoint, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data && Array.isArray(data)) { + const accordionItems: AccordionItem[] = data.map((row: any, index: number) => { + // 제목: 라벨이 있으면 라벨 우선, 없으면 필드값 + const titleFieldName = dataSource.titleField || "title"; + const title = getFieldLabel(row, titleFieldName, tableColumns) || `아이템 ${index + 1}`; + + // 내용: 여러 필드 조합 가능 + const content = buildContentFromFields(row, dataSource.contentFields); + + // ID: 지정된 필드 또는 기본값 + const idFieldName = dataSource.idField || "id"; + const id = row[idFieldName] || `item-${index}`; + + return { + id: String(id), + title, + content, + defaultOpen: index === 0, + }; + }); + setItems(accordionItems); + } + } catch (apiError) { + console.warn("⚠️ 엔드포인트 API 호출 실패, 실제 화면에서도 더미 데이터로 대체:", apiError); + console.log("📊 엔드포인트 API 오류 상세:", { + apiEndpoint: dataSource.apiEndpoint, + error: apiError.message, + dataSource, + timestamp: new Date().toISOString(), + }); + + // 실제 화면에서도 API 오류 시 더미 데이터로 대체 + const dummyData = generateDummyTableData(dataSource, tableColumns); + setItems(dummyData); + + // 사용자에게 알림 (에러는 콘솔에만 표시) + console.info("💡 임시로 샘플 데이터를 표시합니다. 백엔드 API 연결을 확인해주세요."); + } + } + } catch (err) { + console.error("아코디언 데이터 로드 실패:", err); + + // 디자인 모드이거나 개발 환경에서는 더미 데이터로 대체 + if (isDesignMode || process.env.NODE_ENV === "development") { + console.log("🔧 개발 환경: 더미 데이터로 대체"); + const dummyData = dataSource + ? generateDummyTableData(dataSource, tableColumns) + : [ + { + id: "demo-1", + title: "데모 아이템 1", + content: "이것은 데모용 내용입니다.", + defaultOpen: true, + }, + { + id: "demo-2", + title: "데모 아이템 2", + content: "두 번째 데모 아이템의 내용입니다.", + defaultOpen: false, + }, + ]; + setItems(dummyData); + } else { + setError("데이터를 불러오는데 실패했습니다."); + } + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dataSource, isDesignMode, screenTableName, tableColumns]); + + return { items, loading, error }; +}; + +export const AccordionBasicComponent: React.FC = ({ + component, + isDesignMode = false, + isSelected = false, + onClick, + onDragStart, + onDragEnd, + ...props +}) => { + const componentConfig = (component.componentConfig || {}) as AccordionBasicConfig; + + // 화면 테이블 정보 추출 + const screenTableName = (component as any).tableName || props.tableName; + const tableColumns = (component as any).tableColumns || props.tableColumns; + + console.log("🔍 아코디언 컴포넌트 테이블 정보:", { + componentTableName: (component as any).tableName, + propsTableName: props.tableName, + finalScreenTableName: screenTableName, + tableColumnsCount: tableColumns?.length || 0, + componentConfig, + dataSource: componentConfig.dataSource, + isDesignMode, + }); + + // 데이터 소스에서 데이터 가져오기 + const { + items: dataItems, + loading, + error, + } = useAccordionData(componentConfig.dataSource, isDesignMode, screenTableName, tableColumns); + + // 컴포넌트 스타일 계산 + const componentStyle: React.CSSProperties = { + position: "absolute", + left: `${component.style?.positionX || 0}px`, + top: `${component.style?.positionY || 0}px`, + width: `${component.size?.width || 300}px`, + height: `${component.size?.height || 200}px`, + zIndex: component.style?.positionZ || 1, + cursor: isDesignMode ? "pointer" : "default", + border: isSelected ? "2px solid #3b82f6" : "none", + outline: isSelected ? "none" : undefined, + }; + + // 디버깅용 로그 + if (isDesignMode) { + console.log("🎯 Accordion 높이 디버깅:", { + componentSizeHeight: component.size?.height, + componentStyleHeight: component.style?.height, + finalHeight: componentStyle.height, + }); + } + + // 클릭 핸들러 + const handleClick = (e: React.MouseEvent) => { + if (isDesignMode) { + e.stopPropagation(); + onClick?.(e); + } + }; + + // className 생성 + const className = [ + "accordion-basic-component", + isSelected ? "selected" : "", + componentConfig.disabled ? "disabled" : "", + ] + .filter(Boolean) + .join(" "); + + // DOM props 필터링 (React 관련 props 제거) + const { + component: _component, + isDesignMode: _isDesignMode, + isSelected: _isSelected, + isInteractive: _isInteractive, + screenId: _screenId, + tableName: _tableName, + onRefresh: _onRefresh, + onClose: _onClose, + formData: _formData, + onFormDataChange: _onFormDataChange, + componentConfig: _componentConfig, + ...domProps + } = props; + + // 사용할 아이템들 결정 (우선순위: 데이터소스 > 정적아이템 > 기본아이템) + const finalItems = (() => { + // 데이터 소스가 설정되어 있고 데이터가 있으면 데이터 소스 아이템 사용 + if (componentConfig.dataSource && componentConfig.dataSource.sourceType !== "static" && dataItems.length > 0) { + return dataItems; + } + + // 정적 아이템이 설정되어 있으면 사용 + if (componentConfig.items && componentConfig.items.length > 0) { + return componentConfig.items; + } + + // 기본 아이템들 (데모용) + return [ + { + id: "item-1", + title: "제품 정보", + content: + "우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다. 프리미엄 소재로 제작되어 탁월한 성능과 신뢰성을 제공합니다.", + defaultOpen: true, + }, + { + id: "item-2", + title: "배송 정보", + content: + "신뢰할 수 있는 택배 파트너를 통해 전 세계 배송을 제공합니다. 일반 배송은 3-5 영업일, 특급 배송은 1-2 영업일 내 배송됩니다.", + }, + { + id: "item-3", + title: "반품 정책", + content: + "포괄적인 30일 반품 정책으로 제품을 보장합니다. 완전히 만족하지 않으시면 원래 상태로 제품을 반품하시면 됩니다.", + }, + ]; + })(); + + const items = finalItems; + const accordionType = componentConfig.type || "single"; + const collapsible = componentConfig.collapsible !== false; + const defaultValue = componentConfig.defaultValue || items.find((item) => item.defaultOpen)?.id; + + // 값 변경 핸들러 + const handleValueChange = (value: string | string[]) => { + if (!isDesignMode && componentConfig.onValueChange) { + componentConfig.onValueChange(value); + } + }; + + return ( +
+ {/* 라벨 렌더링 */} + {component.label && component.style?.labelDisplay !== false && ( + + )} + + {loading ? ( +
+
데이터를 불러오는 중...
+
+ ) : error && !isDesignMode ? ( +
+
{error}
+
+ ) : ( +
+ +
+ )} +
+ ); +}; + +/** + * AccordionBasic 래퍼 컴포넌트 + * 추가적인 로직이나 상태 관리가 필요한 경우 사용 + */ +export const AccordionBasicWrapper: React.FC = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/accordion-basic/AccordionBasicConfigPanel.tsx b/frontend/lib/registry/components/accordion-basic/AccordionBasicConfigPanel.tsx new file mode 100644 index 00000000..2fa0b1d9 --- /dev/null +++ b/frontend/lib/registry/components/accordion-basic/AccordionBasicConfigPanel.tsx @@ -0,0 +1,533 @@ +"use client"; + +import React, { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Trash2, Plus } from "lucide-react"; +import { AccordionBasicConfig, AccordionItem, DataSourceConfig, ContentFieldConfig } from "./types"; + +export interface AccordionBasicConfigPanelProps { + config: AccordionBasicConfig; + onChange: (config: Partial) => void; + screenTableName?: string; // 화면에서 지정한 테이블명 + tableColumns?: any[]; // 테이블 컬럼 정보 +} + +/** + * AccordionBasic 설정 패널 + * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 + */ +export const AccordionBasicConfigPanel: React.FC = ({ + config, + onChange, + screenTableName, + tableColumns, +}) => { + const [localItems, setLocalItems] = useState( + config.items || [ + { + id: "item-1", + title: "제품 정보", + content: "우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다.", + defaultOpen: true, + }, + ], + ); + + const handleChange = (key: keyof AccordionBasicConfig, value: any) => { + onChange({ [key]: value }); + }; + + const handleItemsChange = (newItems: AccordionItem[]) => { + setLocalItems(newItems); + handleChange("items", newItems); + }; + + const addItem = () => { + const newItem: AccordionItem = { + id: `item-${Date.now()}`, + title: "새 아이템", + content: "새 아이템의 내용을 입력하세요.", + defaultOpen: false, + }; + handleItemsChange([...localItems, newItem]); + }; + + const removeItem = (itemId: string) => { + handleItemsChange(localItems.filter((item) => item.id !== itemId)); + }; + + const updateItem = (itemId: string, updates: Partial) => { + handleItemsChange(localItems.map((item) => (item.id === itemId ? { ...item, ...updates } : item))); + }; + + return ( +
+
아코디언 설정
+ + {/* 데이터 소스 설정 */} + + + 데이터 소스 + + + {/* 데이터 소스 타입 */} +
+ + +
+ + {/* 테이블 데이터 설정 */} + {config.dataSource?.sourceType === "table" && ( + <> + {/* 테이블 선택 방식 */} +
+ +
+ + handleChange("dataSource", { + ...config.dataSource, + useScreenTable: checked as boolean, + }) + } + /> + +
+
+ + {/* 직접 테이블명 입력 (화면 테이블을 사용하지 않을 때) */} + {config.dataSource?.useScreenTable === false && ( +
+ + + handleChange("dataSource", { + ...config.dataSource, + tableName: e.target.value, + }) + } + placeholder="테이블명을 입력하세요" + /> +
+ )} + + {/* 필드 선택 */} +
+
+ + +
+ +
+ + +
+
+ + {/* 내용 필드들 (여러개 가능) */} +
+
+ + +
+ + {config.dataSource?.contentFields?.map((field, index) => ( + +
+
+ + +
+ +
+
+ + +
+ +
+ + { + const newContentFields = [...(config.dataSource?.contentFields || [])]; + newContentFields[index] = { ...field, label: e.target.value }; + handleChange("dataSource", { + ...config.dataSource, + contentFields: newContentFields, + }); + }} + placeholder="예: 설명" + className="text-xs" + /> +
+
+
+
+ ))} + + {(!config.dataSource?.contentFields || config.dataSource.contentFields.length === 0) && ( +
내용 필드를 추가해주세요
+ )} +
+ +
+ + +
+ +
+ 💡 스크롤 처리: 모든 데이터가 표시되며, 컴포넌트 높이를 초과하는 경우 자동으로 스크롤이 + 생성됩니다. +
+ + )} + + {/* API 데이터 설정 */} + {config.dataSource?.sourceType === "api" && ( + <> +
+ + + handleChange("dataSource", { + ...config.dataSource, + apiEndpoint: e.target.value, + }) + } + placeholder="/api/data/accordion-items" + /> +
+ +
+
+ + + handleChange("dataSource", { + ...config.dataSource, + titleField: e.target.value, + }) + } + placeholder="title" + /> +
+ +
+ + + handleChange("dataSource", { + ...config.dataSource, + contentField: e.target.value, + }) + } + placeholder="content" + /> +
+
+ + )} +
+
+ + {/* 기본 설정 */} + + + 기본 설정 + + + {/* 타입 설정 */} +
+ + +
+ + {/* 접을 수 있는지 설정 */} +
+ handleChange("collapsible", checked)} + /> + +
+ + {/* 기본값 설정 */} +
+ + +
+ + {/* 비활성화 */} +
+ handleChange("disabled", checked)} + /> + +
+
+
+ + {/* 아이템 관리 (정적 데이터일 때만 표시) */} + {(!config.dataSource || config.dataSource.sourceType === "static") && ( + + + + 아이템 관리 + + + + + {localItems.map((item, index) => ( + +
+
+ + +
+ + {/* 제목 */} +
+ + updateItem(item.id, { title: e.target.value })} + placeholder="아이템 제목" + /> +
+ + {/* 내용 */} +
+ +