Compare commits
2 Commits
49e8e40521
...
8196201e65
| Author | SHA1 | Date |
|---|---|---|
|
|
8196201e65 | |
|
|
52dd18747a |
|
|
@ -25,6 +25,7 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
import layoutRoutes from "./routes/layoutRoutes";
|
import layoutRoutes from "./routes/layoutRoutes";
|
||||||
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// 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/admin/component-standards", componentStandardRoutes);
|
||||||
app.use("/api/layouts", layoutRoutes);
|
app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
|
app.use("/api/data", dataRoutes);
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -1529,7 +1529,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
},
|
},
|
||||||
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
webTypeConfig: getDefaultWebTypeConfig(component.webType),
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: component.id === "text-display" ? false : true, // 텍스트 표시 컴포넌트는 기본적으로 라벨 숨김
|
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||||
labelFontSize: "14px",
|
labelFontSize: "14px",
|
||||||
labelColor: "#374151",
|
labelColor: "#374151",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
@ -1804,11 +1804,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
required: column.required,
|
required: column.required,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||||
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||||
size: { width: defaultWidth, height: 40 },
|
size: { width: defaultWidth, height: 40 },
|
||||||
gridColumns: 1,
|
gridColumns: 1,
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: true,
|
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
labelColor: "#374151",
|
labelColor: "#374151",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
@ -1836,11 +1837,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
required: column.required,
|
required: column.required,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
componentType: componentId, // DynamicComponentRenderer용 컴포넌트 타입
|
||||||
position: { x, y, z: 1 } as Position,
|
position: { x, y, z: 1 } as Position,
|
||||||
size: { width: defaultWidth, height: 40 },
|
size: { width: defaultWidth, height: 40 },
|
||||||
gridColumns: 1,
|
gridColumns: 1,
|
||||||
style: {
|
style: {
|
||||||
labelDisplay: true,
|
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
|
||||||
labelFontSize: "12px",
|
labelFontSize: "12px",
|
||||||
labelColor: "#374151",
|
labelColor: "#374151",
|
||||||
labelFontWeight: "500",
|
labelFontWeight: "500",
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,21 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
return ComponentRegistry.getAllComponents();
|
return ComponentRegistry.getAllComponents();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 카테고리별 분류
|
// 카테고리별 분류 (input 카테고리 제외)
|
||||||
const componentsByCategory = useMemo(() => {
|
const componentsByCategory = useMemo(() => {
|
||||||
|
// input 카테고리 컴포넌트들을 제외한 컴포넌트만 필터링
|
||||||
|
const filteredComponents = allComponents.filter((component) => component.category !== "input");
|
||||||
|
|
||||||
const categories: Record<ComponentCategory | "all", ComponentDefinition[]> = {
|
const categories: Record<ComponentCategory | "all", ComponentDefinition[]> = {
|
||||||
all: allComponents,
|
all: filteredComponents, // input 카테고리 제외된 컴포넌트들만 포함
|
||||||
input: [],
|
input: [], // 빈 배열로 유지 (사용되지 않음)
|
||||||
display: [],
|
display: [],
|
||||||
action: [],
|
action: [],
|
||||||
layout: [],
|
layout: [],
|
||||||
utility: [],
|
utility: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
allComponents.forEach((component) => {
|
filteredComponents.forEach((component) => {
|
||||||
if (categories[component.category]) {
|
if (categories[component.category]) {
|
||||||
categories[component.category].push(component);
|
categories[component.category].push(component);
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +107,7 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Package className="mr-2 h-5 w-5" />
|
<Package className="mr-2 h-5 w-5" />
|
||||||
컴포넌트 ({allComponents.length})
|
컴포넌트 ({componentsByCategory.all.length})
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
<Button variant="outline" size="sm" onClick={handleRefresh} title="컴포넌트 새로고침">
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
|
@ -128,16 +131,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
onValueChange={(value) => setSelectedCategory(value as ComponentCategory | "all")}
|
||||||
>
|
>
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 (input 카테고리 제외) */}
|
||||||
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-6">
|
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-5">
|
||||||
<TabsTrigger value="all" className="flex items-center">
|
<TabsTrigger value="all" className="flex items-center">
|
||||||
<Package className="mr-1 h-3 w-3" />
|
<Package className="mr-1 h-3 w-3" />
|
||||||
전체
|
전체
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="input" className="flex items-center">
|
|
||||||
<Grid className="mr-1 h-3 w-3" />
|
|
||||||
입력
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="display" className="flex items-center">
|
<TabsTrigger value="display" className="flex items-center">
|
||||||
<Palette className="mr-1 h-3 w-3" />
|
<Palette className="mr-1 h-3 w-3" />
|
||||||
표시
|
표시
|
||||||
|
|
|
||||||
|
|
@ -957,7 +957,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
|
|
||||||
// 새로운 컴포넌트 시스템 처리 (type: "component")
|
// 새로운 컴포넌트 시스템 처리 (type: "component")
|
||||||
if (selectedComponent.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;
|
const webType = selectedComponent.componentConfig?.webType;
|
||||||
|
|
||||||
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
|
console.log("🔧 새로운 컴포넌트 시스템 설정 패널:", { componentId, webType });
|
||||||
|
|
@ -1000,6 +1000,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
<DynamicComponentConfigPanel
|
<DynamicComponentConfigPanel
|
||||||
componentId={componentId}
|
componentId={componentId}
|
||||||
config={selectedComponent.componentConfig || {}}
|
config={selectedComponent.componentConfig || {}}
|
||||||
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||||
|
tableColumns={currentTable?.columns || []}
|
||||||
onChange={(newConfig) => {
|
onChange={(newConfig) => {
|
||||||
console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
console.log("🔧 컴포넌트 설정 변경:", newConfig);
|
||||||
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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)를 참조하세요.
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**⚠️ 중요**: 이 가이드의 규칙을 지키지 않으면 컴포넌트 위치 오류가 발생합니다.
|
|
||||||
새 컴포넌트 생성 시 반드시 이 체크리스트를 확인하세요!
|
|
||||||
|
|
@ -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. **팀 교육**: 새로운 개발 방식 공유
|
|
||||||
|
|
||||||
**🎊 축하합니다! 차세대 컴포넌트 시스템이 완성되었습니다!** ✨
|
|
||||||
|
|
@ -91,13 +91,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// component_config에서 실제 컴포넌트 타입 추출
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
const componentType = component.componentConfig?.type || component.type;
|
const componentType = (component as any).componentType || component.type;
|
||||||
|
|
||||||
console.log("🔍 컴포넌트 타입 추출:", {
|
console.log("🔍 컴포넌트 타입 추출:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentConfigType: component.componentConfig?.type,
|
componentConfigType: component.componentConfig?.type,
|
||||||
componentType: component.type,
|
componentType: component.type,
|
||||||
|
componentTypeProp: (component as any).componentType,
|
||||||
finalComponentType: componentType,
|
finalComponentType: componentType,
|
||||||
componentConfig: component.componentConfig,
|
componentConfig: component.componentConfig,
|
||||||
propsScreenId: props.screenId,
|
propsScreenId: props.screenId,
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import "./slider-basic/SliderBasicRenderer";
|
||||||
import "./toggle-switch/ToggleSwitchRenderer";
|
import "./toggle-switch/ToggleSwitchRenderer";
|
||||||
import "./image-display/ImageDisplayRenderer";
|
import "./image-display/ImageDisplayRenderer";
|
||||||
import "./divider-line/DividerLineRenderer";
|
import "./divider-line/DividerLineRenderer";
|
||||||
|
import "./accordion-basic/AccordionBasicRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
|
"slider-basic": () => import("@/lib/registry/components/slider-basic/SliderBasicConfigPanel"),
|
||||||
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
|
"image-display": () => import("@/lib/registry/components/image-display/ImageDisplayConfigPanel"),
|
||||||
"divider-line": () => import("@/lib/registry/components/divider-line/DividerLineConfigPanel"),
|
"divider-line": () => import("@/lib/registry/components/divider-line/DividerLineConfigPanel"),
|
||||||
|
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ConfigPanel 컴포넌트 캐시
|
// ConfigPanel 컴포넌트 캐시
|
||||||
|
|
@ -44,10 +45,10 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||||
try {
|
try {
|
||||||
console.log(`🔧 ConfigPanel 로드 중: ${componentId}`);
|
console.log(`🔧 ConfigPanel 로드 중: ${componentId}`);
|
||||||
const module = await importFn();
|
const module = await importFn();
|
||||||
|
|
||||||
// 모듈에서 ConfigPanel 컴포넌트 추출
|
// 모듈에서 ConfigPanel 컴포넌트 추출
|
||||||
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
|
const ConfigPanelComponent = module[`${toPascalCase(componentId)}ConfigPanel`] || module.default;
|
||||||
|
|
||||||
if (!ConfigPanelComponent) {
|
if (!ConfigPanelComponent) {
|
||||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
|
console.error(`컴포넌트 "${componentId}"의 ConfigPanel을 모듈에서 찾을 수 없습니다.`);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -56,7 +57,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||||
// 캐시에 저장
|
// 캐시에 저장
|
||||||
configPanelCache.set(componentId, ConfigPanelComponent);
|
configPanelCache.set(componentId, ConfigPanelComponent);
|
||||||
console.log(`✅ ConfigPanel 로드 완료: ${componentId}`);
|
console.log(`✅ ConfigPanel 로드 완료: ${componentId}`);
|
||||||
|
|
||||||
return ConfigPanelComponent;
|
return ConfigPanelComponent;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`컴포넌트 "${componentId}"의 ConfigPanel 로드 실패:`, error);
|
console.error(`컴포넌트 "${componentId}"의 ConfigPanel 로드 실패:`, error);
|
||||||
|
|
@ -84,9 +85,9 @@ export function getSupportedConfigPanelComponents(): string[] {
|
||||||
*/
|
*/
|
||||||
function toPascalCase(str: string): string {
|
function toPascalCase(str: string): string {
|
||||||
return str
|
return str
|
||||||
.split('-')
|
.split("-")
|
||||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
.join('');
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -96,12 +97,16 @@ export interface ComponentConfigPanelProps {
|
||||||
componentId: string;
|
componentId: string;
|
||||||
config: Record<string, any>;
|
config: Record<string, any>;
|
||||||
onChange: (config: Record<string, any>) => void;
|
onChange: (config: Record<string, any>) => void;
|
||||||
|
screenTableName?: string; // 화면에서 지정한 테이블명
|
||||||
|
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||||
componentId,
|
componentId,
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
|
screenTableName,
|
||||||
|
tableColumns,
|
||||||
}) => {
|
}) => {
|
||||||
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
@ -115,10 +120,10 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
||||||
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 시작`);
|
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 시작`);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const component = await getComponentConfigPanel(componentId);
|
const component = await getComponentConfigPanel(componentId);
|
||||||
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 결과:`, component);
|
console.log(`🔧 DynamicComponentConfigPanel: ${componentId} 로드 결과:`, component);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setConfigPanelComponent(() => component);
|
setConfigPanelComponent(() => component);
|
||||||
setLoading(false);
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|
@ -1188,6 +1189,37 @@
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,84 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 자동 생성 CLI 스크립트
|
* 화면 관리 시스템 컴포넌트 자동 생성 CLI 스크립트
|
||||||
* 레이아웃 시스템의 create-layout.js와 동일한 패턴으로 설계
|
* 실제 컴포넌트 구조에 맞게 설계
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
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 인자 파싱
|
// CLI 인자 파싱
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const componentName = args[0];
|
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("❌ 컴포넌트 이름을 제공해주세요.");
|
function validateInputs() {
|
||||||
console.log("사용법: node scripts/create-component.js <컴포넌트이름> [옵션]");
|
if (!componentName) {
|
||||||
console.log("예시: node scripts/create-component.js button-primary --category=ui --webType=button");
|
console.error("❌ 컴포넌트 이름을 제공해주세요.");
|
||||||
process.exit(1);
|
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 = {};
|
const options = {};
|
||||||
args.slice(1).forEach((arg) => {
|
args.slice(5).forEach((arg) => {
|
||||||
if (arg.startsWith("--")) {
|
if (arg.startsWith("--")) {
|
||||||
const [key, value] = arg.substring(2).split("=");
|
const [key, value] = arg.substring(2).split("=");
|
||||||
options[key] = value || true;
|
options[key] = value || true;
|
||||||
|
|
@ -47,10 +88,11 @@ args.slice(1).forEach((arg) => {
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
const config = {
|
const config = {
|
||||||
name: componentName,
|
name: componentName,
|
||||||
category: options.category || "ui",
|
displayName: displayName || componentName,
|
||||||
webType: options.webType || "text",
|
description: description || `${displayName || componentName} 컴포넌트`,
|
||||||
description: options.description || `${componentName} 컴포넌트입니다`,
|
category: category || "display",
|
||||||
author: options.author || "Developer",
|
webType: webType,
|
||||||
|
author: options.author || "개발팀",
|
||||||
size: options.size || "200x36",
|
size: options.size || "200x36",
|
||||||
tags: options.tags ? options.tags.split(",") : [],
|
tags: options.tags ? options.tags.split(",") : [],
|
||||||
};
|
};
|
||||||
|
|
@ -85,7 +127,7 @@ const names = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 1. index.ts 파일 생성
|
// 1. index.ts 파일 생성
|
||||||
function createIndexFile() {
|
function createIndexFile(componentDir, names, config, width, height) {
|
||||||
const content = `"use client";
|
const content = `"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -102,9 +144,9 @@ import { ${names.pascal}Config } from "./types";
|
||||||
*/
|
*/
|
||||||
export const ${names.pascal}Definition = createComponentDefinition({
|
export const ${names.pascal}Definition = createComponentDefinition({
|
||||||
id: "${names.kebab}",
|
id: "${names.kebab}",
|
||||||
name: "${config.koreanName}",
|
name: "${config.displayName}",
|
||||||
nameEng: "${names.pascal} Component",
|
nameEng: "${names.pascal} Component",
|
||||||
description: "${config.koreanDescription}",
|
description: "${config.description}",
|
||||||
category: ComponentCategory.${config.category.toUpperCase()},
|
category: ComponentCategory.${config.category.toUpperCase()},
|
||||||
webType: "${config.webType}",
|
webType: "${config.webType}",
|
||||||
component: ${names.pascal}Wrapper,
|
component: ${names.pascal}Wrapper,
|
||||||
|
|
@ -120,12 +162,10 @@ export const ${names.pascal}Definition = createComponentDefinition({
|
||||||
documentation: "https://docs.example.com/components/${names.kebab}",
|
documentation: "https://docs.example.com/components/${names.kebab}",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 컴포넌트는 ${names.pascal}Renderer에서 자동 등록됩니다
|
||||||
|
|
||||||
// 타입 내보내기
|
// 타입 내보내기
|
||||||
export type { ${names.pascal}Config } from "./types";
|
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);
|
fs.writeFileSync(path.join(componentDir, "index.ts"), content);
|
||||||
|
|
@ -133,7 +173,7 @@ export { ${names.pascal}Renderer } from "./${names.pascal}Renderer";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Component 파일 생성
|
// 2. Component 파일 생성
|
||||||
function createComponentFile() {
|
function createComponentFile(componentDir, names, config) {
|
||||||
const content = `"use client";
|
const content = `"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -152,12 +192,16 @@ export const ${names.pascal}Component: React.FC<${names.pascal}ComponentProps> =
|
||||||
component,
|
component,
|
||||||
isDesignMode = false,
|
isDesignMode = false,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
|
isInteractive = false,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
config,
|
config,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
screenId,
|
||||||
...props
|
...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) => {
|
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 파일 생성
|
// 3. Renderer 파일 생성
|
||||||
function createRendererFile() {
|
function createRendererFile(componentDir, names, config) {
|
||||||
const content = `"use client";
|
const content = `"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -272,11 +316,6 @@ export class ${names.pascal}Renderer extends AutoRegisteringComponentRenderer {
|
||||||
|
|
||||||
// 자동 등록 실행
|
// 자동 등록 실행
|
||||||
${names.pascal}Renderer.registerSelf();
|
${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);
|
fs.writeFileSync(path.join(componentDir, `${names.pascal}Renderer.tsx`), content);
|
||||||
|
|
@ -284,7 +323,7 @@ if (process.env.NODE_ENV === "development") {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Config Panel 파일 생성
|
// 4. Config Panel 파일 생성
|
||||||
function createConfigPanelFile() {
|
function createConfigPanelFile(componentDir, names, config) {
|
||||||
const content = `"use client";
|
const content = `"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
@ -314,7 +353,7 @@ export const ${names.pascal}ConfigPanel: React.FC<${names.pascal}ConfigPanelProp
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
${config.description.replace(" 컴포넌트입니다", "")} 설정
|
${config.description} 설정
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${getConfigPanelJSXByWebType(config.webType)}
|
${getConfigPanelJSXByWebType(config.webType)}
|
||||||
|
|
@ -356,7 +395,7 @@ export const ${names.pascal}ConfigPanel: React.FC<${names.pascal}ConfigPanelProp
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Types 파일 생성
|
// 5. Types 파일 생성
|
||||||
function createTypesFile() {
|
function createTypesFile(componentDir, names, config) {
|
||||||
const content = `"use client";
|
const content = `"use client";
|
||||||
|
|
||||||
import { ComponentConfig } from "@/types/component";
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
|
@ -408,56 +447,8 @@ export interface ${names.pascal}Props {
|
||||||
console.log("✅ types.ts 생성 완료");
|
console.log("✅ types.ts 생성 완료");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Config 파일 생성
|
// README 파일 생성 (config.ts는 제거)
|
||||||
function createConfigFile() {
|
function createReadmeFile(componentDir, names, config, width, height) {
|
||||||
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() {
|
|
||||||
const content = `# ${names.pascal} 컴포넌트
|
const content = `# ${names.pascal} 컴포넌트
|
||||||
|
|
||||||
${config.description}
|
${config.description}
|
||||||
|
|
@ -542,7 +533,7 @@ ${getConfigDocumentationByWebType(config.webType)}
|
||||||
## 개발자 정보
|
## 개발자 정보
|
||||||
|
|
||||||
- **생성일**: ${new Date().toISOString().split("T")[0]}
|
- **생성일**: ${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}/\`
|
- **경로**: \`lib/registry/components/${names.kebab}/\`
|
||||||
|
|
||||||
## 관련 문서
|
## 관련 문서
|
||||||
|
|
@ -555,6 +546,54 @@ ${getConfigDocumentationByWebType(config.webType)}
|
||||||
console.log("✅ README.md 생성 완료");
|
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) {
|
function getDefaultConfigByWebType(webType) {
|
||||||
switch (webType) {
|
switch (webType) {
|
||||||
|
|
@ -1005,7 +1044,6 @@ async function main() {
|
||||||
const [width, height] = config.size.split("x").map(Number);
|
const [width, height] = config.size.split("x").map(Number);
|
||||||
if (!width || !height) {
|
if (!width || !height) {
|
||||||
console.error("❌ 크기 형식이 올바르지 않습니다. 예: 200x36");
|
console.error("❌ 크기 형식이 올바르지 않습니다. 예: 200x36");
|
||||||
rl.close();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,57 +1053,15 @@ async function main() {
|
||||||
console.log("🚀 컴포넌트 생성 시작...");
|
console.log("🚀 컴포넌트 생성 시작...");
|
||||||
console.log(`📁 이름: ${names.camel}`);
|
console.log(`📁 이름: ${names.camel}`);
|
||||||
console.log(`🔖 ID: ${names.kebab}`);
|
console.log(`🔖 ID: ${names.kebab}`);
|
||||||
|
console.log(`📂 카테고리: ${config.category}`);
|
||||||
// 사용자로부터 한글 이름과 설명 입력받기
|
|
||||||
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.webType}`);
|
console.log(`🎯 웹타입: ${config.webType}`);
|
||||||
console.log(`🌐 한글이름: ${config.koreanName}`);
|
console.log(`🌐 표시이름: ${config.displayName}`);
|
||||||
console.log(`📝 설명: ${config.koreanDescription}`);
|
console.log(`📝 설명: ${config.description}`);
|
||||||
console.log(`📏 크기: ${width}x${height}`);
|
console.log(`📏 크기: ${width}x${height}`);
|
||||||
|
|
||||||
// 컴포넌트 디렉토리 생성
|
// 컴포넌트 디렉토리 생성
|
||||||
if (fs.existsSync(componentDir)) {
|
if (fs.existsSync(componentDir)) {
|
||||||
console.error(`❌ 컴포넌트 "${names.kebab}"가 이미 존재합니다.`);
|
console.error(`❌ 컴포넌트 "${names.kebab}"가 이미 존재합니다.`);
|
||||||
rl.close();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1073,35 +1069,33 @@ async function main() {
|
||||||
console.log(`📁 디렉토리 생성: ${componentDir}`);
|
console.log(`📁 디렉토리 생성: ${componentDir}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 파일들 생성
|
// 파일들 생성 (파라미터 전달하여 호출)
|
||||||
createIndexFile();
|
createIndexFile(componentDir, names, config, width, height);
|
||||||
createComponentFile();
|
createComponentFile(componentDir, names, config);
|
||||||
createRendererFile();
|
createRendererFile(componentDir, names, config);
|
||||||
createConfigPanelFile();
|
createConfigPanelFile(componentDir, names, config);
|
||||||
createTypesFile();
|
createTypesFile(componentDir, names, config);
|
||||||
createConfigFile();
|
createReadmeFile(componentDir, names, config, width, height);
|
||||||
createReadmeFile();
|
|
||||||
|
// index.ts 파일에 자동으로 import 추가
|
||||||
|
addToRegistryIndex(names);
|
||||||
|
|
||||||
console.log("\n🎉 컴포넌트 생성 완료!");
|
console.log("\n🎉 컴포넌트 생성 완료!");
|
||||||
console.log(`📁 경로: ${componentDir}`);
|
console.log(`📁 경로: ${componentDir}`);
|
||||||
console.log(`🔗 다음 단계:`);
|
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(` 2. 브라우저에서 자동 등록 확인`);
|
||||||
console.log(` 3. 컴포넌트 패널에서 테스트`);
|
console.log(` 3. 컴포넌트 패널에서 테스트`);
|
||||||
console.log(`\n🛠️ 개발자 도구 사용법:`);
|
console.log(`\n🛠️ 개발자 도구 사용법:`);
|
||||||
console.log(` __COMPONENT_REGISTRY__.get("${names.kebab}")`);
|
console.log(` __COMPONENT_REGISTRY__.get("${names.kebab}")`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 컴포넌트 생성 중 오류 발생:", error);
|
console.error("❌ 컴포넌트 생성 중 오류 발생:", error);
|
||||||
rl.close();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
rl.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메인 함수 실행
|
// 메인 함수 실행
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("❌ 실행 중 오류 발생:", error);
|
console.error("❌ 실행 중 오류 발생:", error);
|
||||||
rl.close();
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue