[agent-pipeline] pipe-20260309055714-23ry round-3
This commit is contained in:
@@ -308,6 +308,253 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
[codeOptions],
|
||||
);
|
||||
|
||||
// 데이터 로드 함수 (useEffect보다 먼저 선언해야 함 - TS2448 방지)
|
||||
const loadData = useCallback(
|
||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
if (!component.tableName) return;
|
||||
|
||||
// 프리뷰 모드에서는 샘플 데이터만 표시
|
||||
if (isPreviewMode) {
|
||||
const sampleData = Array.from({ length: 3 }, (_, i) => {
|
||||
const sample: Record<string, any> = { id: i + 1 };
|
||||
component.columns.forEach((col) => {
|
||||
if (col.widgetType === "number") {
|
||||
sample[col.columnName] = Math.floor(Math.random() * 1000);
|
||||
} else if (col.widgetType === "boolean") {
|
||||
sample[col.columnName] = i % 2 === 0 ? "Y" : "N";
|
||||
} else {
|
||||
sample[col.columnName] = `샘플 ${col.label} ${i + 1}`;
|
||||
}
|
||||
});
|
||||
return sample;
|
||||
});
|
||||
setData(sampleData);
|
||||
setTotal(3);
|
||||
setTotalPages(1);
|
||||
setCurrentPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||
let linkedFilterValues: Record<string, any> = {};
|
||||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||||
|
||||
if (splitPanelContext) {
|
||||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||||
(filter) =>
|
||||
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
|
||||
);
|
||||
|
||||
// 좌측 데이터 선택 여부 확인
|
||||
hasSelectedLeftData =
|
||||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||
|
||||
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
||||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||
const tableSpecificFilters: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(linkedFilterValues)) {
|
||||
// key가 "테이블명.컬럼명" 형식인 경우
|
||||
if (key.includes(".")) {
|
||||
const [tableName, columnName] = key.split(".");
|
||||
if (tableName === component.tableName) {
|
||||
tableSpecificFilters[columnName] = value;
|
||||
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
||||
}
|
||||
} else {
|
||||
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
|
||||
tableSpecificFilters[key] = value;
|
||||
}
|
||||
}
|
||||
linkedFilterValues = tableSpecificFilters;
|
||||
}
|
||||
|
||||
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
||||
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||||
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
||||
console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
setCurrentPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 필터 적용
|
||||
const relatedButtonFilterValues: Record<string, any> = {};
|
||||
if (relatedButtonFilter) {
|
||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||
}
|
||||
|
||||
// 검색 파라미터와 연결 필터 병합
|
||||
const mergedSearchParams = {
|
||||
...searchParams,
|
||||
...linkedFilterValues,
|
||||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||
};
|
||||
|
||||
const currentPageSize = component.pagination?.pageSize || 10;
|
||||
|
||||
console.log("🔍 데이터 조회 시작:", {
|
||||
tableName: component.tableName,
|
||||
page,
|
||||
pageSize: currentPageSize,
|
||||
linkedFilterValues,
|
||||
relatedButtonFilterValues,
|
||||
mergedSearchParams,
|
||||
});
|
||||
|
||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||
page,
|
||||
size: currentPageSize,
|
||||
search: mergedSearchParams,
|
||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||
});
|
||||
|
||||
console.log("✅ 데이터 조회 완료:", {
|
||||
tableName: component.tableName,
|
||||
dataLength: result.data.length,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setCurrentPage(result.page);
|
||||
|
||||
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
|
||||
const detectAndLoadCategoryLabels = async () => {
|
||||
const categoryCodes = new Set<string>();
|
||||
result.data.forEach((row: Record<string, any>) => {
|
||||
Object.values(row).forEach((value) => {
|
||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
||||
categoryCodes.add(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
|
||||
|
||||
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
|
||||
const newCodes = Array.from(categoryCodes);
|
||||
|
||||
if (newCodes.length > 0) {
|
||||
try {
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
|
||||
if (response.data.success && response.data.data) {
|
||||
setCategoryCodeLabels((prev) => {
|
||||
const newLabels = {
|
||||
...prev,
|
||||
...response.data.data,
|
||||
};
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
|
||||
return newLabels;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
detectAndLoadCategoryLabels();
|
||||
|
||||
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
const recordId = rowData[primaryKeyField];
|
||||
|
||||
if (!recordId) return { rowKey: recordId, statuses: {} };
|
||||
|
||||
try {
|
||||
const fileResponse = await getLinkedFiles(component.tableName, recordId);
|
||||
const allFiles = fileResponse.files || [];
|
||||
|
||||
// 전체 행에 대한 파일 상태
|
||||
const rowStatus = {
|
||||
hasFiles: allFiles.length > 0,
|
||||
fileCount: allFiles.length,
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼별 파일 상태
|
||||
const columnStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
// 가상 파일 컬럼 찾기
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
|
||||
virtualFileColumns.forEach((column) => {
|
||||
// 해당 컬럼의 파일만 필터링 (targetObjid로 수정)
|
||||
let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`));
|
||||
|
||||
// fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함
|
||||
if (columnFiles.length === 0) {
|
||||
columnFiles = allFiles.filter((file: any) =>
|
||||
file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`),
|
||||
);
|
||||
}
|
||||
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
columnStatuses[columnKey] = {
|
||||
hasFiles: columnFiles.length > 0,
|
||||
fileCount: columnFiles.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rowKey: recordId,
|
||||
statuses: {
|
||||
[recordId]: rowStatus, // 전체 행 상태
|
||||
...columnStatuses, // 컬럼별 상태
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// 에러 시 기본값
|
||||
const defaultStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {
|
||||
[recordId]: { hasFiles: false, fileCount: 0 },
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼에 대해서도 기본값 설정
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
virtualFileColumns.forEach((column) => {
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 };
|
||||
});
|
||||
|
||||
return { rowKey: recordId, statuses: defaultStatuses };
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 상태 업데이트
|
||||
Promise.all(fileStatusPromises).then((results) => {
|
||||
const statusMap: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
results.forEach((result) => {
|
||||
Object.assign(statusMap, result.statuses);
|
||||
});
|
||||
|
||||
setFileStatusMap(statusMap);
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[component.tableName, component.pagination?.pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter],
|
||||
);
|
||||
|
||||
// 🆕 전역 테이블 새로고침 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleRefreshTable = () => {
|
||||
@@ -695,251 +942,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
}
|
||||
}, [visibleColumns]);
|
||||
|
||||
// 데이터 로드 함수
|
||||
const loadData = useCallback(
|
||||
async (page: number = 1, searchParams: Record<string, any> = {}) => {
|
||||
if (!component.tableName) return;
|
||||
|
||||
// 프리뷰 모드에서는 샘플 데이터만 표시
|
||||
if (isPreviewMode) {
|
||||
const sampleData = Array.from({ length: 3 }, (_, i) => {
|
||||
const sample: Record<string, any> = { id: i + 1 };
|
||||
component.columns.forEach((col) => {
|
||||
if (col.widgetType === "number") {
|
||||
sample[col.columnName] = Math.floor(Math.random() * 1000);
|
||||
} else if (col.widgetType === "boolean") {
|
||||
sample[col.columnName] = i % 2 === 0 ? "Y" : "N";
|
||||
} else {
|
||||
sample[col.columnName] = `샘플 ${col.label} ${i + 1}`;
|
||||
}
|
||||
});
|
||||
return sample;
|
||||
});
|
||||
setData(sampleData);
|
||||
setTotal(3);
|
||||
setTotalPages(1);
|
||||
setCurrentPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
|
||||
let linkedFilterValues: Record<string, any> = {};
|
||||
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
|
||||
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
|
||||
|
||||
if (splitPanelContext) {
|
||||
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
|
||||
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
|
||||
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
|
||||
(filter) =>
|
||||
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
|
||||
);
|
||||
|
||||
// 좌측 데이터 선택 여부 확인
|
||||
hasSelectedLeftData =
|
||||
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
|
||||
|
||||
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
|
||||
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
|
||||
const tableSpecificFilters: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(linkedFilterValues)) {
|
||||
// key가 "테이블명.컬럼명" 형식인 경우
|
||||
if (key.includes(".")) {
|
||||
const [tableName, columnName] = key.split(".");
|
||||
if (tableName === component.tableName) {
|
||||
tableSpecificFilters[columnName] = value;
|
||||
hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음
|
||||
}
|
||||
} else {
|
||||
// 테이블명 없이 컬럼명만 있는 경우 그대로 사용
|
||||
tableSpecificFilters[key] = value;
|
||||
}
|
||||
}
|
||||
linkedFilterValues = tableSpecificFilters;
|
||||
}
|
||||
|
||||
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
|
||||
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
|
||||
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
|
||||
console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시");
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
setTotalPages(0);
|
||||
setCurrentPage(1);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 RelatedDataButtons 필터 적용
|
||||
const relatedButtonFilterValues: Record<string, any> = {};
|
||||
if (relatedButtonFilter) {
|
||||
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
|
||||
}
|
||||
|
||||
// 검색 파라미터와 연결 필터 병합
|
||||
const mergedSearchParams = {
|
||||
...searchParams,
|
||||
...linkedFilterValues,
|
||||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||
};
|
||||
|
||||
console.log("🔍 데이터 조회 시작:", {
|
||||
tableName: component.tableName,
|
||||
page,
|
||||
pageSize,
|
||||
linkedFilterValues,
|
||||
relatedButtonFilterValues,
|
||||
mergedSearchParams,
|
||||
});
|
||||
|
||||
const result = await tableTypeApi.getTableData(component.tableName, {
|
||||
page,
|
||||
size: pageSize,
|
||||
search: mergedSearchParams,
|
||||
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
|
||||
});
|
||||
|
||||
console.log("✅ 데이터 조회 완료:", {
|
||||
tableName: component.tableName,
|
||||
dataLength: result.data.length,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
});
|
||||
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
setTotalPages(result.totalPages);
|
||||
setCurrentPage(result.page);
|
||||
|
||||
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
|
||||
const detectAndLoadCategoryLabels = async () => {
|
||||
const categoryCodes = new Set<string>();
|
||||
result.data.forEach((row: Record<string, any>) => {
|
||||
Object.values(row).forEach((value) => {
|
||||
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
|
||||
categoryCodes.add(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
|
||||
|
||||
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
|
||||
const newCodes = Array.from(categoryCodes);
|
||||
|
||||
if (newCodes.length > 0) {
|
||||
try {
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
|
||||
if (response.data.success && response.data.data) {
|
||||
setCategoryCodeLabels((prev) => {
|
||||
const newLabels = {
|
||||
...prev,
|
||||
...response.data.data,
|
||||
};
|
||||
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
|
||||
return newLabels;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
detectAndLoadCategoryLabels();
|
||||
|
||||
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
|
||||
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
|
||||
const primaryKeyField = Object.keys(rowData)[0];
|
||||
const recordId = rowData[primaryKeyField];
|
||||
|
||||
if (!recordId) return { rowKey: recordId, statuses: {} };
|
||||
|
||||
try {
|
||||
const fileResponse = await getLinkedFiles(component.tableName, recordId);
|
||||
const allFiles = fileResponse.files || [];
|
||||
|
||||
// 전체 행에 대한 파일 상태
|
||||
const rowStatus = {
|
||||
hasFiles: allFiles.length > 0,
|
||||
fileCount: allFiles.length,
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼별 파일 상태
|
||||
const columnStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
// 가상 파일 컬럼 찾기
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
|
||||
virtualFileColumns.forEach((column) => {
|
||||
// 해당 컬럼의 파일만 필터링 (targetObjid로 수정)
|
||||
let columnFiles = allFiles.filter((file: any) => file.targetObjid?.endsWith(`:${column.columnName}`));
|
||||
|
||||
// fallback: 컬럼명으로 찾지 못한 경우 모든 파일 컬럼 파일 포함
|
||||
if (columnFiles.length === 0) {
|
||||
columnFiles = allFiles.filter((file: any) =>
|
||||
file.targetObjid?.startsWith(`${component.tableName}:${recordId}:file_column_`),
|
||||
);
|
||||
}
|
||||
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
columnStatuses[columnKey] = {
|
||||
hasFiles: columnFiles.length > 0,
|
||||
fileCount: columnFiles.length,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rowKey: recordId,
|
||||
statuses: {
|
||||
[recordId]: rowStatus, // 전체 행 상태
|
||||
...columnStatuses, // 컬럼별 상태
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// 에러 시 기본값
|
||||
const defaultStatuses: Record<string, { hasFiles: boolean; fileCount: number }> = {
|
||||
[recordId]: { hasFiles: false, fileCount: 0 },
|
||||
};
|
||||
|
||||
// 가상 파일 컬럼에 대해서도 기본값 설정
|
||||
const virtualFileColumns = component.columns.filter((col) => col.isVirtualFileColumn);
|
||||
virtualFileColumns.forEach((column) => {
|
||||
const columnKey = `${recordId}_${column.columnName}`;
|
||||
defaultStatuses[columnKey] = { hasFiles: false, fileCount: 0 };
|
||||
});
|
||||
|
||||
return { rowKey: recordId, statuses: defaultStatuses };
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 상태 업데이트
|
||||
Promise.all(fileStatusPromises).then((results) => {
|
||||
const statusMap: Record<string, { hasFiles: boolean; fileCount: number }> = {};
|
||||
|
||||
results.forEach((result) => {
|
||||
Object.assign(statusMap, result.statuses);
|
||||
});
|
||||
|
||||
setFileStatusMap(statusMap);
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error("❌ 테이블 데이터 조회 실패:", error);
|
||||
setData([]);
|
||||
setTotal(0);
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData, relatedButtonFilter], // 🆕 autoFilter, 연결필터, RelatedDataButtons 필터 추가
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
@@ -2681,7 +2683,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
className="h-32 text-center"
|
||||
>
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-6 w-6" />
|
||||
<Database className="h-8 w-8 opacity-40" />
|
||||
<p>검색 결과가 없습니다</p>
|
||||
<p className="text-xs">검색 조건을 변경하거나 새로고침을 시도해보세요</p>
|
||||
</div>
|
||||
@@ -2759,7 +2761,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2">
|
||||
<Database className="h-6 w-6" />
|
||||
<Database className="h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">표시할 컬럼이 없습니다</p>
|
||||
<p className="text-xs">테이블 설정에서 컬럼을 추가해주세요</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user