기본적인 crud 구현
This commit is contained in:
parent
4a0c42d80c
commit
941c6d9d84
|
|
@ -449,7 +449,13 @@ export async function getTableData(
|
|||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { page = 1, size = 10, search = {}, sortBy, sortOrder = 'asc' } = req.body;
|
||||
const {
|
||||
page = 1,
|
||||
size = 10,
|
||||
search = {},
|
||||
sortBy,
|
||||
sortOrder = "asc",
|
||||
} = req.body;
|
||||
|
||||
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
|
||||
logger.info(`페이징: page=${page}, size=${size}`);
|
||||
|
|
@ -470,20 +476,19 @@ export async function getTableData(
|
|||
}
|
||||
|
||||
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 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,
|
||||
|
|
@ -507,3 +512,234 @@ export async function getTableData(
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import {
|
|||
getColumnLabels,
|
||||
updateColumnWebType,
|
||||
getTableData,
|
||||
addTableData,
|
||||
editTableData,
|
||||
deleteTableData,
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -70,4 +73,22 @@ router.put(
|
|||
*/
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -519,7 +519,7 @@ export class TableManagementService {
|
|||
* 테이블 데이터 조회 (페이징 + 검색)
|
||||
*/
|
||||
async getTableData(
|
||||
tableName: string,
|
||||
tableName: string,
|
||||
options: {
|
||||
page: number;
|
||||
size: number;
|
||||
|
|
@ -535,7 +535,7 @@ export class TableManagementService {
|
|||
totalPages: number;
|
||||
}> {
|
||||
try {
|
||||
const { page, size, search = {}, sortBy, sortOrder = 'asc' } = options;
|
||||
const { page, size, search = {}, sortBy, sortOrder = "asc" } = options;
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
logger.info(`테이블 데이터 조회: ${tableName}`, options);
|
||||
|
|
@ -547,11 +547,11 @@ export class TableManagementService {
|
|||
|
||||
if (search && Object.keys(search).length > 0) {
|
||||
for (const [column, value] of Object.entries(search)) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
if (value !== null && value !== undefined && value !== "") {
|
||||
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
|
||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
if (typeof value === "string") {
|
||||
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
|
||||
searchValues.push(`%${value}%`);
|
||||
} else {
|
||||
|
|
@ -563,24 +563,29 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(' AND ')}`
|
||||
: '';
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// ORDER BY 조건 구성
|
||||
let orderClause = '';
|
||||
let orderClause = "";
|
||||
if (sortBy) {
|
||||
const safeSortBy = sortBy.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
const safeSortOrder = sortOrder.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
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 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 countResult = await prisma.$queryRawUnsafe<any[]>(
|
||||
countQuery,
|
||||
...searchValues
|
||||
);
|
||||
const total = parseInt(countResult[0].count);
|
||||
|
||||
// 데이터 조회
|
||||
|
|
@ -590,29 +595,452 @@ export class TableManagementService {
|
|||
${orderClause}
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
||||
const data = await prisma.$queryRawUnsafe<any[]>(
|
||||
dataQuery,
|
||||
...searchValues,
|
||||
size,
|
||||
dataQuery,
|
||||
...searchValues,
|
||||
size,
|
||||
offset
|
||||
);
|
||||
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
logger.info(`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`);
|
||||
logger.info(
|
||||
`테이블 데이터 조회 완료: ${tableName}, 총 ${total}건, ${data.length}개 반환`
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size,
|
||||
totalPages
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,24 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2 } from "lucide-react";
|
||||
import { Search, ChevronLeft, ChevronRight, RotateCcw, Database, Loader2, Plus, Edit, Trash2 } from "lucide-react";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -30,6 +41,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addFormData, setAddFormData] = useState<Record<string, any>>({});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editFormData, setEditFormData] = useState<Record<string, any>>({});
|
||||
const [editingRowData, setEditingRowData] = useState<Record<string, any> | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 현재 사용자 정보
|
||||
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
|
||||
|
||||
// 검색 가능한 컬럼만 필터링
|
||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
|
|
@ -79,6 +103,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
[component.tableName, pageSize],
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await getCurrentUser();
|
||||
if (response.success && response.data) {
|
||||
setCurrentUser(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("현재 사용자 정보 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
|
|
@ -106,6 +146,511 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
[loadData, searchValues],
|
||||
);
|
||||
|
||||
// 행 선택 핸들러
|
||||
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
|
||||
setSelectedRows((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (isSelected) {
|
||||
newSet.add(rowIndex);
|
||||
} else {
|
||||
newSet.delete(rowIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 선택/해제 핸들러
|
||||
const handleSelectAll = useCallback(
|
||||
(isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedRows(new Set(Array.from({ length: data.length }, (_, i) => i)));
|
||||
} else {
|
||||
setSelectedRows(new Set());
|
||||
}
|
||||
},
|
||||
[data.length],
|
||||
);
|
||||
|
||||
// 모달에 표시할 컬럼 계산
|
||||
const getDisplayColumns = useCallback(() => {
|
||||
const { hiddenFields, fieldOrder, advancedFieldConfigs } = component.addModalConfig || {};
|
||||
|
||||
// 숨겨진 필드와 고급 설정에서 숨겨진 필드 제외
|
||||
let displayColumns = visibleColumns.filter((col) => {
|
||||
// 기본 숨김 필드 체크
|
||||
if (hiddenFields?.includes(col.columnName)) return false;
|
||||
|
||||
// 고급 설정에서 숨김 체크
|
||||
const config = advancedFieldConfigs?.[col.columnName];
|
||||
if (config?.inputType === "hidden") return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 필드 순서 적용
|
||||
if (fieldOrder && fieldOrder.length > 0) {
|
||||
const orderedColumns: typeof displayColumns = [];
|
||||
const remainingColumns = [...displayColumns];
|
||||
|
||||
// 지정된 순서대로 추가
|
||||
fieldOrder.forEach((columnName) => {
|
||||
const column = remainingColumns.find((col) => col.columnName === columnName);
|
||||
if (column) {
|
||||
orderedColumns.push(column);
|
||||
const index = remainingColumns.indexOf(column);
|
||||
remainingColumns.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 나머지 컬럼들 추가
|
||||
orderedColumns.push(...remainingColumns);
|
||||
displayColumns = orderedColumns;
|
||||
}
|
||||
|
||||
return displayColumns;
|
||||
}, [visibleColumns, component.addModalConfig]);
|
||||
|
||||
// 자동 값 생성
|
||||
const generateAutoValue = useCallback(
|
||||
(autoValueType: string): string => {
|
||||
const now = new Date();
|
||||
switch (autoValueType) {
|
||||
case "current_datetime":
|
||||
return now.toISOString().slice(0, 19); // YYYY-MM-DDTHH:mm:ss
|
||||
case "current_date":
|
||||
return now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
case "current_time":
|
||||
return now.toTimeString().slice(0, 8); // HH:mm:ss
|
||||
case "current_user":
|
||||
return currentUser?.userName || currentUser?.userId || "unknown_user";
|
||||
case "uuid":
|
||||
return crypto.randomUUID();
|
||||
case "sequence":
|
||||
return `SEQ_${Date.now()}`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
[currentUser],
|
||||
);
|
||||
|
||||
// 데이터 추가 핸들러
|
||||
const handleAddData = useCallback(() => {
|
||||
// 폼 데이터 초기화
|
||||
const initialData: Record<string, any> = {};
|
||||
const displayColumns = getDisplayColumns();
|
||||
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
|
||||
|
||||
displayColumns.forEach((col) => {
|
||||
const config = advancedConfigs[col.columnName];
|
||||
|
||||
if (config?.inputType === "auto") {
|
||||
// 자동 값 설정
|
||||
if (config.autoValueType === "custom") {
|
||||
initialData[col.columnName] = config.customValue || "";
|
||||
} else {
|
||||
initialData[col.columnName] = generateAutoValue(config.autoValueType);
|
||||
}
|
||||
} else if (config?.defaultValue) {
|
||||
// 기본값 설정
|
||||
initialData[col.columnName] = config.defaultValue;
|
||||
} else {
|
||||
// 일반 빈 값
|
||||
initialData[col.columnName] = "";
|
||||
}
|
||||
});
|
||||
|
||||
setAddFormData(initialData);
|
||||
setShowAddModal(true);
|
||||
}, [getDisplayColumns, generateAutoValue, component.addModalConfig]);
|
||||
|
||||
// 추가 폼 데이터 변경 핸들러
|
||||
const handleAddFormChange = useCallback((columnName: string, value: any) => {
|
||||
setAddFormData((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 수정 핸들러
|
||||
const handleEditData = useCallback(() => {
|
||||
if (selectedRows.size !== 1) return;
|
||||
|
||||
const selectedIndex = Array.from(selectedRows)[0];
|
||||
const selectedRowData = data[selectedIndex];
|
||||
|
||||
if (!selectedRowData) return;
|
||||
|
||||
// 수정할 데이터로 폼 초기화
|
||||
const initialData: Record<string, any> = {};
|
||||
const displayColumns = getDisplayColumns();
|
||||
|
||||
displayColumns.forEach((col) => {
|
||||
initialData[col.columnName] = selectedRowData[col.columnName] || "";
|
||||
});
|
||||
|
||||
setEditFormData(initialData);
|
||||
setEditingRowData(selectedRowData);
|
||||
setShowEditModal(true);
|
||||
}, [selectedRows, data, getDisplayColumns]);
|
||||
|
||||
// 수정 폼 데이터 변경 핸들러
|
||||
const handleEditFormChange = useCallback((columnName: string, value: any) => {
|
||||
setEditFormData((prev) => ({
|
||||
...prev,
|
||||
[columnName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 추가 제출 핸들러
|
||||
const handleAddSubmit = useCallback(async () => {
|
||||
try {
|
||||
setIsAdding(true);
|
||||
|
||||
// 실제 API 호출로 데이터 추가
|
||||
console.log("🔥 추가할 데이터:", addFormData);
|
||||
await tableTypeApi.addTableData(component.tableName, addFormData);
|
||||
|
||||
// 모달 닫기 및 폼 초기화
|
||||
setShowAddModal(false);
|
||||
setAddFormData({});
|
||||
|
||||
// 첫 페이지로 이동하여 새 데이터 확인
|
||||
loadData(1, searchValues);
|
||||
} catch (error) {
|
||||
console.error("데이터 추가 실패:", error);
|
||||
alert("데이터 추가에 실패했습니다.");
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
}, [addFormData, loadData, searchValues]);
|
||||
|
||||
// 데이터 수정 제출 핸들러
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
try {
|
||||
setIsEditing(true);
|
||||
|
||||
// 실제 API 호출로 데이터 수정
|
||||
console.log("🔥 수정할 데이터:", editFormData);
|
||||
console.log("🔥 원본 데이터:", editingRowData);
|
||||
|
||||
if (editingRowData) {
|
||||
await tableTypeApi.editTableData(component.tableName, editingRowData, editFormData);
|
||||
|
||||
// 모달 닫기 및 폼 초기화
|
||||
setShowEditModal(false);
|
||||
setEditFormData({});
|
||||
setEditingRowData(null);
|
||||
setSelectedRows(new Set()); // 선택 해제
|
||||
|
||||
// 현재 페이지 데이터 새로고침
|
||||
loadData(currentPage, searchValues);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("데이터 수정 실패:", error);
|
||||
alert("데이터 수정에 실패했습니다.");
|
||||
} finally {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editFormData, editingRowData, component.tableName, currentPage, searchValues, loadData]);
|
||||
|
||||
// 추가 모달 닫기 핸들러
|
||||
const handleAddModalClose = useCallback(() => {
|
||||
if (!isAdding) {
|
||||
setShowAddModal(false);
|
||||
setAddFormData({});
|
||||
}
|
||||
}, [isAdding]);
|
||||
|
||||
// 데이터 삭제 핸들러
|
||||
const handleDeleteData = useCallback(() => {
|
||||
if (selectedRows.size === 0) {
|
||||
alert("삭제할 데이터를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
setShowDeleteDialog(true);
|
||||
}, [selectedRows.size]);
|
||||
|
||||
// 삭제 확인 핸들러
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
// 선택된 행의 실제 데이터 가져오기
|
||||
const selectedData = Array.from(selectedRows).map((index) => data[index]);
|
||||
|
||||
// 실제 삭제 API 호출
|
||||
console.log("🗑️ 삭제할 데이터:", selectedData);
|
||||
await tableTypeApi.deleteTableData(component.tableName, selectedData);
|
||||
|
||||
// 선택 해제 및 다이얼로그 닫기
|
||||
setSelectedRows(new Set());
|
||||
setShowDeleteDialog(false);
|
||||
|
||||
// 데이터 새로고침
|
||||
loadData(currentPage, searchValues);
|
||||
} catch (error) {
|
||||
console.error("데이터 삭제 실패:", error);
|
||||
alert("데이터 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [selectedRows, data, currentPage, searchValues, loadData]);
|
||||
|
||||
// 삭제 다이얼로그 닫기 핸들러
|
||||
const handleDeleteDialogClose = useCallback(() => {
|
||||
if (!isDeleting) {
|
||||
setShowDeleteDialog(false);
|
||||
}
|
||||
}, [isDeleting]);
|
||||
|
||||
// 필수 필드 여부 확인
|
||||
const isRequiredField = useCallback(
|
||||
(columnName: string) => {
|
||||
return component.addModalConfig?.requiredFields?.includes(columnName) || false;
|
||||
},
|
||||
[component.addModalConfig],
|
||||
);
|
||||
|
||||
// 모달 크기 클래스 가져오기
|
||||
const getModalSizeClass = useCallback(() => {
|
||||
const width = component.addModalConfig?.width || "lg";
|
||||
const sizeMap = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
"2xl": "max-w-2xl",
|
||||
full: "max-w-full mx-4",
|
||||
};
|
||||
return sizeMap[width];
|
||||
}, [component.addModalConfig]);
|
||||
|
||||
// 레이아웃 클래스 가져오기
|
||||
const getLayoutClass = useCallback(() => {
|
||||
const layout = component.addModalConfig?.layout || "two-column";
|
||||
const gridColumns = component.addModalConfig?.gridColumns || 2;
|
||||
|
||||
switch (layout) {
|
||||
case "single":
|
||||
return "grid grid-cols-1 gap-4";
|
||||
case "two-column":
|
||||
return "grid grid-cols-2 gap-4";
|
||||
case "grid":
|
||||
return `grid grid-cols-${Math.min(gridColumns, 4)} gap-4`;
|
||||
default:
|
||||
return "grid grid-cols-2 gap-4";
|
||||
}
|
||||
}, [component.addModalConfig]);
|
||||
|
||||
// 수정 폼 입력 컴포넌트 렌더링
|
||||
const renderEditFormInput = (column: DataTableColumn) => {
|
||||
const value = editFormData[column.columnName] || "";
|
||||
const isRequired = isRequiredField(column.columnName);
|
||||
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
||||
|
||||
// 자동 생성 필드는 수정에서 읽기 전용으로 처리
|
||||
if (advancedConfig?.inputType === "auto") {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
readOnly
|
||||
className="bg-gray-50 text-gray-700"
|
||||
placeholder={`${column.label} (자동 생성됨)`}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">자동 생성된 필드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 읽기 전용 필드
|
||||
if (advancedConfig?.inputType === "readonly") {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
readOnly
|
||||
className="bg-gray-50 text-gray-700"
|
||||
placeholder={advancedConfig?.placeholder || `${column.label} (읽기 전용)`}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 입력 필드 렌더링
|
||||
const commonProps = {
|
||||
value,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleEditFormChange(column.columnName, e.target.value),
|
||||
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
|
||||
required: isRequired,
|
||||
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
||||
};
|
||||
|
||||
switch (column.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type={column.widgetType === "email" ? "email" : column.widgetType === "tel" ? "tel" : "text"}
|
||||
{...commonProps}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<div>
|
||||
<Input type="number" step={column.widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<div>
|
||||
<Input type="date" {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<div>
|
||||
<Input type="datetime-local" {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
// TODO: 동적 옵션 로드
|
||||
return <Input {...commonProps} placeholder={`${column.label} 선택... (개발 중)`} readOnly />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
<Input {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 추가 폼 입력 컴포넌트 렌더링
|
||||
const renderAddFormInput = (column: DataTableColumn) => {
|
||||
const value = addFormData[column.columnName] || "";
|
||||
const isRequired = isRequiredField(column.columnName);
|
||||
const advancedConfig = component.addModalConfig?.advancedFieldConfigs?.[column.columnName];
|
||||
|
||||
// 읽기 전용 또는 자동 값인 경우
|
||||
if (advancedConfig?.inputType === "readonly" || advancedConfig?.inputType === "auto") {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={value}
|
||||
readOnly
|
||||
className="bg-gray-50 text-gray-700"
|
||||
placeholder={advancedConfig?.placeholder || `${column.label} (자동 생성)`}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 입력 필드 렌더링
|
||||
const commonProps = {
|
||||
value,
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => handleAddFormChange(column.columnName, e.target.value),
|
||||
placeholder: advancedConfig?.placeholder || `${column.label} 입력...`,
|
||||
required: isRequired,
|
||||
className: isRequired && !value ? "border-orange-300 focus:border-orange-500" : "",
|
||||
};
|
||||
|
||||
switch (column.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type={column.widgetType === "email" ? "email" : column.widgetType === "tel" ? "tel" : "text"}
|
||||
{...commonProps}
|
||||
/>
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<div>
|
||||
<Input type="number" step={column.widgetType === "decimal" ? "0.01" : "1"} {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<div>
|
||||
<Input type="date" {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<div>
|
||||
<Input type="datetime-local" {...commonProps} />
|
||||
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
// TODO: 동적 옵션 로드
|
||||
return (
|
||||
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${column.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">선택 안함</SelectItem>
|
||||
<SelectItem value="option1">옵션 1</SelectItem>
|
||||
<SelectItem value="option2">옵션 2</SelectItem>
|
||||
<SelectItem value="option3">옵션 3</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={value === true || value === "true"}
|
||||
onCheckedChange={(checked) => handleAddFormChange(column.columnName, checked)}
|
||||
/>
|
||||
<Label>{column.label}</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => handleAddFormChange(column.columnName, e.target.value)}
|
||||
placeholder={`${column.label} 입력...`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 필터 렌더링
|
||||
const renderSearchFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
|
@ -257,18 +802,49 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 선택된 행 개수 표시 */}
|
||||
{selectedRows.size > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{selectedRows.size}개 선택됨
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{searchFilters.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Search className="mr-1 h-3 w-3" />
|
||||
필터 {searchFilters.length}개
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* CRUD 버튼들 */}
|
||||
{component.enableAdd && (
|
||||
<Button size="sm" onClick={handleAddData} disabled={loading} className="gap-2">
|
||||
<Plus className="h-3 w-3" />
|
||||
{component.addButtonText || "추가"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{component.enableEdit && selectedRows.size === 1 && (
|
||||
<Button size="sm" onClick={handleEditData} disabled={loading} className="gap-2" variant="outline">
|
||||
<Edit className="h-3 w-3" />
|
||||
{component.editButtonText || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{component.enableDelete && selectedRows.size > 0 && (
|
||||
<Button size="sm" variant="destructive" onClick={handleDeleteData} disabled={loading} className="gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{component.deleteButtonText || "삭제"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{component.showSearchButton && (
|
||||
<Button size="sm" onClick={handleSearch} disabled={loading} className="gap-2">
|
||||
<Search className="h-3 w-3" />
|
||||
{component.searchButtonText || "검색"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="outline" onClick={() => loadData(1, {})} disabled={loading} className="gap-2">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
새로고침
|
||||
|
|
@ -313,6 +889,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableHead className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === data.length && data.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableHead
|
||||
key={column.id}
|
||||
|
|
@ -327,7 +912,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
데이터를 불러오는 중...
|
||||
|
|
@ -337,6 +925,15 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
) : data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex} className="hover:bg-muted/50">
|
||||
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
|
||||
{component.enableDelete && (
|
||||
<TableCell className="w-12 px-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.has(rowIndex)}
|
||||
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleColumns.map((column: DataTableColumn) => (
|
||||
<TableCell key={column.id} className="px-4 font-mono text-sm">
|
||||
{formatCellValue(row[column.columnName], column)}
|
||||
|
|
@ -346,7 +943,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={visibleColumns.length} className="h-32 text-center">
|
||||
<TableCell
|
||||
colSpan={visibleColumns.length + (component.enableDelete ? 1 : 0)}
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8" />
|
||||
<p>검색 결과가 없습니다</p>
|
||||
|
|
@ -433,6 +1033,145 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* 데이터 추가 모달 */}
|
||||
<Dialog open={showAddModal} onOpenChange={handleAddModalClose}>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{component.addModalConfig?.title || "새 데이터 추가"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{component.addModalConfig?.description ||
|
||||
`${component.title || component.label}에 새로운 데이터를 추가합니다.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className={getLayoutClass()}>
|
||||
{getDisplayColumns().map((column) => (
|
||||
<div key={column.id} className="space-y-2">
|
||||
<Label htmlFor={column.columnName} className="text-sm font-medium">
|
||||
{column.label}
|
||||
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
|
||||
</Label>
|
||||
<div>{renderAddFormInput(column)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleAddModalClose} disabled={isAdding}>
|
||||
{component.addModalConfig?.cancelButtonText || "취소"}
|
||||
</Button>
|
||||
<Button onClick={handleAddSubmit} disabled={isAdding}>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
추가 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{component.addModalConfig?.submitButtonText || "추가"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 데이터 수정 모달 */}
|
||||
<Dialog
|
||||
open={showEditModal}
|
||||
onOpenChange={(open) => {
|
||||
if (!isEditing && !open) {
|
||||
setShowEditModal(false);
|
||||
setEditFormData({});
|
||||
setEditingRowData(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className={`max-h-[80vh] overflow-y-auto ${getModalSizeClass()}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>데이터 수정</DialogTitle>
|
||||
<DialogDescription>선택된 데이터를 수정합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className={getLayoutClass()}>
|
||||
{getDisplayColumns().map((column) => (
|
||||
<div key={column.id} className="space-y-2">
|
||||
<Label htmlFor={`edit-${column.columnName}`} className="text-sm font-medium">
|
||||
{column.label}
|
||||
{isRequiredField(column.columnName) && <span className="ml-1 text-orange-500">*</span>}
|
||||
</Label>
|
||||
<div>{renderEditFormInput(column)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setEditFormData({});
|
||||
setEditingRowData(null);
|
||||
}}
|
||||
disabled={isEditing}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleEditSubmit} disabled={isEditing}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
수정 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{component.editButtonText || "수정"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 데이터 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={handleDeleteDialogClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>데이터 삭제 확인</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택된 <strong>{selectedRows.size}개</strong>의 데이터를 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-red-600">이 작업은 되돌릴 수 없습니다.</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleDeleteDialogClose} disabled={isDeleting}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={isDeleting}>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ import {
|
|||
ChevronRight,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
interface RealtimePreviewProps {
|
||||
|
|
@ -776,6 +779,29 @@ export const RealtimePreview: React.FC<RealtimePreviewProps> = ({
|
|||
필터 {filters.length}개
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* CRUD 버튼들 (미리보기) */}
|
||||
{dataTableComponent.enableAdd && (
|
||||
<Button size="sm" className="gap-1 text-xs">
|
||||
<Plus className="h-3 w-3" />
|
||||
{dataTableComponent.addButtonText || "추가"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{dataTableComponent.enableEdit && (
|
||||
<Button size="sm" variant="outline" className="gap-1 text-xs">
|
||||
<Edit className="h-3 w-3" />
|
||||
{dataTableComponent.editButtonText || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{dataTableComponent.enableDelete && (
|
||||
<Button size="sm" variant="destructive" className="gap-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{dataTableComponent.deleteButtonText || "삭제"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{dataTableComponent.showSearchButton && (
|
||||
<Button size="sm" className="gap-1 text-xs">
|
||||
<Search className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -724,6 +724,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
searchButtonText: "검색",
|
||||
enableExport: true,
|
||||
enableRefresh: true,
|
||||
enableAdd: true,
|
||||
enableEdit: true,
|
||||
enableDelete: true,
|
||||
addButtonText: "추가",
|
||||
editButtonText: "수정",
|
||||
deleteButtonText: "삭제",
|
||||
addModalConfig: {
|
||||
title: "새 데이터 추가",
|
||||
description: `${templateComp.label}에 새로운 데이터를 추가합니다.`,
|
||||
width: "lg",
|
||||
layout: "two-column",
|
||||
gridColumns: 2,
|
||||
fieldOrder: [], // 초기에는 빈 배열, 나중에 컬럼 추가 시 설정
|
||||
requiredFields: [],
|
||||
hiddenFields: [],
|
||||
advancedFieldConfigs: {}, // 초기에는 빈 객체, 나중에 컬럼별 설정
|
||||
submitButtonText: "추가",
|
||||
cancelButtonText: "취소",
|
||||
},
|
||||
gridColumns,
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
|
|
|
|||
|
|
@ -7,22 +7,10 @@ import { Label } from "@/components/ui/label";
|
|||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings,
|
||||
Filter,
|
||||
Columns,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpDown,
|
||||
Search,
|
||||
Grid3x3,
|
||||
} from "lucide-react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Table, Plus, Trash2, Settings, Filter, Columns, ChevronDown } from "lucide-react";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, TableInfo, ColumnInfo, WebType } from "@/types/screen";
|
||||
import { generateComponentId } from "@/lib/utils/generateId";
|
||||
|
||||
|
|
@ -54,6 +42,20 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
showSearchButton: component.showSearchButton ?? true,
|
||||
enableExport: component.enableExport ?? true,
|
||||
enableRefresh: component.enableRefresh ?? true,
|
||||
enableAdd: component.enableAdd ?? true,
|
||||
enableEdit: component.enableEdit ?? true,
|
||||
enableDelete: component.enableDelete ?? true,
|
||||
addButtonText: component.addButtonText || "추가",
|
||||
editButtonText: component.editButtonText || "수정",
|
||||
deleteButtonText: component.deleteButtonText || "삭제",
|
||||
// 모달 설정
|
||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||
modalDescription: component.addModalConfig?.description || "",
|
||||
modalWidth: component.addModalConfig?.width || "lg",
|
||||
modalLayout: component.addModalConfig?.layout || "two-column",
|
||||
modalGridColumns: component.addModalConfig?.gridColumns || 2,
|
||||
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
|
||||
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
|
||||
paginationEnabled: component.pagination?.enabled ?? true,
|
||||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
|
|
@ -79,6 +81,12 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
// 컬럼별 그리드 컬럼 설정 상태
|
||||
const [localColumnGridColumns, setLocalColumnGridColumns] = useState<Record<string, number>>({});
|
||||
|
||||
// 필터별 로컬 입력 상태
|
||||
const [localFilterInputs, setLocalFilterInputs] = useState<Record<string, string>>({});
|
||||
|
||||
// 모달 설정 확장/축소 상태
|
||||
const [isModalConfigOpen, setIsModalConfigOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 값 동기화
|
||||
useEffect(() => {
|
||||
console.log("🔄 DataTableConfig: 컴포넌트 변경 감지", {
|
||||
|
|
@ -130,6 +138,20 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
showSearchButton: component.showSearchButton ?? true,
|
||||
enableExport: component.enableExport ?? true,
|
||||
enableRefresh: component.enableRefresh ?? true,
|
||||
enableAdd: component.enableAdd ?? true,
|
||||
enableEdit: component.enableEdit ?? true,
|
||||
enableDelete: component.enableDelete ?? true,
|
||||
addButtonText: component.addButtonText || "추가",
|
||||
editButtonText: component.editButtonText || "수정",
|
||||
deleteButtonText: component.deleteButtonText || "삭제",
|
||||
// 모달 설정
|
||||
modalTitle: component.addModalConfig?.title || "새 데이터 추가",
|
||||
modalDescription: component.addModalConfig?.description || "",
|
||||
modalWidth: component.addModalConfig?.width || "lg",
|
||||
modalLayout: component.addModalConfig?.layout || "two-column",
|
||||
modalGridColumns: component.addModalConfig?.gridColumns || 2,
|
||||
modalSubmitButtonText: component.addModalConfig?.submitButtonText || "추가",
|
||||
modalCancelButtonText: component.addModalConfig?.cancelButtonText || "취소",
|
||||
paginationEnabled: component.pagination?.enabled ?? true,
|
||||
showPageSizeSelector: component.pagination?.showPageSizeSelector ?? true,
|
||||
showPageInfo: component.pagination?.showPageInfo ?? true,
|
||||
|
|
@ -203,6 +225,29 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
|
||||
return newGridColumns;
|
||||
});
|
||||
|
||||
// 필터별 로컬 입력 상태 동기화 (기존 값 보존하면서 새 필터만 추가)
|
||||
setLocalFilterInputs((prev) => {
|
||||
const newFilterInputs = { ...prev };
|
||||
component.filters?.forEach((filter, index) => {
|
||||
const filterKey = `${filter.columnName}-${index}`;
|
||||
if (!(filterKey in newFilterInputs)) {
|
||||
newFilterInputs[filterKey] = filter.label || filter.columnName;
|
||||
}
|
||||
});
|
||||
|
||||
// 삭제된 필터의 로컬 상태 제거
|
||||
const currentFilterKeys = new Set(
|
||||
component.filters?.map((filter, index) => `${filter.columnName}-${index}`) || [],
|
||||
);
|
||||
Object.keys(newFilterInputs).forEach((key) => {
|
||||
if (!currentFilterKeys.has(key)) {
|
||||
delete newFilterInputs[key];
|
||||
}
|
||||
});
|
||||
|
||||
return newFilterInputs;
|
||||
});
|
||||
}, [
|
||||
component.id,
|
||||
component.title,
|
||||
|
|
@ -378,6 +423,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
const updateFilter = useCallback(
|
||||
(index: number, updates: Partial<DataTableFilter>) => {
|
||||
const updatedFilters = component.filters.map((filter, i) => (i === index ? { ...filter, ...updates } : filter));
|
||||
console.log("🔄 필터 업데이트:", { index, updates, updatedFilters });
|
||||
onUpdateComponent({ filters: updatedFilters });
|
||||
},
|
||||
[component.filters, onUpdateComponent],
|
||||
|
|
@ -402,7 +448,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
const newFilter: DataTableFilter = {
|
||||
columnName: targetColumn.columnName,
|
||||
widgetType,
|
||||
label: `${targetColumn.columnLabel || targetColumn.columnName} 필터`,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
gridColumns: 3,
|
||||
};
|
||||
|
||||
|
|
@ -440,6 +486,15 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
const removeFilter = useCallback(
|
||||
(index: number) => {
|
||||
const updatedFilters = component.filters.filter((_, i) => i !== index);
|
||||
|
||||
// 로컬 필터 입력 상태에서도 해당 필터 제거
|
||||
setLocalFilterInputs((prev) => {
|
||||
const newFilterInputs = { ...prev };
|
||||
const filterKey = `${component.filters?.[index]?.columnName}-${index}`;
|
||||
delete newFilterInputs[filterKey];
|
||||
return newFilterInputs;
|
||||
});
|
||||
|
||||
onUpdateComponent({ filters: updatedFilters });
|
||||
},
|
||||
[component.filters, onUpdateComponent],
|
||||
|
|
@ -558,7 +613,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="max-h-[80vh] space-y-4 overflow-y-auto p-4">
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
|
@ -598,6 +653,282 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* CRUD 기능 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium">CRUD 기능</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-add"
|
||||
checked={localValues.enableAdd}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalValues((prev) => ({ ...prev, enableAdd: checked as boolean }));
|
||||
onUpdateComponent({ enableAdd: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-add" className="text-sm">
|
||||
데이터 추가 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-edit"
|
||||
checked={localValues.enableEdit}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalValues((prev) => ({ ...prev, enableEdit: checked as boolean }));
|
||||
onUpdateComponent({ enableEdit: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-edit" className="text-sm">
|
||||
데이터 수정 기능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-delete"
|
||||
checked={localValues.enableDelete}
|
||||
onCheckedChange={(checked) => {
|
||||
setLocalValues((prev) => ({ ...prev, enableDelete: checked as boolean }));
|
||||
onUpdateComponent({ enableDelete: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="enable-delete" className="text-sm">
|
||||
데이터 삭제 기능
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-button-text" className="text-sm">
|
||||
추가 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="add-button-text"
|
||||
value={localValues.addButtonText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, addButtonText: newValue }));
|
||||
onUpdateComponent({ addButtonText: newValue });
|
||||
}}
|
||||
placeholder="추가"
|
||||
disabled={!localValues.enableAdd}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-button-text" className="text-sm">
|
||||
수정 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="edit-button-text"
|
||||
value={localValues.editButtonText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, editButtonText: newValue }));
|
||||
onUpdateComponent({ editButtonText: newValue });
|
||||
}}
|
||||
placeholder="수정"
|
||||
disabled={!localValues.enableEdit}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-button-text" className="text-sm">
|
||||
삭제 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-button-text"
|
||||
value={localValues.deleteButtonText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, deleteButtonText: newValue }));
|
||||
onUpdateComponent({ deleteButtonText: newValue });
|
||||
}}
|
||||
placeholder="삭제"
|
||||
disabled={!localValues.enableDelete}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추가 모달 커스터마이징 설정 */}
|
||||
{localValues.enableAdd && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium">추가 모달 설정</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-title" className="text-sm">
|
||||
모달 제목
|
||||
</Label>
|
||||
<Input
|
||||
id="modal-title"
|
||||
value={localValues.modalTitle}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalTitle: newValue }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, title: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="새 데이터 추가"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-width" className="text-sm">
|
||||
모달 크기
|
||||
</Label>
|
||||
<Select
|
||||
value={localValues.modalWidth}
|
||||
onValueChange={(value) => {
|
||||
setLocalValues((prev) => ({ ...prev, modalWidth: value as any }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, width: value as any },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sm">작음 (384px)</SelectItem>
|
||||
<SelectItem value="md">보통 (448px)</SelectItem>
|
||||
<SelectItem value="lg">큼 (512px)</SelectItem>
|
||||
<SelectItem value="xl">매우 큼 (576px)</SelectItem>
|
||||
<SelectItem value="2xl">특대 (672px)</SelectItem>
|
||||
<SelectItem value="full">전체 너비</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-description" className="text-sm">
|
||||
모달 설명
|
||||
</Label>
|
||||
<Input
|
||||
id="modal-description"
|
||||
value={localValues.modalDescription}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalDescription: newValue }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, description: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="모달에 표시될 설명을 입력하세요"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-layout" className="text-sm">
|
||||
레이아웃
|
||||
</Label>
|
||||
<Select
|
||||
value={localValues.modalLayout}
|
||||
onValueChange={(value) => {
|
||||
setLocalValues((prev) => ({ ...prev, modalLayout: value as any }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, layout: value as any },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single">단일 컬럼</SelectItem>
|
||||
<SelectItem value="two-column">2컬럼</SelectItem>
|
||||
<SelectItem value="grid">그리드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{localValues.modalLayout === "grid" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-grid-columns" className="text-sm">
|
||||
그리드 컬럼 수
|
||||
</Label>
|
||||
<Select
|
||||
value={localValues.modalGridColumns.toString()}
|
||||
onValueChange={(value) => {
|
||||
const gridColumns = parseInt(value);
|
||||
setLocalValues((prev) => ({ ...prev, modalGridColumns: gridColumns }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, gridColumns },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2컬럼</SelectItem>
|
||||
<SelectItem value="3">3컬럼</SelectItem>
|
||||
<SelectItem value="4">4컬럼</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-submit-text" className="text-sm">
|
||||
제출 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="modal-submit-text"
|
||||
value={localValues.modalSubmitButtonText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalSubmitButtonText: newValue }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, submitButtonText: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="추가"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="modal-cancel-text" className="text-sm">
|
||||
취소 버튼 텍스트
|
||||
</Label>
|
||||
<Input
|
||||
id="modal-cancel-text"
|
||||
value={localValues.modalCancelButtonText}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalValues((prev) => ({ ...prev, modalCancelButtonText: newValue }));
|
||||
onUpdateComponent({
|
||||
addModalConfig: { ...component.addModalConfig, cancelButtonText: newValue },
|
||||
});
|
||||
}}
|
||||
placeholder="취소"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="grid-columns">그리드 컬럼 수</Label>
|
||||
<Select
|
||||
|
|
@ -710,8 +1041,8 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
|
||||
<div className="max-h-96 space-y-3 overflow-y-auto">
|
||||
{component.columns.map((column, index) => (
|
||||
<Card key={`${column.id}-${column.columnName}-${index}`} className="p-3">
|
||||
<div className="space-y-3">
|
||||
<Card key={`${column.id}-${column.columnName}-${index}`} className="p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
|
@ -830,6 +1161,214 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모달 전용 설정 */}
|
||||
{component.enableAdd && (
|
||||
<div className="border-t pt-2">
|
||||
<Collapsible
|
||||
open={isModalConfigOpen[column.id] || false}
|
||||
onOpenChange={(open) => setIsModalConfigOpen((prev) => ({ ...prev, [column.id]: open }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex h-auto w-full items-center justify-between p-1"
|
||||
>
|
||||
<Label className="text-xs font-medium">모달 설정</Label>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 transition-transform ${
|
||||
isModalConfigOpen[column.id] ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`required-${column.id}`}
|
||||
checked={
|
||||
component.addModalConfig?.requiredFields?.includes(column.columnName) || false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const requiredFields = component.addModalConfig?.requiredFields || [];
|
||||
let newRequiredFields;
|
||||
|
||||
if (checked) {
|
||||
newRequiredFields = [...requiredFields, column.columnName];
|
||||
} else {
|
||||
newRequiredFields = requiredFields.filter((field) => field !== column.columnName);
|
||||
}
|
||||
|
||||
onUpdateComponent({
|
||||
addModalConfig: {
|
||||
...component.addModalConfig,
|
||||
requiredFields: newRequiredFields,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`required-${column.id}`} className="text-xs">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Checkbox
|
||||
id={`hidden-${column.id}`}
|
||||
checked={component.addModalConfig?.hiddenFields?.includes(column.columnName) || false}
|
||||
onCheckedChange={(checked) => {
|
||||
const hiddenFields = component.addModalConfig?.hiddenFields || [];
|
||||
let newHiddenFields;
|
||||
|
||||
if (checked) {
|
||||
newHiddenFields = [...hiddenFields, column.columnName];
|
||||
} else {
|
||||
newHiddenFields = hiddenFields.filter((field) => field !== column.columnName);
|
||||
}
|
||||
|
||||
onUpdateComponent({
|
||||
addModalConfig: {
|
||||
...component.addModalConfig,
|
||||
hiddenFields: newHiddenFields,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`hidden-${column.id}`} className="text-xs">
|
||||
숨김
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">입력 타입</Label>
|
||||
<Select
|
||||
value={
|
||||
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.inputType ||
|
||||
"normal"
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
|
||||
const currentConfig = advancedConfigs[column.columnName] || {
|
||||
columnName: column.columnName,
|
||||
};
|
||||
|
||||
const newConfig = {
|
||||
...currentConfig,
|
||||
inputType: value as any,
|
||||
autoValueType:
|
||||
value === "auto" ? "current_datetime" : currentConfig.autoValueType || "none",
|
||||
};
|
||||
|
||||
onUpdateComponent({
|
||||
addModalConfig: {
|
||||
...component.addModalConfig,
|
||||
advancedFieldConfigs: {
|
||||
...advancedConfigs,
|
||||
[column.columnName]: newConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">일반 입력</SelectItem>
|
||||
<SelectItem value="readonly">읽기 전용</SelectItem>
|
||||
<SelectItem value="auto">자동 생성</SelectItem>
|
||||
<SelectItem value="hidden">숨김</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.inputType ===
|
||||
"auto" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">자동 값 타입</Label>
|
||||
<Select
|
||||
value={
|
||||
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]
|
||||
?.autoValueType || "current_datetime"
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
|
||||
const currentConfig = advancedConfigs[column.columnName] || {
|
||||
columnName: column.columnName,
|
||||
};
|
||||
|
||||
onUpdateComponent({
|
||||
addModalConfig: {
|
||||
...component.addModalConfig,
|
||||
advancedFieldConfigs: {
|
||||
...advancedConfigs,
|
||||
[column.columnName]: {
|
||||
...currentConfig,
|
||||
autoValueType: value as any,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current_datetime">현재 날짜시간</SelectItem>
|
||||
<SelectItem value="current_date">현재 날짜</SelectItem>
|
||||
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||
<SelectItem value="current_user">현재 사용자</SelectItem>
|
||||
<SelectItem value="uuid">UUID</SelectItem>
|
||||
<SelectItem value="sequence">시퀀스</SelectItem>
|
||||
<SelectItem value="custom">사용자 정의</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.autoValueType ===
|
||||
"custom" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">사용자 정의 값</Label>
|
||||
<Input
|
||||
value={
|
||||
component.addModalConfig?.advancedFieldConfigs?.[column.columnName]?.customValue ||
|
||||
""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const advancedConfigs = component.addModalConfig?.advancedFieldConfigs || {};
|
||||
const currentConfig = advancedConfigs[column.columnName] || {
|
||||
columnName: column.columnName,
|
||||
};
|
||||
|
||||
onUpdateComponent({
|
||||
addModalConfig: {
|
||||
...component.addModalConfig,
|
||||
advancedFieldConfigs: {
|
||||
...advancedConfigs,
|
||||
[column.columnName]: {
|
||||
...currentConfig,
|
||||
customValue: e.target.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
placeholder="고정값 입력..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
|
@ -909,14 +1448,33 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
};
|
||||
|
||||
return (
|
||||
<Card key={`filter-${filter.columnName}-${filter.widgetType}-${index}`} className="p-3">
|
||||
<div className="space-y-3">
|
||||
<Card key={`filter-${filter.columnName}-${filter.widgetType}-${index}`} className="p-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
<span className="text-lg">{getWebTypeIcon(filter.widgetType)}</span>
|
||||
<div>
|
||||
<span className="text-sm font-medium">{filter.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{getWebTypeDescription(filter.widgetType)}</p>
|
||||
<div className="flex-1">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">필터 이름</Label>
|
||||
<Input
|
||||
value={
|
||||
localFilterInputs[`${filter.columnName}-${index}`] !== undefined
|
||||
? localFilterInputs[`${filter.columnName}-${index}`]
|
||||
: filter.label
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
const filterKey = `${filter.columnName}-${index}`;
|
||||
setLocalFilterInputs((prev) => ({ ...prev, [filterKey]: newValue }));
|
||||
updateFilter(index, { label: newValue });
|
||||
}}
|
||||
placeholder="필터 이름 입력..."
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{getWebTypeDescription(filter.widgetType)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -944,7 +1502,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
const newWidgetType = getWidgetTypeFromColumn(column);
|
||||
updateFilter(index, {
|
||||
columnName: value,
|
||||
label: `${column.columnLabel || column.columnName} 필터`,
|
||||
label: column.columnLabel || column.columnName,
|
||||
widgetType: newWidgetType,
|
||||
});
|
||||
}
|
||||
|
|
@ -1065,7 +1623,7 @@ export const DataTableConfigPanel: React.FC<DataTableConfigPanelProps> = ({ comp
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-page-size-selector"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -153,6 +153,35 @@ export interface ApiResponse<T = any> {
|
|||
errorCode?: string;
|
||||
}
|
||||
|
||||
// 사용자 정보 타입
|
||||
export interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
deptName?: string;
|
||||
companyCode?: string;
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
email?: string;
|
||||
photo?: string;
|
||||
locale?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
// 현재 사용자 정보 조회
|
||||
export const getCurrentUser = async (): Promise<ApiResponse<UserInfo>> => {
|
||||
try {
|
||||
const response = await apiClient.get("/auth/me");
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("현재 사용자 정보 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || "사용자 정보를 가져올 수 없습니다.",
|
||||
errorCode: error.response?.data?.errorCode,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// API 호출 헬퍼 함수
|
||||
export const apiCall = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
|
|
|
|||
|
|
@ -174,6 +174,28 @@ export const tableTypeApi = {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
|
|
@ -1246,6 +1247,36 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collapsible": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
|
||||
"integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
|
|
|
|||
|
|
@ -232,6 +232,57 @@ export interface DataTablePagination {
|
|||
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";
|
||||
|
|
@ -244,6 +295,13 @@ export interface DataTableComponent extends BaseComponent {
|
|||
searchButtonText: string; // 검색 버튼 텍스트
|
||||
enableExport: boolean; // 내보내기 기능 활성화
|
||||
enableRefresh: boolean; // 새로고침 기능 활성화
|
||||
enableAdd: boolean; // 데이터 추가 기능 활성화
|
||||
enableEdit: boolean; // 데이터 수정 기능 활성화
|
||||
enableDelete: boolean; // 데이터 삭제 기능 활성화
|
||||
addButtonText: string; // 추가 버튼 텍스트
|
||||
editButtonText: string; // 수정 버튼 텍스트
|
||||
deleteButtonText: string; // 삭제 버튼 텍스트
|
||||
addModalConfig: DataTableAddModalConfig; // 추가 모달 커스터마이징 설정
|
||||
gridColumns: number; // 테이블이 차지할 그리드 컬럼 수
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue