Compare commits

...

2 Commits

Author SHA1 Message Date
kjs 8196201e65 생성 2025-09-12 16:47:09 +09:00
kjs 52dd18747a 아코디언 컴포넌트 생성 2025-09-12 16:47:02 +09:00
28 changed files with 3026 additions and 956 deletions

View File

@ -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);

View File

@ -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<string, string>,
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;

View File

@ -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<string, string>;
userCompany?: string;
}
interface ServiceResponse<T> {
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<ServiceResponse<any[]>> {
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<ServiceResponse<any[]>> {
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<boolean> {
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<any[]> {
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<string | null> {
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();

View File

@ -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",

View File

@ -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<ComponentCategory | "all", ComponentDefinition[]> = {
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) {
<CardTitle className="flex items-center justify-between">
<div className="flex items-center">
<Package className="mr-2 h-5 w-5" />
({allComponents.length})
({componentsByCategory.all.length})
</div>
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
<RotateCcw className="h-4 w-4" />
@ -128,16 +131,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
value={selectedCategory}
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
>
{/* 카테고리 탭 */}
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-6">
{/* 카테고리 탭 (input 카테고리 제외) */}
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
<TabsTrigger value="all" className="flex items-center">
<Package className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="input" className="flex items-center">
<Grid className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="display" className="flex items-center">
<Palette className="mr-1 h-3 w-3" />

View File

@ -957,7 +957,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 새로운 컴포넌트 시스템 처리 (type: "component")
if (selectedComponent.type === "component") {
const componentId = selectedComponent.componentConfig?.type;
const componentId = (selectedComponent as any).componentType || selectedComponent.componentConfig?.type;
const webType = selectedComponent.componentConfig?.webType;
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
@ -1000,6 +1000,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<DynamicComponentConfigPanel
componentId={componentId}
config={selectedComponent.componentConfig || {}}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
onChange={(newConfig) => {
console.log("🔧 컴포넌트 설정 변경:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지

View File

@ -0,0 +1,182 @@
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface AccordionContextValue {
type: "single" | "multiple";
collapsible?: boolean;
value?: string | string[];
onValueChange?: (value: string | string[]) => void;
}
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
interface AccordionItemContextValue {
value: string;
}
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
interface AccordionProps {
type: "single" | "multiple";
collapsible?: boolean;
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
className?: string;
children: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
}
function Accordion({
type,
collapsible = false,
value: controlledValue,
defaultValue,
onValueChange,
className,
children,
onClick,
...props
}: AccordionProps) {
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | string[]>(
defaultValue || (type === "multiple" ? [] : ""),
);
const value = controlledValue !== undefined ? controlledValue : uncontrolledValue;
const handleValueChange = React.useCallback(
(newValue: string | string[]) => {
if (controlledValue === undefined) {
setUncontrolledValue(newValue);
}
onValueChange?.(newValue);
},
[controlledValue, onValueChange],
);
const contextValue = React.useMemo(
() => ({
type,
collapsible,
value,
onValueChange: handleValueChange,
}),
[type, collapsible, value, handleValueChange],
);
return (
<AccordionContext.Provider value={contextValue}>
<div className={cn("space-y-2", className)} onClick={onClick} {...props}>
{children}
</div>
</AccordionContext.Provider>
);
}
interface AccordionItemProps {
value: string;
className?: string;
children: React.ReactNode;
}
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
return (
<div className={cn("rounded-md border", className)} data-value={value} {...props}>
{children}
</div>
);
}
interface AccordionTriggerProps {
className?: string;
children: React.ReactNode;
}
function AccordionTrigger({ className, children, ...props }: AccordionTriggerProps) {
const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext);
if (!context || !parent) {
throw new Error("AccordionTrigger must be used within AccordionItem");
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value;
const handleClick = () => {
if (!context.onValueChange) return;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== parent.value) : [...currentValue, parent.value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : parent.value;
context.onValueChange(newValue);
}
};
return (
<button
className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
className,
)}
onClick={handleClick}
type="button"
{...props}
>
{children}
<ChevronDownIcon className={cn("h-4 w-4 transition-transform duration-200", isOpen && "rotate-180")} />
</button>
);
}
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 (
<div className={cn("px-4 pb-4 text-sm text-gray-600", className)} {...props}>
{children}
</div>
);
}
// AccordionItem을 래핑하여 컨텍스트 제공
const AccordionItemWithContext = React.forwardRef<HTMLDivElement, AccordionItemProps>(
({ value, children, ...props }, ref) => {
return (
<AccordionItemContext.Provider value={{ value }}>
<AccordionItem ref={ref} value={value} {...props}>
{children}
</AccordionItem>
</AccordionItemContext.Provider>
);
},
);
AccordionItemWithContext.displayName = "AccordionItem";
export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -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)를 참조하세요.

View File

@ -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 (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 (필요한 경우) */}
{component.label && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
}}
>
{component.label}
{component.componentConfig?.required && (
<span style={{ color: "#ef4444", marginLeft: "2px" }}>*</span>
)}
</label>
)}
{/* 실제 입력 요소 */}
<input
type={componentConfig.inputType || "text"}
placeholder={componentConfig.placeholder || ""}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "8px 12px",
fontSize: "14px",
outline: "none",
}}
/>
</div>
);
```
## 🔧 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 (
<div style={componentStyle} className={className} {...domProps}>
{/* 컴포넌트 내용 */}
</div>
);
```
## 📋 체크리스트
새 컴포넌트 생성 시 반드시 확인해야 할 사항들:
### ✅ 필수 확인 사항
- [ ] `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<ExampleComponentProps> = ({
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 (
<div style={componentStyle} className={className} {...domProps}>
{/* 컴포넌트 내용 */}
</div>
);
};
```
## 📚 참고 자료
- **기존 컴포넌트 예시**: `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 (
<div style={componentStyle} className={className} {...domProps}>
{/* 컴포넌트 내용 */}
</div>
);
```
### ✅ 위치 스타일 자동 제거
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
```
---
**⚠️ 중요**: 이 가이드의 규칙을 지키지 않으면 컴포넌트 위치 오류가 발생합니다.
새 컴포넌트 생성 시 반드시 이 체크리스트를 확인하세요!

View File

@ -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 (
<button
className={this.getClassName()}
style={this.getStyle()}
onClick={this.handleClick}
>
{this.props.text || "고급 버튼"}
</button>
);
}
}
```
## 📋 제거된 레거시 시스템
### 🗑️ 삭제된 파일들
- `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. **팀 교육**: 새로운 개발 방식 공유
**🎊 축하합니다! 차세대 컴포넌트 시스템이 완성되었습니다!** ✨

View File

@ -91,13 +91,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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,

View File

@ -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<CustomAccordionProps> = ({
items,
type,
collapsible = true,
defaultValue,
onValueChange,
className = "",
style,
onClick,
onDragStart,
onDragEnd,
}) => {
const [openItems, setOpenItems] = useState<Set<string>>(() => {
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 (
<div
className={`custom-accordion ${className}`}
style={{
...style,
height: "auto",
minHeight: "0",
}}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
{items.map((item, index) => (
<div key={item.id} className="accordion-item">
<button
className="accordion-trigger"
onClick={() => toggleItem(item.id)}
style={{
width: "100%",
padding: "12px 16px",
textAlign: "left",
borderTop: "1px solid #e5e7eb",
borderLeft: "1px solid #e5e7eb",
borderRight: "1px solid #e5e7eb",
borderBottom: openItems.has(item.id) ? "none" : index === items.length - 1 ? "1px solid #e5e7eb" : "none",
backgroundColor: "#f9fafb",
cursor: "pointer",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "14px",
fontWeight: "500",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#f3f4f6";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#f9fafb";
}}
>
<span>{item.title}</span>
<span
style={{
transform: openItems.has(item.id) ? "rotate(180deg)" : "rotate(0deg)",
transition: "transform 0.2s ease",
}}
>
</span>
</button>
<div
className="accordion-content"
style={{
maxHeight: openItems.has(item.id) ? "200px" : "0px",
overflow: "hidden",
transition: "max-height 0.3s ease",
borderLeft: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
borderRight: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
borderTop: "none",
borderBottom: index === items.length - 1 ? "1px solid #e5e7eb" : "none",
}}
>
<div
style={{
padding: openItems.has(item.id) ? "12px 16px" : "0 16px",
fontSize: "14px",
color: "#6b7280",
transition: "padding 0.3s ease",
whiteSpace: "pre-line", // 줄바꿈 적용
lineHeight: "1.5", // 줄 간격 설정
}}
>
{/* 내용 필드가 배열이거나 복잡한 객체인 경우 처리 */}
{
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) // 기타 타입은 문자열로 변환
}
</div>
</div>
</div>
))}
</div>
);
};
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<AccordionItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string>,
)),
});
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<AccordionBasicComponentProps> = ({
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 (
<div
style={{
...componentStyle,
position: "relative",
height: componentStyle.height, // 명시적 높이 설정
maxHeight: componentStyle.height, // 최대 높이 제한
overflow: "visible", // 자식 요소에서 스크롤 처리
flex: "none", // flex 비활성화
display: "block",
}}
className={className}
{...domProps}
>
{/* 라벨 렌더링 */}
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: "500",
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}
{loading ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-sm text-gray-500"> ...</div>
</div>
) : error && !isDesignMode ? (
<div className="flex h-full w-full items-center justify-center">
<div className="text-sm text-red-500">{error}</div>
</div>
) : (
<div
style={{
height: "100%",
width: "100%",
overflow: "auto",
position: "absolute",
top: "0",
left: "0",
right: "0",
bottom: "0",
}}
>
<CustomAccordion
items={items}
type={accordionType}
collapsible={collapsible}
defaultValue={defaultValue}
onValueChange={handleValueChange}
className="w-full"
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
</div>
)}
</div>
);
};
/**
* AccordionBasic
*
*/
export const AccordionBasicWrapper: React.FC<AccordionBasicComponentProps> = (props) => {
return <AccordionBasicComponent {...props} />;
};

View File

@ -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<AccordionBasicConfig>) => void;
screenTableName?: string; // 화면에서 지정한 테이블명
tableColumns?: any[]; // 테이블 컬럼 정보
}
/**
* AccordionBasic
* UI
*/
export const AccordionBasicConfigPanel: React.FC<AccordionBasicConfigPanelProps> = ({
config,
onChange,
screenTableName,
tableColumns,
}) => {
const [localItems, setLocalItems] = useState<AccordionItem[]>(
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<AccordionItem>) => {
handleItemsChange(localItems.map((item) => (item.id === itemId ? { ...item, ...updates } : item)));
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 데이터 소스 설정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 데이터 소스 타입 */}
<div className="space-y-2">
<Label htmlFor="sourceType"> </Label>
<Select
value={config.dataSource?.sourceType || "static"}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
sourceType: value as "static" | "table" | "api",
})
}
>
<SelectTrigger>
<SelectValue placeholder="데이터 소스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> ( )</SelectItem>
<SelectItem value="table"> </SelectItem>
<SelectItem value="api">API </SelectItem>
</SelectContent>
</Select>
</div>
{/* 테이블 데이터 설정 */}
{config.dataSource?.sourceType === "table" && (
<>
{/* 테이블 선택 방식 */}
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center space-x-2">
<Checkbox
id="useScreenTable"
checked={config.dataSource?.useScreenTable !== false}
onCheckedChange={(checked) =>
handleChange("dataSource", {
...config.dataSource,
useScreenTable: checked as boolean,
})
}
/>
<Label htmlFor="useScreenTable" className="text-sm">
{screenTableName && `(${screenTableName})`}
</Label>
</div>
</div>
{/* 직접 테이블명 입력 (화면 테이블을 사용하지 않을 때) */}
{config.dataSource?.useScreenTable === false && (
<div className="space-y-2">
<Label htmlFor="tableName"></Label>
<Input
id="tableName"
value={config.dataSource?.tableName || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
tableName: e.target.value,
})
}
placeholder="테이블명을 입력하세요"
/>
</div>
)}
{/* 필드 선택 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="titleField"> </Label>
<Select
value={config.dataSource?.titleField || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
titleField: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="제목 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="idField">ID </Label>
<Select
value={config.dataSource?.idField || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
idField: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="ID 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 내용 필드들 (여러개 가능) */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label> </Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
const newContentFields = [
...(config.dataSource?.contentFields || []),
{ fieldName: "", label: "", separator: "\n" },
];
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
className="h-8"
>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
{config.dataSource?.contentFields?.map((field, index) => (
<Card key={index} className="p-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm"> {index + 1}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newContentFields =
config.dataSource?.contentFields?.filter((_, i) => i !== index) || [];
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
className="h-8 w-8 p-0 text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"></Label>
<Select
value={field.fieldName}
onValueChange={(value) => {
const newContentFields = [...(config.dataSource?.contentFields || [])];
newContentFields[index] = { ...field, fieldName: value };
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
>
<SelectTrigger>
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
{column.columnLabel || column.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> ()</Label>
<Input
value={field.label || ""}
onChange={(e) => {
const newContentFields = [...(config.dataSource?.contentFields || [])];
newContentFields[index] = { ...field, label: e.target.value };
handleChange("dataSource", {
...config.dataSource,
contentFields: newContentFields,
});
}}
placeholder="예: 설명"
className="text-xs"
/>
</div>
</div>
</div>
</Card>
))}
{(!config.dataSource?.contentFields || config.dataSource.contentFields.length === 0) && (
<div className="py-4 text-center text-sm text-gray-500"> </div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="orderBy"> </Label>
<Select
value={config.dataSource?.orderBy || ""}
onValueChange={(value) =>
handleChange("dataSource", {
...config.dataSource,
orderBy: value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="정렬 필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns?.map((column) => (
<SelectItem key={`${column.columnName}_asc`} value={`${column.columnName} ASC`}>
{column.columnLabel || column.columnName} ()
</SelectItem>
))}
{tableColumns?.map((column) => (
<SelectItem key={`${column.columnName}_desc`} value={`${column.columnName} DESC`}>
{column.columnLabel || column.columnName} ()
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-muted-foreground rounded-lg bg-blue-50 p-3 text-sm">
💡 <strong> :</strong> ,
.
</div>
</>
)}
{/* API 데이터 설정 */}
{config.dataSource?.sourceType === "api" && (
<>
<div className="space-y-2">
<Label htmlFor="apiEndpoint">API </Label>
<Input
id="apiEndpoint"
value={config.dataSource?.apiEndpoint || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
apiEndpoint: e.target.value,
})
}
placeholder="/api/data/accordion-items"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="titleField"> </Label>
<Input
id="titleField"
value={config.dataSource?.titleField || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
titleField: e.target.value,
})
}
placeholder="title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contentField"> </Label>
<Input
id="contentField"
value={config.dataSource?.contentField || ""}
onChange={(e) =>
handleChange("dataSource", {
...config.dataSource,
contentField: e.target.value,
})
}
placeholder="content"
/>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 기본 설정 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 타입 설정 */}
<div className="space-y-2">
<Label htmlFor="type"> </Label>
<Select
value={config.type || "single"}
onValueChange={(value) => handleChange("type", value as "single" | "multiple")}
>
<SelectTrigger>
<SelectValue placeholder="선택 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="single"> </SelectItem>
<SelectItem value="multiple"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 접을 수 있는지 설정 */}
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible !== false}
onCheckedChange={(checked) => handleChange("collapsible", checked)}
/>
<Label htmlFor="collapsible"> </Label>
</div>
{/* 기본값 설정 */}
<div className="space-y-2">
<Label htmlFor="defaultValue"> </Label>
<Select
value={config.defaultValue || "none"}
onValueChange={(value) => handleChange("defaultValue", value === "none" ? undefined : value)}
>
<SelectTrigger>
<SelectValue placeholder="기본으로 열린 아이템 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{localItems.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비활성화 */}
<div className="flex items-center space-x-2">
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
<Label htmlFor="disabled"></Label>
</div>
</CardContent>
</Card>
{/* 아이템 관리 (정적 데이터일 때만 표시) */}
{(!config.dataSource || config.dataSource.sourceType === "static") && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-sm">
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-8">
<Plus className="mr-1 h-4 w-4" />
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{localItems.map((item, index) => (
<Card key={item.id} className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> {index + 1}</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
className="h-8 w-8 p-0 text-red-500 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 제목 */}
<div className="space-y-1">
<Label htmlFor={`title-${item.id}`} className="text-xs">
</Label>
<Input
id={`title-${item.id}`}
value={item.title}
onChange={(e) => updateItem(item.id, { title: e.target.value })}
placeholder="아이템 제목"
/>
</div>
{/* 내용 */}
<div className="space-y-1">
<Label htmlFor={`content-${item.id}`} className="text-xs">
</Label>
<Textarea
id={`content-${item.id}`}
value={item.content}
onChange={(e) => updateItem(item.id, { content: e.target.value })}
placeholder="아이템 내용"
rows={3}
/>
</div>
{/* 기본으로 열림 */}
<div className="flex items-center space-x-2">
<Checkbox
id={`defaultOpen-${item.id}`}
checked={item.defaultOpen || false}
onCheckedChange={(checked) => updateItem(item.id, { defaultOpen: checked as boolean })}
/>
<Label htmlFor={`defaultOpen-${item.id}`} className="text-xs">
</Label>
</div>
</div>
</Card>
))}
{localItems.length === 0 && (
<div className="py-8 text-center text-gray-500">
<p className="text-sm"> .</p>
<p className="text-xs"> .</p>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
};

View File

@ -0,0 +1,28 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { AccordionBasicDefinition } from "./index";
/**
* AccordionBasic
*
*/
export class AccordionBasicRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = AccordionBasicDefinition;
constructor(props: any) {
super(props);
}
render(): React.ReactElement {
const { component, ...restProps } = this.props;
return React.createElement(AccordionBasicDefinition.component, {
component,
...restProps,
});
}
}
// 렌더러 인스턴스 생성 및 자동 등록
AccordionBasicRenderer.registerSelf();

View File

@ -0,0 +1,74 @@
# AccordionBasic 컴포넌트
접을 수 있는 콘텐츠 섹션을 제공하는 아코디언 컴포넌트입니다.
## 컴포넌트 정보
- **ID**: `accordion-basic`
- **카테고리**: `display`
- **웹타입**: `text`
- **기본 크기**: 300x200
## 주요 기능
- **다중 아이템 지원**: 여러 개의 접을 수 있는 섹션 제공
- **단일/다중 선택**: 한 번에 하나만 열거나 여러 개를 동시에 열 수 있음
- **기본값 설정**: 초기에 열려있을 아이템 지정 가능
- **완전 접기**: 모든 아이템을 닫을 수 있는 옵션
- **동적 아이템 관리**: 상세설정에서 아이템 추가/삭제/편집 가능
## 설정 옵션
### 기본 설정
- `type`: 선택 타입 ("single" | "multiple")
- `collapsible`: 모든 아이템 접기 가능 여부
- `defaultValue`: 기본으로 열린 아이템 ID
- `disabled`: 비활성화 상태
### 아이템 설정
각 아이템은 다음 속성을 가집니다:
- `id`: 고유 식별자
- `title`: 아이템 제목 (헤더에 표시)
- `content`: 아이템 내용 (접었다 펼 수 있는 부분)
- `defaultOpen`: 기본으로 열림 상태
## 사용 예시
```tsx
// 기본 사용
<AccordionBasic
items={[
{
id: "item-1",
title: "제품 정보",
content: "제품에 대한 상세 정보...",
defaultOpen: true,
},
{
id: "item-2",
title: "배송 정보",
content: "배송에 대한 상세 정보...",
},
]}
type="single"
collapsible={true}
/>
```
## 이벤트
- `onValueChange`: 아이템 선택 상태가 변경될 때 호출
## 스타일링
- shadcn/ui의 Accordion 컴포넌트를 기반으로 구현
- 기본 스타일과 함께 커스텀 스타일링 지원
- 반응형 디자인 지원
## 참고 자료
- [shadcn/ui Accordion](https://ui.shadcn.com/docs/components/accordion)
- [Radix UI Accordion](https://www.radix-ui.com/primitives/docs/components/accordion)

View File

@ -0,0 +1,67 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { AccordionBasicWrapper } from "./AccordionBasicComponent";
import { AccordionBasicConfigPanel } from "./AccordionBasicConfigPanel";
import { AccordionBasicConfig } from "./types";
/**
* AccordionBasic
*
*/
export const AccordionBasicDefinition = createComponentDefinition({
id: "accordion-basic",
name: "아코디언",
nameEng: "AccordionBasic Component",
description: "접을 수 있는 콘텐츠 섹션을 제공하는 컴포넌트",
category: ComponentCategory.DISPLAY,
webType: "text",
component: AccordionBasicWrapper,
defaultConfig: {
dataSource: {
sourceType: "static" as const,
useScreenTable: true,
},
items: [
{
id: "item-1",
title: "제품 정보",
content:
"우리의 주력 제품은 최첨단 기술과 세련된 디자인을 결합합니다. 프리미엄 소재로 제작되어 탁월한 성능과 신뢰성을 제공합니다.",
defaultOpen: true,
},
{
id: "item-2",
title: "배송 정보",
content:
"신뢰할 수 있는 택배 파트너를 통해 전 세계 배송을 제공합니다. 일반 배송은 3-5 영업일, 특급 배송은 1-2 영업일 내 배송됩니다.",
},
{
id: "item-3",
title: "반품 정책",
content:
"포괄적인 30일 반품 정책으로 제품을 보장합니다. 완전히 만족하지 않으시면 원래 상태로 제품을 반품하시면 됩니다.",
},
],
type: "single",
collapsible: true,
defaultValue: "item-1",
},
defaultSize: { width: 300, height: 200 },
configPanel: AccordionBasicConfigPanel,
icon: "ChevronDown",
tags: ["아코디언", "접기", "펼치기", "콘텐츠", "섹션"],
version: "1.0.0",
author: "Developer",
documentation: "https://ui.shadcn.com/docs/components/accordion",
});
// 타입 내보내기
export type { AccordionBasicConfig } from "./types";
// 컴포넌트 내보내기
export { AccordionBasicComponent } from "./AccordionBasicComponent";
export { AccordionBasicRenderer } from "./AccordionBasicRenderer";

View File

@ -0,0 +1,82 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Accordion
*/
export interface AccordionItem {
id: string;
title: string;
content: string;
defaultOpen?: boolean;
}
/**
*
*/
export interface ContentFieldConfig {
fieldName: string; // 필드명
label?: string; // 표시할 라벨 (선택사항)
separator?: string; // 구분자 (기본값: 줄바꿈)
}
/**
*
*/
export interface DataSourceConfig {
sourceType: "static" | "table" | "api"; // 데이터 소스 타입
useScreenTable?: boolean; // 화면 테이블 사용 여부 (table 타입일 때)
tableName?: string; // 직접 입력한 테이블명 (useScreenTable이 false일 때)
apiEndpoint?: string; // API 엔드포인트 (api 타입일 때)
titleField?: string; // 제목으로 사용할 필드명
contentFields?: ContentFieldConfig[]; // 내용으로 사용할 필드들 (여러개 가능)
idField?: string; // ID로 사용할 필드명
filters?: Record<string, any>; // 필터 조건
orderBy?: string; // 정렬 기준
limit?: number; // ⚠️ 더 이상 사용되지 않음. 모든 데이터가 표시되고 스크롤로 처리됨
}
/**
* Accordion
*/
export interface AccordionBasicConfig extends ComponentConfig {
// 데이터 소스 설정
dataSource?: DataSourceConfig;
// 정적 아코디언 아이템들 (기존 방식)
items?: AccordionItem[];
// 동작 설정
type?: "single" | "multiple"; // 단일 선택 또는 다중 선택
collapsible?: boolean; // 모든 아이템을 닫을 수 있는지
defaultValue?: string; // 기본으로 열려있을 아이템 ID
// 스타일 설정
variant?: "default" | "bordered" | "ghost";
size?: "sm" | "md" | "lg";
// 애니메이션 설정
animationDuration?: number; // ms 단위
// 공통 설정
disabled?: boolean;
// 이벤트 관련
onValueChange?: (value: string | string[]) => void;
}
/**
* Accordion Props
*/
export interface AccordionBasicProps {
id?: string;
name?: string;
value?: any;
config?: AccordionBasicConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onValueChange?: (value: string | string[]) => void;
}

View File

@ -34,6 +34,7 @@ import "./slider-basic/SliderBasicRenderer";
import "./toggle-switch/ToggleSwitchRenderer";
import "./image-display/ImageDisplayRenderer";
import "./divider-line/DividerLineRenderer";
import "./accordion-basic/AccordionBasicRenderer";
/**
*

View File

@ -0,0 +1,93 @@
# TestInput 컴포넌트
테스트용 입력 컴포넌트
## 개요
- **ID**: `test-input`
- **카테고리**: input
- **웹타입**: text
- **작성자**: 개발팀
- **버전**: 1.0.0
## 특징
- ✅ 자동 등록 시스템
- ✅ 타입 안전성
- ✅ Hot Reload 지원
- ✅ 설정 패널 제공
- ✅ 반응형 디자인
## 사용법
### 기본 사용법
```tsx
import { TestInputComponent } from "@/lib/registry/components/test-input";
<TestInputComponent
component={{
id: "my-test-input",
type: "widget",
webType: "text",
position: { x: 100, y: 100, z: 1 },
size: { width: 200, height: 36 },
config: {
// 설정값들
}
}}
isDesignMode={false}
/>
```
### 설정 옵션
| 속성 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| placeholder | string | "" | 플레이스홀더 텍스트 |
| maxLength | number | 255 | 최대 입력 길이 |
| minLength | number | 0 | 최소 입력 길이 |
| disabled | boolean | false | 비활성화 여부 |
| required | boolean | false | 필수 입력 여부 |
| readonly | boolean | false | 읽기 전용 여부 |
## 이벤트
- `onChange`: 값 변경 시
- `onFocus`: 포커스 시
- `onBlur`: 포커스 해제 시
- `onClick`: 클릭 시
## 스타일링
컴포넌트는 다음과 같은 스타일 옵션을 제공합니다:
- `variant`: "default" | "outlined" | "filled"
- `size`: "sm" | "md" | "lg"
## 예시
```tsx
// 기본 예시
<TestInputComponent
component={{
id: "sample-test-input",
config: {
placeholder: "입력하세요",
required: true,
variant: "outlined"
}
}}
/>
```
## 개발자 정보
- **생성일**: 2025-09-12
- **CLI 명령어**: `node scripts/create-component.js test-input "테스트 입력" "테스트용 입력 컴포넌트" input text`
- **경로**: `lib/registry/components/test-input/`
## 관련 문서
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [개발자 문서](https://docs.example.com/components/test-input)

View File

@ -0,0 +1,128 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import { TestInputConfig } from "./types";
export interface TestInputComponentProps extends ComponentRendererProps {
config?: TestInputConfig;
}
/**
* TestInput
*
*/
export const TestInputComponent: React.FC<TestInputComponentProps> = ({
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
...props
}) => {
// 컴포넌트 설정
const componentConfig = {
...config,
...component.config,
} as TestInputConfig;
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.style,
...style,
};
// 디자인 모드 스타일
if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
}
// 이벤트 핸들러
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.();
};
// 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 (
<div style={componentStyle} className={className} {...domProps}>
{/* 라벨 렌더링 */}
{component.label && (
<label
style={{
position: "absolute",
top: "-25px",
left: "0px",
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: "500",
}}
>
{component.label}
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
</label>
)}
<input
type="text"
value={component.value || ""}
placeholder={componentConfig.placeholder || ""}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
readOnly={componentConfig.readonly || false}
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "8px 12px",
fontSize: "14px",
outline: "none",
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onChange={(e) => {
if (domProps.onChange) {
domProps.onChange(e.target.value);
}
}}
/>
</div>
);
};
/**
* TestInput
*
*/
export const TestInputWrapper: React.FC<TestInputComponentProps> = (props) => {
return <TestInputComponent {...props} />;
};

View File

@ -0,0 +1,82 @@
"use client";
import React 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 { TestInputConfig } from "./types";
export interface TestInputConfigPanelProps {
config: TestInputConfig;
onChange: (config: Partial<TestInputConfig>) => void;
}
/**
* TestInput
* UI
*/
export const TestInputConfigPanel: React.FC<TestInputConfigPanelProps> = ({
config,
onChange,
}) => {
const handleChange = (key: keyof TestInputConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
</div>
{/* 텍스트 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
id="placeholder"
value={config.placeholder || ""}
onChange={(e) => handleChange("placeholder", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxLength"> </Label>
<Input
id="maxLength"
type="number"
value={config.maxLength || ""}
onChange={(e) => handleChange("maxLength", parseInt(e.target.value) || undefined)}
/>
</div>
{/* 공통 설정 */}
<div className="space-y-2">
<Label htmlFor="disabled"></Label>
<Checkbox
id="disabled"
checked={config.disabled || false}
onCheckedChange={(checked) => handleChange("disabled", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="required"> </Label>
<Checkbox
id="required"
checked={config.required || false}
onCheckedChange={(checked) => handleChange("required", checked)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="readonly"> </Label>
<Checkbox
id="readonly"
checked={config.readonly || false}
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,51 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { TestInputDefinition } from "./index";
import { TestInputComponent } from "./TestInputComponent";
/**
* TestInput
*
*/
export class TestInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TestInputDefinition;
render(): React.ReactElement {
return <TestInputComponent {...this.props} renderer={this} />;
}
/**
*
*/
// text 타입 특화 속성 처리
protected getTestInputProps() {
const baseProps = this.getWebTypeProps();
// text 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 text 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
TestInputRenderer.registerSelf();

View File

@ -0,0 +1,39 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { TestInputWrapper } from "./TestInputComponent";
import { TestInputConfigPanel } from "./TestInputConfigPanel";
import { TestInputConfig } from "./types";
/**
* TestInput
*
*/
export const TestInputDefinition = createComponentDefinition({
id: "test-input",
name: "테스트 입력",
nameEng: "TestInput Component",
description: "테스트용 입력 컴포넌트",
category: ComponentCategory.INPUT,
webType: "text",
component: TestInputWrapper,
defaultConfig: {
placeholder: "텍스트를 입력하세요",
maxLength: 255,
},
defaultSize: { width: 200, height: 36 },
configPanel: TestInputConfigPanel,
icon: "Edit",
tags: [],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/test-input",
});
// 컴포넌트는 TestInputRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { TestInputConfig } from "./types";

View File

@ -0,0 +1,48 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* TestInput
*/
export interface TestInputConfig extends ComponentConfig {
// 텍스트 관련 설정
placeholder?: string;
maxLength?: number;
minLength?: number;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}
/**
* TestInput Props
*/
export interface TestInputProps {
id?: string;
name?: string;
value?: any;
config?: TestInputConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
}

View File

@ -20,6 +20,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
"divider-line": () => import("@/lib/registry/components/divider-line/DividerLineConfigPanel"),
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시
@ -44,10 +45,10 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
try {
console.log(`🔧 ConfigPanel 로드 중: ${componentId}`);
const module = await importFn();
// 모듈에서 ConfigPanel 컴포넌트 추출
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
if (!ConfigPanelComponent) {
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
return null;
@ -56,7 +57,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
// 캐시에 저장
configPanelCache.set(componentId, ConfigPanelComponent);
console.log(`✅ ConfigPanel 로드 완료: ${componentId}`);
return ConfigPanelComponent;
} catch (error) {
console.error(`컴포넌트 "${componentId}"의 ConfigPanel 로드 실패:`, error);
@ -84,9 +85,9 @@ export function getSupportedConfigPanelComponents(): string[] {
*/
function toPascalCase(str: string): string {
return str
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('');
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
}
/**
@ -96,12 +97,16 @@ export interface ComponentConfigPanelProps {
componentId: string;
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
screenTableName?: string; // 화면에서 지정한 테이블명
tableColumns?: any[]; // 테이블 컬럼 정보
}
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
componentId,
config,
onChange,
screenTableName,
tableColumns,
}) => {
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
const [loading, setLoading] = React.useState(true);
@ -115,10 +120,10 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 시작`);
setLoading(true);
setError(null);
const component = await getComponentConfigPanel(componentId);
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 결과:`, component);
if (mounted) {
setConfigPanelComponent(() => component);
setLoading(false);
@ -173,5 +178,12 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
);
}
return <ConfigPanelComponent config={config} onChange={onChange} />;
return (
<ConfigPanelComponent
config={config}
onChange={onChange}
screenTableName={screenTableName}
tableColumns={tableColumns}
/>
);
};

View File

@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
@ -1188,6 +1189,37 @@
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-accordion": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
"integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",

View File

@ -18,6 +18,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",

View File

@ -1,43 +1,84 @@
#!/usr/bin/env node
/**
* 컴포넌트 자동 생성 CLI 스크립트
* 레이아웃 시스템의 create-layout.js와 동일한 패턴으로 설계
* 화면 관리 시스템 컴포넌트 자동 생성 CLI 스크립트
* 실제 컴포넌트 구조에 맞게 설계
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
// 대화형 입력을 위한 readline 인터페이스
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
// 사용자 입력을 기다리는 함수
function askQuestion(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
// CLI 인자 파싱
const args = process.argv.slice(2);
const componentName = args[0];
const displayName = args[1];
const description = args[2];
const category = args[3];
const webType = args[4] || "text";
if (!componentName) {
console.error("❌ 컴포넌트 이름을 제공해주세요.");
console.log("사용법: node scripts/create-component.js <컴포넌트이름> [옵션]");
console.log("예시: node scripts/create-component.js button-primary --category=ui --webType=button");
process.exit(1);
// 입력값 검증
function validateInputs() {
if (!componentName) {
console.error("❌ 컴포넌트 이름을 제공해주세요.");
showUsage();
process.exit(1);
}
if (!displayName) {
console.error("❌ 표시 이름을 제공해주세요.");
showUsage();
process.exit(1);
}
if (!description) {
console.error("❌ 설명을 제공해주세요.");
showUsage();
process.exit(1);
}
if (!category) {
console.error("❌ 카테고리를 제공해주세요.");
showUsage();
process.exit(1);
}
// 컴포넌트 이름 형식 검증 (kebab-case)
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(componentName)) {
console.error("❌ 컴포넌트 이름은 kebab-case 형식이어야 합니다. (예: text-input, date-picker)");
process.exit(1);
}
// 카테고리 검증
const validCategories = ['input', 'display', 'action', 'layout', 'form', 'chart', 'media', 'navigation', 'feedback', 'utility', 'container', 'system', 'admin'];
if (!validCategories.includes(category)) {
console.error(`❌ 유효하지 않은 카테고리입니다. 사용 가능한 카테고리: ${validCategories.join(', ')}`);
process.exit(1);
}
// 웹타입 검증
const validWebTypes = ['text', 'number', 'email', 'password', 'textarea', 'select', 'button', 'checkbox', 'radio', 'date', 'file'];
if (webType && !validWebTypes.includes(webType)) {
console.error(`❌ 유효하지 않은 웹타입입니다. 사용 가능한 웹타입: ${validWebTypes.join(', ')}`);
process.exit(1);
}
}
function showUsage() {
console.log("\n📖 사용법:");
console.log("node scripts/create-component.js <컴포넌트이름> <표시이름> <설명> <카테고리> [웹타입]");
console.log("\n📋 예시:");
console.log("node scripts/create-component.js text-input '텍스트 입력' '기본 텍스트 입력 컴포넌트' input text");
console.log("node scripts/create-component.js action-button '액션 버튼' '사용자 액션 버튼' action button");
console.log("\n📂 카테고리: input, display, action, layout, form, chart, media, navigation, feedback, utility");
console.log("🎯 웹타입: text, number, email, password, textarea, select, button, checkbox, radio, date, file");
console.log("\n📚 자세한 사용법: docs/CLI_컴포넌트_생성_가이드.md");
}
validateInputs();
// 옵션 파싱
const options = {};
args.slice(1).forEach((arg) => {
args.slice(5).forEach((arg) => {
if (arg.startsWith("--")) {
const [key, value] = arg.substring(2).split("=");
options[key] = value || true;
@ -47,10 +88,11 @@ args.slice(1).forEach((arg) => {
// 기본값 설정
const config = {
name: componentName,
category: options.category || "ui",
webType: options.webType || "text",
description: options.description || `${componentName} 컴포넌트입니다`,
author: options.author || "Developer",
displayName: displayName || componentName,
description: description || `${displayName || componentName} 컴포넌트`,
category: category || "display",
webType: webType,
author: options.author || "개발팀",
size: options.size || "200x36",
tags: options.tags ? options.tags.split(",") : [],
};
@ -85,7 +127,7 @@ const names = {
};
// 1. index.ts 파일 생성
function createIndexFile() {
function createIndexFile(componentDir, names, config, width, height) {
const content = `"use client";
import React from "react";
@ -102,9 +144,9 @@ import { ${names.pascal}Config } from "./types";
*/
export const ${names.pascal}Definition = createComponentDefinition({
id: "${names.kebab}",
name: "${config.koreanName}",
name: "${config.displayName}",
nameEng: "${names.pascal} Component",
description: "${config.koreanDescription}",
description: "${config.description}",
category: ComponentCategory.${config.category.toUpperCase()},
webType: "${config.webType}",
component: ${names.pascal}Wrapper,
@ -120,12 +162,10 @@ export const ${names.pascal}Definition = createComponentDefinition({
documentation: "https://docs.example.com/components/${names.kebab}",
});
// 컴포넌트는 ${names.pascal}Renderer에서 자동 등록됩니다
// 타입 내보내기
export type { ${names.pascal}Config } from "./types";
// 컴포넌트 내보내기
export { ${names.pascal}Component } from "./${names.pascal}Component";
export { ${names.pascal}Renderer } from "./${names.pascal}Renderer";
`;
fs.writeFileSync(path.join(componentDir, "index.ts"), content);
@ -133,7 +173,7 @@ export { ${names.pascal}Renderer } from "./${names.pascal}Renderer";
}
// 2. Component 파일 생성
function createComponentFile() {
function createComponentFile(componentDir, names, config) {
const content = `"use client";
import React from "react";
@ -152,12 +192,16 @@ export const ${names.pascal}Component: React.FC<${names.pascal}ComponentProps> =
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
onDragStart,
onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
screenId,
...props
}) => {
// 컴포넌트 설정
@ -211,7 +255,7 @@ export const ${names.pascal}Component: React.FC<${names.pascal}ComponentProps> =
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
*/
export const ${names.pascal}Wrapper: React.FC<${names.pascal}ComponentProps> = (props) => {
return <${names.pascal}Component {...domProps} />;
return <${names.pascal}Component {...props} />;
};
`;
@ -220,7 +264,7 @@ export const ${names.pascal}Wrapper: React.FC<${names.pascal}ComponentProps> = (
}
// 3. Renderer 파일 생성
function createRendererFile() {
function createRendererFile(componentDir, names, config) {
const content = `"use client";
import React from "react";
@ -272,11 +316,6 @@ export class ${names.pascal}Renderer extends AutoRegisteringComponentRenderer {
// 자동 등록 실행
${names.pascal}Renderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
${names.pascal}Renderer.enableHotReload();
}
`;
fs.writeFileSync(path.join(componentDir, `${names.pascal}Renderer.tsx`), content);
@ -284,7 +323,7 @@ if (process.env.NODE_ENV === "development") {
}
// 4. Config Panel 파일 생성
function createConfigPanelFile() {
function createConfigPanelFile(componentDir, names, config) {
const content = `"use client";
import React from "react";
@ -314,7 +353,7 @@ export const ${names.pascal}ConfigPanel: React.FC<${names.pascal}ConfigPanelProp
return (
<div className="space-y-4">
<div className="text-sm font-medium">
${config.description.replace(" 컴포넌트입니다", "")} 설정
${config.description} 설정
</div>
${getConfigPanelJSXByWebType(config.webType)}
@ -356,7 +395,7 @@ export const ${names.pascal}ConfigPanel: React.FC<${names.pascal}ConfigPanelProp
}
// 5. Types 파일 생성
function createTypesFile() {
function createTypesFile(componentDir, names, config) {
const content = `"use client";
import { ComponentConfig } from "@/types/component";
@ -408,56 +447,8 @@ export interface ${names.pascal}Props {
console.log("✅ types.ts 생성 완료");
}
// 6. Config 파일 생성
function createConfigFile() {
const content = `"use client";
import { ${names.pascal}Config } from "./types";
/**
* ${names.pascal} 컴포넌트 기본 설정
*/
export const ${names.pascal}DefaultConfig: ${names.pascal}Config = {
${getDefaultConfigByWebType(config.webType)}
// 공통 기본값
disabled: false,
required: false,
readonly: false,
variant: "default",
size: "md",
};
/**
* ${names.pascal} 컴포넌트 설정 스키마
* 유효성 검사 타입 체크에 사용
*/
export const ${names.pascal}ConfigSchema = {
${getConfigSchemaByWebType(config.webType)}
// 공통 스키마
disabled: { type: "boolean", default: false },
required: { type: "boolean", default: false },
readonly: { type: "boolean", default: false },
variant: {
type: "enum",
values: ["default", "outlined", "filled"],
default: "default"
},
size: {
type: "enum",
values: ["sm", "md", "lg"],
default: "md"
},
};
`;
fs.writeFileSync(path.join(componentDir, "config.ts"), content);
console.log("✅ config.ts 생성 완료");
}
// 7. README 파일 생성
function createReadmeFile() {
// README 파일 생성 (config.ts는 제거)
function createReadmeFile(componentDir, names, config, width, height) {
const content = `# ${names.pascal} 컴포넌트
${config.description}
@ -542,7 +533,7 @@ ${getConfigDocumentationByWebType(config.webType)}
## 개발자 정보
- **생성일**: ${new Date().toISOString().split("T")[0]}
- **CLI 명령어**: \`node scripts/create-component.js ${componentName} --category=${config.category} --webType=${config.webType}\`
- **CLI 명령어**: \`node scripts/create-component.js ${componentName} "${config.displayName}" "${config.description}" ${config.category} ${config.webType}\`
- **경로**: \`lib/registry/components/${names.kebab}/\`
## 관련 문서
@ -555,6 +546,54 @@ ${getConfigDocumentationByWebType(config.webType)}
console.log("✅ README.md 생성 완료");
}
// index.ts 파일에 import 자동 추가 함수
function addToRegistryIndex(names) {
const indexFilePath = path.join(__dirname, "../lib/registry/components/index.ts");
try {
// 기존 파일 읽기
const existingContent = fs.readFileSync(indexFilePath, "utf8");
// 새로운 import 구문
const newImport = `import "./${names.kebab}/${names.pascal}Renderer";`;
// 이미 존재하는지 확인
if (existingContent.includes(newImport)) {
console.log("⚠️ import가 이미 존재합니다.");
return;
}
// 기존 import들 찾기 (마지막 import 이후에 추가)
const lines = existingContent.split('\n');
const lastImportIndex = lines.findLastIndex(line => line.trim().startsWith('import ') && line.includes('Renderer'));
if (lastImportIndex !== -1) {
// 마지막 import 다음에 새로운 import 추가
lines.splice(lastImportIndex + 1, 0, newImport);
} else {
// import가 없으면 기존 import 구역 끝에 추가
const importSectionEnd = lines.findIndex(line => line.trim() === '' && lines.indexOf(line) > 10);
if (importSectionEnd !== -1) {
lines.splice(importSectionEnd, 0, newImport);
} else {
// 적절한 위치를 찾지 못했으면 끝에 추가
lines.push(newImport);
}
}
// 파일에 다시 쓰기
const newContent = lines.join('\n');
fs.writeFileSync(indexFilePath, newContent);
console.log("✅ index.ts에 import 자동 추가 완료");
} catch (error) {
console.error("⚠️ index.ts 업데이트 중 오류:", error.message);
console.log(`📝 수동으로 다음을 추가해주세요:`);
console.log(` ${newImport}`);
}
}
// 헬퍼 함수들
function getDefaultConfigByWebType(webType) {
switch (webType) {
@ -1005,7 +1044,6 @@ async function main() {
const [width, height] = config.size.split("x").map(Number);
if (!width || !height) {
console.error("❌ 크기 형식이 올바르지 않습니다. 예: 200x36");
rl.close();
process.exit(1);
}
@ -1015,57 +1053,15 @@ async function main() {
console.log("🚀 컴포넌트 생성 시작...");
console.log(`📁 이름: ${names.camel}`);
console.log(`🔖 ID: ${names.kebab}`);
// 사용자로부터 한글 이름과 설명 입력받기
console.log("\n📝 컴포넌트 정보를 입력해주세요:");
const koreanName = await askQuestion(`한글 이름 (예: 기본 버튼): `);
const koreanDescription = await askQuestion(`설명 (예: 일반적인 액션을 위한 기본 버튼 컴포넌트): `);
// 카테고리와 웹타입 확인
let category = config.category;
let webType = config.webType;
if (!options.category) {
console.log("\n📂 카테고리를 선택해주세요:");
console.log("1. input - 입력 컴포넌트");
console.log("2. display - 표시 컴포넌트");
console.log("3. layout - 레이아웃 컴포넌트");
console.log("4. action - 액션 컴포넌트");
console.log("5. admin - 관리자 컴포넌트");
const categoryChoice = await askQuestion("카테고리 번호 (1-5): ");
const categoryMap = {
1: "input",
2: "display",
3: "layout",
4: "action",
5: "admin",
};
category = categoryMap[categoryChoice] || "input";
}
if (!options.webType) {
console.log("\n🎯 웹타입을 입력해주세요:");
console.log("예시: text, number, email, password, date, select, checkbox, radio, boolean, file, button");
webType = (await askQuestion(`웹타입 (기본: text): `)) || "text";
}
// config 업데이트
config.category = category;
config.webType = webType;
config.koreanName = koreanName;
config.koreanDescription = koreanDescription;
console.log(`\n📂 카테고리: ${config.category}`);
console.log(`📂 카테고리: ${config.category}`);
console.log(`🎯 웹타입: ${config.webType}`);
console.log(`🌐 한글이름: ${config.koreanName}`);
console.log(`📝 설명: ${config.koreanDescription}`);
console.log(`🌐 표시이름: ${config.displayName}`);
console.log(`📝 설명: ${config.description}`);
console.log(`📏 크기: ${width}x${height}`);
// 컴포넌트 디렉토리 생성
if (fs.existsSync(componentDir)) {
console.error(`❌ 컴포넌트 "${names.kebab}"가 이미 존재합니다.`);
rl.close();
process.exit(1);
}
@ -1073,35 +1069,33 @@ async function main() {
console.log(`📁 디렉토리 생성: ${componentDir}`);
try {
// 파일들 생성
createIndexFile();
createComponentFile();
createRendererFile();
createConfigPanelFile();
createTypesFile();
createConfigFile();
createReadmeFile();
// 파일들 생성 (파라미터 전달하여 호출)
createIndexFile(componentDir, names, config, width, height);
createComponentFile(componentDir, names, config);
createRendererFile(componentDir, names, config);
createConfigPanelFile(componentDir, names, config);
createTypesFile(componentDir, names, config);
createReadmeFile(componentDir, names, config, width, height);
// index.ts 파일에 자동으로 import 추가
addToRegistryIndex(names);
console.log("\n🎉 컴포넌트 생성 완료!");
console.log(`📁 경로: ${componentDir}`);
console.log(`🔗 다음 단계:`);
console.log(` 1. lib/registry/components/index.ts에 import 추가`);
console.log(` 1. lib/registry/components/index.ts에 import 자동 추가`);
console.log(` 2. 브라우저에서 자동 등록 확인`);
console.log(` 3. 컴포넌트 패널에서 테스트`);
console.log(`\n🛠️ 개발자 도구 사용법:`);
console.log(` __COMPONENT_REGISTRY__.get("${names.kebab}")`);
} catch (error) {
console.error("❌ 컴포넌트 생성 중 오류 발생:", error);
rl.close();
process.exit(1);
}
rl.close();
}
// 메인 함수 실행
main().catch((error) => {
console.error("❌ 실행 중 오류 발생:", error);
rl.close();
process.exit(1);
});