분할레이아웃
This commit is contained in:
parent
3d242c1c8e
commit
7686158a01
|
|
@ -5,6 +5,92 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 조인 데이터 조회 API (다른 라우트보다 먼저 정의)
|
||||
* GET /api/data/join?leftTable=...&rightTable=...&leftColumn=...&rightColumn=...&leftValue=...
|
||||
*/
|
||||
router.get(
|
||||
"/join",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } =
|
||||
req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!leftTable || !rightTable || !leftColumn || !rightColumn) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 파라미터가 누락되었습니다 (leftTable, rightTable, leftColumn, rightColumn).",
|
||||
error: "MISSING_PARAMETERS",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 검증
|
||||
const tables = [leftTable as string, rightTable as string];
|
||||
const columns = [leftColumn as string, rightColumn as string];
|
||||
|
||||
for (const table of tables) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 테이블명입니다: ${table}`,
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const column of columns) {
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(column)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 컬럼명입니다: ${column}`,
|
||||
error: "INVALID_COLUMN_NAME",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔗 조인 데이터 조회:`, {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
});
|
||||
|
||||
// 조인 데이터 조회
|
||||
const result = await dataService.getJoinedData(
|
||||
leftTable as string,
|
||||
rightTable as string,
|
||||
leftColumn as string,
|
||||
rightColumn as string,
|
||||
leftValue as string
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("조인 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 동적 테이블 데이터 조회 API
|
||||
* GET /api/data/{tableName}
|
||||
|
|
@ -15,7 +101,18 @@ router.get(
|
|||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { limit = "10", offset = "0", orderBy, ...filters } = req.query;
|
||||
const {
|
||||
limit,
|
||||
offset,
|
||||
page,
|
||||
size,
|
||||
orderBy,
|
||||
searchTerm,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
userLang,
|
||||
...filters
|
||||
} = req.query;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
|
|
@ -35,21 +132,43 @@ router.get(
|
|||
});
|
||||
}
|
||||
|
||||
// page/size 또는 limit/offset 방식 지원
|
||||
let finalLimit = 100;
|
||||
let finalOffset = 0;
|
||||
|
||||
if (page && size) {
|
||||
// page/size 방식
|
||||
const pageNum = parseInt(page as string) || 1;
|
||||
const sizeNum = parseInt(size as string) || 100;
|
||||
finalLimit = sizeNum;
|
||||
finalOffset = (pageNum - 1) * sizeNum;
|
||||
} else if (limit || offset) {
|
||||
// limit/offset 방식
|
||||
finalLimit = parseInt(limit as string) || 10;
|
||||
finalOffset = parseInt(offset as string) || 0;
|
||||
}
|
||||
|
||||
console.log(`📊 데이터 조회 요청: ${tableName}`, {
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
orderBy: orderBy as string,
|
||||
limit: finalLimit,
|
||||
offset: finalOffset,
|
||||
orderBy: orderBy || sortBy,
|
||||
searchTerm,
|
||||
filters,
|
||||
user: req.user?.userId,
|
||||
});
|
||||
|
||||
// filters에서 searchTerm과 sortOrder 제거 (이미 별도로 처리됨)
|
||||
const cleanFilters = { ...filters };
|
||||
delete cleanFilters.searchTerm;
|
||||
delete cleanFilters.sortOrder;
|
||||
|
||||
// 데이터 조회
|
||||
const result = await dataService.getTableData({
|
||||
tableName,
|
||||
limit: parseInt(limit as string),
|
||||
offset: parseInt(offset as string),
|
||||
orderBy: orderBy as string,
|
||||
filters: filters as Record<string, string>,
|
||||
limit: finalLimit,
|
||||
offset: finalOffset,
|
||||
orderBy: (orderBy || sortBy) as string,
|
||||
filters: cleanFilters as Record<string, string>,
|
||||
userCompany: req.user?.companyCode,
|
||||
});
|
||||
|
||||
|
|
@ -61,7 +180,21 @@ router.get(
|
|||
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
|
||||
);
|
||||
|
||||
return res.json(result.data);
|
||||
// 페이징 정보 포함하여 반환
|
||||
const total = result.data?.length || 0;
|
||||
const responsePage =
|
||||
finalLimit > 0 ? Math.floor(finalOffset / finalLimit) + 1 : 1;
|
||||
const responseSize = finalLimit;
|
||||
const totalPages = responseSize > 0 ? Math.ceil(total / responseSize) : 1;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
total,
|
||||
page: responsePage,
|
||||
size: responseSize,
|
||||
totalPages,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
|
|
@ -127,4 +260,231 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회 API
|
||||
* GET /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.get(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
|
||||
|
||||
// 레코드 상세 조회
|
||||
const result = await dataService.getRecordDetail(tableName, id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 조회 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 상세 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 생성 API
|
||||
* POST /api/data/{tableName}
|
||||
*/
|
||||
router.post(
|
||||
"/:tableName",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`➕ 레코드 생성: ${tableName}`, data);
|
||||
|
||||
// 레코드 생성
|
||||
const result = await dataService.createRecord(tableName, data);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 생성 성공: ${tableName}`);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: "레코드가 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 생성 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 수정 API
|
||||
* PUT /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.put(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
const data = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✏️ 레코드 수정: ${tableName}/${id}`, data);
|
||||
|
||||
// 레코드 수정
|
||||
const result = await dataService.updateRecord(tableName, id, data);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.data,
|
||||
message: "레코드가 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 수정 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 삭제 API
|
||||
* DELETE /api/data/{tableName}/{id}
|
||||
*/
|
||||
router.delete(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, id } = req.params;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || typeof tableName !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
error: "INVALID_TABLE_NAME",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 레코드 삭제: ${tableName}/${id}`);
|
||||
|
||||
// 레코드 삭제
|
||||
const result = await dataService.deleteRecord(tableName, id);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레코드가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("레코드 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -313,6 +313,283 @@ class DataService {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회
|
||||
*/
|
||||
async getRecordDetail(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id"; // 기본값
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
const result = await query<any>(queryText, [id]);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
*/
|
||||
async getJoinedData(
|
||||
leftTable: string,
|
||||
rightTable: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: string | number
|
||||
): Promise<ServiceResponse<any[]>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(leftTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${leftTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
if (!ALLOWED_TABLES.includes(rightTable)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${rightTable}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
let queryText = `
|
||||
SELECT r.*
|
||||
FROM "${rightTable}" r
|
||||
INNER JOIN "${leftTable}" l
|
||||
ON l."${leftColumn}" = r."${rightColumn}"
|
||||
`;
|
||||
|
||||
const values: any[] = [];
|
||||
if (leftValue !== undefined && leftValue !== null) {
|
||||
queryText += ` WHERE l."${leftColumn}" = $1`;
|
||||
values.push(leftValue);
|
||||
}
|
||||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`조인 데이터 조회 오류 (${leftTable} → ${rightTable}):`,
|
||||
error
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "조인 데이터 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 생성
|
||||
*/
|
||||
async createRecord(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
|
||||
const columnNames = columns.map((col) => `"${col}"`).join(", ");
|
||||
|
||||
const queryText = `
|
||||
INSERT INTO "${tableName}" (${columnNames})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 생성 오류 (${tableName}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 생성 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 수정
|
||||
*/
|
||||
async updateRecord(
|
||||
tableName: string,
|
||||
id: string | number,
|
||||
data: Record<string, any>
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id";
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const setClause = columns
|
||||
.map((col, index) => `"${col}" = $${index + 1}`)
|
||||
.join(", ");
|
||||
|
||||
const queryText = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${setClause}
|
||||
WHERE "${pkColumn}" = $${values.length + 1}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
values.push(id);
|
||||
const result = await query<any>(queryText, values);
|
||||
|
||||
if (result.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드를 찾을 수 없습니다.",
|
||||
error: "RECORD_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 수정 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 수정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레코드 삭제
|
||||
*/
|
||||
async deleteRecord(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
): Promise<ServiceResponse<void>> {
|
||||
try {
|
||||
// 테이블명 화이트리스트 검증
|
||||
if (!ALLOWED_TABLES.includes(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `접근이 허용되지 않은 테이블입니다: ${tableName}`,
|
||||
error: "TABLE_NOT_ALLOWED",
|
||||
};
|
||||
}
|
||||
|
||||
// Primary Key 컬럼 찾기
|
||||
const pkResult = await query<{ attname: string }>(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let pkColumn = "id";
|
||||
if (pkResult.length > 0) {
|
||||
pkColumn = pkResult[0].attname;
|
||||
}
|
||||
|
||||
const queryText = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await query<any>(queryText, [id]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 삭제 오류 (${tableName}/${id}):`, error);
|
||||
return {
|
||||
success: false,
|
||||
message: "레코드 삭제 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataService = new DataService();
|
||||
|
|
|
|||
|
|
@ -942,8 +942,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
migratedComponents: layoutToUse.components.length,
|
||||
sampleComponent: layoutToUse.components[0],
|
||||
});
|
||||
|
||||
toast.success("레이아웃이 새로운 그리드 시스템으로 자동 변환되었습니다.");
|
||||
}
|
||||
|
||||
// 기본 격자 설정 보장 (격자 표시와 스냅 기본 활성화)
|
||||
|
|
@ -1249,9 +1247,42 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
|
||||
// 분할 패널 컴포넌트의 rightPanel.tableName 자동 설정
|
||||
const updatedComponents = layout.components.map((comp) => {
|
||||
if (comp.type === "component" && comp.componentType === "split-panel-layout") {
|
||||
const config = comp.componentConfig || {};
|
||||
const rightPanel = config.rightPanel || {};
|
||||
const leftPanel = config.leftPanel || {};
|
||||
const relationshipType = rightPanel.relation?.type || "detail";
|
||||
|
||||
// 관계 타입이 detail이면 rightPanel.tableName을 leftPanel.tableName과 동일하게 설정
|
||||
if (relationshipType === "detail" && leftPanel.tableName) {
|
||||
console.log("🔧 분할 패널 자동 수정:", {
|
||||
componentId: comp.id,
|
||||
leftTableName: leftPanel.tableName,
|
||||
rightTableName: leftPanel.tableName,
|
||||
});
|
||||
|
||||
return {
|
||||
...comp,
|
||||
componentConfig: {
|
||||
...config,
|
||||
rightPanel: {
|
||||
...rightPanel,
|
||||
tableName: leftPanel.tableName,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return comp;
|
||||
});
|
||||
|
||||
// 해상도 정보를 포함한 레이아웃 데이터 생성
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
screenResolution: screenResolution,
|
||||
};
|
||||
console.log("💾 저장 시작:", {
|
||||
|
|
@ -3744,13 +3775,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
screenResolution={screenResolution}
|
||||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onPreview={() => {
|
||||
toast.info("미리보기 기능은 준비 중입니다.");
|
||||
}}
|
||||
canUndo={historyIndex > 0}
|
||||
canRedo={historyIndex < history.length - 1}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
{/* 메인 컨테이너 (좌측 툴바 + 패널들 + 캔버스) */}
|
||||
|
|
@ -3869,12 +3893,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
ref={canvasContainerRef}
|
||||
className="relative flex-1 overflow-auto bg-gradient-to-br from-gray-50 to-slate-100 px-2 py-6"
|
||||
>
|
||||
{/* Pan 모드 안내 */}
|
||||
{isPanMode && (
|
||||
<div className="pointer-events-none fixed top-20 left-1/2 z-50 -translate-x-1/2 transform rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-lg">
|
||||
🖐️ Pan 모드 활성화 - 드래그하여 캔버스 이동
|
||||
</div>
|
||||
)}
|
||||
{/* Pan 모드 안내 - 제거됨 */}
|
||||
|
||||
{/* 줌 레벨 표시 */}
|
||||
<div className="pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-lg ring-1 ring-gray-200">
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
import React, { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { ComponentDefinition, ComponentCategory } from "@/types/component";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer } from "lucide-react";
|
||||
import { Search, Package, Grid, Layers, Palette, Zap, MousePointer, Edit3, BarChart3 } from "lucide-react";
|
||||
|
||||
interface ComponentsPanelProps {
|
||||
className?: string;
|
||||
|
|
@ -14,21 +14,20 @@ interface ComponentsPanelProps {
|
|||
|
||||
export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<"all" | "display" | "action" | "layout" | "utility">("all");
|
||||
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
|
||||
// 수동으로 table-list 컴포넌트 추가 (임시)
|
||||
const hasTableList = components.some(c => c.id === 'table-list');
|
||||
const hasTableList = components.some((c) => c.id === "table-list");
|
||||
if (!hasTableList) {
|
||||
components.push({
|
||||
id: 'table-list',
|
||||
name: '데이터 테이블 v2',
|
||||
description: '검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트',
|
||||
category: 'display',
|
||||
tags: ['table', 'data', 'crud'],
|
||||
id: "table-list",
|
||||
name: "데이터 테이블 v2",
|
||||
description: "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트",
|
||||
category: "display",
|
||||
tags: ["table", "data", "crud"],
|
||||
defaultSize: { width: 1000, height: 680 },
|
||||
} as ComponentDefinition);
|
||||
}
|
||||
|
|
@ -39,17 +38,16 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
// 카테고리별 컴포넌트 그룹화
|
||||
const componentsByCategory = useMemo(() => {
|
||||
return {
|
||||
all: allComponents,
|
||||
display: allComponents.filter((c) => c.category === "display"),
|
||||
action: allComponents.filter((c) => c.category === "action"),
|
||||
layout: allComponents.filter((c) => c.category === "layout"),
|
||||
utility: allComponents.filter((c) => c.category === "utility"),
|
||||
input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && c.id === "file-upload"),
|
||||
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION),
|
||||
display: allComponents.filter((c) => c.category === ComponentCategory.DISPLAY),
|
||||
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT),
|
||||
};
|
||||
}, [allComponents]);
|
||||
|
||||
// 검색 및 필터링된 컴포넌트
|
||||
const filteredComponents = useMemo(() => {
|
||||
let components = selectedCategory === "all" ? componentsByCategory.all : componentsByCategory[selectedCategory as keyof typeof componentsByCategory];
|
||||
// 카테고리별 검색 필터링
|
||||
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
|
||||
let components = componentsByCategory[category];
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
|
@ -57,12 +55,12 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
(component: ComponentDefinition) =>
|
||||
component.name.toLowerCase().includes(query) ||
|
||||
component.description.toLowerCase().includes(query) ||
|
||||
component.tags?.some((tag: string) => tag.toLowerCase().includes(query))
|
||||
component.tags?.some((tag: string) => tag.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return components;
|
||||
}, [componentsByCategory, selectedCategory, searchQuery]);
|
||||
};
|
||||
|
||||
// 카테고리 아이콘 매핑
|
||||
const getCategoryIcon = (category: ComponentCategory) => {
|
||||
|
|
@ -90,139 +88,127 @@ export function ComponentsPanel({ className }: ComponentsPanelProps) {
|
|||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
// 컴포넌트 카드 렌더링 함수
|
||||
const renderComponentCard = (component: ComponentDefinition) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
e.currentTarget.style.opacity = "0.6";
|
||||
e.currentTarget.style.transform = "rotate(2deg) scale(0.98)";
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
e.currentTarget.style.opacity = "1";
|
||||
e.currentTarget.style.transform = "none";
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 p-4 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:scale-[1.02] hover:border-purple-300/60 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 active:translate-y-0 active:scale-[0.98] active:cursor-grabbing"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-100 text-purple-700 shadow-md transition-all duration-300 group-hover:scale-110 group-hover:shadow-lg">
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="mb-1 text-sm leading-tight font-semibold text-gray-900">{component.name}</h4>
|
||||
<p className="mb-2 line-clamp-2 text-xs leading-relaxed text-gray-500">{component.description}</p>
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="rounded-full bg-purple-100 px-2 py-0.5 font-medium text-purple-700">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 빈 상태 렌더링
|
||||
const renderEmptyState = () => (
|
||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||
<div className="p-8">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm font-medium">컴포넌트를 찾을 수 없습니다</p>
|
||||
<p className="mt-1 text-xs text-gray-400">검색어를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex h-full flex-col bg-slate-50 p-6 border-r border-gray-200/60 shadow-sm ${className}`}>
|
||||
<div className={`flex h-full flex-col border-r border-gray-200/60 bg-slate-50 p-6 shadow-sm ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">컴포넌트</h2>
|
||||
<p className="text-sm text-gray-500">7개의 사용 가능한 컴포넌트</p>
|
||||
<div className="mb-4">
|
||||
<h2 className="mb-1 text-lg font-semibold text-gray-900">컴포넌트</h2>
|
||||
<p className="text-sm text-gray-500">{allComponents.length}개의 사용 가능한 컴포넌트</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="space-y-4">
|
||||
<div className="mb-4">
|
||||
<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={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-0 bg-white/80 backdrop-blur-sm shadow-sm focus:bg-white transition-colors"
|
||||
className="border-0 bg-white/80 pl-10 shadow-sm backdrop-blur-sm transition-colors focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("all")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span>전체</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "display" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("display")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
<span>표시</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "action" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("action")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
{/* 카테고리 탭 */}
|
||||
<Tabs defaultValue="input" className="flex flex-1 flex-col">
|
||||
<TabsList className="mb-4 grid w-full grid-cols-4 bg-white/80 p-1">
|
||||
<TabsTrigger value="input" className="flex items-center gap-1 text-xs">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
입력
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="action" className="flex items-center gap-1 text-xs">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>액션</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "layout" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("layout")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
액션
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="display" className="flex items-center gap-1 text-xs">
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="layout" className="flex items-center gap-1 text-xs">
|
||||
<Layers className="h-3 w-3" />
|
||||
<span>레이아웃</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCategory === "utility" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory("utility")}
|
||||
className="flex items-center space-x-1.5 h-8 px-3 text-xs font-medium rounded-full transition-all"
|
||||
>
|
||||
<Package className="h-3 w-3" />
|
||||
<span>유틸리티</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 space-y-3 overflow-y-auto mt-6">
|
||||
{filteredComponents.length > 0 ? (
|
||||
filteredComponents.map((component) => (
|
||||
<div
|
||||
key={component.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
handleDragStart(e, component);
|
||||
// 드래그 시작 시 시각적 피드백
|
||||
e.currentTarget.style.opacity = '0.6';
|
||||
e.currentTarget.style.transform = 'rotate(2deg) scale(0.98)';
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
// 드래그 종료 시 원래 상태로 복원
|
||||
e.currentTarget.style.opacity = '1';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
className="group cursor-grab rounded-lg border border-gray-200/40 bg-white/90 backdrop-blur-sm p-6 shadow-sm transition-all duration-300 hover:bg-white hover:shadow-lg hover:shadow-purple-500/15 hover:scale-[1.02] hover:border-purple-300/60 hover:-translate-y-1 active:cursor-grabbing active:scale-[0.98] active:translate-y-0"
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-purple-100 text-purple-700 shadow-md group-hover:shadow-lg group-hover:scale-110 transition-all duration-300">
|
||||
{getCategoryIcon(component.category)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900 text-sm leading-tight">{component.name}</h4>
|
||||
<Badge variant="secondary" className="text-xs bg-purple-50 text-purple-600 border-0 ml-2 px-2 py-1 rounded-full font-medium">
|
||||
신규
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 line-clamp-2 leading-relaxed mb-3">{component.description}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-400">
|
||||
<span className="bg-purple-100 px-3 py-1 rounded-full font-medium text-purple-700 shadow-sm">
|
||||
{component.defaultSize.width}×{component.defaultSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-primary capitalize bg-gradient-to-r from-purple-50 to-indigo-50 px-3 py-1 rounded-full border border-primary/20/50">
|
||||
{component.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-center text-gray-500">
|
||||
<div className="p-8">
|
||||
<Package className="mx-auto mb-3 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm font-medium text-muted-foreground">컴포넌트를 찾을 수 없습니다</p>
|
||||
<p className="text-xs text-gray-400 mt-1">검색어나 필터를 조정해보세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 입력 컴포넌트 */}
|
||||
<TabsContent value="input" className="mt-0 flex-1 space-y-3 overflow-y-auto">
|
||||
{getFilteredComponents("input").length > 0
|
||||
? getFilteredComponents("input").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 액션 컴포넌트 */}
|
||||
<TabsContent value="action" className="mt-0 flex-1 space-y-3 overflow-y-auto">
|
||||
{getFilteredComponents("action").length > 0
|
||||
? getFilteredComponents("action").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 표시 컴포넌트 */}
|
||||
<TabsContent value="display" className="mt-0 flex-1 space-y-3 overflow-y-auto">
|
||||
{getFilteredComponents("display").length > 0
|
||||
? getFilteredComponents("display").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<TabsContent value="layout" className="mt-0 flex-1 space-y-3 overflow-y-auto">
|
||||
{getFilteredComponents("layout").length > 0
|
||||
? getFilteredComponents("layout").map(renderComponentCard)
|
||||
: renderEmptyState()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="rounded-xl bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-100/60 p-4 mt-6">
|
||||
<div className="mt-4 rounded-xl border border-purple-100/60 bg-gradient-to-r from-purple-50 to-pink-50 p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<MousePointer className="h-4 w-4 text-purple-600 flex-shrink-0 mt-0.5" />
|
||||
<MousePointer className="mt-0.5 h-4 w-4 flex-shrink-0 text-purple-600" />
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-gray-700 leading-relaxed">
|
||||
<p className="text-xs leading-relaxed text-gray-700">
|
||||
컴포넌트를 <span className="font-semibold text-purple-700">드래그</span>하여 화면에 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -546,10 +546,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
config={selectedComponent.componentConfig || {}}
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
|
||||
// 전체 componentConfig를 업데이트
|
||||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
@ -624,10 +625,11 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
config={widget.componentConfig || {}}
|
||||
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
|
||||
tableColumns={currentTable?.columns || []}
|
||||
tables={tables}
|
||||
onChange={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
|
||||
// 전체 componentConfig를 업데이트
|
||||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, ArrowLeft, Undo, Redo, Play, Save, Monitor } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Database, ArrowLeft, Save, Monitor } from "lucide-react";
|
||||
import { ScreenResolution } from "@/types/screen";
|
||||
|
||||
interface SlimToolbarProps {
|
||||
|
|
@ -12,11 +11,6 @@ interface SlimToolbarProps {
|
|||
screenResolution?: ScreenResolution;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPreview: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -26,11 +20,6 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
screenResolution,
|
||||
onBack,
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPreview,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isSaving = false,
|
||||
}) => {
|
||||
return (
|
||||
|
|
@ -71,37 +60,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
)}
|
||||
</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>
|
||||
|
||||
{/* 우측: 저장 버튼 */}
|
||||
<div className="flex items-center">
|
||||
<Button onClick={onSave} disabled={isSaving} className="flex items-center space-x-2">
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? "저장 중..." : "저장"}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
/**
|
||||
* 데이터 조회 API
|
||||
*/
|
||||
export const dataApi = {
|
||||
/**
|
||||
* 테이블 데이터 조회
|
||||
* @param tableName 테이블명
|
||||
* @param params 조회 파라미터 (검색, 페이징 등)
|
||||
*/
|
||||
getTableData: async (
|
||||
tableName: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
size?: number;
|
||||
searchTerm?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
filters?: Record<string, any>;
|
||||
},
|
||||
): Promise<{
|
||||
data: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/data/${tableName}`, { params });
|
||||
const raw = response.data || {};
|
||||
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||
|
||||
const page = raw.page ?? params?.page ?? 1;
|
||||
const size = raw.size ?? params?.size ?? items.length;
|
||||
const total = raw.total ?? items.length;
|
||||
const totalPages = raw.totalPages ?? Math.max(1, Math.ceil(total / (size || 1)));
|
||||
|
||||
return { data: items, total, page, size, totalPages };
|
||||
},
|
||||
|
||||
/**
|
||||
* 특정 레코드 상세 조회
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
*/
|
||||
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
* @param leftTable 좌측 테이블명
|
||||
* @param rightTable 우측 테이블명
|
||||
* @param leftColumn 좌측 컬럼명
|
||||
* @param rightColumn 우측 컬럼명 (외래키)
|
||||
* @param leftValue 좌측 값 (필터링)
|
||||
*/
|
||||
getJoinedData: async (
|
||||
leftTable: string,
|
||||
rightTable: string,
|
||||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
leftValue?: any,
|
||||
): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/data/join`, {
|
||||
params: {
|
||||
leftTable,
|
||||
rightTable,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
},
|
||||
});
|
||||
const raw = response.data || {};
|
||||
return (raw.data ?? raw.items ?? raw.rows ?? []) as any[];
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 생성
|
||||
* @param tableName 테이블명
|
||||
* @param data 레코드 데이터
|
||||
*/
|
||||
createRecord: async (tableName: string, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.post(`/data/${tableName}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 수정
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param data 수정할 데이터
|
||||
*/
|
||||
updateRecord: async (tableName: string, id: string | number, data: Record<string, any>): Promise<any> => {
|
||||
const response = await apiClient.put(`/data/${tableName}/${id}`, data);
|
||||
return response.data?.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 레코드 삭제
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
*/
|
||||
deleteRecord: async (tableName: string, id: string | number): Promise<void> => {
|
||||
await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
},
|
||||
};
|
||||
|
|
@ -17,7 +17,7 @@ export const CheckboxBasicDefinition = createComponentDefinition({
|
|||
name: "체크박스",
|
||||
nameEng: "CheckboxBasic Component",
|
||||
description: "체크 상태 선택을 위한 체크박스 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "checkbox",
|
||||
component: CheckboxBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import "./divider-line/DividerLineRenderer";
|
|||
import "./accordion-basic/AccordionBasicRenderer";
|
||||
import "./table-list/TableListRenderer";
|
||||
import "./card-display/CardDisplayRenderer";
|
||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const RadioBasicDefinition = createComponentDefinition({
|
|||
name: "라디오 버튼",
|
||||
nameEng: "RadioBasic Component",
|
||||
description: "단일 옵션 선택을 위한 라디오 버튼 그룹 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "radio",
|
||||
component: RadioBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const SelectBasicDefinition = createComponentDefinition({
|
|||
name: "선택상자",
|
||||
nameEng: "SelectBasic Component",
|
||||
description: "옵션 선택을 위한 드롭다운 선택상자 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "select",
|
||||
component: SelectBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const SliderBasicDefinition = createComponentDefinition({
|
|||
name: "슬라이더",
|
||||
nameEng: "SliderBasic Component",
|
||||
description: "범위 값 선택을 위한 슬라이더 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "number",
|
||||
component: SliderBasicWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
# SplitPanelLayout 컴포넌트
|
||||
|
||||
마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트입니다.
|
||||
|
||||
## 특징
|
||||
|
||||
- 🔄 **마스터-디테일 패턴**: 좌측에서 항목 선택 시 우측에 상세 정보 표시
|
||||
- 📏 **크기 조절 가능**: 드래그하여 좌우 패널 크기 조정
|
||||
- 🔍 **검색 기능**: 각 패널에 독립적인 검색 기능
|
||||
- 🔗 **관계 설정**: JOIN, DETAIL, CUSTOM 관계 타입 지원
|
||||
- ⚙️ **유연한 설정**: 다양한 옵션으로 커스터마이징 가능
|
||||
|
||||
## 사용 사례
|
||||
|
||||
### 1. 코드 관리
|
||||
|
||||
- 좌측: 코드 카테고리 목록
|
||||
- 우측: 선택된 카테고리의 코드 목록
|
||||
|
||||
### 2. 테이블 조인 설정
|
||||
|
||||
- 좌측: 기본 테이블 목록
|
||||
- 우측: 선택된 테이블의 조인 조건 설정
|
||||
|
||||
### 3. 메뉴 관리
|
||||
|
||||
- 좌측: 메뉴 트리 구조
|
||||
- 우측: 선택된 메뉴의 상세 설정
|
||||
|
||||
## 설정 옵션
|
||||
|
||||
### 좌측 패널 (leftPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
|
||||
### 우측 패널 (rightPanel)
|
||||
|
||||
- `title`: 패널 제목
|
||||
- `tableName`: 데이터베이스 테이블명
|
||||
- `showSearch`: 검색 기능 표시 여부
|
||||
- `showAdd`: 추가 버튼 표시 여부
|
||||
- `relation`: 좌측 항목과의 관계 설정
|
||||
- `type`: "join" | "detail" | "custom"
|
||||
- `foreignKey`: 외래키 컬럼명
|
||||
|
||||
### 레이아웃 설정
|
||||
|
||||
- `splitRatio`: 좌측 패널 너비 비율 (0-100, 기본 30)
|
||||
- `resizable`: 크기 조절 가능 여부 (기본 true)
|
||||
- `minLeftWidth`: 좌측 최소 너비 (기본 200px)
|
||||
- `minRightWidth`: 우측 최소 너비 (기본 300px)
|
||||
- `autoLoad`: 자동 데이터 로드 (기본 true)
|
||||
|
||||
## 예시
|
||||
|
||||
```typescript
|
||||
const config: SplitPanelLayoutConfig = {
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
tableName: "code_category",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
tableName: "code_info",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
};
|
||||
```
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Plus, Search, GripVertical, Loader2 } from "lucide-react";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
*/
|
||||
export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
onClick,
|
||||
...props
|
||||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
||||
|
||||
// 기본 설정값
|
||||
const splitRatio = componentConfig.splitRatio || 30;
|
||||
const resizable = componentConfig.resizable ?? true;
|
||||
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
||||
const minRightWidth = componentConfig.minRightWidth || 300;
|
||||
|
||||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any>(null);
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 리사이저 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [leftWidth, setLeftWidth] = useState(splitRatio);
|
||||
|
||||
// 컴포넌트 스타일
|
||||
const componentStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${component.style?.positionX || 0}px`,
|
||||
top: `${component.style?.positionY || 0}px`,
|
||||
width: `${component.style?.width || 1000}px`,
|
||||
height: `${component.style?.height || 600}px`,
|
||||
zIndex: component.style?.positionZ || 1,
|
||||
cursor: isDesignMode ? "pointer" : "default",
|
||||
border: isSelected ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||
};
|
||||
|
||||
// 좌측 데이터 로드
|
||||
const loadLeftData = useCallback(async () => {
|
||||
const leftTableName = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTableName || isDesignMode) return;
|
||||
|
||||
setIsLoadingLeft(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData(leftTableName, {
|
||||
page: 1,
|
||||
size: 100,
|
||||
searchTerm: leftSearchQuery || undefined,
|
||||
});
|
||||
setLeftData(result.data);
|
||||
} catch (error) {
|
||||
console.error("좌측 데이터 로드 실패:", error);
|
||||
toast({
|
||||
title: "데이터 로드 실패",
|
||||
description: "좌측 패널 데이터를 불러올 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingLeft(false);
|
||||
}
|
||||
}, [componentConfig.leftPanel?.tableName, leftSearchQuery, isDesignMode, toast]);
|
||||
|
||||
// 우측 데이터 로드
|
||||
const loadRightData = useCallback(
|
||||
async (leftItem: any) => {
|
||||
const relationshipType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||
const rightTableName = componentConfig.rightPanel?.tableName;
|
||||
|
||||
if (!rightTableName || isDesignMode) return;
|
||||
|
||||
setIsLoadingRight(true);
|
||||
try {
|
||||
if (relationshipType === "detail") {
|
||||
// 상세 모드: 동일 테이블의 상세 정보
|
||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
||||
setRightData(detail);
|
||||
} else if (relationshipType === "join") {
|
||||
// 조인 모드: 다른 테이블의 관련 데이터
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||
const leftTable = componentConfig.leftPanel?.tableName;
|
||||
|
||||
if (leftColumn && rightColumn && leftTable) {
|
||||
const leftValue = leftItem[leftColumn];
|
||||
const joinedData = await dataApi.getJoinedData(
|
||||
leftTable,
|
||||
rightTableName,
|
||||
leftColumn,
|
||||
rightColumn,
|
||||
leftValue,
|
||||
);
|
||||
setRightData(joinedData[0] || null); // 첫 번째 관련 레코드
|
||||
}
|
||||
} else {
|
||||
// 커스텀 모드: 상세 정보로 폴백
|
||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
||||
setRightData(detail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("우측 데이터 로드 실패:", error);
|
||||
toast({
|
||||
title: "데이터 로드 실패",
|
||||
description: "우측 패널 데이터를 불러올 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingRight(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
componentConfig.rightPanel?.tableName,
|
||||
componentConfig.rightPanel?.relation,
|
||||
componentConfig.leftPanel?.tableName,
|
||||
isDesignMode,
|
||||
toast,
|
||||
],
|
||||
);
|
||||
|
||||
// 좌측 항목 선택 핸들러
|
||||
const handleLeftItemSelect = useCallback(
|
||||
(item: any) => {
|
||||
setSelectedLeftItem(item);
|
||||
loadRightData(item);
|
||||
},
|
||||
[loadRightData],
|
||||
);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
loadLeftData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// 검색어 변경 시 재로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && leftSearchQuery) {
|
||||
const timer = setTimeout(() => {
|
||||
loadLeftData();
|
||||
}, 300); // 디바운스
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftSearchQuery, isDesignMode]);
|
||||
|
||||
// 리사이저 드래그 핸들러
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!resizable) return;
|
||||
setIsDragging(true);
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
const containerWidth = (e.currentTarget as HTMLElement)?.offsetWidth || 1000;
|
||||
const newLeftWidth = (e.clientX / containerWidth) * 100;
|
||||
|
||||
if (newLeftWidth > 20 && newLeftWidth < 80) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove as any);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove as any);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={componentStyle}
|
||||
onClick={(e) => {
|
||||
if (isDesignMode) {
|
||||
e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}
|
||||
}}
|
||||
className="flex overflow-hidden rounded-lg bg-white shadow-sm"
|
||||
>
|
||||
{/* 좌측 패널 */}
|
||||
<div
|
||||
style={{ width: `${leftWidth}%`, minWidth: `${minLeftWidth}px` }}
|
||||
className="flex flex-col border-r border-gray-200"
|
||||
>
|
||||
<Card className="flex h-full flex-col border-0 shadow-none">
|
||||
<CardHeader className="border-b border-gray-100 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.leftPanel?.title || "좌측 패널"}
|
||||
</CardTitle>
|
||||
{componentConfig.leftPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{componentConfig.leftPanel?.showSearch && (
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={leftSearchQuery}
|
||||
onChange={(e) => setLeftSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-2">
|
||||
{/* 좌측 데이터 목록 */}
|
||||
<div className="space-y-1">
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: 샘플 데이터
|
||||
<>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
selectedLeftItem?.id === 1 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 1</div>
|
||||
<div className="text-xs text-gray-500">설명 텍스트</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
selectedLeftItem?.id === 2 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 2</div>
|
||||
<div className="text-xs text-gray-500">설명 텍스트</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
selectedLeftItem?.id === 3 ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">항목 3</div>
|
||||
<div className="text-xs text-gray-500">설명 텍스트</div>
|
||||
</div>
|
||||
</>
|
||||
) : isLoadingLeft ? (
|
||||
// 로딩 중
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
<span className="ml-2 text-sm text-gray-500">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
) : leftData.length > 0 ? (
|
||||
// 실제 데이터 표시
|
||||
leftData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || item[Object.keys(item)[0]] || index;
|
||||
const isSelected = selectedLeftItem && (selectedLeftItem.id === itemId || selectedLeftItem === item);
|
||||
// 첫 번째 2-3개 필드를 표시
|
||||
const keys = Object.keys(item).filter((k) => k !== "id" && k !== "ID");
|
||||
const displayTitle = item[keys[0]] || item.name || item.title || `항목 ${index + 1}`;
|
||||
const displaySubtitle = keys[1] ? item[keys[1]] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`cursor-pointer rounded-md p-3 transition-colors hover:bg-gray-50 ${
|
||||
isSelected ? "bg-blue-50 text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="truncate font-medium">{displayTitle}</div>
|
||||
{displaySubtitle && <div className="truncate text-xs text-gray-500">{displaySubtitle}</div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// 데이터 없음
|
||||
<div className="py-8 text-center text-sm text-gray-500">데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{resizable && (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="group flex w-1 cursor-col-resize items-center justify-center bg-gray-200 transition-colors hover:bg-blue-400"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-gray-400 group-hover:text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - leftWidth}%`, minWidth: `${minRightWidth}px` }} className="flex flex-col">
|
||||
<Card className="flex h-full flex-col border-0 shadow-none">
|
||||
<CardHeader className="border-b border-gray-100 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{componentConfig.rightPanel?.title || "우측 패널"}
|
||||
</CardTitle>
|
||||
{componentConfig.rightPanel?.showAdd && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{componentConfig.rightPanel?.showSearch && (
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
placeholder="검색..."
|
||||
value={rightSearchQuery}
|
||||
onChange={(e) => setRightSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
{/* 우측 상세 데이터 */}
|
||||
{isLoadingRight ? (
|
||||
// 로딩 중
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="mt-2 text-sm text-gray-500">상세 정보를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : rightData ? (
|
||||
// 실제 데이터 표시
|
||||
<div className="space-y-2">
|
||||
{Object.entries(rightData).map(([key, value]) => {
|
||||
// null, undefined, 빈 문자열 제외
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
|
||||
return (
|
||||
<div key={key} className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-1 text-xs font-semibold tracking-wide text-gray-500 uppercase">{key}</div>
|
||||
<div className="text-sm text-gray-900">{String(value)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : selectedLeftItem && isDesignMode ? (
|
||||
// 디자인 모드: 샘플 데이터
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-gray-200 p-4">
|
||||
<h3 className="mb-2 font-medium text-gray-900">{selectedLeftItem.name} 상세 정보</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 1:</span>
|
||||
<span className="font-medium">값 1</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 2:</span>
|
||||
<span className="font-medium">값 2</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">항목 3:</span>
|
||||
<span className="font-medium">값 3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 선택 없음
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<p className="mb-2">좌측에서 항목을 선택하세요</p>
|
||||
<p className="text-xs">선택한 항목의 상세 정보가 여기에 표시됩니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 래퍼 컴포넌트
|
||||
*/
|
||||
export const SplitPanelLayoutWrapper: React.FC<SplitPanelLayoutComponentProps> = (props) => {
|
||||
return <SplitPanelLayoutComponent {...props} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { TableInfo } from "@/types/screen";
|
||||
|
||||
interface SplitPanelLayoutConfigPanelProps {
|
||||
config: SplitPanelLayoutConfig;
|
||||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
|
||||
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
|
||||
}
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 설정 패널
|
||||
*/
|
||||
export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
tables = [], // 기본값 빈 배열
|
||||
screenTableName, // 현재 화면의 테이블명
|
||||
}) => {
|
||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
|
||||
const [rightColumnOpen, setRightColumnOpen] = useState(false);
|
||||
|
||||
// screenTableName이 변경되면 leftPanel.tableName 자동 업데이트
|
||||
useEffect(() => {
|
||||
if (screenTableName) {
|
||||
// 좌측 패널 테이블명 업데이트
|
||||
if (config.leftPanel?.tableName !== screenTableName) {
|
||||
updateLeftPanel({ tableName: screenTableName });
|
||||
}
|
||||
|
||||
// 관계 타입이 detail이면 우측 패널도 동일한 테이블 사용
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
if (relationshipType === "detail" && config.rightPanel?.tableName !== screenTableName) {
|
||||
updateRightPanel({ tableName: screenTableName });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [screenTableName]);
|
||||
|
||||
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
||||
console.log(" - config:", config);
|
||||
console.log(" - tables:", tables);
|
||||
console.log(" - tablesCount:", tables.length);
|
||||
console.log(" - screenTableName:", screenTableName);
|
||||
console.log(" - leftTable:", config.leftPanel?.tableName);
|
||||
console.log(" - rightTable:", config.rightPanel?.tableName);
|
||||
|
||||
const updateConfig = (updates: Partial<SplitPanelLayoutConfig>) => {
|
||||
const newConfig = { ...config, ...updates };
|
||||
console.log("🔄 Config 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
const updateLeftPanel = (updates: Partial<SplitPanelLayoutConfig["leftPanel"]>) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
leftPanel: { ...config.leftPanel, ...updates },
|
||||
};
|
||||
console.log("🔄 Left Panel 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
const updateRightPanel = (updates: Partial<SplitPanelLayoutConfig["rightPanel"]>) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
rightPanel: { ...config.rightPanel, ...updates },
|
||||
};
|
||||
console.log("🔄 Right Panel 업데이트:", newConfig);
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
// 좌측 테이블은 현재 화면의 테이블 (screenTableName) 사용
|
||||
const leftTableColumns = useMemo(() => {
|
||||
const tableName = screenTableName || config.leftPanel?.tableName;
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
return table?.columns || [];
|
||||
}, [tables, screenTableName, config.leftPanel?.tableName]);
|
||||
|
||||
// 우측 테이블의 컬럼 목록 가져오기
|
||||
const rightTableColumns = useMemo(() => {
|
||||
const table = tables.find((t) => t.tableName === config.rightPanel?.tableName);
|
||||
return table?.columns || [];
|
||||
}, [tables, config.rightPanel?.tableName]);
|
||||
|
||||
// 테이블 데이터 로딩 상태 확인
|
||||
if (!tables || tables.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||
<p className="text-sm text-yellow-800">⚠️ 테이블 데이터를 불러올 수 없습니다.</p>
|
||||
<p className="mt-1 text-xs text-yellow-600">
|
||||
화면에 테이블이 연결되지 않았거나 테이블 목록이 로드되지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 관계 타입에 따라 우측 테이블을 자동으로 설정
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 정보 표시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-3">
|
||||
<p className="text-xs text-blue-600">📊 사용 가능한 테이블: {tables.length}개</p>
|
||||
</div>
|
||||
|
||||
{/* 관계 타입 선택 (최상단) */}
|
||||
<div className="space-y-3 rounded-lg border-2 border-indigo-200 bg-indigo-50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-600 text-white">
|
||||
<span className="text-sm font-bold">1</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-indigo-900">패널 관계 타입 선택</h3>
|
||||
</div>
|
||||
<p className="text-xs text-indigo-700">좌측과 우측 패널 간의 데이터 관계를 선택하세요</p>
|
||||
<Select
|
||||
value={relationshipType}
|
||||
onValueChange={(value: "join" | "detail" | "custom") => {
|
||||
// 상세 모드로 변경 시 우측 테이블을 현재 화면 테이블로 설정
|
||||
if (value === "detail" && screenTableName) {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
tableName: screenTableName,
|
||||
});
|
||||
} else {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, type: value },
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="관계 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="detail">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">상세 (DETAIL)</span>
|
||||
<span className="text-xs text-gray-500">좌측 목록 → 우측 상세 정보 (동일 테이블)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="join">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">조인 (JOIN)</span>
|
||||
<span className="text-xs text-gray-500">좌측 테이블 → 우측 관련 테이블 (다른 테이블)</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">커스텀 (CUSTOM)</span>
|
||||
<span className="text-xs text-gray-500">사용자 정의 관계</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 좌측 패널 설정 (마스터) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||
<span className="text-sm font-bold">2</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">좌측 패널 설정 (마스터)</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.title || ""}
|
||||
onChange={(e) => updateLeftPanel({ title: e.target.value })}
|
||||
placeholder="좌측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (현재 화면)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">좌측 패널은 현재 화면의 테이블 데이터를 표시합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>검색 기능</Label>
|
||||
<Switch
|
||||
checked={config.leftPanel?.showSearch ?? true}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showSearch: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>추가 버튼</Label>
|
||||
<Switch
|
||||
checked={config.leftPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateLeftPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-600 text-white">
|
||||
<span className="text-sm font-bold">3</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
우측 패널 설정 ({relationshipType === "detail" ? "상세" : relationshipType === "join" ? "조인" : "커스텀"})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.title || ""}
|
||||
onChange={(e) => updateRightPanel({ title: e.target.value })}
|
||||
placeholder="우측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 관계 타입에 따라 테이블 선택 UI 변경 */}
|
||||
{relationshipType === "detail" ? (
|
||||
// 상세 모드: 좌측과 동일한 테이블 (비활성화)
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 (좌측과 동일)</Label>
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-sm font-medium text-gray-900">{screenTableName || "테이블이 지정되지 않음"}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">상세 모드에서는 좌측과 동일한 테이블을 사용합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 조인/커스텀 모드: 전체 테이블에서 선택 가능
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택 (전체 테이블)</Label>
|
||||
<Popover open={rightTableOpen} onOpenChange={setRightTableOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightTableOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{config.rightPanel?.tableName || "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({ tableName: value });
|
||||
setRightTableOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{table.tableName}
|
||||
<span className="ml-2 text-xs text-gray-500">({table.tableLabel || ""})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 매핑 - 조인/커스텀 모드에서만 표시 */}
|
||||
{relationshipType !== "detail" && (
|
||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-sm font-semibold">컬럼 매핑 (외래키 관계)</Label>
|
||||
<p className="text-xs text-gray-600">좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">좌측 컬럼</Label>
|
||||
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftColumnOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={!config.leftPanel?.tableName}
|
||||
>
|
||||
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." />
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{leftTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, leftColumn: value },
|
||||
});
|
||||
setLeftColumnOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.relation?.leftColumn === column.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column.columnName}
|
||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">우측 컬럼 (외래키)</Label>
|
||||
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightColumnOpen}
|
||||
className="w-full justify-between"
|
||||
disabled={!config.rightPanel?.tableName}
|
||||
>
|
||||
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." />
|
||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
||||
{rightTableColumns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={(value) => {
|
||||
updateRightPanel({
|
||||
relation: { ...config.rightPanel?.relation, foreignKey: value },
|
||||
});
|
||||
setRightColumnOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
config.rightPanel?.relation?.foreignKey === column.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{column.columnName}
|
||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>검색 기능</Label>
|
||||
<Switch
|
||||
checked={config.rightPanel?.showSearch ?? true}
|
||||
onCheckedChange={(checked) => updateRightPanel({ showSearch: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>추가 버튼</Label>
|
||||
<Switch
|
||||
checked={config.rightPanel?.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateRightPanel({ showAdd: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-600 text-white">
|
||||
<span className="text-sm font-bold">4</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">레이아웃 설정</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>좌측 패널 너비: {config.splitRatio || 30}%</Label>
|
||||
<Slider
|
||||
value={[config.splitRatio || 30]}
|
||||
onValueChange={(value) => updateConfig({ splitRatio: value[0] })}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>크기 조절 가능</Label>
|
||||
<Switch
|
||||
checked={config.resizable ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ resizable: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>자동 데이터 로드</Label>
|
||||
<Switch
|
||||
checked={config.autoLoad ?? true}
|
||||
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { SplitPanelLayoutDefinition } from "./index";
|
||||
import { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 렌더러
|
||||
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||
*/
|
||||
export class SplitPanelLayoutRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = SplitPanelLayoutDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <SplitPanelLayoutComponent {...this.props} renderer={this} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트별 특화 메서드들
|
||||
*/
|
||||
|
||||
// 좌측 패널 데이터 로드
|
||||
protected async loadLeftPanelData() {
|
||||
// 좌측 패널 데이터 로드 로직
|
||||
}
|
||||
|
||||
// 우측 패널 데이터 로드 (선택된 항목 기반)
|
||||
protected async loadRightPanelData(selectedItem: any) {
|
||||
// 우측 패널 데이터 로드 로직
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
SplitPanelLayoutRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
SplitPanelLayoutRenderer.enableHotReload();
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 설정
|
||||
*/
|
||||
|
||||
export const splitPanelLayoutConfig = {
|
||||
// 기본 스타일
|
||||
defaultStyle: {
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
|
||||
// 프리셋 설정들
|
||||
presets: {
|
||||
codeManagement: {
|
||||
name: "코드 관리",
|
||||
leftPanel: {
|
||||
title: "코드 카테고리",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "코드 목록",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "category_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
},
|
||||
tableJoin: {
|
||||
name: "테이블 조인",
|
||||
leftPanel: {
|
||||
title: "기본 테이블",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "조인 조건",
|
||||
showSearch: false,
|
||||
showAdd: true,
|
||||
relation: {
|
||||
type: "join",
|
||||
},
|
||||
},
|
||||
splitRatio: 35,
|
||||
},
|
||||
menuSettings: {
|
||||
name: "메뉴 설정",
|
||||
leftPanel: {
|
||||
title: "메뉴 트리",
|
||||
showSearch: true,
|
||||
showAdd: true,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "메뉴 상세",
|
||||
showSearch: false,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "menu_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 25,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SplitPanelLayoutWrapper } from "./SplitPanelLayoutComponent";
|
||||
import { SplitPanelLayoutConfigPanel } from "./SplitPanelLayoutConfigPanel";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트 정의
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
*/
|
||||
export const SplitPanelLayoutDefinition = createComponentDefinition({
|
||||
id: "split-panel-layout",
|
||||
name: "분할 패널",
|
||||
nameEng: "SplitPanelLayout Component",
|
||||
description: "마스터-디테일 패턴의 좌우 분할 레이아웃 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: SplitPanelLayoutWrapper,
|
||||
defaultConfig: {
|
||||
leftPanel: {
|
||||
title: "마스터",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
},
|
||||
rightPanel: {
|
||||
title: "디테일",
|
||||
showSearch: true,
|
||||
showAdd: false,
|
||||
relation: {
|
||||
type: "detail",
|
||||
foreignKey: "parent_id",
|
||||
},
|
||||
},
|
||||
splitRatio: 30,
|
||||
resizable: true,
|
||||
minLeftWidth: 200,
|
||||
minRightWidth: 300,
|
||||
autoLoad: true,
|
||||
syncSelection: true,
|
||||
} as SplitPanelLayoutConfig,
|
||||
defaultSize: { width: 1000, height: 600 },
|
||||
configPanel: SplitPanelLayoutConfigPanel,
|
||||
icon: "PanelLeftRight",
|
||||
tags: ["분할", "마스터", "디테일", "레이아웃"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
documentation: "https://docs.example.com/components/split-panel-layout",
|
||||
});
|
||||
|
||||
// 컴포넌트는 SplitPanelLayoutRenderer에서 자동 등록됩니다
|
||||
|
||||
// 타입 내보내기
|
||||
export type { SplitPanelLayoutConfig } from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
|
||||
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* SplitPanelLayout 컴포넌트 타입 정의
|
||||
*/
|
||||
|
||||
export interface SplitPanelLayoutConfig {
|
||||
// 좌측 패널 설정
|
||||
leftPanel: {
|
||||
title: string;
|
||||
tableName?: string; // 데이터베이스 테이블명
|
||||
dataSource?: string; // API 엔드포인트
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// 우측 패널 설정
|
||||
rightPanel: {
|
||||
title: string;
|
||||
tableName?: string;
|
||||
dataSource?: string;
|
||||
showSearch?: boolean;
|
||||
showAdd?: boolean;
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
}>;
|
||||
|
||||
// 좌측 선택 항목과의 관계 설정
|
||||
relation?: {
|
||||
type: "join" | "detail" | "custom"; // 관계 타입
|
||||
leftColumn?: string; // 좌측 테이블의 연결 컬럼
|
||||
foreignKey?: string; // 우측 테이블의 외래키 컬럼명
|
||||
condition?: string; // 커스텀 조건
|
||||
};
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
splitRatio?: number; // 좌우 비율 (0-100, 기본 30)
|
||||
resizable?: boolean; // 크기 조절 가능 여부
|
||||
minLeftWidth?: number; // 좌측 최소 너비 (px)
|
||||
minRightWidth?: number; // 우측 최소 너비 (px)
|
||||
|
||||
// 동작 설정
|
||||
autoLoad?: boolean; // 자동 데이터 로드
|
||||
syncSelection?: boolean; // 선택 항목 동기화
|
||||
}
|
||||
|
|
@ -75,9 +75,9 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
color: componentConfig.color || "#212121",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "8px 12px",
|
||||
borderRadius: componentConfig.borderRadius || "8px",
|
||||
border: componentConfig.border || "1px solid #e5e7eb",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
|
@ -91,7 +91,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
wordBreak: "break-word",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
boxShadow: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export const ToggleSwitchDefinition = createComponentDefinition({
|
|||
name: "토글 스위치",
|
||||
nameEng: "ToggleSwitch Component",
|
||||
description: "ON/OFF 상태 전환을 위한 토글 스위치 컴포넌트",
|
||||
category: ComponentCategory.INPUT,
|
||||
category: ComponentCategory.FORM,
|
||||
webType: "boolean",
|
||||
component: ToggleSwitchWrapper,
|
||||
defaultConfig: {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
|||
"accordion-basic": () => import("@/lib/registry/components/accordion-basic/AccordionBasicConfigPanel"),
|
||||
"table-list": () => import("@/lib/registry/components/table-list/TableListConfigPanel"),
|
||||
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
||||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -101,6 +102,7 @@ export interface ComponentConfigPanelProps {
|
|||
onChange: (config: Record<string, any>) => void;
|
||||
screenTableName?: string; // 화면에서 지정한 테이블명
|
||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||
tables?: any[]; // 전체 테이블 목록
|
||||
}
|
||||
|
||||
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
|
||||
|
|
@ -109,6 +111,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
onChange,
|
||||
screenTableName,
|
||||
tableColumns,
|
||||
tables,
|
||||
}) => {
|
||||
console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`);
|
||||
|
||||
|
|
@ -187,18 +190,21 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
|
|||
ConfigPanelComponent: ConfigPanelComponent?.name,
|
||||
config,
|
||||
configType: typeof config,
|
||||
configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
|
||||
configKeys: typeof config === "object" ? Object.keys(config || {}) : "not object",
|
||||
screenTableName,
|
||||
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns
|
||||
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns,
|
||||
tables: Array.isArray(tables) ? tables.length : tables,
|
||||
tablesType: typeof tables,
|
||||
});
|
||||
|
||||
return (
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
|
||||
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
|
||||
screenTableName={screenTableName}
|
||||
tableColumns={tableColumns}
|
||||
tables={tables} // 전체 테이블 목록 전달
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue