Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into commonCodeMng
This commit is contained in:
commit
f3da984a18
|
|
@ -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) => {
|
export const getTables = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -439,3 +439,307 @@ export async function updateColumnWebType(
|
||||||
res.status(500).json(response);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
createScreen,
|
createScreen,
|
||||||
updateScreen,
|
updateScreen,
|
||||||
deleteScreen,
|
deleteScreen,
|
||||||
|
copyScreen,
|
||||||
getTables,
|
getTables,
|
||||||
getTableInfo,
|
getTableInfo,
|
||||||
getTableColumns,
|
getTableColumns,
|
||||||
|
|
@ -28,6 +29,7 @@ router.get("/screens/:id", getScreen);
|
||||||
router.post("/screens", createScreen);
|
router.post("/screens", createScreen);
|
||||||
router.put("/screens/:id", updateScreen);
|
router.put("/screens/:id", updateScreen);
|
||||||
router.delete("/screens/:id", deleteScreen);
|
router.delete("/screens/:id", deleteScreen);
|
||||||
|
router.post("/screens/:id/copy", copyScreen);
|
||||||
|
|
||||||
// 화면 코드 자동 생성
|
// 화면 코드 자동 생성
|
||||||
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
router.get("/generate-screen-code/:companyCode", generateScreenCode);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import {
|
||||||
getTableLabels,
|
getTableLabels,
|
||||||
getColumnLabels,
|
getColumnLabels,
|
||||||
updateColumnWebType,
|
updateColumnWebType,
|
||||||
|
getTableData,
|
||||||
|
addTableData,
|
||||||
|
editTableData,
|
||||||
|
deleteTableData,
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -63,4 +67,28 @@ router.put(
|
||||||
updateColumnWebType
|
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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,18 @@ import {
|
||||||
WebType,
|
WebType,
|
||||||
WidgetData,
|
WidgetData,
|
||||||
} from "../types/screen";
|
} from "../types/screen";
|
||||||
|
|
||||||
import { generateId } from "../utils/generateId";
|
import { generateId } from "../utils/generateId";
|
||||||
|
|
||||||
|
// 화면 복사 요청 인터페이스
|
||||||
|
interface CopyScreenRequest {
|
||||||
|
screenName: string;
|
||||||
|
screenCode: string;
|
||||||
|
description?: string;
|
||||||
|
companyCode: string;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 백엔드에서 사용할 테이블 정보 타입
|
// 백엔드에서 사용할 테이블 정보 타입
|
||||||
interface TableInfo {
|
interface TableInfo {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
|
|
@ -968,6 +978,120 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
return `${companyCode}_${paddedNumber}`;
|
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
|
// 서비스 인스턴스 export
|
||||||
|
|
|
||||||
|
|
@ -514,4 +514,533 @@ export class TableManagementService {
|
||||||
return {};
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1084
docs/화면관리_시스템_설계.md
1084
docs/화면관리_시스템_설계.md
File diff suppressed because it is too large
Load Diff
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 ScreenList from "@/components/screen/ScreenList";
|
||||||
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
import ScreenDesigner from "@/components/screen/ScreenDesigner";
|
||||||
import TemplateManager from "@/components/screen/TemplateManager";
|
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";
|
const isLastStep = currentStep === "template";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<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">
|
<div className="flex-1 overflow-hidden">
|
||||||
{/* 화면 목록 단계 */}
|
{/* 화면 목록 단계 */}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Loader2 } from "lucide-react";
|
||||||
import { ArrowLeft, Save, Loader2 } from "lucide-react";
|
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
import { ScreenDefinition, LayoutData } from "@/types/screen";
|
||||||
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
|
import { InteractiveScreenViewer } from "@/components/screen/InteractiveScreenViewer";
|
||||||
|
|
@ -21,7 +19,6 @@ export default function ScreenViewPage() {
|
||||||
const [layout, setLayout] = useState<LayoutData | null>(null);
|
const [layout, setLayout] = useState<LayoutData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -59,30 +56,9 @@ export default function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
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="text-center">
|
||||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-600" />
|
||||||
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
<p className="mt-2 text-gray-600">화면을 불러오는 중...</p>
|
||||||
|
|
@ -93,7 +69,7 @@ export default function ScreenViewPage() {
|
||||||
|
|
||||||
if (error || !screen) {
|
if (error || !screen) {
|
||||||
return (
|
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="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
<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>
|
<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>
|
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면을 찾을 수 없습니다</h2>
|
||||||
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
<p className="mb-4 text-gray-600">{error || "요청하신 화면이 존재하지 않습니다."}</p>
|
||||||
<Button onClick={() => router.back()} variant="outline">
|
<Button onClick={() => router.back()} variant="outline">
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
이전으로 돌아가기
|
이전으로 돌아가기
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,167 +85,108 @@ export default function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="h-screen w-screen bg-white">
|
||||||
{/* 헤더 */}
|
{layout && layout.components.length > 0 ? (
|
||||||
<div className="flex items-center justify-between border-b bg-white p-4 shadow-sm">
|
// 캔버스 컴포넌트들만 표시 - 전체 화면 사용
|
||||||
<div className="flex items-center space-x-4">
|
<div className="relative h-full w-full">
|
||||||
<Button variant="outline" size="sm" onClick={() => router.back()}>
|
{layout.components
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
.filter((comp) => !comp.parentId) // 최상위 컴포넌트만 렌더링 (그룹 포함)
|
||||||
이전
|
.map((component) => {
|
||||||
</Button>
|
// 그룹 컴포넌트인 경우 특별 처리
|
||||||
<div>
|
if (component.type === "group") {
|
||||||
<h1 className="text-xl font-semibold text-gray-900">{screen.screenName}</h1>
|
const groupChildren = layout.components.filter((child) => child.parentId === component.id);
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 메인 컨텐츠 영역 */}
|
return (
|
||||||
<div className="flex-1 overflow-hidden">
|
<div
|
||||||
{layout && layout.components.length > 0 ? (
|
key={component.id}
|
||||||
<div className="h-full p-6">
|
style={{
|
||||||
<Card className="h-full">
|
position: "absolute",
|
||||||
<CardHeader>
|
left: `${component.position.x}px`,
|
||||||
<CardTitle className="flex items-center justify-between">
|
top: `${component.position.y}px`,
|
||||||
<span>{screen.screenName}</span>
|
width: `${component.size.width}px`,
|
||||||
<Button
|
height: `${component.size.height}px`,
|
||||||
size="sm"
|
zIndex: component.position.z || 1,
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
|
||||||
onClick={handleSaveData}
|
border: (component as any).border || "2px dashed #3b82f6",
|
||||||
disabled={saving}
|
borderRadius: (component as any).borderRadius || "8px",
|
||||||
|
padding: "16px",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{/* 그룹 제목 */}
|
||||||
<>
|
{(component as any).title && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
|
||||||
저장 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
저장
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</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
|
{groupChildren.map((child) => (
|
||||||
key={component.id}
|
<div
|
||||||
style={{
|
key={child.id}
|
||||||
position: "absolute",
|
style={{
|
||||||
left: `${component.position.x}px`,
|
position: "absolute",
|
||||||
top: `${component.position.y}px`,
|
left: `${child.position.x}px`,
|
||||||
width: `${component.size.width}px`,
|
top: `${child.position.y}px`,
|
||||||
height: `${component.size.height}px`,
|
width: `${child.size.width}px`,
|
||||||
zIndex: component.position.z || 1,
|
height: `${child.size.height}px`,
|
||||||
backgroundColor: (component as any).backgroundColor || "rgba(59, 130, 246, 0.1)",
|
zIndex: child.position.z || 1,
|
||||||
border: (component as any).border || "2px dashed #3b82f6",
|
}}
|
||||||
borderRadius: (component as any).borderRadius || "8px",
|
>
|
||||||
padding: "16px",
|
<InteractiveScreenViewer
|
||||||
}}
|
component={child}
|
||||||
>
|
allComponents={layout.components}
|
||||||
{/* 그룹 제목 */}
|
formData={formData}
|
||||||
{(component as any).title && (
|
onFormDataChange={(fieldName, value) => {
|
||||||
<div className="mb-2 text-sm font-medium text-blue-700">{(component as any).title}</div>
|
setFormData((prev) => ({
|
||||||
)}
|
...prev,
|
||||||
|
[fieldName]: value,
|
||||||
{/* 그룹 내 자식 컴포넌트들 렌더링 */}
|
}));
|
||||||
{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,
|
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<InteractiveScreenViewer
|
</div>
|
||||||
component={component}
|
))}
|
||||||
allComponents={layout.components}
|
</div>
|
||||||
formData={formData}
|
);
|
||||||
onFormDataChange={(fieldName, value) => {
|
}
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
// 일반 컴포넌트 렌더링
|
||||||
[fieldName]: value,
|
return (
|
||||||
}));
|
<div
|
||||||
}}
|
key={component.id}
|
||||||
/>
|
style={{
|
||||||
</div>
|
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>
|
</div>
|
||||||
</CardContent>
|
);
|
||||||
</Card>
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center">
|
// 빈 화면일 때도 깔끔하게 표시
|
||||||
<div className="text-center">
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
<div className="text-center">
|
||||||
<span className="text-2xl">📄</span>
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white shadow-sm">
|
||||||
</div>
|
<span className="text-2xl">📄</span>
|
||||||
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
|
||||||
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="mb-2 text-xl font-semibold text-gray-900">화면이 비어있습니다</h2>
|
||||||
|
<p className="text-gray-600">이 화면에는 아직 설계된 컴포넌트가 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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
|
|
@ -11,7 +11,22 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
||||||
import { CalendarIcon } from "lucide-react";
|
import { CalendarIcon } from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { ko } from "date-fns/locale";
|
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 {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -57,6 +72,20 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
// 실제 사용 가능한 위젯 렌더링
|
// 실제 사용 가능한 위젯 렌더링
|
||||||
const renderInteractiveWidget = (comp: ComponentData) => {
|
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 { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||||
const fieldName = columnName || comp.id;
|
const fieldName = columnName || comp.id;
|
||||||
const currentValue = formData[fieldName] || "";
|
const currentValue = formData[fieldName] || "";
|
||||||
|
|
@ -78,192 +107,553 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
switch (widgetType) {
|
switch (widgetType) {
|
||||||
case "text":
|
case "text":
|
||||||
case "email":
|
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(
|
return applyStyles(
|
||||||
<Input
|
<Input
|
||||||
type={widgetType === "email" ? "email" : widgetType === "tel" ? "tel" : "text"}
|
type={inputType}
|
||||||
placeholder={placeholder || "입력하세요..."}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={handleInputChange}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
|
minLength={config?.minLength}
|
||||||
|
maxLength={config?.maxLength}
|
||||||
|
pattern={getPatternByFormat(config?.format || "none")}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "number":
|
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(
|
return applyStyles(
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder={placeholder || "숫자를 입력하세요..."}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
|
onChange={(e) => updateFormData(fieldName, e.target.valueAsNumber || 0)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
|
min={config?.min}
|
||||||
|
max={config?.max}
|
||||||
|
step={step}
|
||||||
className="h-full w-full"
|
className="h-full w-full"
|
||||||
step={widgetType === "decimal" ? "0.01" : "1"}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "textarea":
|
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(
|
return applyStyles(
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={placeholder || "내용을 입력하세요..."}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-full w-full resize-none"
|
minLength={config?.minLength}
|
||||||
rows={3}
|
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 "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(
|
return applyStyles(
|
||||||
<Select
|
<Select
|
||||||
value={currentValue}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-full w-full">
|
<SelectTrigger className="h-full w-full">
|
||||||
<SelectValue placeholder={placeholder || "선택하세요..."} />
|
<SelectValue placeholder={finalPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="option1">옵션 1</SelectItem>
|
{options.map((option, index) => (
|
||||||
<SelectItem value="option2">옵션 2</SelectItem>
|
<SelectItem key={index} value={option.value} disabled={option.disabled}>
|
||||||
<SelectItem value="option3">옵션 3</SelectItem>
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>,
|
</Select>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "checkbox":
|
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(
|
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
|
<Checkbox
|
||||||
id={fieldName}
|
id={fieldName}
|
||||||
checked={currentValue === true || currentValue === "true"}
|
checked={isChecked}
|
||||||
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
|
onCheckedChange={(checked) => updateFormData(fieldName, checked)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={fieldName} className="text-sm">
|
<label htmlFor={fieldName} className="text-sm">
|
||||||
{label || "확인"}
|
{checkboxText}
|
||||||
</label>
|
</label>
|
||||||
</div>,
|
</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(
|
return applyStyles(
|
||||||
<div className="h-full w-full space-y-2">
|
<div className={`h-full w-full ${layout === "horizontal" ? "flex flex-wrap gap-4" : "space-y-2"}`}>
|
||||||
{["옵션 1", "옵션 2", "옵션 3"].map((option, index) => (
|
{config?.allowNone && (
|
||||||
<div key={index} className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
id={`${fieldName}_${index}`}
|
id={`${fieldName}_none`}
|
||||||
name={fieldName}
|
name={fieldName}
|
||||||
value={option}
|
value=""
|
||||||
checked={currentValue === option}
|
checked={selectedValue === ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
className="h-4 w-4"
|
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">
|
<label htmlFor={`${fieldName}_${index}`} className="text-sm">
|
||||||
{option}
|
{option.label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case "date":
|
case "date": {
|
||||||
const dateValue = dateValues[fieldName];
|
const widget = comp as WidgetComponent;
|
||||||
return applyStyles(
|
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
console.log("📅 InteractiveScreenViewer - Date 위젯:", {
|
||||||
<Button
|
componentId: widget.id,
|
||||||
variant="outline"
|
widgetType: widget.widgetType,
|
||||||
className="h-full w-full justify-start text-left font-normal"
|
config,
|
||||||
disabled={readonly}
|
appliedSettings: {
|
||||||
>
|
format: config?.format,
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
showTime: config?.showTime,
|
||||||
{dateValue ? format(dateValue, "PPP", { locale: ko }) : placeholder || "날짜를 선택하세요"}
|
defaultValue: config?.defaultValue,
|
||||||
</Button>
|
minDate: config?.minDate,
|
||||||
</PopoverTrigger>
|
maxDate: config?.maxDate,
|
||||||
<PopoverContent className="w-auto p-0">
|
},
|
||||||
<Calendar
|
});
|
||||||
mode="single"
|
|
||||||
selected={dateValue}
|
const shouldShowTime = config?.showTime || config?.format?.includes("HH:mm");
|
||||||
onSelect={(date) => updateDateValue(fieldName, date)}
|
const finalPlaceholder = config?.placeholder || placeholder || "날짜를 선택하세요";
|
||||||
initialFocus
|
|
||||||
/>
|
if (shouldShowTime) {
|
||||||
</PopoverContent>
|
// 시간 포함 날짜 입력
|
||||||
</Popover>,
|
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(
|
return applyStyles(
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
placeholder={placeholder || "날짜와 시간을 입력하세요..."}
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
|
min={config?.minDate}
|
||||||
|
max={config?.maxDate}
|
||||||
className="h-full w-full"
|
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(
|
return applyStyles(
|
||||||
<Input
|
<Input
|
||||||
type="file"
|
type="file"
|
||||||
onChange={(e) => {
|
onChange={handleFileChange}
|
||||||
const file = e.target.files?.[0];
|
|
||||||
updateFormData(fieldName, file);
|
|
||||||
}}
|
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
|
multiple={config?.multiple}
|
||||||
|
accept={config?.accept}
|
||||||
className="h-full w-full"
|
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(
|
return applyStyles(
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="코드를 입력하세요..."
|
placeholder={finalPlaceholder}
|
||||||
value={currentValue}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
|
rows={rows}
|
||||||
className="h-full w-full resize-none font-mono text-sm"
|
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(
|
return applyStyles(
|
||||||
<Select
|
<Select
|
||||||
value={currentValue}
|
value={currentValue || config?.defaultValue || ""}
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-full w-full">
|
<SelectTrigger className="h-full w-full">
|
||||||
<SelectValue placeholder="엔티티를 선택하세요..." />
|
<SelectValue placeholder={finalPlaceholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="user">사용자</SelectItem>
|
{defaultOptions.map((option) => (
|
||||||
<SelectItem value="product">제품</SelectItem>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<SelectItem value="order">주문</SelectItem>
|
{config?.displayFormat
|
||||||
|
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
|
||||||
|
: option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>,
|
</Select>,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return applyStyles(
|
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 (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
{/* 라벨이 있는 경우 표시 */}
|
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||||
{component.label && (
|
{shouldShowLabel && (
|
||||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
<div className="block" style={labelStyle}>
|
||||||
{component.label}
|
{labelText}
|
||||||
{component.required && <span className="ml-1 text-red-500">*</span>}
|
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
|
@ -16,6 +16,7 @@ import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette } from "
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import CreateScreenModal from "./CreateScreenModal";
|
import CreateScreenModal from "./CreateScreenModal";
|
||||||
|
import CopyScreenModal from "./CopyScreenModal";
|
||||||
|
|
||||||
interface ScreenListProps {
|
interface ScreenListProps {
|
||||||
onScreenSelect: (screen: ScreenDefinition) => void;
|
onScreenSelect: (screen: ScreenDefinition) => void;
|
||||||
|
|
@ -30,6 +31,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||||
|
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||||
|
|
||||||
// 화면 목록 로드 (실제 API)
|
// 화면 목록 로드 (실제 API)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,6 +61,20 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
|
|
||||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
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) => {
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
onScreenSelect(screen);
|
onScreenSelect(screen);
|
||||||
};
|
};
|
||||||
|
|
@ -75,8 +92,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (screen: ScreenDefinition) => {
|
const handleCopy = (screen: ScreenDefinition) => {
|
||||||
// 복사 모달 열기
|
setScreenToCopy(screen);
|
||||||
console.log("복사:", screen);
|
setIsCopyOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleView = (screen: ScreenDefinition) => {
|
const handleView = (screen: ScreenDefinition) => {
|
||||||
|
|
@ -84,6 +101,11 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
console.log("미리보기:", screen);
|
console.log("미리보기:", screen);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCopySuccess = () => {
|
||||||
|
// 복사 성공 후 화면 목록 다시 로드
|
||||||
|
reloadScreens();
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
|
|
@ -239,6 +261,14 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
||||||
setScreens((prev) => [created, ...prev]);
|
setScreens((prev) => [created, ...prev]);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 화면 복사 모달 */}
|
||||||
|
<CopyScreenModal
|
||||||
|
isOpen={isCopyOpen}
|
||||||
|
onClose={() => setIsCopyOpen(false)}
|
||||||
|
sourceScreen={screenToCopy}
|
||||||
|
onCopySuccess={handleCopySuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -173,6 +173,35 @@ export interface ApiResponse<T = unknown> {
|
||||||
errorCode?: string;
|
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 호출 헬퍼 함수
|
// API 호출 헬퍼 함수
|
||||||
export const apiCall = async <T>(
|
export const apiCall = async <T>(
|
||||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,19 @@ export const screenApi = {
|
||||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`);
|
||||||
return response.data.data;
|
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
|
// 템플릿 관련 API
|
||||||
|
|
@ -145,6 +158,57 @@ export const tableTypeApi = {
|
||||||
detailSettings,
|
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
|
// 메뉴-화면 할당 관련 API
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,28 @@ export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: Gri
|
||||||
const { gap } = gridSettings;
|
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;
|
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||||
|
|
||||||
// 높이는 20px 단위로 스냅
|
// 높이는 20px 단위로 스냅
|
||||||
const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20);
|
const snappedHeight = Math.max(40, Math.round(size.height / 20) * 20);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
width: Math.max(columnWidth, snappedWidth),
|
width: Math.max(columnWidth, snappedWidth),
|
||||||
height: snappedHeight,
|
height: snappedHeight,
|
||||||
|
|
@ -97,6 +113,38 @@ export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, g
|
||||||
return columns * columnWidth + (columns - 1) * gap;
|
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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// 화면관리 시스템 타입 정의
|
// 화면관리 시스템 타입 정의
|
||||||
|
|
||||||
// 기본 컴포넌트 타입
|
// 기본 컴포넌트 타입
|
||||||
export type ComponentType = "container" | "row" | "column" | "widget" | "group";
|
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable";
|
||||||
|
|
||||||
// 웹 타입 정의
|
// 웹 타입 정의
|
||||||
export type WebType =
|
export type WebType =
|
||||||
|
|
@ -114,18 +114,32 @@ export interface ComponentStyle {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
transition?: string;
|
transition?: string;
|
||||||
transform?: 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에 스타일 속성 추가
|
// BaseComponent에 스타일 속성 추가
|
||||||
export interface BaseComponent {
|
export interface BaseComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: ComponentType;
|
type: ComponentType;
|
||||||
position: { x: number; y: number };
|
position: Position;
|
||||||
size: { width: number; height: number };
|
size: { width: number; height: number };
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
style?: ComponentStyle; // 스타일 속성 추가
|
style?: ComponentStyle; // 스타일 속성 추가
|
||||||
tableName?: string; // 테이블명 추가
|
tableName?: string; // 테이블명 추가
|
||||||
label?: string; // 라벨 추가
|
label?: string; // 라벨 추가
|
||||||
|
gridColumns?: number; // 그리드에서 차지할 컬럼 수 (1-12)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컨테이너 컴포넌트
|
// 컨테이너 컴포넌트
|
||||||
|
|
@ -181,11 +195,124 @@ export interface WidgetComponent extends BaseComponent {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
readonly: boolean;
|
readonly: boolean;
|
||||||
validationRules?: ValidationRule[];
|
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 {
|
export interface LayoutData {
|
||||||
|
|
@ -199,6 +326,9 @@ export interface GridSettings {
|
||||||
gap: number; // 기본값: 16px
|
gap: number; // 기본값: 16px
|
||||||
padding: number; // 기본값: 16px
|
padding: number; // 기본값: 16px
|
||||||
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
|
snapToGrid?: boolean; // 격자에 맞춤 여부 (기본값: true)
|
||||||
|
showGrid?: boolean; // 격자 표시 여부 (기본값: true)
|
||||||
|
gridColor?: string; // 격자 색상 (기본값: #d1d5db)
|
||||||
|
gridOpacity?: number; // 격자 투명도 (기본값: 0.5)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 유효성 검증 규칙
|
// 유효성 검증 규칙
|
||||||
|
|
@ -291,8 +421,8 @@ export interface DropZone {
|
||||||
export interface GroupState {
|
export interface GroupState {
|
||||||
isGrouping: boolean;
|
isGrouping: boolean;
|
||||||
selectedComponents: string[];
|
selectedComponents: string[];
|
||||||
groupTarget: string | null;
|
groupTarget?: string | null;
|
||||||
groupMode: "create" | "add" | "remove" | "ungroup";
|
groupMode?: "create" | "add" | "remove" | "ungroup";
|
||||||
groupTitle?: string;
|
groupTitle?: string;
|
||||||
groupStyle?: ComponentStyle;
|
groupStyle?: ComponentStyle;
|
||||||
}
|
}
|
||||||
|
|
@ -313,7 +443,9 @@ export interface ColumnInfo {
|
||||||
columnLabel?: string;
|
columnLabel?: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
webType?: WebType;
|
webType?: WebType;
|
||||||
|
widgetType?: WebType; // 프론트엔드에서 사용하는 필드 (webType과 동일)
|
||||||
isNullable: string;
|
isNullable: string;
|
||||||
|
required?: boolean; // isNullable에서 변환된 필드
|
||||||
columnDefault?: string;
|
columnDefault?: string;
|
||||||
characterMaximumLength?: number;
|
characterMaximumLength?: number;
|
||||||
numericPrecision?: number;
|
numericPrecision?: number;
|
||||||
|
|
@ -370,3 +502,115 @@ export interface PaginatedResponse<T> {
|
||||||
size: number;
|
size: number;
|
||||||
totalPages: 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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue