Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into commonCodeMng

This commit is contained in:
hyeonsu 2025-09-04 10:00:13 +09:00
commit f3da984a18
43 changed files with 18493 additions and 2360 deletions

View File

@ -108,6 +108,41 @@ export const deleteScreen = async (
}
};
// 화면 복사
export const copyScreen = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { screenName, screenCode, description } = req.body;
const { companyCode, userId } = req.user as any;
const copiedScreen = await screenManagementService.copyScreen(
parseInt(id),
{
screenName,
screenCode,
description,
companyCode,
createdBy: userId,
}
);
res.json({
success: true,
data: copiedScreen,
message: "화면이 복사되었습니다.",
});
} catch (error: any) {
console.error("화면 복사 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 복사에 실패했습니다.",
});
}
};
// 테이블 목록 조회 (모든 테이블)
export const getTables = async (req: AuthenticatedRequest, res: Response) => {
try {

View File

@ -439,3 +439,307 @@ export async function updateColumnWebType(
res.status(500).json(response);
}
}
/**
* ( + )
*/
export async function getTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const {
page = 1,
size = 10,
search = {},
sortBy,
sortOrder = "asc",
} = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
logger.info(`페이징: page=${page}, size=${size}`);
logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
size: parseInt(size),
search,
sortBy,
sortOrder,
});
logger.info(
`테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}`
);
const response: ApiResponse<any> = {
success: true,
message: "테이블 데이터를 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_DATA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function addTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const data = req.body;
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!data || Object.keys(data).length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "추가할 데이터가 필요합니다.",
error: {
code: "MISSING_DATA",
details: "요청 본문에 데이터가 없습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
const response: ApiResponse<null> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
};
res.status(201).json(response);
} catch (error) {
logger.error("테이블 데이터 추가 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 추가 중 오류가 발생했습니다.",
error: {
code: "TABLE_ADD_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function editTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { originalData, updatedData } = req.body;
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
logger.info(`원본 데이터:`, originalData);
logger.info(`수정할 데이터:`, updatedData);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "INVALID_TABLE_NAME",
details: "테이블명이 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!originalData || !updatedData) {
const response: ApiResponse<null> = {
success: false,
message: "원본 데이터와 수정할 데이터가 모두 필요합니다.",
error: {
code: "INVALID_DATA",
details: "originalData와 updatedData가 모두 제공되어야 합니다.",
},
};
res.status(400).json(response);
return;
}
if (Object.keys(updatedData).length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "수정할 데이터가 없습니다.",
error: {
code: "INVALID_DATA",
details: "수정할 데이터가 비어있습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 수정
await tableManagementService.editTableData(
tableName,
originalData,
updatedData
);
logger.info(`테이블 데이터 수정 완료: ${tableName}`);
const response: ApiResponse<null> = {
success: true,
message: "테이블 데이터를 성공적으로 수정했습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 수정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 수정 중 오류가 발생했습니다.",
error: {
code: "TABLE_EDIT_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
*
*/
export async function deleteTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const data = req.body;
logger.info(`=== 테이블 데이터 삭제 시작: ${tableName} ===`);
logger.info(`삭제할 데이터:`, data);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
const response: ApiResponse<null> = {
success: false,
message: "삭제할 데이터가 필요합니다.",
error: {
code: "MISSING_DATA",
details: "요청 본문에 삭제할 데이터가 없습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 삭제
const deletedCount = await tableManagementService.deleteTableData(
tableName,
data
);
logger.info(
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
);
const response: ApiResponse<{ deletedCount: number }> = {
success: true,
message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`,
data: { deletedCount },
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 삭제 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 삭제 중 오류가 발생했습니다.",
error: {
code: "TABLE_DELETE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}

View File

@ -6,6 +6,7 @@ import {
createScreen,
updateScreen,
deleteScreen,
copyScreen,
getTables,
getTableInfo,
getTableColumns,
@ -28,6 +29,7 @@ router.get("/screens/:id", getScreen);
router.post("/screens", createScreen);
router.put("/screens/:id", updateScreen);
router.delete("/screens/:id", deleteScreen);
router.post("/screens/:id/copy", copyScreen);
// 화면 코드 자동 생성
router.get("/generate-screen-code/:companyCode", generateScreenCode);

View File

@ -8,6 +8,10 @@ import {
getTableLabels,
getColumnLabels,
updateColumnWebType,
getTableData,
addTableData,
editTableData,
deleteTableData,
} from "../controllers/tableManagementController";
const router = express.Router();
@ -63,4 +67,28 @@ router.put(
updateColumnWebType
);
/**
* ( + )
* POST /api/table-management/tables/:tableName/data
*/
router.post("/tables/:tableName/data", getTableData);
/**
*
* POST /api/table-management/tables/:tableName/add
*/
router.post("/tables/:tableName/add", addTableData);
/**
*
* PUT /api/table-management/tables/:tableName/edit
*/
router.put("/tables/:tableName/edit", editTableData);
/**
*
* DELETE /api/table-management/tables/:tableName/delete
*/
router.delete("/tables/:tableName/delete", deleteTableData);
export default router;

View File

@ -14,8 +14,18 @@ import {
WebType,
WidgetData,
} from "../types/screen";
import { generateId } from "../utils/generateId";
// 화면 복사 요청 인터페이스
interface CopyScreenRequest {
screenName: string;
screenCode: string;
description?: string;
companyCode: string;
createdBy: string;
}
// 백엔드에서 사용할 테이블 정보 타입
interface TableInfo {
tableName: string;
@ -968,6 +978,120 @@ export class ScreenManagementService {
return `${companyCode}_${paddedNumber}`;
}
/**
* ( + )
*/
async copyScreen(
sourceScreenId: number,
copyData: CopyScreenRequest
): Promise<ScreenDefinition> {
// 트랜잭션으로 처리
return await prisma.$transaction(async (tx) => {
// 1. 원본 화면 정보 조회
const sourceScreen = await tx.screen_definitions.findFirst({
where: {
screen_id: sourceScreenId,
company_code: copyData.companyCode,
},
});
if (!sourceScreen) {
throw new Error("복사할 화면을 찾을 수 없습니다.");
}
// 2. 화면 코드 중복 체크
const existingScreen = await tx.screen_definitions.findFirst({
where: {
screen_code: copyData.screenCode,
company_code: copyData.companyCode,
},
});
if (existingScreen) {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 3. 새 화면 생성
const newScreen = await tx.screen_definitions.create({
data: {
screen_code: copyData.screenCode,
screen_name: copyData.screenName,
description: copyData.description || sourceScreen.description,
company_code: copyData.companyCode,
table_name: sourceScreen.table_name,
is_active: sourceScreen.is_active,
created_by: copyData.createdBy,
created_date: new Date(),
updated_by: copyData.createdBy,
updated_date: new Date(),
},
});
// 4. 원본 화면의 레이아웃 정보 조회
const sourceLayouts = await tx.screen_layouts.findMany({
where: {
screen_id: sourceScreenId,
},
orderBy: { display_order: "asc" },
});
// 5. 레이아웃이 있다면 복사
if (sourceLayouts.length > 0) {
try {
// ID 매핑 맵 생성
const idMapping: { [oldId: string]: string } = {};
// 새로운 컴포넌트 ID 미리 생성
sourceLayouts.forEach((layout) => {
idMapping[layout.component_id] = generateId();
});
// 각 레이아웃 컴포넌트 복사
for (const sourceLayout of sourceLayouts) {
const newComponentId = idMapping[sourceLayout.component_id];
const newParentId = sourceLayout.parent_id
? idMapping[sourceLayout.parent_id]
: null;
await tx.screen_layouts.create({
data: {
screen_id: newScreen.screen_id,
component_type: sourceLayout.component_type,
component_id: newComponentId,
parent_id: newParentId,
position_x: sourceLayout.position_x,
position_y: sourceLayout.position_y,
width: sourceLayout.width,
height: sourceLayout.height,
properties: sourceLayout.properties as any,
display_order: sourceLayout.display_order,
created_date: new Date(),
},
});
}
} catch (error) {
console.error("레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
}
}
// 6. 생성된 화면 정보 반환
return {
screenId: newScreen.screen_id,
screenCode: newScreen.screen_code,
screenName: newScreen.screen_name,
description: newScreen.description || "",
companyCode: newScreen.company_code,
tableName: newScreen.table_name,
isActive: newScreen.is_active,
createdBy: newScreen.created_by || undefined,
createdDate: newScreen.created_date,
updatedBy: newScreen.updated_by || undefined,
updatedDate: newScreen.updated_date,
};
});
}
}
// 서비스 인스턴스 export

View File

@ -514,4 +514,533 @@ export class TableManagementService {
return {};
}
}
/**
* ( + )
*/
async getTableData(
tableName: string,
options: {
page: number;
size: number;
search?: Record<string, any>;
sortBy?: string;
sortOrder?: string;
}
): Promise<{
data: any[];
total: number;
page: number;
size: number;
totalPages: number;
}> {
try {
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options);
// WHERE 조건 구성
let whereConditions: string[] = [];
let searchValues: any[] = [];
let paramIndex = 1;
if (search && Object.keys(search).length > 0) {
for (const [column, value] of Object.entries(search)) {
if (value !== null && value !== undefined && value !== "") {
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
if (typeof value === "string") {
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
searchValues.push(`%${value}%`);
} else {
whereConditions.push(`${safeColumn} = $${paramIndex}`);
searchValues.push(value);
}
paramIndex++;
}
}
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// ORDER BY 조건 구성
let orderClause = "";
if (sortBy) {
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, "");
const safeSortOrder =
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
}
// 안전한 테이블명 검증
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
// 전체 개수 조회
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`;
const countResult = await prisma.$queryRawUnsafe<any[]>(
countQuery,
...searchValues
);
const total = parseInt(countResult[0].count);
// 데이터 조회
const dataQuery = `
SELECT * FROM ${safeTableName}
${whereClause}
${orderClause}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const data = await prisma.$queryRawUnsafe<any[]>(
dataQuery,
...searchValues,
size,
offset
);
const totalPages = Math.ceil(total / size);
logger.info(
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
);
return {
data,
total,
page,
size,
totalPages,
};
} catch (error) {
logger.error(`테이블 데이터 조회 오류: ${tableName}`, error);
throw error;
}
}
/**
* (JWT )
*/
private getCurrentUserFromRequest(req?: any): {
userId: string;
userName: string;
} {
// 실제 프로젝트에서는 req 객체에서 JWT 토큰을 파싱하여 사용자 정보를 가져올 수 있습니다
// 현재는 기본값을 반환
return {
userId: "system",
userName: "시스템 사용자",
};
}
/**
* PostgreSQL
*/
private convertValueForPostgreSQL(value: any, dataType: string): any {
if (value === null || value === undefined || value === "") {
return null;
}
const lowerDataType = dataType.toLowerCase();
// 날짜/시간 타입 처리
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
// YYYY-MM-DDTHH:mm:ss 형식을 PostgreSQL timestamp로 변환
if (typeof value === "string") {
try {
const date = new Date(value);
return date.toISOString();
} catch {
return null;
}
}
return value;
}
// 날짜 타입 처리
if (lowerDataType.includes("date")) {
if (typeof value === "string") {
try {
// YYYY-MM-DD 형식 유지
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return value;
}
const date = new Date(value);
return date.toISOString().split("T")[0];
} catch {
return null;
}
}
return value;
}
// 시간 타입 처리
if (lowerDataType.includes("time")) {
if (typeof value === "string") {
// HH:mm:ss 형식 유지
if (/^\d{2}:\d{2}:\d{2}$/.test(value)) {
return value;
}
}
return value;
}
// 숫자 타입 처리
if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return parseInt(value) || null;
}
if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal") ||
lowerDataType.includes("real") ||
lowerDataType.includes("double")
) {
return parseFloat(value) || null;
}
// 불린 타입 처리
if (lowerDataType.includes("boolean")) {
if (typeof value === "string") {
return value.toLowerCase() === "true" || value === "1";
}
return Boolean(value);
}
// 기본적으로 문자열로 처리
return value;
}
/**
*
*/
async addTableData(
tableName: string,
data: Record<string, any>
): Promise<void> {
try {
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
// 테이블의 컬럼 정보 조회
const columnInfoQuery = `
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position
`;
const columnInfoResult = (await prisma.$queryRawUnsafe(
columnInfoQuery,
tableName
)) as any[];
const columnTypeMap = new Map<string, string>();
columnInfoResult.forEach((col: any) => {
columnTypeMap.set(col.column_name, col.data_type);
});
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
// 컬럼명과 값을 분리하고 타입에 맞게 변환
const columns = Object.keys(data);
const values = Object.values(data).map((value, index) => {
const columnName = columns[index];
const dataType = columnTypeMap.get(columnName) || "text";
const convertedValue = this.convertValueForPostgreSQL(value, dataType);
logger.info(
`컬럼 "${columnName}" (${dataType}): "${value}" → "${convertedValue}"`
);
return convertedValue;
});
// 동적 INSERT 쿼리 생성 (타입 캐스팅 포함)
const placeholders = columns
.map((col, index) => {
const dataType = columnTypeMap.get(col) || "text";
const lowerDataType = dataType.toLowerCase();
// PostgreSQL에서 직접 타입 캐스팅
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
return `$${index + 1}::timestamp`;
} else if (lowerDataType.includes("date")) {
return `$${index + 1}::date`;
} else if (lowerDataType.includes("time")) {
return `$${index + 1}::time`;
} else if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return `$${index + 1}::integer`;
} else if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal")
) {
return `$${index + 1}::numeric`;
} else if (lowerDataType.includes("boolean")) {
return `$${index + 1}::boolean`;
}
return `$${index + 1}`;
})
.join(", ");
const columnNames = columns.map((col) => `"${col}"`).join(", ");
const query = `
INSERT INTO "${tableName}" (${columnNames})
VALUES (${placeholders})
`;
logger.info(`실행할 쿼리: ${query}`);
logger.info(`쿼리 파라미터:`, values);
await prisma.$queryRawUnsafe(query, ...values);
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
} catch (error) {
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
throw error;
}
}
/**
*
*/
async editTableData(
tableName: string,
originalData: Record<string, any>,
updatedData: Record<string, any>
): Promise<void> {
try {
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
logger.info(`원본 데이터:`, originalData);
logger.info(`수정할 데이터:`, updatedData);
// 테이블의 컬럼 정보 조회 (PRIMARY KEY 찾기용)
const columnInfoQuery = `
SELECT c.column_name, c.data_type, c.is_nullable,
CASE WHEN tc.constraint_type = 'PRIMARY KEY' THEN 'YES' ELSE 'NO' END as is_primary_key
FROM information_schema.columns c
LEFT JOIN information_schema.key_column_usage kcu ON c.column_name = kcu.column_name AND c.table_name = kcu.table_name
LEFT JOIN information_schema.table_constraints tc ON kcu.constraint_name = tc.constraint_name AND tc.table_name = c.table_name
WHERE c.table_name = $1
ORDER BY c.ordinal_position
`;
const columnInfoResult = (await prisma.$queryRawUnsafe(
columnInfoQuery,
tableName
)) as any[];
const columnTypeMap = new Map<string, string>();
const primaryKeys: string[] = [];
columnInfoResult.forEach((col: any) => {
columnTypeMap.set(col.column_name, col.data_type);
if (col.is_primary_key === "YES") {
primaryKeys.push(col.column_name);
}
});
logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap));
logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys);
// SET 절 생성 (수정할 데이터) - 먼저 생성
const setConditions: string[] = [];
const setValues: any[] = [];
let paramIndex = 1;
Object.keys(updatedData).forEach((column) => {
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
setValues.push(
this.convertValueForPostgreSQL(updatedData[column], dataType)
);
paramIndex++;
});
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = [];
let whereValues: any[] = [];
if (primaryKeys.length > 0) {
// PRIMARY KEY로 WHERE 조건 생성
primaryKeys.forEach((pkColumn) => {
if (originalData[pkColumn] !== undefined) {
const dataType = columnTypeMap.get(pkColumn) || "text";
whereConditions.push(
`"${pkColumn}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
whereValues.push(
this.convertValueForPostgreSQL(originalData[pkColumn], dataType)
);
paramIndex++;
}
});
} else {
// PRIMARY KEY가 없으면 모든 원본 데이터로 WHERE 조건 생성
Object.keys(originalData).forEach((column) => {
const dataType = columnTypeMap.get(column) || "text";
whereConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
);
whereValues.push(
this.convertValueForPostgreSQL(originalData[column], dataType)
);
paramIndex++;
});
}
// UPDATE 쿼리 생성
const query = `
UPDATE "${tableName}"
SET ${setConditions.join(", ")}
WHERE ${whereConditions.join(" AND ")}
`;
const allValues = [...setValues, ...whereValues];
logger.info(`실행할 UPDATE 쿼리: ${query}`);
logger.info(`쿼리 파라미터:`, allValues);
const result = await prisma.$queryRawUnsafe(query, ...allValues);
logger.info(`테이블 데이터 수정 완료: ${tableName}`, result);
} catch (error) {
logger.error(`테이블 데이터 수정 오류: ${tableName}`, error);
throw error;
}
}
/**
* PostgreSQL
*/
private getPostgreSQLType(dataType: string): string {
const lowerDataType = dataType.toLowerCase();
if (
lowerDataType.includes("timestamp") ||
lowerDataType.includes("datetime")
) {
return "timestamp";
} else if (lowerDataType.includes("date")) {
return "date";
} else if (lowerDataType.includes("time")) {
return "time";
} else if (
lowerDataType.includes("integer") ||
lowerDataType.includes("bigint") ||
lowerDataType.includes("serial")
) {
return "integer";
} else if (
lowerDataType.includes("numeric") ||
lowerDataType.includes("decimal")
) {
return "numeric";
} else if (lowerDataType.includes("boolean")) {
return "boolean";
}
return "text"; // 기본값
}
/**
*
*/
async deleteTableData(
tableName: string,
dataToDelete: Record<string, any>[]
): Promise<number> {
try {
logger.info(`테이블 데이터 삭제: ${tableName}`, dataToDelete);
if (!Array.isArray(dataToDelete) || dataToDelete.length === 0) {
throw new Error("삭제할 데이터가 없습니다.");
}
let deletedCount = 0;
// 테이블의 기본 키 컬럼 찾기 (정확한 식별을 위해)
const primaryKeyQuery = `
SELECT column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = $1
AND tc.constraint_type = 'PRIMARY KEY'
ORDER BY kcu.ordinal_position
`;
const primaryKeys = await prisma.$queryRawUnsafe<
{ column_name: string }[]
>(primaryKeyQuery, tableName);
if (primaryKeys.length === 0) {
// 기본 키가 없는 경우, 모든 컬럼으로 삭제 조건 생성
logger.warn(
`테이블 ${tableName}에 기본 키가 없습니다. 모든 컬럼으로 삭제 조건을 생성합니다.`
);
for (const rowData of dataToDelete) {
const conditions = Object.keys(rowData)
.map((key, index) => `"${key}" = $${index + 1}`)
.join(" AND ");
const values = Object.values(rowData);
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await prisma.$queryRawUnsafe(deleteQuery, ...values);
deletedCount += Number(result);
}
} else {
// 기본 키를 사용한 삭제
const primaryKeyNames = primaryKeys.map((pk) => pk.column_name);
for (const rowData of dataToDelete) {
const conditions = primaryKeyNames
.map((key, index) => `"${key}" = $${index + 1}`)
.join(" AND ");
const values = primaryKeyNames.map((key) => rowData[key]);
// null 값이 있는 경우 스킵
if (values.some((val) => val === null || val === undefined)) {
logger.warn(`기본 키 값이 null인 행을 스킵합니다:`, rowData);
continue;
}
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${conditions}`;
const result = await prisma.$queryRawUnsafe(deleteQuery, ...values);
deletedCount += Number(result);
}
}
logger.info(
`테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제`
);
return deletedCount;
} catch (error) {
logger.error(`테이블 데이터 삭제 오류: ${tableName}`, error);
throw error;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, ArrowLeft, ArrowRight, CheckCircle, Circle } from "lucide-react";
import { Plus, ArrowLeft, ArrowRight, Circle } from "lucide-react";
import ScreenList from "@/components/screen/ScreenList";
import ScreenDesigner from "@/components/screen/ScreenDesigner";
import TemplateManager from "@/components/screen/TemplateManager";
@ -62,69 +62,11 @@ export default function ScreenManagementPage() {
}
};
// 단계별 진행 상태 확인
const isStepCompleted = (step: Step) => {
return stepHistory.includes(step);
};
// 현재 단계가 마지막 단계인지 확인
const isLastStep = currentStep === "template";
return (
<div className="flex h-full w-full flex-col">
{/* 페이지 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<div className="text-sm text-gray-500">{stepConfig[currentStep].description}</div>
</div>
{/* 단계별 진행 표시 */}
<div className="border-b bg-white p-4">
<div className="flex items-center justify-between">
{Object.entries(stepConfig).map(([step, config], index) => (
<div key={step} className="flex items-center">
<div className="flex flex-col items-center">
<button
onClick={() => goToStep(step as Step)}
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-all ${
currentStep === step
? "border-blue-600 bg-blue-600 text-white"
: isStepCompleted(step as Step)
? "border-green-500 bg-green-500 text-white"
: "border-gray-300 bg-white text-gray-400"
} ${isStepCompleted(step as Step) ? "cursor-pointer hover:bg-green-600" : ""}`}
>
{isStepCompleted(step as Step) && currentStep !== step ? (
<CheckCircle className="h-6 w-6" />
) : (
<span className="text-lg">{config.icon}</span>
)}
</button>
<div className="mt-2 text-center">
<div
className={`text-sm font-medium ${
currentStep === step
? "text-blue-600"
: isStepCompleted(step as Step)
? "text-green-600"
: "text-gray-500"
}`}
>
{config.title}
</div>
</div>
</div>
{index < Object.keys(stepConfig).length - 1 && (
<div className={`mx-4 h-0.5 w-16 ${isStepCompleted(step as Step) ? "bg-green-500" : "bg-gray-300"}`} />
)}
</div>
))}
</div>
</div>
{/* 단계별 내용 */}
<div className="flex-1 overflow-hidden">
{/* 화면 목록 단계 */}

View File

@ -2,10 +2,8 @@
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition, LayoutData } from "@/types/screen";
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
@ -21,7 +19,6 @@ export default function ScreenViewPage() {
const [layout, setLayout] = useState<LayoutData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
useEffect(() => {
@ -59,30 +56,9 @@ export default function ScreenViewPage() {
}
}, [screenId]);
// 폼 데이터 저장 함수
const handleSaveData = async () => {
if (!screen) return;
try {
setSaving(true);
console.log("저장할 데이터:", formData);
console.log("화면 정보:", screen);
// 여기에 실제 데이터 저장 API 호출을 추가할 수 있습니다
// await saveFormData(screen.tableName, formData);
toast.success("데이터가 성공적으로 저장되었습니다.");
} catch (error) {
console.error("데이터 저장 실패:", error);
toast.error("데이터 저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex h-screen w-screen items-center justify-center bg-white">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
<p className="mt-2 text-gray-600"> ...</p>
@ -93,7 +69,7 @@ export default function ScreenViewPage() {
if (error || !screen) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex h-screen w-screen items-center justify-center bg-white">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<span className="text-2xl"></span>
@ -101,7 +77,6 @@ export default function ScreenViewPage() {
<h2 className="mb-2 text-xl font-semibold text-gray-900"> </h2>
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
<Button onClick={() => router.back()} variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
</div>
@ -110,167 +85,108 @@ export default function ScreenViewPage() {
}
return (
<div className="flex h-full w-full flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between border-b bg-white p-4 shadow-sm">
<div className="flex items-center space-x-4">
<Button variant="outline" size="sm" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold text-gray-900">{screen.screenName}</h1>
<div className="mt-1 flex items-center space-x-2">
<Badge variant="outline" className="font-mono text-xs">
{screen.screenCode}
</Badge>
<Badge variant="secondary" className="text-xs">
{screen.tableName}
</Badge>
<Badge variant={screen.isActive === "Y" ? "default" : "secondary"} className="text-xs">
{screen.isActive === "Y" ? "활성" : "비활성"}
</Badge>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">: {screen.createdDate.toLocaleDateString()}</span>
</div>
</div>
<div className="h-screen w-screen bg-white">
{layout && layout.components.length > 0 ? (
// 캔버스 컴포넌트들만 표시 - 전체 화면 사용
<div className="relative h-full w-full">
{layout.components
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
.map((component) => {
// 그룹 컴포넌트인 경우 특별 처리
if (component.type === "group") {
const groupChildren = layout.components.filter((child) => child.parentId === component.id);
{/* 메인 컨텐츠 영역 */}
<div className="flex-1 overflow-hidden">
{layout && layout.components.length > 0 ? (
<div className="h-full p-6">
<Card className="h-full">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{screen.screenName}</span>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700"
onClick={handleSaveData}
disabled={saving}
return (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width}px`,
height: `${component.size.height}px`,
zIndex: component.position.z || 1,
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
border: (component as any).border || "2px dashed #3b82f6",
borderRadius: (component as any).borderRadius || "8px",
padding: "16px",
}}
>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
{/* 그룹 제목 */}
{(component as any).title && (
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
)}
</Button>
</CardTitle>
{screen.description && <p className="text-sm text-gray-600">{screen.description}</p>}
</CardHeader>
<CardContent className="h-[calc(100%-5rem)] overflow-auto">
{/* 실제 화면 렌더링 영역 */}
<div className="relative h-full w-full bg-white">
{layout.components
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
.map((component) => {
// 그룹 컴포넌트인 경우 특별 처리
if (component.type === "group") {
const groupChildren = layout.components.filter((child) => child.parentId === component.id);
return (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width}px`,
height: `${component.size.height}px`,
zIndex: component.position.z || 1,
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
border: (component as any).border || "2px dashed #3b82f6",
borderRadius: (component as any).borderRadius || "8px",
padding: "16px",
}}
>
{/* 그룹 제목 */}
{(component as any).title && (
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
)}
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
{groupChildren.map((child) => (
<div
key={child.id}
style={{
position: "absolute",
left: `${child.position.x}px`,
top: `${child.position.y}px`,
width: `${child.size.width}px`,
height: `${child.size.height}px`,
zIndex: child.position.z || 1,
}}
>
<InteractiveScreenViewer
component={child}
allComponents={layout.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
</div>
))}
</div>
);
}
// 일반 컴포넌트 렌더링
return (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width}px`,
height: `${component.size.height}px`,
zIndex: component.position.z || 1,
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
{groupChildren.map((child) => (
<div
key={child.id}
style={{
position: "absolute",
left: `${child.position.x}px`,
top: `${child.position.y}px`,
width: `${child.size.width}px`,
height: `${child.size.height}px`,
zIndex: child.position.z || 1,
}}
>
<InteractiveScreenViewer
component={child}
allComponents={layout.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
>
<InteractiveScreenViewer
component={component}
allComponents={layout.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
</div>
);
})}
/>
</div>
))}
</div>
);
}
// 일반 컴포넌트 렌더링
return (
<div
key={component.id}
style={{
position: "absolute",
left: `${component.position.x}px`,
top: `${component.position.y}px`,
width: `${component.size.width}px`,
height: `${component.size.height}px`,
zIndex: component.position.z || 1,
}}
>
<InteractiveScreenViewer
component={component}
allComponents={layout.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}}
/>
</div>
</CardContent>
</Card>
</div>
) : (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<span className="text-2xl">📄</span>
</div>
<h2 className="mb-2 text-xl font-semibold text-gray-900"> </h2>
<p className="text-gray-600"> .</p>
);
})}
</div>
) : (
// 빈 화면일 때도 깔끔하게 표시
<div className="flex h-full items-center justify-center bg-gray-50">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
<span className="text-2xl">📄</span>
</div>
<h2 className="mb-2 text-xl font-semibold text-gray-900"> </h2>
<p className="text-gray-600"> .</p>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,192 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2, Copy } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
interface CopyScreenModalProps {
isOpen: boolean;
onClose: () => void;
sourceScreen: ScreenDefinition | null;
onCopySuccess: () => void;
}
export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopySuccess }: CopyScreenModalProps) {
const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState("");
const [description, setDescription] = useState("");
const [isCopying, setIsCopying] = useState(false);
// 모달이 열릴 때 초기값 설정
useEffect(() => {
if (isOpen && sourceScreen) {
setScreenName(`${sourceScreen.screenName} (복사본)`);
setDescription(sourceScreen.description || "");
// 화면 코드 자동 생성
generateNewScreenCode();
}
}, [isOpen, sourceScreen]);
// 새로운 화면 코드 자동 생성
const generateNewScreenCode = async () => {
if (!sourceScreen?.companyCode) return;
try {
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode);
setScreenCode(newCode);
} catch (error) {
console.error("화면 코드 생성 실패:", error);
toast.error("화면 코드 생성에 실패했습니다.");
}
};
// 화면 복사 실행
const handleCopy = async () => {
if (!sourceScreen) return;
// 입력값 검증
if (!screenName.trim()) {
toast.error("화면명을 입력해주세요.");
return;
}
if (!screenCode.trim()) {
toast.error("화면 코드 생성에 실패했습니다. 잠시 후 다시 시도해주세요.");
return;
}
try {
setIsCopying(true);
// 화면 복사 API 호출
await screenApi.copyScreen(sourceScreen.screenId, {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
description: description.trim(),
});
toast.success("화면이 성공적으로 복사되었습니다.");
onCopySuccess();
handleClose();
} catch (error: any) {
console.error("화면 복사 실패:", error);
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
toast.error(errorMessage);
} finally {
setIsCopying(false);
}
};
// 모달 닫기
const handleClose = () => {
setScreenName("");
setScreenCode("");
setDescription("");
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" />
</DialogTitle>
<DialogDescription>
{sourceScreen?.screenName} . .
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 원본 화면 정보 */}
<div className="rounded-md bg-gray-50 p-3">
<h4 className="mb-2 text-sm font-medium text-gray-700"> </h4>
<div className="space-y-1 text-sm text-gray-600">
<div>
<span className="font-medium">:</span> {sourceScreen?.screenName}
</div>
<div>
<span className="font-medium">:</span> {sourceScreen?.screenCode}
</div>
<div>
<span className="font-medium">:</span> {sourceScreen?.companyCode}
</div>
</div>
</div>
{/* 새 화면 정보 입력 */}
<div className="space-y-3">
<div>
<Label htmlFor="screenName"> *</Label>
<Input
id="screenName"
value={screenName}
onChange={(e) => setScreenName(e.target.value)}
placeholder="복사될 화면의 이름을 입력하세요"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="screenCode"> ()</Label>
<Input
id="screenCode"
value={screenCode}
readOnly
className="mt-1 bg-gray-50"
placeholder="화면 코드가 자동으로 생성됩니다"
/>
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="화면 설명을 입력하세요 (선택사항)"
className="mt-1"
rows={3}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,197 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Menu,
Database,
Settings,
Palette,
Grid3X3,
Save,
Undo,
Redo,
Play,
ArrowLeft,
Cog,
Layout,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface DesignerToolbarProps {
screenName?: string;
tableName?: string;
onBack: () => void;
onSave: () => void;
onUndo: () => void;
onRedo: () => void;
onPreview: () => void;
onTogglePanel: (panelId: string) => void;
panelStates: Record<string, { isOpen: boolean }>;
canUndo: boolean;
canRedo: boolean;
isSaving?: boolean;
}
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
screenName,
tableName,
onBack,
onSave,
onUndo,
onRedo,
onPreview,
onTogglePanel,
panelStates,
canUndo,
canRedo,
isSaving = false,
}) => {
return (
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
{/* 좌측: 네비게이션 및 화면 정보 */}
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center space-x-2">
<ArrowLeft className="h-4 w-4" />
<span></span>
</Button>
<div className="h-6 w-px bg-gray-300" />
<div className="flex items-center space-x-3">
<Menu className="h-5 w-5 text-gray-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">{screenName || "화면 설계"}</h1>
{tableName && (
<div className="mt-0.5 flex items-center space-x-1">
<Database className="h-3 w-3 text-gray-500" />
<span className="font-mono text-xs text-gray-500">{tableName}</span>
</div>
)}
</div>
</div>
</div>
{/* 중앙: 패널 토글 버튼들 */}
<div className="flex items-center space-x-2">
<Button
variant={panelStates.tables?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("tables")}
className={cn("flex items-center space-x-2", panelStates.tables?.isOpen && "bg-blue-600 text-white")}
>
<Database className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
T
</Badge>
</Button>
<Button
variant={panelStates.templates?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("templates")}
className={cn("flex items-center space-x-2", panelStates.templates?.isOpen && "bg-blue-600 text-white")}
>
<Layout className="h-4 w-4" />
<span>릿</span>
<Badge variant="secondary" className="ml-1 text-xs">
M
</Badge>
</Button>
<Button
variant={panelStates.properties?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("properties")}
className={cn("flex items-center space-x-2", panelStates.properties?.isOpen && "bg-blue-600 text-white")}
>
<Settings className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
P
</Badge>
</Button>
<Button
variant={panelStates.styles?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("styles")}
className={cn("flex items-center space-x-2", panelStates.styles?.isOpen && "bg-blue-600 text-white")}
>
<Palette className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
S
</Badge>
</Button>
<Button
variant={panelStates.grid?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("grid")}
className={cn("flex items-center space-x-2", panelStates.grid?.isOpen && "bg-blue-600 text-white")}
>
<Grid3X3 className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
R
</Badge>
</Button>
<Button
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
size="sm"
onClick={() => onTogglePanel("detailSettings")}
className={cn("flex items-center space-x-2", panelStates.detailSettings?.isOpen && "bg-blue-600 text-white")}
>
<Cog className="h-4 w-4" />
<span></span>
<Badge variant="secondary" className="ml-1 text-xs">
D
</Badge>
</Button>
</div>
{/* 우측: 액션 버튼들 */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={onUndo}
disabled={!canUndo}
className="flex items-center space-x-1"
>
<Undo className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<Button
variant="outline"
size="sm"
onClick={onRedo}
disabled={!canRedo}
className="flex items-center space-x-1"
>
<Redo className="h-4 w-4" />
<span className="hidden sm:inline"></span>
</Button>
<div className="h-6 w-px bg-gray-300" />
<Button variant="outline" size="sm" onClick={onPreview} className="flex items-center space-x-2">
<Play className="h-4 w-4" />
<span></span>
</Button>
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
<Save className="h-4 w-4" />
<span>{isSaving ? "저장 중..." : "저장"}</span>
</Button>
</div>
</div>
);
};
export default DesignerToolbar;

View File

@ -0,0 +1,292 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { X, GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
interface FloatingPanelProps {
id: string;
title: string;
children: React.ReactNode;
isOpen: boolean;
onClose: () => void;
position?: "left" | "right" | "top" | "bottom";
width?: number;
height?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
resizable?: boolean;
draggable?: boolean;
autoHeight?: boolean; // 자동 높이 조정 옵션
className?: string;
}
export const FloatingPanel: React.FC<FloatingPanelProps> = ({
id,
title,
children,
isOpen,
onClose,
position = "right",
width = 320,
height = 400,
minWidth = 280,
minHeight = 300,
maxWidth = 600,
maxHeight = 1200, // 800 → 1200 (더 큰 패널 지원)
resizable = true,
draggable = true,
autoHeight = true, // 자동 높이 조정 활성화 (컨텐츠 크기에 맞게)
className,
}) => {
const [panelSize, setPanelSize] = useState({ width, height });
// props 변경 시 패널 크기 업데이트
useEffect(() => {
setPanelSize({ width, height });
}, [width, height]);
const [panelPosition, setPanelPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const panelRef = useRef<HTMLDivElement>(null);
const dragHandleRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 초기 위치 설정 (패널이 처음 열릴 때만)
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
if (isOpen && !hasInitialized && panelRef.current) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let initialX = 0;
let initialY = 0;
switch (position) {
case "left":
initialX = 20;
initialY = 80;
break;
case "right":
initialX = viewportWidth - panelSize.width - 20;
initialY = 80;
break;
case "top":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = 20;
break;
case "bottom":
initialX = (viewportWidth - panelSize.width) / 2;
initialY = viewportHeight - panelSize.height - 20;
break;
}
setPanelPosition({ x: initialX, y: initialY });
setHasInitialized(true);
}
// 패널이 닫힐 때 초기화 상태 리셋
if (!isOpen) {
setHasInitialized(false);
}
}, [isOpen, position, hasInitialized]);
// 자동 높이 조정 기능
useEffect(() => {
if (!autoHeight || !contentRef.current || isResizing) return;
const updateHeight = () => {
if (!contentRef.current) return;
// 일시적으로 높이 제한을 해제하여 실제 컨텐츠 높이 측정
contentRef.current.style.maxHeight = "none";
// 컨텐츠의 실제 높이 측정
const contentHeight = contentRef.current.scrollHeight;
const headerHeight = 60; // 헤더 높이
const padding = 30; // 여유 공간 (좀 더 넉넉하게)
const newHeight = Math.min(Math.max(minHeight, contentHeight + headerHeight + padding), maxHeight);
console.log(`🔧 패널 높이 자동 조정:`, {
panelId: id,
contentHeight,
calculatedHeight: newHeight,
currentHeight: panelSize.height,
willUpdate: Math.abs(panelSize.height - newHeight) > 10,
});
// 현재 높이와 다르면 업데이트
if (Math.abs(panelSize.height - newHeight) > 10) {
setPanelSize((prev) => ({ ...prev, height: newHeight }));
}
};
// 초기 높이 설정
updateHeight();
// ResizeObserver로 컨텐츠 크기 변화 감지
const resizeObserver = new ResizeObserver((entries) => {
// DOM 업데이트가 완료된 후에 높이 측정
requestAnimationFrame(() => {
setTimeout(updateHeight, 50); // 약간의 지연으로 렌더링 완료 후 측정
});
});
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
};
}, [autoHeight, minHeight, maxHeight, isResizing, panelSize.height, children]);
// 드래그 시작 - 성능 최적화
const handleDragStart = (e: React.MouseEvent) => {
if (!draggable) return;
e.preventDefault(); // 기본 동작 방지로 딜레이 제거
e.stopPropagation(); // 이벤트 버블링 방지
setIsDragging(true);
const rect = panelRef.current?.getBoundingClientRect();
if (rect) {
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
setIsResizing(true);
};
// 마우스 이동 처리 - 초고속 최적화
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
// 직접 DOM 조작으로 최고 성능
if (panelRef.current) {
const newX = e.clientX - dragOffset.x;
const newY = e.clientY - dragOffset.y;
panelRef.current.style.left = `${newX}px`;
panelRef.current.style.top = `${newY}px`;
// 상태는 throttle로 업데이트
setPanelPosition({ x: newX, y: newY });
}
} else if (isResizing) {
const newWidth = Math.max(minWidth, Math.min(maxWidth, e.clientX - panelPosition.x));
const newHeight = Math.max(minHeight, Math.min(maxHeight, e.clientY - panelPosition.y));
if (panelRef.current) {
panelRef.current.style.width = `${newWidth}px`;
panelRef.current.style.height = `${newHeight}px`;
}
setPanelSize({ width: newWidth, height: newHeight });
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
};
// 고성능 이벤트 리스너
document.addEventListener("mousemove", handleMouseMove, {
passive: true,
capture: false,
});
document.addEventListener("mouseup", handleMouseUp, {
passive: true,
capture: false,
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, isResizing, dragOffset.x, dragOffset.y, panelPosition.x, minWidth, maxWidth, minHeight, maxHeight]);
if (!isOpen) return null;
return (
<div
ref={panelRef}
className={cn(
"fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg",
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
isResizing && "cursor-se-resize",
className,
)}
style={{
left: `${panelPosition.x}px`,
top: `${panelPosition.y}px`,
width: `${panelSize.width}px`,
height: `${panelSize.height}px`,
transform: isDragging ? "scale(1.01)" : "scale(1)",
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시
}}
>
{/* 헤더 */}
<div
ref={dragHandleRef}
data-header="true"
className="flex cursor-move items-center justify-between rounded-t-lg border-b border-gray-200 bg-gray-50 p-3"
onMouseDown={handleDragStart}
style={{
userSelect: "none", // 텍스트 선택 방지
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
}}
>
<div className="flex items-center space-x-2">
<GripVertical className="h-4 w-4 text-gray-400" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
</div>
<button onClick={onClose} className="rounded p-1 transition-colors hover:bg-gray-200">
<X className="h-4 w-4 text-gray-500" />
</button>
</div>
{/* 컨텐츠 */}
<div
ref={contentRef}
className={autoHeight ? "flex-1" : "flex-1 overflow-auto"}
style={
autoHeight
? {}
: {
maxHeight: `${panelSize.height - 60}px`, // 헤더 높이 제외
}
}
>
{children}
</div>
{/* 리사이즈 핸들 */}
{resizable && !autoHeight && (
<div className="absolute right-0 bottom-0 h-4 w-4 cursor-se-resize" onMouseDown={handleResizeStart}>
<div className="absolute right-1 bottom-1 h-2 w-2 rounded-sm bg-gray-400" />
</div>
)}
</div>
);
};
export default FloatingPanel;

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,22 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { ComponentData } from "@/types/screen";
import {
ComponentData,
WidgetComponent,
DataTableComponent,
TextTypeConfig,
NumberTypeConfig,
DateTypeConfig,
SelectTypeConfig,
RadioTypeConfig,
CheckboxTypeConfig,
TextareaTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { InteractiveDataTable } from "./InteractiveDataTable";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -57,6 +72,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 실제 사용 가능한 위젯 렌더링
const renderInteractiveWidget = (comp: ComponentData) => {
// 데이터 테이블 컴포넌트 처리
if (comp.type === "datatable") {
return (
<InteractiveDataTable
component={comp as DataTableComponent}
className="h-full w-full"
style={{
width: "100%",
height: "100%",
}}
/>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";
@ -78,192 +107,553 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
switch (widgetType) {
case "text":
case "email":
case "tel":
case "tel": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextTypeConfig | undefined;
console.log("📝 InteractiveScreenViewer - Text 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
minLength: config?.minLength,
maxLength: config?.maxLength,
pattern: config?.pattern,
placeholder: config?.placeholder,
},
});
// 형식별 패턴 생성
const getPatternByFormat = (format: string) => {
switch (format) {
case "korean":
return "[가-힣\\s]*";
case "english":
return "[a-zA-Z\\s]*";
case "alphanumeric":
return "[a-zA-Z0-9]*";
case "numeric":
return "[0-9]*";
case "email":
return "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}";
case "phone":
return "\\d{3}-\\d{4}-\\d{4}";
case "url":
return "https?://[\\w\\-]+(\\.[\\w\\-]+)+([\\w\\-\\.,@?^=%&:/~\\+#]*[\\w\\-\\@?^=%&/~\\+#])?";
default:
return config?.pattern || undefined;
}
};
// 입력 검증 함수
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// 형식별 실시간 검증
if (config?.format && config.format !== "none") {
const pattern = getPatternByFormat(config.format);
if (pattern) {
const regex = new RegExp(`^${pattern}$`);
if (value && !regex.test(value)) {
return; // 유효하지 않은 입력 차단
}
}
}
// 길이 제한 검증
if (config?.maxLength && value.length > config.maxLength) {
return; // 최대 길이 초과 차단
}
updateFormData(fieldName, value);
};
const finalPlaceholder = config?.placeholder || placeholder || "입력하세요...";
const inputType = widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text";
return applyStyles(
<Input
type={widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"}
placeholder={placeholder || "입력하세요..."}
type={inputType}
placeholder={finalPlaceholder}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.value)}
onChange={handleInputChange}
disabled={readonly}
required={required}
minLength={config?.minLength}
maxLength={config?.maxLength}
pattern={getPatternByFormat(config?.format || "none")}
className="h-full w-full"
/>,
);
}
case "number":
case "decimal":
case "decimal": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as NumberTypeConfig | undefined;
console.log("🔢 InteractiveScreenViewer - Number 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
min: config?.min,
max: config?.max,
step: config?.step,
decimalPlaces: config?.decimalPlaces,
thousandSeparator: config?.thousandSeparator,
prefix: config?.prefix,
suffix: config?.suffix,
},
});
const step = config?.step || (widgetType === "decimal" ? 0.01 : 1);
const finalPlaceholder = config?.placeholder || placeholder || "숫자를 입력하세요...";
return applyStyles(
<Input
type="number"
placeholder={placeholder || "숫자를 입력하세요..."}
placeholder={finalPlaceholder}
value={currentValue}
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
disabled={readonly}
required={required}
min={config?.min}
max={config?.max}
step={step}
className="h-full w-full"
step={widgetType === "decimal" ? "0.01" : "1"}
/>,
);
}
case "textarea":
case "text_area":
case "text_area": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as TextareaTypeConfig | undefined;
console.log("📄 InteractiveScreenViewer - Textarea 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
rows: config?.rows,
maxLength: config?.maxLength,
minLength: config?.minLength,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
resizable: config?.resizable,
wordWrap: config?.wordWrap,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "내용을 입력하세요...";
const rows = config?.rows || 3;
return applyStyles(
<Textarea
placeholder={placeholder || "내용을 입력하세요..."}
value={currentValue}
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
className="h-full w-full resize-none"
rows={3}
minLength={config?.minLength}
maxLength={config?.maxLength}
rows={rows}
className={`h-full w-full ${config?.resizable === false ? "resize-none" : ""}`}
style={{
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
}}
/>,
);
}
case "select":
case "dropdown":
case "dropdown": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as SelectTypeConfig | undefined;
console.log("📋 InteractiveScreenViewer - Select 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
options: config?.options,
multiple: config?.multiple,
searchable: config?.searchable,
placeholder: config?.placeholder,
defaultValue: config?.defaultValue,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "선택하세요...";
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
return applyStyles(
<Select
value={currentValue}
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectValue placeholder={placeholder || "선택하세요..."} />
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="option1"> 1</SelectItem>
<SelectItem value="option2"> 2</SelectItem>
<SelectItem value="option3"> 3</SelectItem>
{options.map((option, index) => (
<SelectItem key={index} value={option.value} disabled={option.disabled}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
case "checkbox":
case "boolean":
case "boolean": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CheckboxTypeConfig | undefined;
console.log("☑️ InteractiveScreenViewer - Checkbox 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
defaultChecked: config?.defaultChecked,
labelPosition: config?.labelPosition,
checkboxText: config?.checkboxText,
trueValue: config?.trueValue,
falseValue: config?.falseValue,
},
});
const isChecked = currentValue === true || currentValue === "true" || config?.defaultChecked;
const checkboxText = config?.checkboxText || label || "확인";
const labelPosition = config?.labelPosition || "right";
return applyStyles(
<div className="flex h-full w-full items-center space-x-2">
<div
className={`flex h-full w-full items-center space-x-2 ${labelPosition === "left" ? "flex-row-reverse" : ""}`}
>
<Checkbox
id={fieldName}
checked={currentValue === true || currentValue === "true"}
checked={isChecked}
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
disabled={readonly}
required={required}
/>
<label htmlFor={fieldName} className="text-sm">
{label || "확인"}
{checkboxText}
</label>
</div>,
);
}
case "radio": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as RadioTypeConfig | undefined;
console.log("🔘 InteractiveScreenViewer - Radio 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
options: config?.options,
defaultValue: config?.defaultValue,
layout: config?.layout,
allowNone: config?.allowNone,
},
});
const options = config?.options || [
{ label: "옵션 1", value: "option1" },
{ label: "옵션 2", value: "option2" },
{ label: "옵션 3", value: "option3" },
];
const layout = config?.layout || "vertical";
const selectedValue = currentValue || config?.defaultValue || "";
case "radio":
return applyStyles(
<div className="h-full w-full space-y-2">
{["옵션 1", "옵션 2", "옵션 3"].map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<div className={`h-full w-full ${layout === "horizontal" ? "flex flex-wrap gap-4" : "space-y-2"}`}>
{config?.allowNone && (
<div className="flex items-center space-x-2">
<input
type="radio"
id={`${fieldName}_${index}`}
id={`${fieldName}_none`}
name={fieldName}
value={option}
checked={currentValue === option}
value=""
checked={selectedValue === ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
className="h-4 w-4"
/>
<label htmlFor={`${fieldName}_none`} className="text-sm">
</label>
</div>
)}
{options.map((option, index) => (
<div key={index} className="flex items-center space-x-2">
<input
type="radio"
id={`${fieldName}_${index}`}
name={fieldName}
value={option.value}
checked={selectedValue === option.value}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly || option.disabled}
required={required}
className="h-4 w-4"
/>
<label htmlFor={`${fieldName}_${index}`} className="text-sm">
{option}
{option.label}
</label>
</div>
))}
</div>,
);
}
case "date":
const dateValue = dateValues[fieldName];
return applyStyles(
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-full w-full justify-start text-left font-normal"
disabled={readonly}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : placeholder || "날짜를 선택하세요"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateValue}
onSelect={(date) => updateDateValue(fieldName, date)}
initialFocus
/>
</PopoverContent>
</Popover>,
);
case "date": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
console.log("📅 InteractiveScreenViewer - Date 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
showTime: config?.showTime,
defaultValue: config?.defaultValue,
minDate: config?.minDate,
maxDate: config?.maxDate,
},
});
const shouldShowTime = config?.showTime || config?.format?.includes("HH:mm");
const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요";
if (shouldShowTime) {
// 시간 포함 날짜 입력
return applyStyles(
<Input
type="datetime-local"
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
className="h-full w-full"
/>,
);
} else {
// 날짜만 입력
const dateValue = dateValues[fieldName];
return applyStyles(
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-full w-full justify-start text-left font-normal"
disabled={readonly}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : config?.defaultValue || finalPlaceholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={dateValue}
onSelect={(date) => updateDateValue(fieldName, date)}
initialFocus
/>
</PopoverContent>
</Popover>,
);
}
}
case "datetime": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as DateTypeConfig | undefined;
console.log("🕐 InteractiveScreenViewer - DateTime 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
format: config?.format,
defaultValue: config?.defaultValue,
minDate: config?.minDate,
maxDate: config?.maxDate,
},
});
const finalPlaceholder = config?.placeholder || placeholder || "날짜와 시간을 입력하세요...";
case "datetime":
return applyStyles(
<Input
type="datetime-local"
placeholder={placeholder || "날짜와 시간을 입력하세요..."}
value={currentValue}
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
min={config?.minDate}
max={config?.maxDate}
className="h-full w-full"
/>,
);
}
case "file": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as FileTypeConfig | undefined;
console.log("📁 InteractiveScreenViewer - File 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
accept: config?.accept,
multiple: config?.multiple,
maxSize: config?.maxSize,
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
// 파일 크기 검증
if (config?.maxSize) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > config.maxSize * 1024 * 1024) {
alert(`파일 크기가 ${config.maxSize}MB를 초과합니다: ${file.name}`);
e.target.value = "";
return;
}
}
}
const file = config?.multiple ? files : files[0];
updateFormData(fieldName, file);
};
case "file":
return applyStyles(
<Input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
updateFormData(fieldName, file);
}}
onChange={handleFileChange}
disabled={readonly}
required={required}
multiple={config?.multiple}
accept={config?.accept}
className="h-full w-full"
/>,
);
}
case "code": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log("💻 InteractiveScreenViewer - Code 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
language: config?.language,
theme: config?.theme,
fontSize: config?.fontSize,
defaultValue: config?.defaultValue,
wordWrap: config?.wordWrap,
tabSize: config?.tabSize,
},
});
const finalPlaceholder = config?.placeholder || "코드를 입력하세요...";
const rows = config?.rows || 4;
case "code":
return applyStyles(
<Textarea
placeholder="코드를 입력하세요..."
value={currentValue}
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
rows={rows}
className="h-full w-full resize-none font-mono text-sm"
rows={4}
style={{
fontSize: `${config?.fontSize || 14}px`,
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
color: config?.theme === "dark" ? "#ffffff" : "#000000",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
tabSize: config?.tabSize || 2,
}}
/>,
);
}
case "entity": {
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
componentId: widget.id,
widgetType: widget.widgetType,
config,
appliedSettings: {
entityName: config?.entityName,
displayField: config?.displayField,
valueField: config?.valueField,
multiple: config?.multiple,
defaultValue: config?.defaultValue,
},
});
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
const defaultOptions = [
{ label: "사용자", value: "user" },
{ label: "제품", value: "product" },
{ label: "주문", value: "order" },
{ label: "카테고리", value: "category" },
];
case "entity":
return applyStyles(
<Select
value={currentValue}
value={currentValue || config?.defaultValue || ""}
onValueChange={(value) => updateFormData(fieldName, value)}
disabled={readonly}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectValue placeholder="엔티티를 선택하세요..." />
<SelectValue placeholder={finalPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="product"></SelectItem>
<SelectItem value="order"></SelectItem>
{defaultOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{config?.displayFormat
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
: option.label}
</SelectItem>
))}
</SelectContent>
</Select>,
);
}
default:
return applyStyles(
@ -312,18 +702,40 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
}
// 일반 위젯 컴포넌트
// 템플릿 컴포넌트 목록 (자체적으로 제목을 가지므로 라벨 불필요)
const templateTypes = ["datatable"];
// 라벨 표시 여부 계산
const shouldShowLabel =
component.style?.labelDisplay !== false &&
(component.label || component.style?.labelText) &&
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
const labelText = component.style?.labelText || component.label || "";
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151",
fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "0",
marginBottom: component.style?.labelMarginBottom || "4px",
};
return (
<div className="h-full w-full">
{/* 라벨이 있는 경우 표시 */}
{component.label && (
<label className="mb-1 block text-sm font-medium text-gray-700">
{component.label}
{component.required && <span className="ml-1 text-red-500">*</span>}
</label>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<div className="block" style={labelStyle}>
{labelText}
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
</div>
)}
{/* 실제 위젯 */}
<div className={component.label ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
<div className={shouldShowLabel ? "flex-1" : "h-full w-full"}>{renderInteractiveWidget(component)}</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,667 @@
"use client";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Group, Database, Trash2, Copy, Clipboard } from "lucide-react";
import {
ScreenDefinition,
ComponentData,
LayoutData,
GroupState,
WebType,
TableInfo,
GroupComponent,
Position,
} from "@/types/screen";
import { generateComponentId } from "@/lib/utils/generateId";
import {
createGroupComponent,
calculateBoundingBox,
calculateRelativePositions,
restoreAbsolutePositions,
getGroupChildren,
} from "@/lib/utils/groupingUtils";
import {
calculateGridInfo,
snapToGrid,
snapSizeToGrid,
generateGridLines,
GridSettings as GridUtilSettings,
} from "@/lib/utils/gridUtils";
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi } from "@/lib/api/screen";
import { toast } from "sonner";
import StyleEditor from "./StyleEditor";
import { RealtimePreview } from "./RealtimePreview";
import FloatingPanel from "./FloatingPanel";
import DesignerToolbar from "./DesignerToolbar";
import TablesPanel from "./panels/TablesPanel";
import PropertiesPanel from "./panels/PropertiesPanel";
import GridPanel from "./panels/GridPanel";
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
interface ScreenDesignerProps {
selectedScreen: ScreenDefinition | null;
onBackToList: () => void;
}
// 패널 설정
const panelConfigs: PanelConfig[] = [
{
id: "tables",
title: "테이블 목록",
defaultPosition: "left",
defaultWidth: 320,
defaultHeight: 600,
shortcutKey: "t",
},
{
id: "properties",
title: "속성 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 500,
shortcutKey: "p",
},
{
id: "styles",
title: "스타일 편집",
defaultPosition: "right",
defaultWidth: 320,
defaultHeight: 400,
shortcutKey: "s",
},
{
id: "grid",
title: "격자 설정",
defaultPosition: "right",
defaultWidth: 280,
defaultHeight: 450,
shortcutKey: "g",
},
];
export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenDesignerProps) {
// 패널 상태 관리
const { panelStates, togglePanel, openPanel, closePanel, closeAllPanels } = usePanelState(panelConfigs);
const [layout, setLayout] = useState<LayoutData>({
components: [],
gridSettings: { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true },
});
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [selectedComponent, setSelectedComponent] = useState<ComponentData | null>(null);
// 실행취소/다시실행을 위한 히스토리 상태
const [history, setHistory] = useState<LayoutData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// 그룹 상태
const [groupState, setGroupState] = useState<GroupState>({
selectedComponents: [],
isGrouping: false,
});
// 드래그 상태
const [dragState, setDragState] = useState({
isDragging: false,
draggedComponent: null as ComponentData | null,
originalPosition: { x: 0, y: 0 },
currentPosition: { x: 0, y: 0 },
grabOffset: { x: 0, y: 0 },
});
// 테이블 데이터
const [tables, setTables] = useState<TableInfo[]>([]);
const [searchTerm, setSearchTerm] = useState("");
// 클립보드
const [clipboard, setClipboard] = useState<{
type: "single" | "multiple" | "group";
data: ComponentData[];
} | null>(null);
// 그룹 생성 다이얼로그
const [showGroupCreateDialog, setShowGroupCreateDialog] = useState(false);
const canvasRef = useRef<HTMLDivElement>(null);
// 격자 정보 계산
const gridInfo = useMemo(() => {
if (!canvasRef.current || !layout.gridSettings) return null;
return calculateGridInfo(canvasRef.current, layout.gridSettings);
}, [layout.gridSettings]);
// 격자 라인 생성
const gridLines = useMemo(() => {
if (!gridInfo || !layout.gridSettings?.showGrid) return [];
return generateGridLines(gridInfo, layout.gridSettings);
}, [gridInfo, layout.gridSettings]);
// 필터된 테이블 목록
const filteredTables = useMemo(() => {
if (!searchTerm) return tables;
return tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
}, [tables, searchTerm]);
// 히스토리에 저장
const saveToHistory = useCallback(
(newLayout: LayoutData) => {
setHistory((prev) => {
const newHistory = prev.slice(0, historyIndex + 1);
newHistory.push(newLayout);
return newHistory.slice(-50); // 최대 50개까지만 저장
});
setHistoryIndex((prev) => Math.min(prev + 1, 49));
setHasUnsavedChanges(true);
},
[historyIndex],
);
// 실행취소
const undo = useCallback(() => {
if (historyIndex > 0) {
setHistoryIndex((prev) => prev - 1);
setLayout(history[historyIndex - 1]);
}
}, [history, historyIndex]);
// 다시실행
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
setHistoryIndex((prev) => prev + 1);
setLayout(history[historyIndex + 1]);
}
}, [history, historyIndex]);
// 컴포넌트 속성 업데이트
const updateComponentProperty = useCallback(
(componentId: string, path: string, value: any) => {
const pathParts = path.split(".");
const updatedComponents = layout.components.map((comp) => {
if (comp.id !== componentId) return comp;
const newComp = { ...comp };
let current: any = newComp;
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
current[pathParts[pathParts.length - 1]] = value;
// 크기 변경 시 격자 스냅 적용
if ((path === "size.width" || path === "size.height") && layout.gridSettings?.snapToGrid && gridInfo) {
const snappedSize = snapSizeToGrid(newComp.size, gridInfo, layout.gridSettings as GridUtilSettings);
newComp.size = snappedSize;
}
return newComp;
});
const newLayout = { ...layout, components: updatedComponents };
setLayout(newLayout);
saveToHistory(newLayout);
},
[layout, gridInfo, saveToHistory],
);
// 테이블 데이터 로드
useEffect(() => {
if (selectedScreen?.tableName) {
const loadTables = async () => {
try {
setIsLoading(true);
const response = await screenApi.getTableInfo([selectedScreen.tableName]);
setTables(response.data || []);
} catch (error) {
console.error("테이블 정보 로드 실패:", error);
toast.error("테이블 정보를 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadTables();
}
}, [selectedScreen?.tableName]);
// 화면 레이아웃 로드
useEffect(() => {
if (selectedScreen?.screenId) {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await screenApi.getScreenLayout(selectedScreen.screenId);
if (response.success && response.data) {
setLayout(response.data);
setHistory([response.data]);
setHistoryIndex(0);
setHasUnsavedChanges(false);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
toast.error("화면 레이아웃을 불러오는데 실패했습니다.");
} finally {
setIsLoading(false);
}
};
loadLayout();
}
}, [selectedScreen?.screenId]);
// 저장
const handleSave = useCallback(async () => {
if (!selectedScreen?.screenId) return;
try {
setIsSaving(true);
const response = await screenApi.saveScreenLayout(selectedScreen.screenId, layout);
if (response.success) {
toast.success("화면이 저장되었습니다.");
setHasUnsavedChanges(false);
} else {
toast.error("저장에 실패했습니다.");
}
} catch (error) {
console.error("저장 실패:", error);
toast.error("저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
}, [selectedScreen?.screenId, layout]);
// 드래그 앤 드롭 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
const dragData = e.dataTransfer.getData("application/json");
if (!dragData) return;
try {
const { type, table, column } = JSON.parse(dragData);
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let newComponent: ComponentData;
if (type === "table") {
// 테이블 컨테이너 생성
newComponent = {
id: generateComponentId(),
type: "container",
label: table.tableName,
tableName: table.tableName,
position: { x, y, z: 1 },
size: { width: 300, height: 200 },
};
} else if (type === "column") {
// 컬럼 위젯 생성
newComponent = {
id: generateComponentId(),
type: "widget",
label: column.columnName,
tableName: table.tableName,
columnName: column.columnName,
widgetType: column.widgetType,
dataType: column.dataType,
required: column.required,
position: { x, y, z: 1 },
size: { width: 200, height: 40 },
};
} else {
return;
}
// 격자 스냅 적용
if (layout.gridSettings?.snapToGrid && gridInfo) {
newComponent.position = snapToGrid(newComponent.position, gridInfo, layout.gridSettings as GridUtilSettings);
newComponent.size = snapSizeToGrid(newComponent.size, gridInfo, layout.gridSettings as GridUtilSettings);
}
const newLayout = {
...layout,
components: [...layout.components, newComponent],
};
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(newComponent);
// 속성 패널 자동 열기
openPanel("properties");
} catch (error) {
console.error("드롭 처리 실패:", error);
}
},
[layout, gridInfo, saveToHistory, openPanel],
);
// 컴포넌트 클릭 처리
const handleComponentClick = useCallback(
(component: ComponentData, event?: React.MouseEvent) => {
event?.stopPropagation();
setSelectedComponent(component);
// 속성 패널 자동 열기
openPanel("properties");
},
[openPanel],
);
// 컴포넌트 삭제
const deleteComponent = useCallback(() => {
if (!selectedComponent) return;
const newComponents = layout.components.filter((comp) => comp.id !== selectedComponent.id);
const newLayout = { ...layout, components: newComponents };
setLayout(newLayout);
saveToHistory(newLayout);
setSelectedComponent(null);
}, [selectedComponent, layout, saveToHistory]);
// 컴포넌트 복사
const copyComponent = useCallback(() => {
if (!selectedComponent) return;
setClipboard({
type: "single",
data: [{ ...selectedComponent, id: generateComponentId() }],
});
toast.success("컴포넌트가 복사되었습니다.");
}, [selectedComponent]);
// 그룹 생성
const handleGroupCreate = useCallback(
(componentIds: string[], title: string, style?: any) => {
const selectedComponents = layout.components.filter((comp) => componentIds.includes(comp.id));
if (selectedComponents.length < 2) return;
// 경계 박스 계산
const boundingBox = calculateBoundingBox(selectedComponents);
// 그룹 컴포넌트 생성
const groupComponent = createGroupComponent(
componentIds,
title,
{ x: boundingBox.minX, y: boundingBox.minY },
{ width: boundingBox.width, height: boundingBox.height },
style,
);
// 자식 컴포넌트들의 상대 위치 계산
const relativeChildren = calculateRelativePositions(
selectedComponents,
{ x: boundingBox.minX, y: boundingBox.minY },
groupComponent.id,
);
const newLayout = {
...layout,
components: [
...layout.components.filter((comp) => !componentIds.includes(comp.id)),
groupComponent,
...relativeChildren,
],
};
setLayout(newLayout);
saveToHistory(newLayout);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
},
[layout, saveToHistory],
);
// 키보드 이벤트 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Delete 키로 컴포넌트 삭제
if (e.key === "Delete" && selectedComponent) {
deleteComponent();
}
// Ctrl+C로 복사
if (e.ctrlKey && e.key === "c" && selectedComponent) {
copyComponent();
}
// Ctrl+Z로 실행취소
if (e.ctrlKey && e.key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
}
// Ctrl+Y 또는 Ctrl+Shift+Z로 다시실행
if ((e.ctrlKey && e.key === "y") || (e.ctrlKey && e.shiftKey && e.key === "z")) {
e.preventDefault();
redo();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [selectedComponent, deleteComponent, copyComponent, undo, redo]);
if (!selectedScreen) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900"> </h3>
<p className="text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex h-screen w-full flex-col bg-gray-100">
{/* 상단 툴바 */}
<DesignerToolbar
screenName={selectedScreen?.screenName}
tableName={selectedScreen?.tableName}
onBack={onBackToList}
onSave={handleSave}
onUndo={undo}
onRedo={redo}
onPreview={() => {
toast.info("미리보기 기능은 준비 중입니다.");
}}
onTogglePanel={togglePanel}
panelStates={panelStates}
canUndo={historyIndex > 0}
canRedo={historyIndex < history.length - 1}
isSaving={isSaving}
/>
{/* 메인 캔버스 영역 (전체 화면) */}
<div
ref={canvasRef}
className="relative flex-1 overflow-hidden bg-white"
onClick={(e) => {
if (e.target === e.currentTarget) {
setSelectedComponent(null);
setGroupState((prev) => ({ ...prev, selectedComponents: [] }));
}
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* 격자 라인 */}
{gridLines.map((line, index) => (
<div
key={index}
className="pointer-events-none absolute"
style={{
left: line.type === "vertical" ? `${line.position}px` : 0,
top: line.type === "horizontal" ? `${line.position}px` : 0,
width: line.type === "vertical" ? "1px" : "100%",
height: line.type === "horizontal" ? "1px" : "100%",
backgroundColor: layout.gridSettings?.gridColor || "#e5e7eb",
opacity: layout.gridSettings?.gridOpacity || 0.3,
}}
/>
))}
{/* 컴포넌트들 */}
{layout.components
.filter((component) => !component.parentId) // 최상위 컴포넌트만 렌더링
.map((component) => {
const children =
component.type === "group" ? layout.components.filter((child) => child.parentId === component.id) : [];
return (
<RealtimePreview
key={component.id}
component={component}
isSelected={selectedComponent?.id === component.id}
onClick={(e) => handleComponentClick(component, e)}
>
{children.map((child) => (
<RealtimePreview
key={child.id}
component={child}
isSelected={selectedComponent?.id === child.id}
onClick={(e) => handleComponentClick(child, e)}
/>
))}
</RealtimePreview>
);
})}
{/* 빈 캔버스 안내 */}
{layout.components.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-400">
<Database className="mx-auto mb-4 h-16 w-16" />
<h3 className="mb-2 text-xl font-medium"> </h3>
<p className="text-sm"> </p>
<p className="mt-2 text-xs">단축키: T(), P(), S(), G()</p>
</div>
</div>
)}
</div>
{/* 플로팅 패널들 */}
<FloatingPanel
id="tables"
title="테이블 목록"
isOpen={panelStates.tables?.isOpen || false}
onClose={() => closePanel("tables")}
position="left"
width={320}
height={600}
>
<TablesPanel
tables={filteredTables}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onDragStart={(e, table, column) => {
const dragData = {
type: column ? "column" : "table",
table,
column,
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
}}
selectedTableName={selectedScreen.tableName}
/>
</FloatingPanel>
<FloatingPanel
id="properties"
title="속성 편집"
isOpen={panelStates.properties?.isOpen || false}
onClose={() => closePanel("properties")}
position="right"
width={320}
height={500}
>
<PropertiesPanel
selectedComponent={selectedComponent}
onUpdateProperty={updateComponentProperty}
onDeleteComponent={deleteComponent}
onCopyComponent={copyComponent}
/>
</FloatingPanel>
<FloatingPanel
id="styles"
title="스타일 편집"
isOpen={panelStates.styles?.isOpen || false}
onClose={() => closePanel("styles")}
position="right"
width={320}
height={400}
>
{selectedComponent ? (
<div className="p-4">
<StyleEditor
style={selectedComponent.style || {}}
onStyleChange={(newStyle) => updateComponentProperty(selectedComponent.id, "style", newStyle)}
/>
</div>
) : (
<div className="flex h-full items-center justify-center text-gray-500">
</div>
)}
</FloatingPanel>
<FloatingPanel
id="grid"
title="격자 설정"
isOpen={panelStates.grid?.isOpen || false}
onClose={() => closePanel("grid")}
position="right"
width={280}
height={450}
>
<GridPanel
gridSettings={layout.gridSettings || { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true }}
onGridSettingsChange={(settings) => {
const newLayout = { ...layout, gridSettings: settings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
onResetGrid={() => {
const defaultSettings = { columns: 12, gap: 16, padding: 16, snapToGrid: true, showGrid: true };
const newLayout = { ...layout, gridSettings: defaultSettings };
setLayout(newLayout);
saveToHistory(newLayout);
}}
/>
</FloatingPanel>
{/* 그룹 생성 툴바 (필요시) */}
{groupState.selectedComponents.length > 1 && (
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2 transform">
<GroupingToolbar
selectedComponents={groupState.selectedComponents}
onGroupCreate={handleGroupCreate}
showCreateDialog={showGroupCreateDialog}
onShowCreateDialogChange={setShowGroupCreateDialog}
/>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
import CreateScreenModal from "./CreateScreenModal";
import CopyScreenModal from "./CopyScreenModal";
interface ScreenListProps {
onScreenSelect: (screen: ScreenDefinition) => void;
@ -30,6 +31,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isCopyOpen, setIsCopyOpen] = useState(false);
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
// 화면 목록 로드 (실제 API)
useEffect(() => {
@ -58,6 +61,20 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
const filteredScreens = screens; // 서버 필터 기준 사용
// 화면 목록 다시 로드
const reloadScreens = async () => {
try {
setLoading(true);
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
setScreens(resp.data || []);
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
} catch (e) {
console.error("화면 목록 조회 실패", e);
} finally {
setLoading(false);
}
};
const handleScreenSelect = (screen: ScreenDefinition) => {
onScreenSelect(screen);
};
@ -75,8 +92,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
};
const handleCopy = (screen: ScreenDefinition) => {
// 복사 모달 열기
console.log("복사:", screen);
setScreenToCopy(screen);
setIsCopyOpen(true);
};
const handleView = (screen: ScreenDefinition) => {
@ -84,6 +101,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
console.log("미리보기:", screen);
};
const handleCopySuccess = () => {
// 복사 성공 후 화면 목록 다시 로드
reloadScreens();
};
if (loading) {
return (
<div className="flex items-center justify-center py-8">
@ -239,6 +261,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
setScreens((prev) => [created, ...prev]);
}}
/>
{/* 화면 복사 모달 */}
<CopyScreenModal
isOpen={isCopyOpen}
onClose={() => setIsCopyOpen(false)}
sourceScreen={screenToCopy}
onCopySuccess={handleCopySuccess}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,219 @@
"use client";
import React from "react";
import { Settings } from "lucide-react";
import {
ComponentData,
WidgetComponent,
WebTypeConfig,
DateTypeConfig,
NumberTypeConfig,
SelectTypeConfig,
TextTypeConfig,
TextareaTypeConfig,
CheckboxTypeConfig,
RadioTypeConfig,
FileTypeConfig,
CodeTypeConfig,
EntityTypeConfig,
} from "@/types/screen";
import { DateTypeConfigPanel } from "./webtype-configs/DateTypeConfigPanel";
import { NumberTypeConfigPanel } from "./webtype-configs/NumberTypeConfigPanel";
import { SelectTypeConfigPanel } from "./webtype-configs/SelectTypeConfigPanel";
import { TextTypeConfigPanel } from "./webtype-configs/TextTypeConfigPanel";
import { TextareaTypeConfigPanel } from "./webtype-configs/TextareaTypeConfigPanel";
import { CheckboxTypeConfigPanel } from "./webtype-configs/CheckboxTypeConfigPanel";
import { RadioTypeConfigPanel } from "./webtype-configs/RadioTypeConfigPanel";
import { FileTypeConfigPanel } from "./webtype-configs/FileTypeConfigPanel";
import { CodeTypeConfigPanel } from "./webtype-configs/CodeTypeConfigPanel";
import { EntityTypeConfigPanel } from "./webtype-configs/EntityTypeConfigPanel";
interface DetailSettingsPanelProps {
selectedComponent?: ComponentData;
onUpdateProperty: (componentId: string, path: string, value: any) => void;
}
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ selectedComponent, onUpdateProperty }) => {
// 웹타입별 상세 설정 렌더링 함수
const renderWebTypeConfig = React.useCallback(
(widget: WidgetComponent) => {
const currentConfig = widget.webTypeConfig || {};
console.log("🎨 DetailSettingsPanel renderWebTypeConfig 호출:", {
componentId: widget.id,
widgetType: widget.widgetType,
currentConfig,
configExists: !!currentConfig,
configKeys: Object.keys(currentConfig),
configStringified: JSON.stringify(currentConfig),
widgetWebTypeConfig: widget.webTypeConfig,
widgetWebTypeConfigExists: !!widget.webTypeConfig,
timestamp: new Date().toISOString(),
});
const handleConfigChange = (newConfig: WebTypeConfig) => {
console.log("🔧 WebTypeConfig 업데이트:", {
widgetType: widget.widgetType,
oldConfig: currentConfig,
newConfig,
componentId: widget.id,
isEqual: JSON.stringify(currentConfig) === JSON.stringify(newConfig),
});
// 강제 새 객체 생성으로 React 변경 감지 보장
const freshConfig = { ...newConfig };
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
};
switch (widget.widgetType) {
case "date":
case "datetime":
return (
<DateTypeConfigPanel
key={`date-config-${widget.id}`}
config={currentConfig as DateTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "number":
case "decimal":
return (
<NumberTypeConfigPanel
key={`${widget.id}-number`}
config={currentConfig as NumberTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "select":
case "dropdown":
return (
<SelectTypeConfigPanel
key={`${widget.id}-select`}
config={currentConfig as SelectTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "text":
case "email":
case "tel":
return (
<TextTypeConfigPanel
key={`${widget.id}-text`}
config={currentConfig as TextTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "textarea":
return (
<TextareaTypeConfigPanel
key={`${widget.id}-textarea`}
config={currentConfig as TextareaTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "checkbox":
case "boolean":
return (
<CheckboxTypeConfigPanel
key={`${widget.id}-checkbox`}
config={currentConfig as CheckboxTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "radio":
return (
<RadioTypeConfigPanel
key={`${widget.id}-radio`}
config={currentConfig as RadioTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "file":
return (
<FileTypeConfigPanel
key={`${widget.id}-file`}
config={currentConfig as FileTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "code":
return (
<CodeTypeConfigPanel
key={`${widget.id}-code`}
config={currentConfig as CodeTypeConfig}
onConfigChange={handleConfigChange}
/>
);
case "entity":
return (
<EntityTypeConfigPanel
key={`${widget.id}-entity`}
config={currentConfig as EntityTypeConfig}
onConfigChange={handleConfigChange}
/>
);
default:
return <div className="text-sm text-gray-500 italic"> .</div>;
}
},
[onUpdateProperty],
);
if (!selectedComponent) {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> .</p>
</div>
);
}
if (selectedComponent.type !== "widget") {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500">
.
<br />
: {selectedComponent.type}
</p>
</div>
);
}
const widget = selectedComponent as WidgetComponent;
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<div className="mt-2 flex items-center space-x-2">
<span className="text-sm text-gray-600">:</span>
<span className="rounded bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">{widget.widgetType}</span>
</div>
<div className="mt-1 text-xs text-gray-500">: {widget.columnName}</div>
</div>
{/* 상세 설정 영역 */}
<div className="flex-1 overflow-y-auto p-4">{renderWebTypeConfig(widget)}</div>
</div>
);
};
export default DetailSettingsPanel;

View File

@ -0,0 +1,223 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Grid3X3, RotateCcw, Eye, EyeOff, Zap } from "lucide-react";
import { GridSettings } from "@/types/screen";
interface GridPanelProps {
gridSettings: GridSettings;
onGridSettingsChange: (settings: GridSettings) => void;
onResetGrid: () => void;
}
export const GridPanel: React.FC<GridPanelProps> = ({ gridSettings, onGridSettingsChange, onResetGrid }) => {
const updateSetting = (key: keyof GridSettings, value: any) => {
onGridSettingsChange({
...gridSettings,
[key]: value,
});
};
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Grid3X3 className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<Button size="sm" variant="outline" onClick={onResetGrid} className="flex items-center space-x-1">
<RotateCcw className="h-3 w-3" />
<span></span>
</Button>
</div>
{/* 주요 토글들 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{gridSettings.showGrid ? (
<Eye className="h-4 w-4 text-blue-600" />
) : (
<EyeOff className="h-4 w-4 text-gray-400" />
)}
<Label htmlFor="showGrid" className="text-sm font-medium">
</Label>
</div>
<Checkbox
id="showGrid"
checked={gridSettings.showGrid}
onCheckedChange={(checked) => updateSetting("showGrid", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4 text-green-600" />
<Label htmlFor="snapToGrid" className="text-sm font-medium">
</Label>
</div>
<Checkbox
id="snapToGrid"
checked={gridSettings.snapToGrid}
onCheckedChange={(checked) => updateSetting("snapToGrid", checked)}
/>
</div>
</div>
</div>
{/* 설정 영역 */}
<div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* 격자 구조 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="columns" className="mb-2 block text-sm font-medium">
: {gridSettings.columns}
</Label>
<Slider
id="columns"
min={1}
max={24}
step={1}
value={[gridSettings.columns]}
onValueChange={([value]) => updateSetting("columns", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>24</span>
</div>
</div>
<div>
<Label htmlFor="gap" className="mb-2 block text-sm font-medium">
: {gridSettings.gap}px
</Label>
<Slider
id="gap"
min={0}
max={40}
step={2}
value={[gridSettings.gap]}
onValueChange={([value]) => updateSetting("gap", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>0px</span>
<span>40px</span>
</div>
</div>
<div>
<Label htmlFor="padding" className="mb-2 block text-sm font-medium">
: {gridSettings.padding}px
</Label>
<Slider
id="padding"
min={0}
max={60}
step={4}
value={[gridSettings.padding]}
onValueChange={([value]) => updateSetting("padding", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>0px</span>
<span>60px</span>
</div>
</div>
</div>
<Separator />
{/* 격자 스타일 */}
<div className="space-y-4">
<h4 className="font-medium text-gray-900"> </h4>
<div>
<Label htmlFor="gridColor" className="text-sm font-medium">
</Label>
<div className="mt-1 flex items-center space-x-2">
<Input
id="gridColor"
type="color"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
className="h-8 w-12 rounded border p-1"
/>
<Input
type="text"
value={gridSettings.gridColor || "#d1d5db"}
onChange={(e) => updateSetting("gridColor", e.target.value)}
placeholder="#d1d5db"
className="flex-1"
/>
</div>
</div>
<div>
<Label htmlFor="gridOpacity" className="mb-2 block text-sm font-medium">
: {Math.round((gridSettings.gridOpacity || 0.5) * 100)}%
</Label>
<Slider
id="gridOpacity"
min={0.1}
max={1}
step={0.1}
value={[gridSettings.gridOpacity || 0.5]}
onValueChange={([value]) => updateSetting("gridOpacity", value)}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10%</span>
<span>100%</span>
</div>
</div>
</div>
<Separator />
{/* 미리보기 */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900"></h4>
<div
className="rounded-md border border-gray-200 bg-white p-4"
style={{
backgroundImage: gridSettings.showGrid
? `linear-gradient(to right, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px),
linear-gradient(to bottom, ${gridSettings.gridColor || "#d1d5db"} 1px, transparent 1px)`
: "none",
backgroundSize: gridSettings.showGrid ? `${100 / gridSettings.columns}% 20px` : "none",
opacity: gridSettings.gridOpacity || 0.5,
}}
>
<div className="flex h-16 items-center justify-center rounded border-2 border-dashed border-blue-300 bg-blue-100">
<span className="text-xs text-blue-600"> </span>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-xs text-gray-600">💡 </div>
</div>
</div>
);
};
export default GridPanel;

View File

@ -0,0 +1,651 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Settings, Move, Type, Trash2, Copy, Group, Ungroup } from "lucide-react";
import { ComponentData, WebType, WidgetComponent, GroupComponent, DataTableComponent, TableInfo } from "@/types/screen";
import DataTableConfigPanel from "./DataTableConfigPanel";
interface PropertiesPanelProps {
selectedComponent?: ComponentData;
tables?: TableInfo[];
onUpdateProperty: (path: string, value: unknown) => void;
onDeleteComponent: () => void;
onCopyComponent: () => void;
onGroupComponents?: () => void;
onUngroupComponents?: () => void;
canGroup?: boolean;
canUngroup?: boolean;
}
const webTypeOptions: { value: WebType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "email", label: "이메일" },
{ value: "tel", label: "전화번호" },
{ value: "number", label: "숫자" },
{ value: "decimal", label: "소수" },
{ value: "date", label: "날짜" },
{ value: "datetime", label: "날짜시간" },
{ value: "select", label: "선택박스" },
{ value: "dropdown", label: "드롭다운" },
{ value: "textarea", label: "텍스트영역" },
{ value: "boolean", label: "불린" },
{ value: "checkbox", label: "체크박스" },
{ value: "radio", label: "라디오" },
{ value: "code", label: "코드" },
{ value: "entity", label: "엔티티" },
{ value: "file", label: "파일" },
];
export const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedComponent,
tables = [],
onUpdateProperty,
onDeleteComponent,
onCopyComponent,
onGroupComponents,
onUngroupComponents,
canGroup = false,
canUngroup = false,
}) => {
// 데이터테이블 설정 탭 상태를 여기서 관리
const [dataTableActiveTab, setDataTableActiveTab] = useState("basic");
// 최신 값들의 참조를 유지
const selectedComponentRef = useRef(selectedComponent);
const onUpdatePropertyRef = useRef(onUpdateProperty);
// 입력 필드들의 로컬 상태 (실시간 타이핑 반영용)
const [localInputs, setLocalInputs] = useState({
placeholder: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).placeholder : "") || "",
title: (selectedComponent?.type === "group" ? (selectedComponent as GroupComponent).title : "") || "",
positionX: selectedComponent?.position.x?.toString() || "0",
positionY: selectedComponent?.position.y?.toString() || "0",
positionZ: selectedComponent?.position.z?.toString() || "1",
width: selectedComponent?.size.width?.toString() || "0",
height: selectedComponent?.size.height?.toString() || "0",
gridColumns: selectedComponent?.gridColumns?.toString() || "1",
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
labelDisplay: selectedComponent?.style?.labelDisplay !== false,
});
useEffect(() => {
selectedComponentRef.current = selectedComponent;
onUpdatePropertyRef.current = onUpdateProperty;
});
// 선택된 컴포넌트가 변경될 때 로컬 입력 상태 업데이트
useEffect(() => {
if (selectedComponent) {
const widget = selectedComponent.type === "widget" ? (selectedComponent as WidgetComponent) : null;
const group = selectedComponent.type === "group" ? (selectedComponent as GroupComponent) : null;
console.log("🔄 PropertiesPanel: 컴포넌트 변경 감지", {
componentId: selectedComponent.id,
componentType: selectedComponent.type,
currentValues: {
placeholder: widget?.placeholder,
title: group?.title,
positionX: selectedComponent.position.x,
labelText: selectedComponent.style?.labelText || selectedComponent.label,
},
});
setLocalInputs({
placeholder: widget?.placeholder || "",
title: group?.title || "",
positionX: selectedComponent.position.x?.toString() || "0",
positionY: selectedComponent.position.y?.toString() || "0",
positionZ: selectedComponent.position.z?.toString() || "1",
width: selectedComponent.size.width?.toString() || "0",
height: selectedComponent.size.height?.toString() || "0",
gridColumns: selectedComponent.gridColumns?.toString() || "1",
labelText: selectedComponent.style?.labelText || selectedComponent.label || "",
labelFontSize: selectedComponent.style?.labelFontSize || "12px",
labelColor: selectedComponent.style?.labelColor || "#374151",
labelMarginBottom: selectedComponent.style?.labelMarginBottom || "4px",
required: widget?.required || false,
readonly: widget?.readonly || false,
labelDisplay: selectedComponent.style?.labelDisplay !== false,
});
}
}, [
selectedComponent,
selectedComponent?.position,
selectedComponent?.size,
selectedComponent?.style,
selectedComponent?.label,
]);
if (!selectedComponent) {
return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium text-gray-900"> </h3>
<p className="text-sm text-gray-500"> .</p>
</div>
);
}
// 데이터 테이블 컴포넌트인 경우 전용 패널 사용
if (selectedComponent.type === "datatable") {
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-5 w-5 text-gray-600" />
<span className="text-lg font-semibold"> </span>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onCopyComponent}>
<Copy className="mr-1 h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={onDeleteComponent}>
<Trash2 className="mr-1 h-4 w-4" />
</Button>
</div>
</div>
{/* 데이터 테이블 설정 패널 */}
<div className="flex-1 overflow-y-auto">
<DataTableConfigPanel
key={`datatable-${selectedComponent.id}-${selectedComponent.columns.length}-${selectedComponent.filters.length}-${JSON.stringify(selectedComponent.columns.map((c) => c.id))}-${JSON.stringify(selectedComponent.filters.map((f) => f.columnName))}`}
component={selectedComponent as DataTableComponent}
tables={tables}
activeTab={dataTableActiveTab}
onTabChange={setDataTableActiveTab}
onUpdateComponent={(updates) => {
console.log("🔄 DataTable 컴포넌트 업데이트:", updates);
console.log("🔄 업데이트 항목들:", Object.keys(updates));
// 각 속성을 개별적으로 업데이트
Object.entries(updates).forEach(([key, value]) => {
console.log(` - ${key}:`, value);
if (key === "columns") {
console.log(` 컬럼 개수: ${Array.isArray(value) ? value.length : 0}`);
}
if (key === "filters") {
console.log(` 필터 개수: ${Array.isArray(value) ? value.length : 0}`);
}
onUpdateProperty(key, value);
});
console.log("✅ DataTable 컴포넌트 업데이트 완료");
}}
/>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Settings className="h-4 w-4 text-gray-600" />
<h3 className="font-medium text-gray-900"> </h3>
</div>
<Badge variant="secondary" className="text-xs">
{selectedComponent.type}
</Badge>
</div>
{/* 액션 버튼들 */}
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={onCopyComponent} className="flex items-center space-x-1">
<Copy className="h-3 w-3" />
<span></span>
</Button>
{canGroup && (
<Button size="sm" variant="outline" onClick={onGroupComponents} className="flex items-center space-x-1">
<Group className="h-3 w-3" />
<span></span>
</Button>
)}
{canUngroup && (
<Button size="sm" variant="outline" onClick={onUngroupComponents} className="flex items-center space-x-1">
<Ungroup className="h-3 w-3" />
<span></span>
</Button>
)}
<Button size="sm" variant="destructive" onClick={onDeleteComponent} className="flex items-center space-x-1">
<Trash2 className="h-3 w-3" />
<span></span>
</Button>
</div>
</div>
{/* 속성 편집 영역 */}
<div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* 기본 정보 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div className="space-y-3">
{selectedComponent.type === "widget" && (
<>
<div>
<Label htmlFor="columnName" className="text-sm font-medium">
( )
</Label>
<Input
id="columnName"
value={selectedComponent.columnName || ""}
readOnly
placeholder="데이터베이스 컬럼명"
className="mt-1 bg-gray-50 text-gray-600"
title="컬럼명은 변경할 수 없습니다"
/>
</div>
<div>
<Label htmlFor="widgetType" className="text-sm font-medium">
</Label>
<Select
value={selectedComponent.widgetType || "text"}
onValueChange={(value) => onUpdateProperty("widgetType", value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{webTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localInputs.placeholder}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 placeholder 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, placeholder: newValue }));
onUpdateProperty("placeholder", newValue);
}}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Checkbox
id="required"
checked={localInputs.required}
onCheckedChange={(checked) => {
setLocalInputs((prev) => ({ ...prev, required: !!checked }));
onUpdateProperty("required", checked);
}}
/>
<Label htmlFor="required" className="text-sm">
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="readonly"
checked={localInputs.readonly}
onCheckedChange={(checked) => {
setLocalInputs((prev) => ({ ...prev, readonly: !!checked }));
onUpdateProperty("readonly", checked);
}}
/>
<Label htmlFor="readonly" className="text-sm">
</Label>
</div>
</div>
</>
)}
</div>
</div>
<Separator />
{/* 위치 및 크기 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Move className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="positionX" className="text-sm font-medium">
X
</Label>
<Input
id="positionX"
type="number"
value={localInputs.positionX}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionX: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, x: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="positionY" className="text-sm font-medium">
Y
</Label>
<Input
id="positionY"
type="number"
value={localInputs.positionY}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionY: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, y: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="width" className="text-sm font-medium">
</Label>
<Input
id="width"
type="number"
value={localInputs.width}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, width: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, width: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="height" className="text-sm font-medium">
</Label>
<Input
id="height"
type="number"
value={localInputs.height}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, height: newValue }));
onUpdateProperty("size", { ...selectedComponent.size, height: Number(newValue) });
}}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="zIndex" className="text-sm font-medium">
Z-Index ( )
</Label>
<Input
id="zIndex"
type="number"
min="0"
max="9999"
value={localInputs.positionZ}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, positionZ: newValue }));
onUpdateProperty("position", { ...selectedComponent.position, z: Number(newValue) });
}}
className="mt-1"
placeholder="1"
/>
</div>
<div>
<Label htmlFor="gridColumns" className="text-sm font-medium">
(1-12)
</Label>
<Input
id="gridColumns"
type="number"
min="1"
max="12"
value={localInputs.gridColumns}
onChange={(e) => {
const newValue = e.target.value;
const numValue = Number(newValue);
if (numValue >= 1 && numValue <= 12) {
setLocalInputs((prev) => ({ ...prev, gridColumns: newValue }));
onUpdateProperty("gridColumns", numValue);
}
}}
placeholder="1"
className="mt-1"
/>
<div className="mt-1 text-xs text-gray-500">
(기본: 1)
</div>
</div>
</div>
</div>
<Separator />
{/* 라벨 스타일 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Type className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
{/* 라벨 표시 토글 */}
<div className="flex items-center justify-between">
<Label htmlFor="labelDisplay" className="text-sm font-medium">
</Label>
<Checkbox
id="labelDisplay"
checked={localInputs.labelDisplay}
onCheckedChange={(checked) => {
console.log("🔄 라벨 표시 변경:", checked);
setLocalInputs((prev) => ({ ...prev, labelDisplay: checked as boolean }));
onUpdateProperty("style.labelDisplay", checked);
}}
/>
</div>
{/* 라벨 텍스트 */}
<div>
<Label htmlFor="labelText" className="text-sm font-medium">
</Label>
<Input
id="labelText"
value={localInputs.labelText}
onChange={(e) => {
const newValue = e.target.value;
console.log("🔄 라벨 텍스트 변경:", newValue);
setLocalInputs((prev) => ({ ...prev, labelText: newValue }));
// 기본 라벨과 스타일 라벨을 모두 업데이트
onUpdateProperty("label", newValue);
onUpdateProperty("style.labelText", newValue);
}}
placeholder="라벨 텍스트"
className="mt-1"
/>
</div>
{/* 라벨 스타일 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="labelFontSize" className="text-sm font-medium">
</Label>
<Input
id="labelFontSize"
value={localInputs.labelFontSize}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, labelFontSize: newValue }));
onUpdateProperty("style.labelFontSize", newValue);
}}
placeholder="12px"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="labelColor" className="text-sm font-medium">
</Label>
<Input
id="labelColor"
type="color"
value={localInputs.labelColor}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, labelColor: newValue }));
onUpdateProperty("style.labelColor", newValue);
}}
className="mt-1 h-8"
/>
</div>
<div>
<Label htmlFor="labelFontWeight" className="text-sm font-medium">
</Label>
<Select
value={selectedComponent.style?.labelFontWeight || "500"}
onValueChange={(value) => onUpdateProperty("style.labelFontWeight", value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="bold">Bold</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="200">200</SelectItem>
<SelectItem value="300">300</SelectItem>
<SelectItem value="400">400</SelectItem>
<SelectItem value="500">500</SelectItem>
<SelectItem value="600">600</SelectItem>
<SelectItem value="700">700</SelectItem>
<SelectItem value="800">800</SelectItem>
<SelectItem value="900">900</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="labelTextAlign" className="text-sm font-medium">
</Label>
<Select
value={selectedComponent.style?.labelTextAlign || "left"}
onValueChange={(value) => onUpdateProperty("style.labelTextAlign", value)}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 라벨 여백 */}
<div>
<Label htmlFor="labelMarginBottom" className="text-sm font-medium">
</Label>
<Input
id="labelMarginBottom"
value={localInputs.labelMarginBottom}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, labelMarginBottom: newValue }));
onUpdateProperty("style.labelMarginBottom", newValue);
}}
placeholder="4px"
className="mt-1"
/>
</div>
</div>
{selectedComponent.type === "group" && (
<>
<Separator />
{/* 그룹 설정 */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Group className="h-4 w-4 text-gray-600" />
<h4 className="font-medium text-gray-900"> </h4>
</div>
<div>
<Label htmlFor="groupTitle" className="text-sm font-medium">
</Label>
<Input
id="groupTitle"
value={localInputs.title}
onChange={(e) => {
const newValue = e.target.value;
setLocalInputs((prev) => ({ ...prev, title: newValue }));
onUpdateProperty("title", newValue);
}}
placeholder="그룹 제목"
className="mt-1"
/>
</div>
</div>
</>
)}
</div>
</div>
);
};
export default PropertiesPanel;

View File

@ -0,0 +1,224 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Database,
ChevronDown,
ChevronRight,
Type,
Hash,
Calendar,
CheckSquare,
List,
AlignLeft,
Code,
Building,
File,
Search,
} from "lucide-react";
import { TableInfo, WebType } from "@/types/screen";
interface TablesPanelProps {
tables: TableInfo[];
searchTerm: string;
onSearchChange: (term: string) => void;
onDragStart: (e: React.DragEvent, table: TableInfo, column?: any) => void;
selectedTableName?: string;
}
// 위젯 타입별 아이콘
const getWidgetIcon = (widgetType: WebType) => {
switch (widgetType) {
case "text":
case "email":
case "tel":
return <Type className="h-3 w-3 text-blue-600" />;
case "number":
case "decimal":
return <Hash className="h-3 w-3 text-green-600" />;
case "date":
case "datetime":
return <Calendar className="h-3 w-3 text-purple-600" />;
case "select":
case "dropdown":
return <List className="h-3 w-3 text-orange-600" />;
case "textarea":
case "text_area":
return <AlignLeft className="h-3 w-3 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="h-3 w-3 text-blue-600" />;
case "code":
return <Code className="h-3 w-3 text-gray-600" />;
case "entity":
return <Building className="h-3 w-3 text-cyan-600" />;
case "file":
return <File className="h-3 w-3 text-yellow-600" />;
default:
return <Type className="h-3 w-3 text-gray-500" />;
}
};
export const TablesPanel: React.FC<TablesPanelProps> = ({
tables,
searchTerm,
onSearchChange,
onDragStart,
selectedTableName,
}) => {
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
const toggleTable = (tableName: string) => {
const newExpanded = new Set(expandedTables);
if (newExpanded.has(tableName)) {
newExpanded.delete(tableName);
} else {
newExpanded.add(tableName);
}
setExpandedTables(newExpanded);
};
const filteredTables = tables.filter(
(table) =>
table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.columns.some((col) => col.columnName.toLowerCase().includes(searchTerm.toLowerCase())),
);
return (
<div className="flex h-full flex-col">
{/* 헤더 */}
<div className="border-b border-gray-200 p-4">
{selectedTableName && (
<div className="mb-3 rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 flex items-center space-x-2">
<Database className="h-3 w-3 text-blue-600" />
<span className="font-mono text-xs text-blue-800">{selectedTableName}</span>
</div>
</div>
)}
{/* 검색 */}
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<input
type="text"
placeholder="테이블명, 컬럼명으로 검색..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full rounded-md border border-gray-300 py-2 pr-3 pl-10 focus:border-transparent focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mt-2 text-xs text-gray-600"> {filteredTables.length} </div>
</div>
{/* 테이블 목록 */}
<div className="scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 flex-1 overflow-y-auto">
<div className="space-y-1 p-2">
{filteredTables.map((table) => {
const isExpanded = expandedTables.has(table.tableName);
return (
<div key={table.tableName} className="rounded-md border border-gray-200">
{/* 테이블 헤더 */}
<div
className="flex cursor-pointer items-center justify-between p-3 hover:bg-gray-50"
onClick={() => toggleTable(table.tableName)}
>
<div className="flex flex-1 items-center space-x-2">
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
<Database className="h-4 w-4 text-blue-600" />
<div className="flex-1">
<div className="text-sm font-medium">{table.tableName}</div>
<div className="text-xs text-gray-500">{table.columns.length} </div>
</div>
</div>
<Button
size="sm"
variant="ghost"
draggable
onDragStart={(e) => onDragStart(e, table)}
className="ml-2 text-xs"
>
</Button>
</div>
{/* 컬럼 목록 */}
{isExpanded && (
<div className="border-t border-gray-200 bg-gray-50">
<div
className={`${
table.columns.length > 8
? "scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-gray-100 max-h-64 overflow-y-auto"
: ""
}`}
style={{
scrollbarWidth: "thin",
scrollbarColor: "#cbd5e1 #f1f5f9",
}}
>
{table.columns.map((column, index) => (
<div
key={column.columnName}
className={`flex cursor-pointer items-center justify-between p-2 hover:bg-white ${
index < table.columns.length - 1 ? "border-b border-gray-100" : ""
}`}
draggable
onDragStart={(e) => onDragStart(e, table, column)}
>
<div className="flex flex-1 items-center space-x-2">
{getWidgetIcon(column.widgetType)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{column.columnName}</div>
<div className="truncate text-xs text-gray-500">{column.dataType}</div>
</div>
</div>
<div className="flex flex-shrink-0 items-center space-x-1">
<Badge variant="secondary" className="text-xs">
{column.widgetType}
</Badge>
{column.required && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
</div>
</div>
))}
{/* 컬럼 수가 많을 때 안내 메시지 */}
{table.columns.length > 8 && (
<div className="sticky bottom-0 bg-gray-100 p-2 text-center">
<div className="text-xs text-gray-600">
📜 {table.columns.length} ( )
</div>
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 푸터 */}
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="text-xs text-gray-600">💡 </div>
</div>
</div>
);
};
export default TablesPanel;

View File

@ -0,0 +1,170 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Table, Search, FileText, Grid3x3, Info } from "lucide-react";
// 템플릿 컴포넌트 타입 정의
export interface TemplateComponent {
id: string;
name: string;
description: string;
category: "table" | "button" | "form" | "layout" | "chart" | "status";
icon: React.ReactNode;
defaultSize: { width: number; height: number };
components: Array<{
type: "widget" | "container";
widgetType?: string;
label: string;
placeholder?: string;
position: { x: number; y: number };
size: { width: number; height: number };
style?: any;
required?: boolean;
readonly?: boolean;
}>;
}
// 미리 정의된 템플릿 컴포넌트들
const templateComponents: TemplateComponent[] = [
// 고급 데이터 테이블 템플릿
{
id: "advanced-data-table",
name: "고급 데이터 테이블",
description: "컬럼 설정, 필터링, 페이지네이션이 포함된 완전한 데이터 테이블",
category: "table",
icon: <Table className="h-4 w-4" />,
defaultSize: { width: 1000, height: 680 },
components: [
// 데이터 테이블 컴포넌트 (특별한 타입)
{
type: "datatable",
label: "데이터 테이블",
position: { x: 0, y: 0 },
size: { width: 1000, height: 680 },
style: {
border: "1px solid #e5e7eb",
borderRadius: "8px",
backgroundColor: "#ffffff",
padding: "16px",
},
},
],
},
];
interface TemplatesPanelProps {
onDragStart: (e: React.DragEvent, template: TemplateComponent) => void;
}
export const TemplatesPanel: React.FC<TemplatesPanelProps> = ({ onDragStart }) => {
const [searchTerm, setSearchTerm] = React.useState("");
const [selectedCategory, setSelectedCategory] = React.useState<string>("all");
const categories = [
{ id: "all", name: "전체", icon: <Grid3x3 className="h-4 w-4" /> },
{ id: "table", name: "테이블", icon: <Table className="h-4 w-4" /> },
];
const filteredTemplates = templateComponents.filter((template) => {
const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === "all" || template.category === selectedCategory;
return matchesSearch && matchesCategory;
});
return (
<div className="flex h-full flex-col space-y-4 p-4">
{/* 검색 */}
<div className="space-y-3">
<div className="relative">
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="템플릿 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* 카테고리 필터 */}
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<Button
key={category.id}
variant={selectedCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category.id)}
className="flex items-center space-x-1"
>
{category.icon}
<span>{category.name}</span>
</Button>
))}
</div>
</div>
<Separator />
{/* 템플릿 목록 */}
<div className="flex-1 space-y-2 overflow-y-auto">
{filteredTemplates.length === 0 ? (
<div className="flex h-32 items-center justify-center text-center text-gray-500">
<div>
<FileText className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> </p>
</div>
</div>
) : (
filteredTemplates.map((template) => (
<div
key={template.id}
draggable
onDragStart={(e) => onDragStart(e, template)}
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-blue-300 hover:shadow-md"
>
<div className="flex items-start space-x-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-50 text-blue-600 group-hover:bg-blue-100">
{template.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center space-x-2">
<h4 className="truncate font-medium text-gray-900">{template.name}</h4>
<Badge variant="secondary" className="text-xs">
{template.components.length}
</Badge>
</div>
<p className="mt-1 line-clamp-2 text-xs text-gray-500">{template.description}</p>
<div className="mt-2 flex items-center space-x-2 text-xs text-gray-400">
<span>
{template.defaultSize.width}×{template.defaultSize.height}
</span>
<span></span>
<span className="capitalize">{template.category}</span>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* 도움말 */}
<div className="rounded-lg bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
<div className="text-xs text-blue-700">
<p className="mb-1 font-medium"> </p>
<p>릿 .</p>
</div>
</div>
</div>
</div>
);
};
export default TemplatesPanel;

View File

@ -0,0 +1,233 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { CheckboxTypeConfig } from "@/types/screen";
interface CheckboxTypeConfigPanelProps {
config: CheckboxTypeConfig;
onConfigChange: (config: CheckboxTypeConfig) => void;
}
export const CheckboxTypeConfigPanel: React.FC<CheckboxTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
defaultChecked: false,
labelPosition: "right" as const,
checkboxText: "",
trueValue: true,
falseValue: false,
indeterminate: false,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
defaultChecked: safeConfig.defaultChecked,
labelPosition: safeConfig.labelPosition,
checkboxText: safeConfig.checkboxText,
trueValue: safeConfig.trueValue?.toString() || "true",
falseValue: safeConfig.falseValue?.toString() || "false",
indeterminate: safeConfig.indeterminate,
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
defaultChecked: safeConfig.defaultChecked,
labelPosition: safeConfig.labelPosition,
checkboxText: safeConfig.checkboxText,
trueValue: safeConfig.trueValue?.toString() || "true",
falseValue: safeConfig.falseValue?.toString() || "false",
indeterminate: safeConfig.indeterminate,
});
}, [
safeConfig.defaultChecked,
safeConfig.labelPosition,
safeConfig.checkboxText,
safeConfig.trueValue,
safeConfig.falseValue,
safeConfig.indeterminate,
]);
const updateConfig = (key: keyof CheckboxTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "trueValue" || key === "falseValue") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || (key === "trueValue" ? "true" : "false") }));
// 값을 적절한 타입으로 변환
const convertedValue = value === "true" ? true : value === "false" ? false : value;
value = convertedValue;
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const currentValues = {
defaultChecked: key === "defaultChecked" ? value : localValues.defaultChecked,
labelPosition: key === "labelPosition" ? value : localValues.labelPosition,
checkboxText: key === "checkboxText" ? value : localValues.checkboxText,
trueValue:
key === "trueValue"
? value
: localValues.trueValue === "true"
? true
: localValues.trueValue === "false"
? false
: localValues.trueValue,
falseValue:
key === "falseValue"
? value
: localValues.falseValue === "true"
? true
: localValues.falseValue === "false"
? false
: localValues.falseValue,
indeterminate: key === "indeterminate" ? value : localValues.indeterminate,
};
const newConfig = JSON.parse(JSON.stringify(currentValues));
console.log("☑️ CheckboxTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
});
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 기본 체크 상태 */}
<div className="flex items-center justify-between">
<Label htmlFor="defaultChecked" className="text-sm font-medium">
</Label>
<Checkbox
id="defaultChecked"
checked={localValues.defaultChecked}
onCheckedChange={(checked) => updateConfig("defaultChecked", !!checked)}
/>
</div>
{/* 라벨 위치 */}
<div>
<Label htmlFor="labelPosition" className="text-sm font-medium">
</Label>
<Select value={localValues.labelPosition} onValueChange={(value) => updateConfig("labelPosition", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="라벨 위치 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="right"></SelectItem>
<SelectItem value="top"></SelectItem>
<SelectItem value="bottom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 체크박스 옆 텍스트 */}
<div>
<Label htmlFor="checkboxText" className="text-sm font-medium">
</Label>
<Input
id="checkboxText"
value={localValues.checkboxText}
onChange={(e) => updateConfig("checkboxText", e.target.value)}
placeholder="체크박스와 함께 표시될 텍스트"
className="mt-1"
/>
</div>
{/* 값 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="trueValue" className="text-sm font-medium">
</Label>
<Input
id="trueValue"
value={localValues.trueValue}
onChange={(e) => updateConfig("trueValue", e.target.value)}
className="mt-1"
placeholder="true"
/>
</div>
<div>
<Label htmlFor="falseValue" className="text-sm font-medium">
</Label>
<Input
id="falseValue"
value={localValues.falseValue}
onChange={(e) => updateConfig("falseValue", e.target.value)}
className="mt-1"
placeholder="false"
/>
</div>
</div>
{/* 불확정 상태 지원 */}
<div className="flex items-center justify-between">
<Label htmlFor="indeterminate" className="text-sm font-medium">
</Label>
<Checkbox
id="indeterminate"
checked={localValues.indeterminate}
onCheckedChange={(checked) => updateConfig("indeterminate", !!checked)}
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2 flex items-center space-x-2">
{localValues.labelPosition === "left" && localValues.checkboxText && (
<span className="text-sm">{localValues.checkboxText}</span>
)}
{localValues.labelPosition === "top" && localValues.checkboxText && (
<div className="w-full">
<div className="text-sm">{localValues.checkboxText}</div>
<Checkbox checked={localValues.defaultChecked} className="mt-1" />
</div>
)}
{(localValues.labelPosition === "right" || localValues.labelPosition === "bottom") && (
<>
<Checkbox checked={localValues.defaultChecked} />
{localValues.checkboxText && <span className="text-sm">{localValues.checkboxText}</span>}
</>
)}
{localValues.labelPosition === "left" && <Checkbox checked={localValues.defaultChecked} />}
</div>
<div className="mt-2 text-xs text-gray-500">
: {localValues.trueValue}, : {localValues.falseValue}
{localValues.indeterminate && ", 불확정 상태 지원"}
</div>
</div>
{/* 안내 메시지 */}
{localValues.indeterminate && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
.
.
</div>
</div>
)}
</div>
);
};
export default CheckboxTypeConfigPanel;

View File

@ -0,0 +1,304 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Slider } from "@/components/ui/slider";
import { CodeTypeConfig } from "@/types/screen";
interface CodeTypeConfigPanelProps {
config: CodeTypeConfig;
onConfigChange: (config: CodeTypeConfig) => void;
}
export const CodeTypeConfigPanel: React.FC<CodeTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
language: "javascript",
theme: "light",
fontSize: 14,
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "",
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
language: safeConfig.language,
theme: safeConfig.theme,
fontSize: safeConfig.fontSize,
lineNumbers: safeConfig.lineNumbers,
wordWrap: safeConfig.wordWrap,
readOnly: safeConfig.readOnly,
autoFormat: safeConfig.autoFormat,
placeholder: safeConfig.placeholder,
});
// 지원하는 프로그래밍 언어들
const languages = [
{ value: "javascript", label: "JavaScript" },
{ value: "typescript", label: "TypeScript" },
{ value: "python", label: "Python" },
{ value: "java", label: "Java" },
{ value: "csharp", label: "C#" },
{ value: "cpp", label: "C++" },
{ value: "c", label: "C" },
{ value: "php", label: "PHP" },
{ value: "ruby", label: "Ruby" },
{ value: "go", label: "Go" },
{ value: "rust", label: "Rust" },
{ value: "kotlin", label: "Kotlin" },
{ value: "swift", label: "Swift" },
{ value: "html", label: "HTML" },
{ value: "css", label: "CSS" },
{ value: "scss", label: "SCSS" },
{ value: "json", label: "JSON" },
{ value: "xml", label: "XML" },
{ value: "yaml", label: "YAML" },
{ value: "sql", label: "SQL" },
{ value: "markdown", label: "Markdown" },
{ value: "bash", label: "Bash" },
{ value: "powershell", label: "PowerShell" },
];
// 테마 옵션
const themes = [
{ value: "light", label: "라이트" },
{ value: "dark", label: "다크" },
{ value: "monokai", label: "Monokai" },
{ value: "github", label: "GitHub" },
{ value: "vs-code", label: "VS Code" },
];
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
language: safeConfig.language,
theme: safeConfig.theme,
fontSize: safeConfig.fontSize,
lineNumbers: safeConfig.lineNumbers,
wordWrap: safeConfig.wordWrap,
readOnly: safeConfig.readOnly,
autoFormat: safeConfig.autoFormat,
placeholder: safeConfig.placeholder,
});
}, [
safeConfig.language,
safeConfig.theme,
safeConfig.fontSize,
safeConfig.lineNumbers,
safeConfig.wordWrap,
safeConfig.readOnly,
safeConfig.autoFormat,
safeConfig.placeholder,
]);
const updateConfig = (key: keyof CodeTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value }));
// 실제 config 업데이트
const newConfig = { ...safeConfig, [key]: value };
console.log("💻 CodeTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
});
onConfigChange(newConfig);
};
return (
<div className="space-y-4">
{/* 프로그래밍 언어 */}
<div>
<Label htmlFor="language" className="text-sm font-medium">
</Label>
<Select value={localValues.language} onValueChange={(value) => updateConfig("language", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="언어 선택" />
</SelectTrigger>
<SelectContent className="max-h-60">
{languages.map((lang) => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테마 */}
<div>
<Label htmlFor="theme" className="text-sm font-medium">
</Label>
<Select value={localValues.theme} onValueChange={(value) => updateConfig("theme", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="테마 선택" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.value} value={theme.value}>
{theme.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 폰트 크기 */}
<div>
<Label htmlFor="fontSize" className="text-sm font-medium">
: {localValues.fontSize}px
</Label>
<div className="mt-2">
<Slider
value={[localValues.fontSize]}
onValueChange={(value) => updateConfig("fontSize", value[0])}
min={10}
max={24}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>10px</span>
<span>24px</span>
</div>
</div>
</div>
{/* 라인 넘버 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="lineNumbers" className="text-sm font-medium">
</Label>
<Checkbox
id="lineNumbers"
checked={localValues.lineNumbers}
onCheckedChange={(checked) => updateConfig("lineNumbers", !!checked)}
/>
</div>
{/* 단어 줄바꿈 */}
<div className="flex items-center justify-between">
<Label htmlFor="wordWrap" className="text-sm font-medium">
</Label>
<Checkbox
id="wordWrap"
checked={localValues.wordWrap}
onCheckedChange={(checked) => updateConfig("wordWrap", !!checked)}
/>
</div>
{/* 읽기 전용 */}
<div className="flex items-center justify-between">
<Label htmlFor="readOnly" className="text-sm font-medium">
</Label>
<Checkbox
id="readOnly"
checked={localValues.readOnly}
onCheckedChange={(checked) => updateConfig("readOnly", !!checked)}
/>
</div>
{/* 자동 포맷팅 */}
<div className="flex items-center justify-between">
<Label htmlFor="autoFormat" className="text-sm font-medium">
</Label>
<Checkbox
id="autoFormat"
checked={localValues.autoFormat}
onCheckedChange={(checked) => updateConfig("autoFormat", !!checked)}
/>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="코드를 입력하세요..."
className="mt-1"
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div
className={`rounded border p-3 font-mono ${
localValues.theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-black"
}`}
style={{ fontSize: `${localValues.fontSize}px` }}
>
<div className="flex">
{localValues.lineNumbers && (
<div className="mr-3 text-gray-400">
<div>1</div>
<div>2</div>
<div>3</div>
</div>
)}
<div className={localValues.wordWrap ? "whitespace-pre-wrap" : "whitespace-pre"}>
{localValues.placeholder || getCodeSample(localValues.language)}
</div>
</div>
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
: {languages.find((l) => l.value === localValues.language)?.label}, :{" "}
{themes.find((t) => t.value === localValues.theme)?.label}, : {localValues.fontSize}px
{localValues.readOnly && ", 읽기전용"}
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
<br />
<br /> Monaco Editor CodeMirror를
</div>
</div>
</div>
);
};
// 언어별 샘플 코드
const getCodeSample = (language: string): string => {
switch (language) {
case "javascript":
return "function hello() {\n console.log('Hello World!');\n}";
case "python":
return "def hello():\n print('Hello World!')\n";
case "java":
return 'public class Hello {\n public static void main(String[] args) {\n System.out.println("Hello World!");\n }\n}';
case "html":
return "<div>\n <h1>Hello World!</h1>\n</div>";
case "css":
return ".hello {\n color: blue;\n font-size: 16px;\n}";
case "json":
return '{\n "message": "Hello World!",\n "status": "success"\n}';
default:
return "// Hello World!\nconsole.log('Hello World!');";
}
};
export default CodeTypeConfigPanel;

View File

@ -0,0 +1,320 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTypeConfig } from "@/types/screen";
interface DateTypeConfigPanelProps {
config: DateTypeConfig;
onConfigChange: (config: DateTypeConfig) => void;
}
export const DateTypeConfigPanel: React.FC<DateTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
format: "YYYY-MM-DD" as const,
showTime: false,
placeholder: "",
minDate: "",
maxDate: "",
defaultValue: "",
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState(() => {
console.log("📅 DateTypeConfigPanel 초기 상태 설정:", {
config,
safeConfig,
});
return {
format: safeConfig.format,
showTime: safeConfig.showTime,
placeholder: safeConfig.placeholder,
minDate: safeConfig.minDate,
maxDate: safeConfig.maxDate,
defaultValue: safeConfig.defaultValue,
};
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
// config가 실제로 존재하고 의미있는 데이터가 있을 때만 업데이트
const hasValidConfig = config && Object.keys(config).length > 0;
console.log("📅 DateTypeConfigPanel config 변경 감지:", {
config,
configExists: !!config,
configKeys: config ? Object.keys(config) : [],
hasValidConfig,
safeConfig,
safeConfigKeys: Object.keys(safeConfig),
currentLocalValues: localValues,
configStringified: JSON.stringify(config),
safeConfigStringified: JSON.stringify(safeConfig),
willUpdateLocalValues: hasValidConfig,
timestamp: new Date().toISOString(),
});
// config가 없거나 비어있으면 로컬 상태를 유지
if (!hasValidConfig) {
console.log("⚠️ config가 없거나 비어있음 - 로컬 상태 유지");
return;
}
const newLocalValues = {
format: safeConfig.format,
showTime: safeConfig.showTime,
placeholder: safeConfig.placeholder,
minDate: safeConfig.minDate,
maxDate: safeConfig.maxDate,
defaultValue: safeConfig.defaultValue,
};
// 실제로 변경된 값이 있을 때만 업데이트
const hasChanges =
localValues.format !== newLocalValues.format ||
localValues.showTime !== newLocalValues.showTime ||
localValues.defaultValue !== newLocalValues.defaultValue ||
localValues.placeholder !== newLocalValues.placeholder ||
localValues.minDate !== newLocalValues.minDate ||
localValues.maxDate !== newLocalValues.maxDate;
console.log("🔄 로컬 상태 업데이트 검사:", {
oldLocalValues: localValues,
newLocalValues,
hasChanges,
changes: {
format: localValues.format !== newLocalValues.format,
showTime: localValues.showTime !== newLocalValues.showTime,
defaultValue: localValues.defaultValue !== newLocalValues.defaultValue,
placeholder: localValues.placeholder !== newLocalValues.placeholder,
minDate: localValues.minDate !== newLocalValues.minDate,
maxDate: localValues.maxDate !== newLocalValues.maxDate,
},
});
if (hasChanges) {
console.log("✅ 로컬 상태 업데이트 실행");
setLocalValues(newLocalValues);
} else {
console.log("⏭️ 변경사항 없음 - 로컬 상태 유지");
}
}, [JSON.stringify(config)]);
const updateConfig = (key: keyof DateTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value }));
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const newConfig = JSON.parse(JSON.stringify({ ...localValues, [key]: value }));
console.log("📅 DateTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
timestamp: new Date().toISOString(),
changes: {
format: newConfig.format !== safeConfig.format,
showTime: newConfig.showTime !== safeConfig.showTime,
placeholder: newConfig.placeholder !== safeConfig.placeholder,
minDate: newConfig.minDate !== safeConfig.minDate,
maxDate: newConfig.maxDate !== safeConfig.maxDate,
defaultValue: newConfig.defaultValue !== safeConfig.defaultValue,
},
willCallOnConfigChange: true,
});
console.log("🔄 onConfigChange 호출 직전:", {
newConfig,
configStringified: JSON.stringify(newConfig),
});
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
setTimeout(() => {
console.log("✅ onConfigChange 호출 완료:", {
key,
newConfig,
timestamp: new Date().toISOString(),
});
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 날짜 형식 */}
<div>
<Label htmlFor="dateFormat" className="text-sm font-medium">
</Label>
<Select
value={localValues.format}
onValueChange={(value) => {
console.log("📅 날짜 형식 변경:", {
oldFormat: localValues.format,
newFormat: value,
oldShowTime: localValues.showTime,
});
// format 변경 시 showTime도 자동 동기화
const hasTime = value.includes("HH:mm");
// 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
const newConfig = JSON.parse(
JSON.stringify({
...localValues,
format: value,
showTime: hasTime,
}),
);
console.log("🔄 format+showTime 동시 업데이트:", {
newFormat: value,
newShowTime: hasTime,
newConfig,
});
// 로컬 상태도 동시 업데이트
setLocalValues((prev) => ({
...prev,
format: value,
showTime: hasTime,
}));
// 한 번에 업데이트
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
}}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="날짜 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm">YYYY-MM-DD HH:mm</SelectItem>
<SelectItem value="YYYY-MM-DD HH:mm:ss">YYYY-MM-DD HH:mm:ss</SelectItem>
</SelectContent>
</Select>
</div>
{/* 시간 표시 여부 */}
<div className="flex items-center justify-between">
<Label htmlFor="showTime" className="text-sm font-medium">
</Label>
<Checkbox
id="showTime"
checked={localValues.showTime}
onCheckedChange={(checked) => {
const newShowTime = !!checked;
console.log("⏰ 시간 표시 체크박스 변경:", {
oldShowTime: localValues.showTime,
newShowTime,
currentFormat: localValues.format,
});
// showTime 변경 시 format도 적절히 조정
let newFormat = localValues.format;
if (newShowTime && !localValues.format.includes("HH:mm")) {
// 시간 표시를 켰는데 format에 시간이 없으면 기본 시간 format으로 변경
newFormat = "YYYY-MM-DD HH:mm";
} else if (!newShowTime && localValues.format.includes("HH:mm")) {
// 시간 표시를 껐는데 format에 시간이 있으면 날짜만 format으로 변경
newFormat = "YYYY-MM-DD";
}
console.log("🔄 showTime+format 동시 업데이트:", {
newShowTime,
oldFormat: localValues.format,
newFormat,
});
// 한 번에 두 값을 모두 업데이트 - 현재 로컬 상태 기반으로 생성
const newConfig = JSON.parse(
JSON.stringify({
...localValues,
showTime: newShowTime,
format: newFormat,
}),
);
// 로컬 상태도 동시 업데이트
setLocalValues((prev) => ({
...prev,
showTime: newShowTime,
format: newFormat,
}));
// 한 번에 업데이트
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
}}
/>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="날짜를 선택하세요"
className="mt-1"
/>
</div>
{/* 최소 날짜 */}
<div>
<Label htmlFor="minDate" className="text-sm font-medium">
</Label>
<Input
id="minDate"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.minDate}
onChange={(e) => updateConfig("minDate", e.target.value)}
className="mt-1"
/>
</div>
{/* 최대 날짜 */}
<div>
<Label htmlFor="maxDate" className="text-sm font-medium">
</Label>
<Input
id="maxDate"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.maxDate}
onChange={(e) => updateConfig("maxDate", e.target.value)}
className="mt-1"
/>
</div>
{/* 기본값 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Input
id="defaultValue"
type={localValues.showTime || localValues.format.includes("HH:mm") ? "datetime-local" : "date"}
value={localValues.defaultValue}
onChange={(e) => updateConfig("defaultValue", e.target.value)}
className="mt-1"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,394 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Search, Database, Link, X, Plus } from "lucide-react";
import { EntityTypeConfig } from "@/types/screen";
interface EntityTypeConfigPanelProps {
config: EntityTypeConfig;
onConfigChange: (config: EntityTypeConfig) => void;
}
export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
entityName: "",
displayField: "name",
valueField: "id",
searchable: true,
multiple: false,
allowClear: true,
placeholder: "",
apiEndpoint: "",
filters: [],
displayFormat: "simple",
maxSelections: undefined,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
entityName: safeConfig.entityName,
displayField: safeConfig.displayField,
valueField: safeConfig.valueField,
searchable: safeConfig.searchable,
multiple: safeConfig.multiple,
allowClear: safeConfig.allowClear,
placeholder: safeConfig.placeholder,
apiEndpoint: safeConfig.apiEndpoint,
displayFormat: safeConfig.displayFormat,
maxSelections: safeConfig.maxSelections?.toString() || "",
});
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
// 표시 형식 옵션
const displayFormats = [
{ value: "simple", label: "단순 (이름만)" },
{ value: "detailed", label: "상세 (이름 + 설명)" },
{ value: "custom", label: "사용자 정의" },
];
// 필터 연산자들
const operators = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
{ value: "like", label: "포함 (LIKE)" },
{ value: ">", label: "초과 (>)" },
{ value: "<", label: "미만 (<)" },
{ value: ">=", label: "이상 (>=)" },
{ value: "<=", label: "이하 (<=)" },
{ value: "in", label: "포함됨 (IN)" },
{ value: "not_in", label: "포함안됨 (NOT IN)" },
];
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
entityName: safeConfig.entityName,
displayField: safeConfig.displayField,
valueField: safeConfig.valueField,
searchable: safeConfig.searchable,
multiple: safeConfig.multiple,
allowClear: safeConfig.allowClear,
placeholder: safeConfig.placeholder,
apiEndpoint: safeConfig.apiEndpoint,
displayFormat: safeConfig.displayFormat,
maxSelections: safeConfig.maxSelections?.toString() || "",
});
}, [
safeConfig.entityName,
safeConfig.displayField,
safeConfig.valueField,
safeConfig.searchable,
safeConfig.multiple,
safeConfig.allowClear,
safeConfig.placeholder,
safeConfig.apiEndpoint,
safeConfig.displayFormat,
safeConfig.maxSelections,
]);
const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "maxSelections") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트
const newConfig = { ...safeConfig, [key]: value };
console.log("🏢 EntityTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
});
onConfigChange(newConfig);
};
const addFilter = () => {
if (newFilter.field.trim() && newFilter.value.trim()) {
const updatedFilters = [...(safeConfig.filters || []), { ...newFilter }];
updateConfig("filters", updatedFilters);
setNewFilter({ field: "", operator: "=", value: "" });
}
};
const removeFilter = (index: number) => {
const updatedFilters = (safeConfig.filters || []).filter((_, i) => i !== index);
updateConfig("filters", updatedFilters);
};
const updateFilter = (index: number, field: keyof typeof newFilter, value: string) => {
const updatedFilters = [...(safeConfig.filters || [])];
updatedFilters[index] = { ...updatedFilters[index], [field]: value };
updateConfig("filters", updatedFilters);
};
return (
<div className="space-y-4">
{/* 엔터티 이름 */}
<div>
<Label htmlFor="entityName" className="text-sm font-medium">
</Label>
<Input
id="entityName"
value={localValues.entityName}
onChange={(e) => updateConfig("entityName", e.target.value)}
placeholder="예: User, Company, Product"
className="mt-1"
/>
</div>
{/* API 엔드포인트 */}
<div>
<Label htmlFor="apiEndpoint" className="text-sm font-medium">
API
</Label>
<Input
id="apiEndpoint"
value={localValues.apiEndpoint}
onChange={(e) => updateConfig("apiEndpoint", e.target.value)}
placeholder="예: /api/users"
className="mt-1"
/>
</div>
{/* 필드 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="valueField" className="text-sm font-medium">
</Label>
<Input
id="valueField"
value={localValues.valueField}
onChange={(e) => updateConfig("valueField", e.target.value)}
placeholder="id"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="displayField" className="text-sm font-medium">
</Label>
<Input
id="displayField"
value={localValues.displayField}
onChange={(e) => updateConfig("displayField", e.target.value)}
placeholder="name"
className="mt-1"
/>
</div>
</div>
{/* 표시 형식 */}
<div>
<Label htmlFor="displayFormat" className="text-sm font-medium">
</Label>
<Select value={localValues.displayFormat} onValueChange={(value) => updateConfig("displayFormat", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="형식 선택" />
</SelectTrigger>
<SelectContent>
{displayFormats.map((format) => (
<SelectItem key={format.value} value={format.value}>
{format.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="엔터티를 선택하세요"
className="mt-1"
/>
</div>
{/* 옵션들 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="searchable" className="text-sm font-medium">
</Label>
<Checkbox
id="searchable"
checked={localValues.searchable}
onCheckedChange={(checked) => updateConfig("searchable", !!checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="allowClear" className="text-sm font-medium">
</Label>
<Checkbox
id="allowClear"
checked={localValues.allowClear}
onCheckedChange={(checked) => updateConfig("allowClear", !!checked)}
/>
</div>
</div>
{/* 최대 선택 개수 (다중 선택 시) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxSelections" className="text-sm font-medium">
</Label>
<Input
id="maxSelections"
type="number"
min="1"
value={localValues.maxSelections}
onChange={(e) => updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
)}
{/* 필터 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 필터 목록 */}
<div className="max-h-40 space-y-2 overflow-y-auto">
{(safeConfig.filters || []).map((filter, index) => (
<div key={index} className="flex items-center space-x-2 rounded border p-2 text-sm">
<Input
value={filter.field}
onChange={(e) => updateFilter(index, "field", e.target.value)}
placeholder="필드명"
className="flex-1"
/>
<Select value={filter.operator} onValueChange={(value) => updateFilter(index, "operator", value)}>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={filter.value}
onChange={(e) => updateFilter(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={() => removeFilter(index)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 새 필터 추가 */}
<div className="flex items-center space-x-2 rounded border-2 border-dashed border-gray-300 p-2">
<Input
value={newFilter.field}
onChange={(e) => setNewFilter((prev) => ({ ...prev, field: e.target.value }))}
placeholder="필드명"
className="flex-1"
/>
<Select
value={newFilter.operator}
onValueChange={(value) => setNewFilter((prev) => ({ ...prev, operator: value }))}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.value}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={newFilter.value}
onChange={(e) => setNewFilter((prev) => ({ ...prev, value: e.target.value }))}
placeholder="값"
className="flex-1"
/>
<Button size="sm" onClick={addFilter} disabled={!newFilter.field.trim() || !newFilter.value.trim()}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-xs text-gray-500"> {(safeConfig.filters || []).length} </div>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div className="flex items-center space-x-2 rounded border bg-white p-2">
{localValues.searchable && <Search className="h-4 w-4 text-gray-400" />}
<div className="flex-1 text-sm text-gray-600">
{localValues.placeholder || `${localValues.entityName || "엔터티"}를 선택하세요`}
</div>
<Database className="h-4 w-4 text-gray-400" />
</div>
</div>
<div className="mt-2 text-xs text-gray-500">
: {localValues.entityName || "없음"}, API: {localValues.apiEndpoint || "없음"}, :{" "}
{localValues.valueField}, : {localValues.displayField}
{localValues.multiple && `, 다중선택`}
{localValues.searchable && `, 검색가능`}
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
<br />
API
<br />
<br /> ,
</div>
</div>
</div>
);
};
export default EntityTypeConfigPanel;

View File

@ -0,0 +1,301 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import { X, Upload, FileText, Image, FileVideo, FileAudio } from "lucide-react";
import { FileTypeConfig } from "@/types/screen";
interface FileTypeConfigPanelProps {
config: FileTypeConfig;
onConfigChange: (config: FileTypeConfig) => void;
}
export const FileTypeConfigPanel: React.FC<FileTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
accept: "",
multiple: false,
maxSize: 10, // MB
maxFiles: 1,
preview: true,
dragDrop: true,
allowedExtensions: [],
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
accept: safeConfig.accept,
multiple: safeConfig.multiple,
maxSize: safeConfig.maxSize,
maxFiles: safeConfig.maxFiles,
preview: safeConfig.preview,
dragDrop: safeConfig.dragDrop,
});
const [newExtension, setNewExtension] = useState("");
// 미리 정의된 파일 타입들
const fileTypePresets = [
{ label: "이미지", accept: "image/*", extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp"], icon: Image },
{
label: "문서",
accept: ".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx",
extensions: [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"],
icon: FileText,
},
{ label: "비디오", accept: "video/*", extensions: [".mp4", ".avi", ".mov", ".mkv"], icon: FileVideo },
{ label: "오디오", accept: "audio/*", extensions: [".mp3", ".wav", ".ogg", ".m4a"], icon: FileAudio },
];
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
accept: safeConfig.accept,
multiple: safeConfig.multiple,
maxSize: safeConfig.maxSize,
maxFiles: safeConfig.maxFiles,
preview: safeConfig.preview,
dragDrop: safeConfig.dragDrop,
});
}, [
safeConfig.accept,
safeConfig.multiple,
safeConfig.maxSize,
safeConfig.maxFiles,
safeConfig.preview,
safeConfig.dragDrop,
]);
const updateConfig = (key: keyof FileTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: value }));
// 실제 config 업데이트
const newConfig = { ...safeConfig, [key]: value };
console.log("📁 FileTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
});
onConfigChange(newConfig);
};
const applyFileTypePreset = (preset: (typeof fileTypePresets)[0]) => {
updateConfig("accept", preset.accept);
updateConfig("allowedExtensions", preset.extensions);
};
const addExtension = () => {
if (newExtension.trim() && !newExtension.includes(" ")) {
const extension = newExtension.startsWith(".") ? newExtension : `.${newExtension}`;
const updatedExtensions = [...(safeConfig.allowedExtensions || []), extension];
updateConfig("allowedExtensions", updatedExtensions);
setNewExtension("");
}
};
const removeExtension = (index: number) => {
const updatedExtensions = (safeConfig.allowedExtensions || []).filter((_, i) => i !== index);
updateConfig("allowedExtensions", updatedExtensions);
};
const formatFileSize = (sizeInMB: number) => {
if (sizeInMB < 1) {
return `${Math.round(sizeInMB * 1024)} KB`;
}
return `${sizeInMB} MB`;
};
return (
<div className="space-y-4">
{/* 파일 타입 프리셋 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 grid grid-cols-2 gap-2">
{fileTypePresets.map((preset) => {
const IconComponent = preset.icon;
return (
<Button
key={preset.label}
variant="outline"
size="sm"
onClick={() => applyFileTypePreset(preset)}
className="flex items-center space-x-2"
>
<IconComponent className="h-3 w-3" />
<span>{preset.label}</span>
</Button>
);
})}
</div>
</div>
{/* Accept 속성 */}
<div>
<Label htmlFor="accept" className="text-sm font-medium">
(accept)
</Label>
<Input
id="accept"
value={localValues.accept}
onChange={(e) => updateConfig("accept", e.target.value)}
placeholder="예: image/*,.pdf,.docx"
className="mt-1"
/>
<div className="mt-1 text-xs text-gray-500">MIME </div>
</div>
{/* 허용 확장자 관리 */}
<div>
<Label className="text-sm font-medium"> </Label>
<div className="mt-2 flex flex-wrap gap-1">
{(safeConfig.allowedExtensions || []).map((extension, index) => (
<Badge key={index} variant="secondary" className="flex items-center space-x-1">
<span>{extension}</span>
<X className="h-3 w-3 cursor-pointer" onClick={() => removeExtension(index)} />
</Badge>
))}
</div>
<div className="mt-2 flex space-x-2">
<Input
value={newExtension}
onChange={(e) => setNewExtension(e.target.value)}
placeholder="확장자 입력 (예: jpg)"
className="flex-1"
/>
<Button size="sm" onClick={addExtension} disabled={!newExtension.trim()}>
</Button>
</div>
</div>
{/* 다중 파일 선택 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
{/* 최대 파일 크기 */}
<div>
<Label htmlFor="maxSize" className="text-sm font-medium">
: {formatFileSize(localValues.maxSize)}
</Label>
<div className="mt-2">
<Slider
value={[localValues.maxSize]}
onValueChange={(value) => updateConfig("maxSize", value[0])}
min={0.1}
max={100}
step={0.1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>100 KB</span>
<span>100 MB</span>
</div>
</div>
</div>
{/* 최대 파일 개수 (다중 선택 시) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxFiles" className="text-sm font-medium">
: {localValues.maxFiles}
</Label>
<div className="mt-2">
<Slider
value={[localValues.maxFiles]}
onValueChange={(value) => updateConfig("maxFiles", value[0])}
min={1}
max={20}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>20</span>
</div>
</div>
</div>
)}
{/* 미리보기 표시 */}
<div className="flex items-center justify-between">
<Label htmlFor="preview" className="text-sm font-medium">
()
</Label>
<Checkbox
id="preview"
checked={localValues.preview}
onCheckedChange={(checked) => updateConfig("preview", !!checked)}
/>
</div>
{/* 드래그 앤 드롭 */}
<div className="flex items-center justify-between">
<Label htmlFor="dragDrop" className="text-sm font-medium">
</Label>
<Checkbox
id="dragDrop"
checked={localValues.dragDrop}
onCheckedChange={(checked) => updateConfig("dragDrop", !!checked)}
/>
</div>
{/* 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<div
className={`rounded border-2 border-dashed p-4 text-center ${
localValues.dragDrop ? "border-blue-300 bg-blue-50" : "border-gray-300"
}`}
>
<Upload className="mx-auto h-8 w-8 text-gray-400" />
<div className="mt-2 text-sm text-gray-600">
{localValues.dragDrop ? "파일을 드래그하여 놓거나 클릭하여 선택" : "클릭하여 파일 선택"}
</div>
<div className="mt-1 text-xs text-gray-500">
{localValues.accept && `허용 타입: ${localValues.accept}`}
{(safeConfig.allowedExtensions || []).length > 0 && (
<div>: {(safeConfig.allowedExtensions || []).join(", ")}</div>
)}
: {formatFileSize(localValues.maxSize)}
{localValues.multiple && `, 최대 ${localValues.maxFiles}`}
</div>
</div>
</div>
</div>
{/* 안내 메시지 */}
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
Accept
<br />
<br />
<br />
</div>
</div>
</div>
);
};
export default FileTypeConfigPanel;

View File

@ -0,0 +1,248 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { NumberTypeConfig } from "@/types/screen";
interface NumberTypeConfigPanelProps {
config: NumberTypeConfig;
onConfigChange: (config: NumberTypeConfig) => void;
}
export const NumberTypeConfigPanel: React.FC<NumberTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
format: "integer" as const,
min: undefined,
max: undefined,
step: undefined,
decimalPlaces: undefined,
thousandSeparator: false,
prefix: "",
suffix: "",
placeholder: "",
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
format: safeConfig.format,
min: safeConfig.min?.toString() || "",
max: safeConfig.max?.toString() || "",
step: safeConfig.step?.toString() || "",
decimalPlaces: safeConfig.decimalPlaces?.toString() || "",
thousandSeparator: safeConfig.thousandSeparator,
prefix: safeConfig.prefix,
suffix: safeConfig.suffix,
placeholder: safeConfig.placeholder,
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
format: safeConfig.format,
min: safeConfig.min?.toString() || "",
max: safeConfig.max?.toString() || "",
step: safeConfig.step?.toString() || "",
decimalPlaces: safeConfig.decimalPlaces?.toString() || "",
thousandSeparator: safeConfig.thousandSeparator,
prefix: safeConfig.prefix,
suffix: safeConfig.suffix,
placeholder: safeConfig.placeholder,
});
}, [
safeConfig.format,
safeConfig.min,
safeConfig.max,
safeConfig.step,
safeConfig.decimalPlaces,
safeConfig.thousandSeparator,
safeConfig.prefix,
safeConfig.suffix,
safeConfig.placeholder,
]);
const updateConfig = (key: keyof NumberTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "min" || key === "max" || key === "step" || key === "decimalPlaces") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const currentValues = {
format: key === "format" ? value : localValues.format,
min: key === "min" ? value : localValues.min ? Number(localValues.min) : undefined,
max: key === "max" ? value : localValues.max ? Number(localValues.max) : undefined,
step: key === "step" ? value : localValues.step ? Number(localValues.step) : undefined,
decimalPlaces:
key === "decimalPlaces" ? value : localValues.decimalPlaces ? Number(localValues.decimalPlaces) : undefined,
thousandSeparator: key === "thousandSeparator" ? value : localValues.thousandSeparator,
prefix: key === "prefix" ? value : localValues.prefix,
suffix: key === "suffix" ? value : localValues.suffix,
placeholder: key === "placeholder" ? value : localValues.placeholder,
};
const newConfig = JSON.parse(JSON.stringify(currentValues));
console.log("🔢 NumberTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
timestamp: new Date().toISOString(),
});
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 숫자 형식 */}
<div>
<Label htmlFor="format" className="text-sm font-medium">
</Label>
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="숫자 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="integer"></SelectItem>
<SelectItem value="decimal"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percentage"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 범위 설정 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="min" className="text-sm font-medium">
</Label>
<Input
id="min"
type="number"
value={localValues.min}
onChange={(e) => updateConfig("min", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="최소값"
/>
</div>
<div>
<Label htmlFor="max" className="text-sm font-medium">
</Label>
<Input
id="max"
type="number"
value={localValues.max}
onChange={(e) => updateConfig("max", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="최대값"
/>
</div>
</div>
{/* 단계값 */}
<div>
<Label htmlFor="step" className="text-sm font-medium">
( )
</Label>
<Input
id="step"
type="number"
step="0.01"
value={localValues.step}
onChange={(e) => updateConfig("step", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="1"
/>
</div>
{/* 소수점 자릿수 (decimal 형식인 경우) */}
{localValues.format === "decimal" && (
<div>
<Label htmlFor="decimalPlaces" className="text-sm font-medium">
릿
</Label>
<Input
id="decimalPlaces"
type="number"
min="0"
max="10"
value={localValues.decimalPlaces}
onChange={(e) => updateConfig("decimalPlaces", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="2"
/>
</div>
)}
{/* 천 단위 구분자 */}
<div className="flex items-center justify-between">
<Label htmlFor="thousandSeparator" className="text-sm font-medium">
</Label>
<Checkbox
id="thousandSeparator"
checked={localValues.thousandSeparator}
onCheckedChange={(checked) => updateConfig("thousandSeparator", !!checked)}
/>
</div>
{/* 접두사/접미사 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="prefix" className="text-sm font-medium">
</Label>
<Input
id="prefix"
value={localValues.prefix}
onChange={(e) => updateConfig("prefix", e.target.value)}
className="mt-1"
placeholder="$, ₩ 등"
/>
</div>
<div>
<Label htmlFor="suffix" className="text-sm font-medium">
</Label>
<Input
id="suffix"
value={localValues.suffix}
onChange={(e) => updateConfig("suffix", e.target.value)}
className="mt-1"
placeholder="%, kg 등"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="숫자를 입력하세요"
className="mt-1"
/>
</div>
</div>
);
};

View File

@ -0,0 +1,295 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Plus, X } from "lucide-react";
import { RadioTypeConfig } from "@/types/screen";
interface RadioTypeConfigPanelProps {
config: RadioTypeConfig;
onConfigChange: (config: RadioTypeConfig) => void;
}
export const RadioTypeConfigPanel: React.FC<RadioTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
options: [],
layout: "vertical" as const,
defaultValue: "",
allowNone: false,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
layout: safeConfig.layout,
defaultValue: safeConfig.defaultValue,
allowNone: safeConfig.allowNone,
});
const [newOption, setNewOption] = useState({ label: "", value: "" });
// 옵션들의 로컬 편집 상태
const [localOptions, setLocalOptions] = useState(
(safeConfig.options || []).map((option) => ({
label: option.label || "",
value: option.value || "",
})),
);
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
layout: safeConfig.layout,
defaultValue: safeConfig.defaultValue,
allowNone: safeConfig.allowNone,
});
setLocalOptions(
(safeConfig.options || []).map((option) => ({
label: option.label || "",
value: option.value || "",
})),
);
}, [safeConfig.layout, safeConfig.defaultValue, safeConfig.allowNone, JSON.stringify(safeConfig.options)]);
const updateConfig = (key: keyof RadioTypeConfig, value: any) => {
// "__none__" 값을 빈 문자열로 변환
const processedValue = key === "defaultValue" && value === "__none__" ? "" : value;
// 로컬 상태 즉시 업데이트
setLocalValues((prev) => ({ ...prev, [key]: processedValue }));
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: processedValue }));
console.log("📻 RadioTypeConfig 업데이트:", {
key,
value,
processedValue,
oldConfig: safeConfig,
newConfig,
timestamp: new Date().toISOString(),
});
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
const addOption = () => {
if (newOption.label.trim() && newOption.value.trim()) {
const newOptionData = { ...newOption };
const updatedOptions = [...(safeConfig.options || []), newOptionData];
console.log(" RadioType 옵션 추가:", {
newOption: newOptionData,
updatedOptions,
currentLocalOptions: localOptions,
timestamp: new Date().toISOString(),
});
// 로컬 상태 즉시 업데이트
setLocalOptions((prev) => {
const newLocalOptions = [
...prev,
{
label: newOption.label,
value: newOption.value,
},
];
console.log("📻 RadioType 로컬 옵션 업데이트:", newLocalOptions);
return newLocalOptions;
});
updateConfig("options", updatedOptions);
setNewOption({ label: "", value: "" });
}
};
const removeOption = (index: number) => {
console.log(" RadioType 옵션 삭제:", {
removeIndex: index,
currentOptions: safeConfig.options,
currentLocalOptions: localOptions,
});
// 로컬 상태 즉시 업데이트
setLocalOptions((prev) => {
const newLocalOptions = prev.filter((_, i) => i !== index);
console.log("📻 RadioType 로컬 옵션 삭제 후:", newLocalOptions);
return newLocalOptions;
});
const updatedOptions = (safeConfig.options || []).filter((_, i) => i !== index);
updateConfig("options", updatedOptions);
};
const updateOption = (index: number, field: "label" | "value", value: string) => {
// 로컬 상태 즉시 업데이트 (실시간 입력 반영)
const updatedLocalOptions = [...localOptions];
updatedLocalOptions[index] = { ...updatedLocalOptions[index], [field]: value };
setLocalOptions(updatedLocalOptions);
// 실제 config 업데이트
const updatedOptions = [...(safeConfig.options || [])];
updatedOptions[index] = { ...updatedOptions[index], [field]: value };
updateConfig("options", updatedOptions);
};
return (
<div className="space-y-4">
{/* 레이아웃 방향 */}
<div>
<Label htmlFor="layout" className="text-sm font-medium">
</Label>
<Select value={localValues.layout} onValueChange={(value) => updateConfig("layout", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="레이아웃 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vertical"></SelectItem>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="grid"> (2)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 기본값 */}
<div>
<Label htmlFor="defaultValue" className="text-sm font-medium">
</Label>
<Select
value={localValues.defaultValue || "__none__"}
onValueChange={(value) => updateConfig("defaultValue", value)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="기본값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{(safeConfig.options || []).map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 선택 안함 허용 */}
<div className="flex items-center justify-between">
<Label htmlFor="allowNone" className="text-sm font-medium">
</Label>
<Checkbox
id="allowNone"
checked={localValues.allowNone}
onCheckedChange={(checked) => updateConfig("allowNone", !!checked)}
/>
</div>
{/* 옵션 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 옵션 목록 */}
<div className="max-h-64 space-y-2 overflow-y-auto">
{localOptions.map((option, index) => (
<div key={`${option.value}-${index}`} className="flex items-center space-x-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Button size="sm" variant="outline" onClick={() => removeOption(index)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
{/* 새 옵션 추가 */}
<div className="flex items-center space-x-2 rounded border-2 border-dashed border-gray-300 p-2">
<Input
value={newOption.label}
onChange={(e) => setNewOption((prev) => ({ ...prev, label: e.target.value }))}
placeholder="새 옵션 라벨"
className="flex-1"
/>
<Input
value={newOption.value}
onChange={(e) => setNewOption((prev) => ({ ...prev, value: e.target.value }))}
placeholder="새 옵션 값"
className="flex-1"
/>
<Button size="sm" onClick={addOption} disabled={!newOption.label.trim() || !newOption.value.trim()}>
<Plus className="h-3 w-3" />
</Button>
</div>
<div className="text-xs text-gray-500"> {(safeConfig.options || []).length} </div>
</div>
{/* 미리보기 */}
{(safeConfig.options || []).length > 0 && (
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<RadioGroup
value={localValues.defaultValue}
className={
localValues.layout === "horizontal"
? "flex flex-row space-x-4"
: localValues.layout === "grid"
? "grid grid-cols-2 gap-2"
: "space-y-2"
}
>
{(safeConfig.options || []).map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`preview-${option.value}`} />
<Label htmlFor={`preview-${option.value}`} className="text-sm">
{option.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="mt-2 text-xs text-gray-500">
:{" "}
{localValues.layout === "vertical" ? "세로" : localValues.layout === "horizontal" ? "가로" : "격자"},
: {localValues.defaultValue || "없음"}
{localValues.allowNone && ", 선택해제 가능"}
</div>
</div>
)}
{/* 안내 메시지 */}
{localValues.allowNone && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
.
</div>
</div>
)}
</div>
);
};
export default RadioTypeConfigPanel;

View File

@ -0,0 +1,290 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { SelectTypeConfig } from "@/types/screen";
interface SelectTypeConfigPanelProps {
config: SelectTypeConfig;
onConfigChange: (config: SelectTypeConfig) => void;
}
export const SelectTypeConfigPanel: React.FC<SelectTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
options: [],
multiple: false,
searchable: false,
placeholder: "",
allowClear: false,
maxSelections: undefined,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
multiple: safeConfig.multiple,
searchable: safeConfig.searchable,
placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "",
});
const [newOption, setNewOption] = useState({ label: "", value: "" });
// 옵션들의 로컬 편집 상태
const [localOptions, setLocalOptions] = useState(
(safeConfig.options || []).map((option) => ({
label: option.label || "",
value: option.value || "",
disabled: option.disabled || false,
})),
);
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
multiple: safeConfig.multiple,
searchable: safeConfig.searchable,
placeholder: safeConfig.placeholder,
allowClear: safeConfig.allowClear,
maxSelections: safeConfig.maxSelections?.toString() || "",
});
setLocalOptions(
(safeConfig.options || []).map((option) => ({
label: option.label || "",
value: option.value || "",
disabled: option.disabled || false,
})),
);
}, [
safeConfig.multiple,
safeConfig.searchable,
safeConfig.placeholder,
safeConfig.allowClear,
safeConfig.maxSelections,
JSON.stringify(safeConfig.options), // 옵션 배열의 전체 내용 변화 감지
]);
const updateConfig = (key: keyof SelectTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "maxSelections") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트 - 깊은 복사로 새 객체 보장
const newConfig = JSON.parse(JSON.stringify({ ...safeConfig, [key]: value }));
console.log("📋 SelectTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
timestamp: new Date().toISOString(),
});
// 약간의 지연을 두고 업데이트 (배치 업데이트 방지)
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
const addOption = () => {
if (newOption.label.trim() && newOption.value.trim()) {
const newOptionData = { ...newOption, disabled: false };
const updatedOptions = [...(safeConfig.options || []), newOptionData];
console.log(" SelectType 옵션 추가:", {
newOption: newOptionData,
updatedOptions,
currentLocalOptions: localOptions,
timestamp: new Date().toISOString(),
});
// 로컬 상태 즉시 업데이트
setLocalOptions((prev) => {
const newLocalOptions = [
...prev,
{
label: newOption.label,
value: newOption.value,
disabled: false,
},
];
console.log("📋 SelectType 로컬 옵션 업데이트:", newLocalOptions);
return newLocalOptions;
});
updateConfig("options", updatedOptions);
setNewOption({ label: "", value: "" });
}
};
const removeOption = (index: number) => {
console.log(" SelectType 옵션 삭제:", {
removeIndex: index,
currentOptions: safeConfig.options,
currentLocalOptions: localOptions,
});
// 로컬 상태 즉시 업데이트
setLocalOptions((prev) => {
const newLocalOptions = prev.filter((_, i) => i !== index);
console.log("📋 SelectType 로컬 옵션 삭제 후:", newLocalOptions);
return newLocalOptions;
});
const updatedOptions = (safeConfig.options || []).filter((_, i) => i !== index);
updateConfig("options", updatedOptions);
};
const updateOption = (index: number, field: "label" | "value" | "disabled", value: any) => {
// 로컬 상태 즉시 업데이트 (실시간 입력 반영)
const updatedLocalOptions = [...localOptions];
updatedLocalOptions[index] = { ...updatedLocalOptions[index], [field]: value };
setLocalOptions(updatedLocalOptions);
// 실제 config 업데이트
const updatedOptions = [...(safeConfig.options || [])];
updatedOptions[index] = { ...updatedOptions[index], [field]: value };
updateConfig("options", updatedOptions);
};
return (
<div className="space-y-4">
{/* 기본 설정 */}
<div className="space-y-3">
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="옵션을 선택하세요"
className="mt-1"
/>
</div>
{/* 다중 선택 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiple" className="text-sm font-medium">
</Label>
<Checkbox
id="multiple"
checked={localValues.multiple}
onCheckedChange={(checked) => updateConfig("multiple", !!checked)}
/>
</div>
{/* 검색 가능 */}
<div className="flex items-center justify-between">
<Label htmlFor="searchable" className="text-sm font-medium">
</Label>
<Checkbox
id="searchable"
checked={localValues.searchable}
onCheckedChange={(checked) => updateConfig("searchable", !!checked)}
/>
</div>
{/* 클리어 허용 */}
<div className="flex items-center justify-between">
<Label htmlFor="allowClear" className="text-sm font-medium">
</Label>
<Checkbox
id="allowClear"
checked={localValues.allowClear}
onCheckedChange={(checked) => updateConfig("allowClear", !!checked)}
/>
</div>
{/* 최대 선택 개수 (다중 선택인 경우) */}
{localValues.multiple && (
<div>
<Label htmlFor="maxSelections" className="text-sm font-medium">
</Label>
<Input
id="maxSelections"
type="number"
min="1"
value={localValues.maxSelections}
onChange={(e) => updateConfig("maxSelections", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
)}
</div>
{/* 옵션 관리 */}
<div className="space-y-3">
<Label className="text-sm font-medium"> </Label>
{/* 기존 옵션 목록 */}
<div className="max-h-40 space-y-2 overflow-y-auto">
{localOptions.map((option, index) => (
<div key={`${option.value}-${index}`} className="flex items-center space-x-2 rounded border p-2">
<Input
value={option.label}
onChange={(e) => updateOption(index, "label", e.target.value)}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={option.value}
onChange={(e) => updateOption(index, "value", e.target.value)}
placeholder="값"
className="flex-1"
/>
<Checkbox
checked={option.disabled}
onCheckedChange={(checked) => updateOption(index, "disabled", !!checked)}
title="비활성화"
/>
<Button size="sm" variant="ghost" onClick={() => removeOption(index)} className="h-8 w-8 p-1">
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
{/* 새 옵션 추가 */}
<div className="flex items-center space-x-2">
<Input
value={newOption.label}
onChange={(e) => setNewOption({ ...newOption, label: e.target.value })}
placeholder="표시 텍스트"
className="flex-1"
/>
<Input
value={newOption.value}
onChange={(e) => setNewOption({ ...newOption, value: e.target.value })}
placeholder="값"
className="flex-1"
/>
<Button
size="sm"
onClick={addOption}
disabled={!newOption.label.trim() || !newOption.value.trim()}
className="h-8 w-8 p-1"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,206 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { TextTypeConfig } from "@/types/screen";
interface TextTypeConfigPanelProps {
config: TextTypeConfig;
onConfigChange: (config: TextTypeConfig) => void;
}
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
minLength: undefined,
maxLength: undefined,
pattern: "",
format: "none" as const,
placeholder: "",
multiline: false,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
minLength: safeConfig.minLength?.toString() || "",
maxLength: safeConfig.maxLength?.toString() || "",
pattern: safeConfig.pattern,
format: safeConfig.format,
placeholder: safeConfig.placeholder,
multiline: safeConfig.multiline,
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
minLength: safeConfig.minLength?.toString() || "",
maxLength: safeConfig.maxLength?.toString() || "",
pattern: safeConfig.pattern,
format: safeConfig.format,
placeholder: safeConfig.placeholder,
multiline: safeConfig.multiline,
});
}, [
safeConfig.minLength,
safeConfig.maxLength,
safeConfig.pattern,
safeConfig.format,
safeConfig.placeholder,
safeConfig.multiline,
]);
const updateConfig = (key: keyof TextTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "minLength" || key === "maxLength") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const currentValues = {
minLength: key === "minLength" ? value : localValues.minLength ? Number(localValues.minLength) : undefined,
maxLength: key === "maxLength" ? value : localValues.maxLength ? Number(localValues.maxLength) : undefined,
pattern: key === "pattern" ? value : localValues.pattern,
format: key === "format" ? value : localValues.format,
placeholder: key === "placeholder" ? value : localValues.placeholder,
multiline: key === "multiline" ? value : localValues.multiline,
};
const newConfig = JSON.parse(JSON.stringify(currentValues));
console.log("📝 TextTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
});
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 입력 형식 */}
<div>
<Label htmlFor="format" className="text-sm font-medium">
</Label>
<Select value={localValues.format} onValueChange={(value) => updateConfig("format", value)}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="입력 형식 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="email"></SelectItem>
<SelectItem value="phone"></SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="korean"></SelectItem>
<SelectItem value="english"></SelectItem>
<SelectItem value="alphanumeric"></SelectItem>
<SelectItem value="numeric"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 길이 제한 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="minLength" className="text-sm font-medium">
</Label>
<Input
id="minLength"
type="number"
min="0"
value={localValues.minLength}
onChange={(e) => updateConfig("minLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
<div>
<Label htmlFor="maxLength" className="text-sm font-medium">
</Label>
<Input
id="maxLength"
type="number"
min="0"
value={localValues.maxLength}
onChange={(e) => updateConfig("maxLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
</div>
{/* 정규식 패턴 */}
<div>
<Label htmlFor="pattern" className="text-sm font-medium">
</Label>
<Input
id="pattern"
value={localValues.pattern}
onChange={(e) => updateConfig("pattern", e.target.value)}
className="mt-1"
placeholder="예: ^[0-9]{3}-[0-9]{4}-[0-9]{4}$"
/>
<div className="mt-1 text-xs text-gray-500">JavaScript ()</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
{/* 여러 줄 입력 */}
<div className="flex items-center justify-between">
<Label htmlFor="multiline" className="text-sm font-medium">
(textarea)
</Label>
<Checkbox
id="multiline"
checked={localValues.multiline}
onCheckedChange={(checked) => updateConfig("multiline", !!checked)}
/>
</div>
{/* 형식별 안내 메시지 */}
{localValues.format !== "none" && (
<div className="rounded-md bg-blue-50 p-3">
<div className="text-sm font-medium text-blue-900"> </div>
<div className="mt-1 text-xs text-blue-800">
{localValues.format === "email" && "유효한 이메일 주소를 입력해야 합니다 (예: user@example.com)"}
{localValues.format === "phone" && "전화번호 형식으로 입력해야 합니다 (예: 010-1234-5678)"}
{localValues.format === "url" && "유효한 URL을 입력해야 합니다 (예: https://example.com)"}
{localValues.format === "korean" && "한글만 입력할 수 있습니다"}
{localValues.format === "english" && "영어만 입력할 수 있습니다"}
{localValues.format === "alphanumeric" && "영문자와 숫자만 입력할 수 있습니다"}
{localValues.format === "numeric" && "숫자만 입력할 수 있습니다"}
</div>
</div>
)}
</div>
);
};
export default TextTypeConfigPanel;

View File

@ -0,0 +1,225 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Slider } from "@/components/ui/slider";
import { TextareaTypeConfig } from "@/types/screen";
interface TextareaTypeConfigPanelProps {
config: TextareaTypeConfig;
onConfigChange: (config: TextareaTypeConfig) => void;
}
export const TextareaTypeConfigPanel: React.FC<TextareaTypeConfigPanelProps> = ({ config, onConfigChange }) => {
// 기본값이 설정된 config 사용
const safeConfig = {
rows: 3,
maxLength: undefined,
minLength: undefined,
placeholder: "",
resizable: true,
autoResize: false,
wordWrap: true,
...config,
};
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
rows: safeConfig.rows,
maxLength: safeConfig.maxLength?.toString() || "",
minLength: safeConfig.minLength?.toString() || "",
placeholder: safeConfig.placeholder,
resizable: safeConfig.resizable,
autoResize: safeConfig.autoResize,
wordWrap: safeConfig.wordWrap,
});
// config가 변경될 때 로컬 상태 동기화
useEffect(() => {
setLocalValues({
rows: safeConfig.rows,
maxLength: safeConfig.maxLength?.toString() || "",
minLength: safeConfig.minLength?.toString() || "",
placeholder: safeConfig.placeholder,
resizable: safeConfig.resizable,
autoResize: safeConfig.autoResize,
wordWrap: safeConfig.wordWrap,
});
}, [
safeConfig.rows,
safeConfig.maxLength,
safeConfig.minLength,
safeConfig.placeholder,
safeConfig.resizable,
safeConfig.autoResize,
safeConfig.wordWrap,
]);
const updateConfig = (key: keyof TextareaTypeConfig, value: any) => {
// 로컬 상태 즉시 업데이트
if (key === "maxLength" || key === "minLength") {
setLocalValues((prev) => ({ ...prev, [key]: value?.toString() || "" }));
} else {
setLocalValues((prev) => ({ ...prev, [key]: value }));
}
// 실제 config 업데이트 - 현재 로컬 상태를 기반으로 새 객체 생성 (safeConfig 기본값 덮어쓰기 방지)
const currentValues = {
rows: key === "rows" ? value : localValues.rows ? Number(localValues.rows) : undefined,
maxLength: key === "maxLength" ? value : localValues.maxLength ? Number(localValues.maxLength) : undefined,
minLength: key === "minLength" ? value : localValues.minLength ? Number(localValues.minLength) : undefined,
placeholder: key === "placeholder" ? value : localValues.placeholder,
defaultValue: key === "defaultValue" ? value : localValues.defaultValue,
resizable: key === "resizable" ? value : localValues.resizable,
autoResize: key === "autoResize" ? value : localValues.autoResize,
wordWrap: key === "wordWrap" ? value : localValues.wordWrap,
};
const newConfig = JSON.parse(JSON.stringify(currentValues));
console.log("📄 TextareaTypeConfig 업데이트:", {
key,
value,
oldConfig: safeConfig,
newConfig,
localValues,
});
setTimeout(() => {
onConfigChange(newConfig);
}, 0);
};
return (
<div className="space-y-4">
{/* 기본 행 수 */}
<div>
<Label htmlFor="rows" className="text-sm font-medium">
: {localValues.rows}
</Label>
<div className="mt-2">
<Slider
value={[localValues.rows]}
onValueChange={(value) => updateConfig("rows", value[0])}
min={1}
max={20}
step={1}
className="w-full"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>1</span>
<span>20</span>
</div>
</div>
</div>
{/* 길이 제한 */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="minLength" className="text-sm font-medium">
</Label>
<Input
id="minLength"
type="number"
min="0"
value={localValues.minLength}
onChange={(e) => updateConfig("minLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
<div>
<Label htmlFor="maxLength" className="text-sm font-medium">
</Label>
<Input
id="maxLength"
type="number"
min="0"
value={localValues.maxLength}
onChange={(e) => updateConfig("maxLength", e.target.value ? Number(e.target.value) : undefined)}
className="mt-1"
placeholder="제한 없음"
/>
</div>
</div>
{/* 플레이스홀더 */}
<div>
<Label htmlFor="placeholder" className="text-sm font-medium">
</Label>
<Input
id="placeholder"
value={localValues.placeholder}
onChange={(e) => updateConfig("placeholder", e.target.value)}
placeholder="입력 힌트 텍스트"
className="mt-1"
/>
</div>
{/* 크기 조정 가능 */}
<div className="flex items-center justify-between">
<Label htmlFor="resizable" className="text-sm font-medium">
</Label>
<Checkbox
id="resizable"
checked={localValues.resizable}
onCheckedChange={(checked) => updateConfig("resizable", !!checked)}
/>
</div>
{/* 자동 크기 조정 */}
<div className="flex items-center justify-between">
<Label htmlFor="autoResize" className="text-sm font-medium">
</Label>
<Checkbox
id="autoResize"
checked={localValues.autoResize}
onCheckedChange={(checked) => updateConfig("autoResize", !!checked)}
/>
</div>
{/* 단어 자동 줄바꿈 */}
<div className="flex items-center justify-between">
<Label htmlFor="wordWrap" className="text-sm font-medium">
</Label>
<Checkbox
id="wordWrap"
checked={localValues.wordWrap}
onCheckedChange={(checked) => updateConfig("wordWrap", !!checked)}
/>
</div>
{/* 설정 미리보기 */}
<div className="rounded-md border bg-gray-50 p-3">
<Label className="text-sm font-medium text-gray-700"></Label>
<div className="mt-2">
<textarea
className="w-full rounded border border-gray-300 p-2 text-sm"
rows={localValues.rows}
placeholder={localValues.placeholder || "텍스트를 입력하세요..."}
style={{
resize: localValues.resizable ? "both" : "none",
whiteSpace: localValues.wordWrap ? "pre-wrap" : "nowrap",
}}
readOnly
/>
</div>
<div className="mt-1 text-xs text-gray-500">
: {localValues.rows},{localValues.minLength && ` 최소: ${localValues.minLength}자,`}
{localValues.maxLength && ` 최대: ${localValues.maxLength}자,`}
{localValues.resizable ? " 크기조정 가능" : " 크기고정"}
</div>
</div>
</div>
);
};
export default TextareaTypeConfigPanel;

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@ -0,0 +1,172 @@
"use client";
import { useState, useCallback, useEffect } from "react";
export interface PanelState {
isOpen: boolean;
position: { x: number; y: number };
size: { width: number; height: number };
}
export interface PanelConfig {
id: string;
title: string;
defaultPosition: "left" | "right" | "top" | "bottom";
defaultWidth: number;
defaultHeight: number;
shortcutKey?: string;
}
export const usePanelState = (panels: PanelConfig[]) => {
const [panelStates, setPanelStates] = useState<Record<string, PanelState>>(() => {
const initialStates: Record<string, PanelState> = {};
panels.forEach((panel) => {
initialStates[panel.id] = {
isOpen: false,
position: { x: 0, y: 0 },
size: { width: panel.defaultWidth, height: panel.defaultHeight },
};
});
return initialStates;
});
// 패널 설정이 변경되었을 때 크기 업데이트
useEffect(() => {
setPanelStates((prev) => {
const newStates = { ...prev };
panels.forEach((panel) => {
if (newStates[panel.id]) {
// 기존 패널의 위치는 유지하고 크기만 업데이트
newStates[panel.id] = {
...newStates[panel.id],
size: { width: panel.defaultWidth, height: panel.defaultHeight },
};
} else {
// 새로운 패널이면 전체 초기화
newStates[panel.id] = {
isOpen: false,
position: { x: 0, y: 0 },
size: { width: panel.defaultWidth, height: panel.defaultHeight },
};
}
});
return newStates;
});
}, [panels]);
// 패널 토글
const togglePanel = useCallback((panelId: string) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: !prev[panelId]?.isOpen,
},
}));
}, []);
// 패널 열기
const openPanel = useCallback((panelId: string) => {
console.log("📂 패널 열기:", {
panelId,
timestamp: new Date().toISOString(),
});
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: true,
},
}));
}, []);
// 패널 닫기
const closePanel = useCallback((panelId: string) => {
console.log("📁 패널 닫기:", {
panelId,
timestamp: new Date().toISOString(),
});
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
isOpen: false,
},
}));
}, []);
// 모든 패널 닫기
const closeAllPanels = useCallback(() => {
setPanelStates((prev) => {
const newStates = { ...prev };
Object.keys(newStates).forEach((panelId) => {
newStates[panelId] = {
...newStates[panelId],
isOpen: false,
};
});
return newStates;
});
}, []);
// 패널 위치 업데이트
const updatePanelPosition = useCallback((panelId: string, position: { x: number; y: number }) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
position,
},
}));
}, []);
// 패널 크기 업데이트
const updatePanelSize = useCallback((panelId: string, size: { width: number; height: number }) => {
setPanelStates((prev) => ({
...prev,
[panelId]: {
...prev[panelId],
size,
},
}));
}, []);
// 키보드 단축키 처리
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Esc 키로 모든 패널 닫기
if (e.key === "Escape") {
closeAllPanels();
return;
}
// 단축키 처리
panels.forEach((panel) => {
if (panel.shortcutKey && e.key?.toLowerCase() === panel.shortcutKey?.toLowerCase()) {
// Ctrl/Cmd 키와 함께 사용
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
togglePanel(panel.id);
}
}
});
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [panels, togglePanel, closeAllPanels]);
return {
panelStates,
togglePanel,
openPanel,
closePanel,
closeAllPanels,
updatePanelPosition,
updatePanelSize,
};
};

View File

@ -173,6 +173,35 @@ export interface ApiResponse<T = unknown> {
errorCode?: string;
}
// 사용자 정보 타입
export interface UserInfo {
userId: string;
userName: string;
deptName?: string;
companyCode?: string;
userType?: string;
userTypeName?: string;
email?: string;
photo?: string;
locale?: string;
isAdmin?: boolean;
}
// 현재 사용자 정보 조회
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
try {
const response = await apiClient.get("/auth/me");
return response.data;
} catch (error: any) {
console.error("현재 사용자 정보 조회 실패:", error);
return {
success: false,
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
errorCode: error.response?.data?.errorCode,
};
}
};
// API 호출 헬퍼 함수
export const apiCall = async <T>(
method: "GET" | "POST" | "PUT" | "DELETE",

View File

@ -79,6 +79,19 @@ export const screenApi = {
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
return response.data.data;
},
// 화면 복사 (화면정보 + 레이아웃 모두 복사)
copyScreen: async (
sourceScreenId: number,
copyData: {
screenName: string;
screenCode: string;
description?: string;
},
): Promise<ScreenDefinition> => {
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData);
return response.data.data;
},
};
// 템플릿 관련 API
@ -145,6 +158,57 @@ export const tableTypeApi = {
detailSettings,
});
},
// 테이블 데이터 조회 (페이지네이션 + 검색)
getTableData: async (
tableName: string,
params: {
page?: number;
size?: number;
search?: Record<string, any>; // 검색 조건
sortBy?: string;
sortOrder?: "asc" | "desc";
} = {},
): Promise<{
data: Record<string, any>[];
total: number;
page: number;
size: number;
totalPages: number;
}> => {
const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params);
const raw = response.data?.data || response.data;
return {
data: raw.data || [],
total: raw.total || 0,
page: raw.page || params.page || 1,
size: raw.size || params.size || 10,
totalPages: raw.totalPages || Math.ceil((raw.total || 0) / (params.size || 10)),
};
},
// 데이터 추가
addTableData: async (tableName: string, data: Record<string, any>): Promise<void> => {
await apiClient.post(`/table-management/tables/${tableName}/add`, data);
},
// 데이터 수정
editTableData: async (
tableName: string,
originalData: Record<string, any>,
updatedData: Record<string, any>,
): Promise<void> => {
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData,
updatedData,
});
},
// 데이터 삭제 (단일 또는 다중)
deleteTableData: async (tableName: string, data: Record<string, any>[] | { ids: string[] }): Promise<void> => {
await apiClient.delete(`/table-management/tables/${tableName}/delete`, { data });
},
};
// 메뉴-화면 할당 관련 API

View File

@ -75,12 +75,28 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
const { gap } = gridSettings;
// 격자 단위로 너비 계산
const gridColumns = Math.max(1, Math.round(size.width / (columnWidth + gap)));
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 20px 단위로 스냅
const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
@ -97,6 +113,38 @@ export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, g
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
@ -164,3 +212,170 @@ export function isOnGridBoundary(
return positionMatch && sizeMatch;
}
/**
*
*/
export function alignGroupChildrenToGrid(
children: any[],
groupPosition: Position,
gridInfo: GridInfo,
gridSettings: GridSettings,
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 20px 단위로 스냅
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / 20);
const snappedY = padding + rowIndex * 20;
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(child.size.height / 20) * 20);
const snappedChild = {
...child,
position: {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
width: snappedWidth,
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
/**
*
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
}
console.log("📏 calculateOptimalGroupSize 시작:", {
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
minY: Math.min(acc.minY, child.position.y),
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
}
/**
*
*/
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔄 normalizeGroupChildPositions 시작:", {
childrenCount: children.length,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
});
// 모든 자식의 최소 위치 찾기
const minX = Math.min(...children.map((child) => child.position.x));
const minY = Math.min(...children.map((child) => child.position.y));
console.log("📍 최소 위치:", { minX, minY });
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
const padding = 16;
const startX = padding;
const startY = padding;
const normalizedChildren = children.map((child) => ({
...child,
position: {
x: child.position.x - minX + startX,
y: child.position.y - minY + startY,
z: child.position.z || 1,
},
}));
console.log("✅ 정규화 완료:", {
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
});
return normalizedChildren;
}

View File

@ -15,6 +15,7 @@
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",
@ -1304,6 +1305,36 @@
}
}
},
"node_modules/@radix-ui/react-collapsible": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"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-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",

View File

@ -20,6 +20,7 @@
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.7",

View File

@ -1,7 +1,7 @@
// 화면관리 시스템 타입 정의
// 기본 컴포넌트 타입
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable";
// 웹 타입 정의
export type WebType =
@ -114,18 +114,32 @@ export interface ComponentStyle {
cursor?: string;
transition?: string;
transform?: string;
// 라벨 스타일
labelDisplay?: boolean; // 라벨 표시 여부
labelText?: string; // 라벨 텍스트 (기본값은 label 속성 사용)
labelFontSize?: string | number; // 라벨 폰트 크기
labelColor?: string; // 라벨 색상
labelFontWeight?: "normal" | "bold" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900"; // 라벨 폰트 굵기
labelFontFamily?: string; // 라벨 폰트 패밀리
labelTextAlign?: "left" | "center" | "right"; // 라벨 텍스트 정렬
labelMarginBottom?: string | number; // 라벨과 컴포넌트 사이의 간격
labelBackgroundColor?: string; // 라벨 배경색
labelPadding?: string; // 라벨 패딩
labelBorderRadius?: string | number; // 라벨 모서리 둥글기
}
// BaseComponent에 스타일 속성 추가
export interface BaseComponent {
id: string;
type: ComponentType;
position: { x: number; y: number };
position: Position;
size: { width: number; height: number };
parentId?: string;
style?: ComponentStyle; // 스타일 속성 추가
tableName?: string; // 테이블명 추가
label?: string; // 라벨 추가
gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12)
}
// 컨테이너 컴포넌트
@ -181,11 +195,124 @@ export interface WidgetComponent extends BaseComponent {
required: boolean;
readonly: boolean;
validationRules?: ValidationRule[];
displayProperties?: Record<string, any>;
displayProperties?: Record<string, any>; // 레거시 지원용 (향후 제거 예정)
webTypeConfig?: WebTypeConfig; // 웹타입별 상세 설정
}
// 데이터 테이블 컬럼 설정
export interface DataTableColumn {
id: string;
columnName: string; // 실제 DB 컬럼명
label: string; // 화면에 표시될 라벨
widgetType: WebType; // 컬럼의 데이터 타입
gridColumns: number; // 그리드에서 차지할 컬럼 수 (1-12)
visible: boolean; // 테이블에 표시할지 여부
filterable: boolean; // 필터링 가능 여부
sortable: boolean; // 정렬 가능 여부
searchable: boolean; // 검색 대상 여부
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
}
// 데이터 테이블 필터 설정
export interface DataTableFilter {
columnName: string;
widgetType: WebType;
label: string;
gridColumns: number; // 필터에서 차지할 컬럼 수
webTypeConfig?: WebTypeConfig;
}
// 데이터 테이블 페이지네이션 설정
export interface DataTablePagination {
enabled: boolean;
pageSize: number; // 페이지당 행 수
pageSizeOptions: number[]; // 선택 가능한 페이지 크기들
showPageSizeSelector: boolean; // 페이지 크기 선택기 표시 여부
showPageInfo: boolean; // 페이지 정보 표시 여부
showFirstLast: boolean; // 처음/마지막 버튼 표시 여부
}
// 필드 자동 값 타입
export type FieldAutoValueType =
| "none" // 일반 입력
| "current_datetime" // 현재 날짜시간
| "current_date" // 현재 날짜
| "current_time" // 현재 시간
| "current_user" // 현재 사용자
| "uuid" // UUID 생성
| "sequence" // 시퀀스 번호
| "custom" // 사용자 정의 값
| "calculated"; // 계산 필드
// 고급 필드 설정
export interface AdvancedFieldConfig {
columnName: string; // 컬럼명
inputType: "normal" | "readonly" | "hidden" | "auto"; // 입력 타입
autoValueType: FieldAutoValueType; // 자동 값 타입
defaultValue?: string; // 기본값
customValue?: string; // 사용자 정의 값
calculationFormula?: string; // 계산 공식 (예: "{price} * {quantity}")
placeholder?: string; // 플레이스홀더
helpText?: string; // 도움말 텍스트
validationRules?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
customValidation?: string;
};
conditionalDisplay?: {
enabled: boolean;
condition: string; // 조건식 (예: "{status} === 'active'")
};
}
// 데이터 추가 모달 커스터마이징 설정
export interface DataTableAddModalConfig {
title: string; // 모달 제목
description: string; // 모달 설명
width: "sm" | "md" | "lg" | "xl" | "2xl" | "full"; // 모달 크기
layout: "single" | "two-column" | "grid"; // 레이아웃 타입
gridColumns: number; // 그리드 레이아웃 시 컬럼 수 (2-4)
fieldOrder: string[]; // 필드 표시 순서 (컬럼명 배열)
requiredFields: string[]; // 필수 필드 (컬럼명 배열)
hiddenFields: string[]; // 숨길 필드 (컬럼명 배열)
advancedFieldConfigs: Record<string, AdvancedFieldConfig>; // 고급 필드 설정
submitButtonText: string; // 제출 버튼 텍스트
cancelButtonText: string; // 취소 버튼 텍스트
}
// 데이터 테이블 컴포넌트
export interface DataTableComponent extends BaseComponent {
type: "datatable";
tableName: string; // 연결된 테이블명
title?: string; // 테이블 제목
columns: DataTableColumn[]; // 테이블 컬럼 설정
filters: DataTableFilter[]; // 검색 필터 설정
pagination: DataTablePagination; // 페이지네이션 설정
showSearchButton: boolean; // 검색 버튼 표시 여부
searchButtonText: string; // 검색 버튼 텍스트
enableExport: boolean; // 내보내기 기능 활성화
enableRefresh: boolean; // 새로고침 기능 활성화
enableAdd: boolean; // 데이터 추가 기능 활성화
enableEdit: boolean; // 데이터 수정 기능 활성화
enableDelete: boolean; // 데이터 삭제 기능 활성화
addButtonText: string; // 추가 버튼 텍스트
editButtonText: string; // 수정 버튼 텍스트
deleteButtonText: string; // 삭제 버튼 텍스트
addModalConfig: DataTableAddModalConfig; // 추가 모달 커스터마이징 설정
gridColumns: number; // 테이블이 차지할 그리드 컬럼 수
}
// 컴포넌트 유니온 타입
export type ComponentData = ContainerComponent | GroupComponent | RowComponent | ColumnComponent | WidgetComponent;
export type ComponentData =
| ContainerComponent
| GroupComponent
| RowComponent
| ColumnComponent
| WidgetComponent
| DataTableComponent;
// 레이아웃 데이터
export interface LayoutData {
@ -199,6 +326,9 @@ export interface GridSettings {
gap: number; // 기본값: 16px
padding: number; // 기본값: 16px
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
showGrid?: boolean; // 격자 표시 여부 (기본값: true)
gridColor?: string; // 격자 색상 (기본값: #d1d5db)
gridOpacity?: number; // 격자 투명도 (기본값: 0.5)
}
// 유효성 검증 규칙
@ -291,8 +421,8 @@ export interface DropZone {
export interface GroupState {
isGrouping: boolean;
selectedComponents: string[];
groupTarget: string | null;
groupMode: "create" | "add" | "remove" | "ungroup";
groupTarget?: string | null;
groupMode?: "create" | "add" | "remove" | "ungroup";
groupTitle?: string;
groupStyle?: ComponentStyle;
}
@ -313,7 +443,9 @@ export interface ColumnInfo {
columnLabel?: string;
dataType: string;
webType?: WebType;
widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일)
isNullable: string;
required?: boolean; // isNullable에서 변환된 필드
columnDefault?: string;
characterMaximumLength?: number;
numericPrecision?: number;
@ -370,3 +502,115 @@ export interface PaginatedResponse<T> {
size: number;
totalPages: number;
}
// ===== 웹타입별 상세 설정 인터페이스 =====
// 날짜/시간 타입 설정
export interface DateTypeConfig {
format: "YYYY-MM-DD" | "YYYY-MM-DD HH:mm" | "YYYY-MM-DD HH:mm:ss";
showTime: boolean;
minDate?: string;
maxDate?: string;
defaultValue?: string;
placeholder?: string;
}
// 숫자 타입 설정
export interface NumberTypeConfig {
min?: number;
max?: number;
step?: number;
format?: "integer" | "decimal" | "currency" | "percentage";
decimalPlaces?: number;
thousandSeparator?: boolean;
prefix?: string; // 접두사 (예: $, ₩)
suffix?: string; // 접미사 (예: %, kg)
placeholder?: string;
}
// 선택박스 타입 설정
export interface SelectTypeConfig {
options: Array<{ label: string; value: string; disabled?: boolean }>;
multiple?: boolean;
searchable?: boolean;
placeholder?: string;
allowClear?: boolean;
maxSelections?: number; // 다중 선택 시 최대 선택 개수
}
// 텍스트 타입 설정
export interface TextTypeConfig {
minLength?: number;
maxLength?: number;
pattern?: string; // 정규식 패턴
format?: "none" | "email" | "phone" | "url" | "korean" | "english";
placeholder?: string;
autocomplete?: string;
spellcheck?: boolean;
}
// 파일 타입 설정
export interface FileTypeConfig {
accept?: string; // MIME 타입 또는 확장자 (예: ".jpg,.png" 또는 "image/*")
multiple?: boolean;
maxSize?: number; // bytes
maxFiles?: number; // 다중 업로드 시 최대 파일 개수
preview?: boolean; // 미리보기 표시 여부
dragDrop?: boolean; // 드래그 앤 드롭 지원 여부
}
// 텍스트 영역 타입 설정
export interface TextareaTypeConfig extends TextTypeConfig {
rows?: number;
cols?: number;
resize?: "none" | "both" | "horizontal" | "vertical";
wrap?: "soft" | "hard" | "off";
}
// 체크박스 타입 설정
export interface CheckboxTypeConfig {
defaultChecked?: boolean;
trueValue?: string | number | boolean; // 체크 시 값
falseValue?: string | number | boolean; // 미체크 시 값
indeterminate?: boolean; // 불확실한 상태 지원
}
// 라디오 타입 설정
export interface RadioTypeConfig {
options: Array<{ label: string; value: string; disabled?: boolean }>;
inline?: boolean; // 가로 배치 여부
defaultValue?: string;
}
// 코드 타입 설정 (공통코드 연계)
export interface CodeTypeConfig {
codeCategory: string; // 공통코드 카테고리
displayFormat?: "label" | "value" | "both"; // 표시 형식
searchable?: boolean;
placeholder?: string;
allowClear?: boolean;
}
// 엔티티 타입 설정 (참조 테이블 연계)
export interface EntityTypeConfig {
referenceTable: string;
referenceColumn: string;
displayColumn?: string; // 표시할 컬럼명 (기본값: referenceColumn)
searchable?: boolean;
placeholder?: string;
allowClear?: boolean;
filters?: Record<string, any>; // 추가 필터 조건
}
// 웹타입별 설정 유니온 타입
export type WebTypeConfig =
| DateTypeConfig
| NumberTypeConfig
| SelectTypeConfig
| TextTypeConfig
| FileTypeConfig
| TextareaTypeConfig
| CheckboxTypeConfig
| RadioTypeConfig
| CodeTypeConfig
| EntityTypeConfig;