기본적인 crud 구현

This commit is contained in:
kjs 2025-09-03 16:38:10 +09:00
parent 4a0c42d80c
commit 941c6d9d84
13 changed files with 2265 additions and 64 deletions

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;
}
}
}

View File

@ -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>
);
};

View File

@ -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" />

View File

@ -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,

View File

@ -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"

View File

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

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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; // 테이블이 차지할 그리드 컬럼 수
}