diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 09d7dbe3..bbee31d4 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -449,7 +449,13 @@ export async function getTableData( ): Promise { 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 = { success: true, @@ -507,3 +512,234 @@ export async function getTableData( res.status(500).json(response); } } + +/** + * 테이블 데이터 추가 + */ +export async function addTableData( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName } = req.params; + const data = req.body; + + logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); + logger.info(`추가할 데이터:`, data); + + if (!tableName) { + const response: ApiResponse = { + 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 = { + 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 = { + success: true, + message: "테이블 데이터를 성공적으로 추가했습니다.", + }; + + res.status(201).json(response); + } catch (error) { + logger.error("테이블 데이터 추가 중 오류 발생:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + const { originalData, updatedData } = req.body; + + logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`); + logger.info(`원본 데이터:`, originalData); + logger.info(`수정할 데이터:`, updatedData); + + if (!tableName) { + const response: ApiResponse = { + success: false, + message: "테이블명이 필요합니다.", + error: { + code: "INVALID_TABLE_NAME", + details: "테이블명이 누락되었습니다.", + }, + }; + res.status(400).json(response); + return; + } + + if (!originalData || !updatedData) { + const response: ApiResponse = { + 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 = { + 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 = { + success: true, + message: "테이블 데이터를 성공적으로 수정했습니다.", + }; + + res.status(200).json(response); + } catch (error) { + logger.error("테이블 데이터 수정 중 오류 발생:", error); + + const response: ApiResponse = { + 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 { + try { + const { tableName } = req.params; + const data = req.body; + + logger.info(`=== 테이블 데이터 삭제 시작: ${tableName} ===`); + logger.info(`삭제할 데이터:`, data); + + if (!tableName) { + const response: ApiResponse = { + 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 = { + 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 = { + success: false, + message: "테이블 데이터 삭제 중 오류가 발생했습니다.", + error: { + code: "TABLE_DELETE_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index 83e32656..94558881 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -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; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 124a04f0..2f2b76a5 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -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(countQuery, ...searchValues); + const countResult = await prisma.$queryRawUnsafe( + 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( - 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 + ): Promise { + 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(); + + 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, + updatedData: Record + ): Promise { + 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(); + 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[] + ): Promise { + 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; + } + } } diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index f3b1fadb..79724a53 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -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 = ({ const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [total, setTotal] = useState(0); + const [selectedRows, setSelectedRows] = useState>(new Set()); + const [showAddModal, setShowAddModal] = useState(false); + const [addFormData, setAddFormData] = useState>({}); + const [isAdding, setIsAdding] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editFormData, setEditFormData] = useState>({}); + const [editingRowData, setEditingRowData] = useState | null>(null); + const [isEditing, setIsEditing] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // 현재 사용자 정보 + const [currentUser, setCurrentUser] = useState(null); // 검색 가능한 컬럼만 필터링 const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || []; @@ -79,6 +103,22 @@ export const InteractiveDataTable: React.FC = ({ [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 = ({ [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 = {}; + 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 = {}; + 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 ( +
+ +

자동 생성된 필드는 수정할 수 없습니다.

+
+ ); + } + + // 읽기 전용 필드 + if (advancedConfig?.inputType === "readonly") { + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + } + + // 일반 입력 필드 렌더링 + const commonProps = { + value, + onChange: (e: React.ChangeEvent) => 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 ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "number": + case "decimal": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "date": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "datetime": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "select": + case "dropdown": + // TODO: 동적 옵션 로드 + return ; + + default: + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + } + }; + + // 추가 폼 입력 컴포넌트 렌더링 + 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 ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + } + + // 일반 입력 필드 렌더링 + const commonProps = { + value, + onChange: (e: React.ChangeEvent) => 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 ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "number": + case "decimal": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "date": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "datetime": + return ( +
+ + {advancedConfig?.helpText &&

{advancedConfig.helpText}

} +
+ ); + + case "select": + case "dropdown": + // TODO: 동적 옵션 로드 + return ( + + ); + + case "boolean": + case "checkbox": + return ( +
+ handleAddFormChange(column.columnName, checked)} + /> + +
+ ); + + default: + return ( + 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 = ({ )}
+ {/* 선택된 행 개수 표시 */} + {selectedRows.size > 0 && ( + + {selectedRows.size}개 선택됨 + + )} + {searchFilters.length > 0 && ( 필터 {searchFilters.length}개 )} + + {/* CRUD 버튼들 */} + {component.enableAdd && ( + + )} + + {component.enableEdit && selectedRows.size === 1 && ( + + )} + + {component.enableDelete && selectedRows.size > 0 && ( + + )} + {component.showSearchButton && ( )} +