Merge pull request 'fix/429error' (#75) from fix/429error into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/75
This commit is contained in:
hyeonsu 2025-09-29 17:47:16 +09:00
commit 55f4c7fa26
5 changed files with 661 additions and 746 deletions

View File

@ -352,13 +352,13 @@ export default function ScreenViewPage() {
}, },
onFormDataChange: (fieldName, value) => { onFormDataChange: (fieldName, value) => {
console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`); console.log(`🎯 page.tsx onFormDataChange 호출: ${fieldName} = "${value}"`);
console.log(`📋 현재 formData:`, formData); console.log("📋 현재 formData:", formData);
setFormData((prev) => { setFormData((prev) => {
const newFormData = { const newFormData = {
...prev, ...prev,
[fieldName]: value, [fieldName]: value,
}; };
console.log(`📝 업데이트된 formData:`, newFormData); console.log("📝 업데이트된 formData:", newFormData);
return newFormData; return newFormData;
}); });
}, },

View File

@ -1,31 +1,107 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { queryKeys } from "@/lib/queryKeys"; import { tableTypeApi } from "@/lib/api/screen";
import type { CodeFilter, CreateCodeData, UpdateCodeData } from "@/lib/schemas/commonCode"; import { useMemo } from "react";
/** // Query Keys
* export const queryKeys = {
*/ codes: {
export function useCodes(categoryCode: string, filters?: CodeFilter) { all: ["codes"] as const,
list: (categoryCode: string) => ["codes", "list", categoryCode] as const,
options: (categoryCode: string) => ["codes", "options", categoryCode] as const,
detail: (categoryCode: string, codeValue: string) =>
["codes", "detail", categoryCode, codeValue] as const,
infiniteList: (categoryCode: string, filters?: any) =>
["codes", "infiniteList", categoryCode, filters] as const,
},
tables: {
all: ["tables"] as const,
columns: (tableName: string) => ["tables", "columns", tableName] as const,
codeCategory: (tableName: string, columnName: string) =>
["tables", "codeCategory", tableName, columnName] as const,
},
categories: {
all: ["categories"] as const,
list: (filters?: any) => ["categories", "list", filters] as const,
},
};
// 테이블 컬럼의 코드 카테고리 조회
export function useTableCodeCategory(tableName?: string, columnName?: string) {
return useQuery({ return useQuery({
queryKey: queryKeys.codes.list(categoryCode, filters), queryKey: queryKeys.tables.codeCategory(tableName || "", columnName || ""),
queryFn: () => commonCodeApi.codes.getList(categoryCode, filters), queryFn: async () => {
select: (data) => data.data || [], if (!tableName || !columnName) return null;
enabled: !!categoryCode, // categoryCode가 있을 때만 실행
console.log(`🔍 [React Query] 테이블 코드 카테고리 조회: ${tableName}.${columnName}`);
const columns = await tableTypeApi.getColumns(tableName);
const targetColumn = columns.find((col) => col.columnName === columnName);
const codeCategory = targetColumn?.codeCategory && targetColumn.codeCategory !== "none"
? targetColumn.codeCategory
: null;
console.log(`✅ [React Query] 테이블 코드 카테고리 결과: ${tableName}.${columnName} -> ${codeCategory}`);
return codeCategory;
},
enabled: !!(tableName && columnName),
staleTime: 10 * 60 * 1000, // 10분 캐시
gcTime: 30 * 60 * 1000, // 30분 가비지 컬렉션
}); });
} }
/** // 코드 옵션 조회 (select용)
* export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
*/ const query = useQuery({
queryKey: queryKeys.codes.options(codeCategory || ""),
queryFn: async () => {
if (!codeCategory || codeCategory === "none") return [];
console.log(`🔍 [React Query] 코드 옵션 조회: ${codeCategory}`);
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
if (response.success && response.data) {
const options = response.data.map((code: any) => {
const actualValue = code.code || code.CODE || code.value || code.code_value || code.codeValue;
const actualLabel = code.codeName || code.code_name || code.name || code.CODE_NAME ||
code.NAME || code.label || code.LABEL || code.text || code.title ||
code.description || actualValue;
return {
value: actualValue,
label: actualLabel,
};
});
console.log(`✅ [React Query] 코드 옵션 결과: ${codeCategory} (${options.length}개)`);
return options;
}
return [];
},
enabled: enabled && !!(codeCategory && codeCategory !== "none"),
staleTime: 10 * 60 * 1000, // 10분 캐시
gcTime: 30 * 60 * 1000, // 30분 가비지 컬렉션
});
return {
options: query.data || [],
isLoading: query.isLoading,
isFetching: query.isFetching,
error: query.error,
refetch: query.refetch,
};
}
// 코드 생성
export function useCreateCode() { export function useCreateCode() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ categoryCode, data }: { categoryCode: string; data: CreateCodeData }) => mutationFn: ({ categoryCode, data }: { categoryCode: string; data: any }) =>
commonCodeApi.codes.create(categoryCode, data), commonCodeApi.codes.create(categoryCode, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤) // 해당 카테고리의 모든 코드 관련 쿼리 무효화
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.codes.all, queryKey: queryKeys.codes.all,
}); });
@ -34,15 +110,10 @@ export function useCreateCode() {
queryKey: queryKeys.codes.infiniteList(variables.categoryCode), queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
}); });
}, },
onError: (error) => {
console.error("코드 생성 실패:", error);
},
}); });
} }
/** // 코드 수정
*
*/
export function useUpdateCode() { export function useUpdateCode() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -54,14 +125,14 @@ export function useUpdateCode() {
}: { }: {
categoryCode: string; categoryCode: string;
codeValue: string; codeValue: string;
data: UpdateCodeData; data: any;
}) => commonCodeApi.codes.update(categoryCode, codeValue, data), }) => commonCodeApi.codes.update(categoryCode, codeValue, data),
onSuccess: (_, variables) => { onSuccess: (_, variables) => {
// 해당 코드 상세 쿼리 무효화 // 해당 코드 상세 쿼리 무효화
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue), queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
}); });
// 해당 카테고리의 모든 코드 관련 쿼리 무효화 (일반 목록 + 무한 스크롤) // 해당 카테고리의 모든 코드 관련 쿼리 무효화
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: queryKeys.codes.all, queryKey: queryKeys.codes.all,
}); });
@ -70,15 +141,10 @@ export function useUpdateCode() {
queryKey: queryKeys.codes.infiniteList(variables.categoryCode), queryKey: queryKeys.codes.infiniteList(variables.categoryCode),
}); });
}, },
onError: (error) => {
console.error("코드 수정 실패:", error);
},
}); });
} }
/** // 코드 삭제
*
*/
export function useDeleteCode() { export function useDeleteCode() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -98,15 +164,10 @@ export function useDeleteCode() {
queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue), queryKey: queryKeys.codes.detail(variables.categoryCode, variables.codeValue),
}); });
}, },
onError: (error) => {
console.error("코드 삭제 실패:", error);
},
}); });
} }
/** // 코드 순서 변경
*
*/
export function useReorderCodes() { export function useReorderCodes() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -126,7 +187,7 @@ export function useReorderCodes() {
const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode)); const previousCodes = queryClient.getQueryData(queryKeys.codes.list(categoryCode));
// Optimistic update: 새로운 순서로 즉시 업데이트 // Optimistic update: 새로운 순서로 즉시 업데이트
if (previousCodes && (previousCodes as any).data && Array.isArray((previousCodes as any).data)) { if (previousCodes && Array.isArray((previousCodes as any).data)) {
const previousCodesArray = (previousCodes as any).data; const previousCodesArray = (previousCodes as any).data;
// 기존 데이터를 복사하고 sort_order만 업데이트 // 기존 데이터를 복사하고 sort_order만 업데이트
@ -135,8 +196,8 @@ export function useReorderCodes() {
return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code; return newCodeData ? { ...code, sort_order: newCodeData.sortOrder } : code;
}); });
// sort_order로 정렬 // 순서대로 정렬
updatedCodes.sort((a: any, b: any) => a.sort_order - b.sort_order); updatedCodes.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
// API 응답 형태로 캐시에 저장 (기존 구조 유지) // API 응답 형태로 캐시에 저장 (기존 구조 유지)
queryClient.setQueryData(queryKeys.codes.list(categoryCode), { queryClient.setQueryData(queryKeys.codes.list(categoryCode), {
@ -145,11 +206,9 @@ export function useReorderCodes() {
}); });
} }
// 롤백용 데이터 반환
return { previousCodes }; return { previousCodes };
}, },
onError: (error, variables, context) => { onError: (err, variables, context) => {
console.error("코드 순서 변경 실패:", error);
// 에러 시 이전 데이터로 롤백 // 에러 시 이전 데이터로 롤백
if (context?.previousCodes) { if (context?.previousCodes) {
queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes); queryClient.setQueryData(queryKeys.codes.list(variables.categoryCode), context.previousCodes);

View File

@ -24,7 +24,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
webType, webType,
propsKeys: Object.keys(props), propsKeys: Object.keys(props),
component: props.component, component: props.component,
isFileComponent: props.component?.type === 'file' || webType === 'file' isFileComponent: props.component?.type === "file" || webType === "file",
}); });
const webTypeDefinition = useMemo(() => { const webTypeDefinition = useMemo(() => {
@ -70,12 +70,12 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
if (dbWebType?.component_name) { if (dbWebType?.component_name) {
try { try {
console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`); console.log(`웹타입 "${webType}" → DB 지정 컴포넌트 "${dbWebType.component_name}" 사용`);
console.log(`DB 웹타입 정보:`, dbWebType); console.log("DB 웹타입 정보:", dbWebType);
// FileWidget의 경우 FileUploadComponent 직접 사용 // FileWidget의 경우 FileUploadComponent 직접 사용
if (dbWebType.component_name === "FileWidget" || webType === "file") { if (dbWebType.component_name === "FileWidget" || webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ FileWidget → FileUploadComponent 사용`); console.log("✅ FileWidget → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
@ -99,7 +99,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 // 파일 웹타입의 경우 FileUploadComponent 직접 사용
if (webType === "file") { if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ 파일 웹타입 → FileUploadComponent 사용`); console.log("✅ 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
@ -131,7 +131,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백) // 파일 웹타입의 경우 FileUploadComponent 직접 사용 (최종 폴백)
if (webType === "file") { if (webType === "file") {
const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent"); const { FileUploadComponent } = require("@/lib/registry/components/file-upload/FileUploadComponent");
console.log(`✅ 폴백: 파일 웹타입 → FileUploadComponent 사용`); console.log("✅ 폴백: 파일 웹타입 → FileUploadComponent 사용");
return <FileUploadComponent {...props} {...finalProps} />; return <FileUploadComponent {...props} {...finalProps} />;
} }
@ -159,12 +159,7 @@ export const DynamicWebTypeRenderer: React.FC<DynamicComponentProps> = ({
// 기본 폴백: Input 컴포넌트 사용 // 기본 폴백: Input 컴포넌트 사용
const { Input } = require("@/components/ui/input"); const { Input } = require("@/components/ui/input");
console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`); console.log(`✅ 폴백: ${webType} 웹타입 → 기본 Input 사용`);
return <Input return <Input placeholder={`${webType}`} disabled={props.readonly} className="w-full" {...props} />;
placeholder={`${webType}`}
disabled={props.readonly}
className="w-full"
{...props}
/>;
} catch (error) { } catch (error) {
console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error); console.error(`웹타입 "${webType}" 폴백 컴포넌트 렌더링 실패:`, error);
return ( return (

View File

@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef, useMemo } from "react";
import { commonCodeApi } from "../../../api/commonCode";
import { tableTypeApi } from "../../../api/screen";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
interface Option { interface Option {
value: string; value: string;
@ -26,210 +25,10 @@ export interface SelectBasicComponentProps {
[key: string]: any; [key: string]: any;
} }
// 🚀 전역 상태 관리: 모든 컴포넌트가 공유하는 상태 // ✅ React Query를 사용하여 중복 요청 방지 및 자동 캐싱 처리
interface GlobalState { // - 동일한 queryKey에 대해서는 자동으로 중복 요청 제거
tableCategories: Map<string, string>; // tableName.columnName -> codeCategory // - 10분 staleTime으로 적절한 캐시 관리
codeOptions: Map<string, { options: Option[]; timestamp: number }>; // codeCategory -> options // - 30분 gcTime으로 메모리 효율성 확보
activeRequests: Map<string, Promise<any>>; // 진행 중인 요청들
subscribers: Set<() => void>; // 상태 변경 구독자들
}
const globalState: GlobalState = {
tableCategories: new Map(),
codeOptions: new Map(),
activeRequests: new Map(),
subscribers: new Set(),
};
// 전역 상태 변경 알림
const notifyStateChange = () => {
globalState.subscribers.forEach((callback) => callback());
};
// 캐시 유효 시간 (5분)
const CACHE_DURATION = 5 * 60 * 1000;
// 🔧 전역 테이블 코드 카테고리 로딩 (중복 방지)
const loadGlobalTableCodeCategory = async (tableName: string, columnName: string): Promise<string | null> => {
const key = `${tableName}.${columnName}`;
// 이미 진행 중인 요청이 있으면 대기
if (globalState.activeRequests.has(`table_${key}`)) {
try {
await globalState.activeRequests.get(`table_${key}`);
} catch (error) {
console.error("❌ 테이블 설정 로딩 대기 중 오류:", error);
}
}
// 캐시된 값이 있으면 반환
if (globalState.tableCategories.has(key)) {
const cachedCategory = globalState.tableCategories.get(key);
console.log(`✅ 캐시된 테이블 설정 사용: ${key} -> ${cachedCategory}`);
return cachedCategory || null;
}
// 새로운 요청 생성
const request = (async () => {
try {
console.log(`🔍 테이블 코드 카테고리 조회: ${key}`);
const columns = await tableTypeApi.getColumns(tableName);
const targetColumn = columns.find((col) => col.columnName === columnName);
const codeCategory =
targetColumn?.codeCategory && targetColumn.codeCategory !== "none" ? targetColumn.codeCategory : null;
// 전역 상태에 저장
globalState.tableCategories.set(key, codeCategory || "");
console.log(`✅ 테이블 설정 조회 완료: ${key} -> ${codeCategory}`);
// 상태 변경 알림
notifyStateChange();
return codeCategory;
} catch (error) {
console.error(`❌ 테이블 코드 카테고리 조회 실패: ${key}`, error);
return null;
} finally {
globalState.activeRequests.delete(`table_${key}`);
}
})();
globalState.activeRequests.set(`table_${key}`, request);
return request;
};
// 🔧 전역 코드 옵션 로딩 (중복 방지)
const loadGlobalCodeOptions = async (codeCategory: string): Promise<Option[]> => {
if (!codeCategory || codeCategory === "none") {
return [];
}
// 이미 진행 중인 요청이 있으면 대기
if (globalState.activeRequests.has(`code_${codeCategory}`)) {
try {
await globalState.activeRequests.get(`code_${codeCategory}`);
} catch (error) {
console.error("❌ 코드 옵션 로딩 대기 중 오류:", error);
}
}
// 캐시된 값이 유효하면 반환
const cached = globalState.codeOptions.get(codeCategory);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
console.log(`✅ 캐시된 코드 옵션 사용: ${codeCategory} (${cached.options.length}개)`);
return cached.options;
}
// 새로운 요청 생성
const request = (async () => {
try {
console.log(`🔄 코드 옵션 로딩: ${codeCategory}`);
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
console.log(`🔍 [API 응답 원본] ${codeCategory}:`, {
response,
success: response.success,
data: response.data,
dataType: typeof response.data,
isArray: Array.isArray(response.data),
dataLength: response.data?.length,
firstItem: response.data?.[0],
});
if (response.success && response.data) {
const options = response.data.map((code: any, index: number) => {
console.log(`🔍 [코드 매핑] ${index}:`, {
originalCode: code,
codeKeys: Object.keys(code),
values: Object.values(code),
// 가능한 모든 필드 확인
code: code.code,
codeName: code.codeName,
name: code.name,
label: code.label,
// 대문자 버전
CODE: code.CODE,
CODE_NAME: code.CODE_NAME,
NAME: code.NAME,
LABEL: code.LABEL,
// 스네이크 케이스
code_name: code.code_name,
code_value: code.code_value,
// 기타 가능한 필드들
value: code.value,
text: code.text,
title: code.title,
description: code.description,
});
// 실제 값 찾기 시도 (우선순위 순)
const actualValue = code.code || code.CODE || code.value || code.code_value || `code_${index}`;
const actualLabel =
code.codeName ||
code.code_name || // 스네이크 케이스 추가!
code.name ||
code.CODE_NAME ||
code.NAME ||
code.label ||
code.LABEL ||
code.text ||
code.title ||
code.description ||
actualValue;
console.log(`✨ [최종 매핑] ${index}:`, {
actualValue,
actualLabel,
hasValue: !!actualValue,
hasLabel: !!actualLabel,
});
return {
value: actualValue,
label: actualLabel,
};
});
console.log(`🔍 [최종 옵션 배열] ${codeCategory}:`, {
optionsLength: options.length,
options: options.map((opt, idx) => ({
index: idx,
value: opt.value,
label: opt.label,
hasLabel: !!opt.label,
hasValue: !!opt.value,
})),
});
// 전역 상태에 저장
globalState.codeOptions.set(codeCategory, {
options,
timestamp: Date.now(),
});
console.log(`✅ 코드 옵션 로딩 완료: ${codeCategory} (${options.length}개)`);
// 상태 변경 알림
notifyStateChange();
return options;
} else {
console.log(`⚠️ 빈 응답: ${codeCategory}`);
return [];
}
} catch (error) {
console.error(`❌ 코드 옵션 로딩 실패: ${codeCategory}`, error);
return [];
} finally {
globalState.activeRequests.delete(`code_${codeCategory}`);
}
})();
globalState.activeRequests.set(`code_${codeCategory}`, request);
return request;
};
const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
component, component,
@ -248,6 +47,22 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
value: externalValue, // 명시적으로 value prop 받기 value: externalValue, // 명시적으로 value prop 받기
...props ...props
}) => { }) => {
// 🚨 최우선 디버깅: 컴포넌트가 실행되는지 확인
console.log("🚨🚨🚨 SelectBasicComponent 실행됨!!!", {
componentId: component?.id,
componentType: component?.type,
webType: component?.webType,
tableName: component?.tableName,
columnName: component?.columnName,
screenId,
timestamp: new Date().toISOString(),
});
// 브라우저 알림으로도 확인
if (typeof window !== "undefined" && !(window as any).selectBasicAlerted) {
(window as any).selectBasicAlerted = true;
alert("SelectBasicComponent가 실행되었습니다!");
}
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성) // webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
@ -257,23 +72,74 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || ""); const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
const [selectedLabel, setSelectedLabel] = useState(""); const [selectedLabel, setSelectedLabel] = useState("");
console.log("🔍 SelectBasicComponent 초기화:", { console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
componentId: component.id, componentId: component.id,
externalValue, externalValue,
componentConfigValue: componentConfig?.value, componentConfigValue: componentConfig?.value,
webTypeConfigValue: (props as any).webTypeConfig?.value, webTypeConfigValue: (props as any).webTypeConfig?.value,
configValue: config?.value, configValue: config?.value,
finalSelectedValue: externalValue || config?.value || "", finalSelectedValue: externalValue || config?.value || "",
props: Object.keys(props), tableName: component.tableName,
columnName: component.columnName,
staticCodeCategory: config?.codeCategory,
// React Query 디버깅 정보
timestamp: new Date().toISOString(),
mountCount: ++(window as any).selectMountCount || ((window as any).selectMountCount = 1),
}); });
const [codeOptions, setCodeOptions] = useState<Option[]>([]);
const [isLoadingCodes, setIsLoadingCodes] = useState(false); // 언마운트 시 로깅
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null); useEffect(() => {
const [globalStateVersion, setGlobalStateVersion] = useState(0); // 전역 상태 변경 감지용 const componentId = component.id;
console.log(`🔍 [${componentId}] SelectBasicComponent 마운트됨`);
return () => {
console.log(`🔍 [${componentId}] SelectBasicComponent 언마운트됨`);
};
}, [component.id]);
const selectRef = useRef<HTMLDivElement>(null); const selectRef = useRef<HTMLDivElement>(null);
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 // 안정적인 쿼리 키를 위한 메모이제이션
const codeCategory = dynamicCodeCategory || config?.codeCategory; const stableTableName = useMemo(() => component.tableName, [component.tableName]);
const stableColumnName = useMemo(() => component.columnName, [component.columnName]);
const staticCodeCategory = useMemo(() => config?.codeCategory, [config?.codeCategory]);
// 🚀 React Query: 테이블 코드 카테고리 조회
const { data: dynamicCodeCategory } = useTableCodeCategory(stableTableName, stableColumnName);
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
const codeCategory = useMemo(() => {
const category = dynamicCodeCategory || staticCodeCategory;
console.log(`🔑 [${component.id}] 코드 카테고리 결정:`, {
dynamicCodeCategory,
staticCodeCategory,
finalCategory: category,
});
return category;
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
// 🚀 React Query: 코드 옵션 조회 (안정적인 enabled 조건)
const isCodeCategoryValid = useMemo(() => {
return !!codeCategory && codeCategory !== "none";
}, [codeCategory]);
const {
options: codeOptions,
isLoading: isLoadingCodes,
isFetching,
} = useCodeOptions(codeCategory, isCodeCategoryValid);
// React Query 상태 디버깅
useEffect(() => {
console.log(`🎯 [${component.id}] React Query 상태:`, {
codeCategory,
isCodeCategoryValid,
codeOptionsLength: codeOptions.length,
isLoadingCodes,
isFetching,
cacheStatus: isFetching ? "FETCHING" : "FROM_CACHE",
});
}, [component.id, codeCategory, isCodeCategoryValid, codeOptions.length, isLoadingCodes, isFetching]);
// 외부 value prop 변경 시 selectedValue 업데이트 // 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => { useEffect(() => {
@ -293,109 +159,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
} }
}, [externalValue, config?.value]); }, [externalValue, config?.value]);
// 🚀 전역 상태 구독 및 동기화 // ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
useEffect(() => { // - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
const updateFromGlobalState = () => { // - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
setGlobalStateVersion((prev) => prev + 1); // - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
};
// 전역 상태 변경 구독
globalState.subscribers.add(updateFromGlobalState);
return () => {
globalState.subscribers.delete(updateFromGlobalState);
};
}, []);
// 🔧 테이블 코드 카테고리 로드 (전역 상태 사용)
const loadTableCodeCategory = async () => {
if (!component.tableName || !component.columnName) return;
try {
console.log(`🔍 [${component.id}] 전역 테이블 코드 카테고리 조회`);
const category = await loadGlobalTableCodeCategory(component.tableName, component.columnName);
if (category !== dynamicCodeCategory) {
console.log(`🔄 [${component.id}] 코드 카테고리 변경: ${dynamicCodeCategory}${category}`);
setDynamicCodeCategory(category);
}
} catch (error) {
console.error(`❌ [${component.id}] 테이블 코드 카테고리 조회 실패:`, error);
}
};
// 🔧 코드 옵션 로드 (전역 상태 사용)
const loadCodeOptions = async (category: string) => {
if (!category || category === "none") {
setCodeOptions([]);
setIsLoadingCodes(false);
return;
}
try {
setIsLoadingCodes(true);
console.log(`🔄 [${component.id}] 전역 코드 옵션 로딩: ${category}`);
const options = await loadGlobalCodeOptions(category);
setCodeOptions(options);
console.log(`✅ [${component.id}] 코드 옵션 업데이트 완료: ${category} (${options.length}개)`);
} catch (error) {
console.error(`❌ [${component.id}] 코드 옵션 로딩 실패:`, error);
setCodeOptions([]);
} finally {
setIsLoadingCodes(false);
}
};
// 초기 테이블 코드 카테고리 로드
useEffect(() => {
loadTableCodeCategory();
}, [component.tableName, component.columnName]);
// 전역 상태 변경 시 동기화
useEffect(() => {
if (component.tableName && component.columnName) {
const key = `${component.tableName}.${component.columnName}`;
const cachedCategory = globalState.tableCategories.get(key);
if (cachedCategory && cachedCategory !== dynamicCodeCategory) {
console.log(`🔄 [${component.id}] 전역 상태 동기화: ${dynamicCodeCategory}${cachedCategory}`);
setDynamicCodeCategory(cachedCategory || null);
}
}
}, [globalStateVersion, component.tableName, component.columnName]);
// 코드 카테고리 변경 시 옵션 로드
useEffect(() => {
if (codeCategory && codeCategory !== "none") {
// 전역 캐시된 옵션부터 확인
const cached = globalState.codeOptions.get(codeCategory);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
console.log(`🚀 [${component.id}] 전역 캐시 즉시 적용: ${codeCategory} (${cached.options.length}개)`);
setCodeOptions(cached.options);
setIsLoadingCodes(false);
} else {
loadCodeOptions(codeCategory);
}
} else {
setCodeOptions([]);
setIsLoadingCodes(false);
}
}, [codeCategory]);
// 전역 상태에서 코드 옵션 변경 감지
useEffect(() => {
if (codeCategory) {
const cached = globalState.codeOptions.get(codeCategory);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
if (JSON.stringify(cached.options) !== JSON.stringify(codeOptions)) {
console.log(`🔄 [${component.id}] 전역 옵션 변경 감지: ${codeCategory}`);
setCodeOptions(cached.options);
}
}
}
}, [globalStateVersion, codeCategory]);
// 선택된 값에 따른 라벨 업데이트 // 선택된 값에 따른 라벨 업데이트
useEffect(() => { useEffect(() => {
@ -438,41 +206,20 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
} }
}, [selectedValue, codeOptions, config.options]); }, [selectedValue, codeOptions, config.options]);
// 클릭 이벤트 핸들러 (전역 상태 새로고침) // 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => { const handleToggle = () => {
if (isDesignMode) return; if (isDesignMode) return;
console.log(`🖱️ [${component.id}] 드롭다운 토글: ${isOpen}${!isOpen}`); console.log(`🖱️ [${component.id}] 드롭다운 토글 (React Query): ${isOpen}${!isOpen}`);
console.log(`📊 [${component.id}] 현재 상태:`, { console.log(`📊 [${component.id}] 현재 상태:`, {
isDesignMode, codeCategory,
isLoadingCodes, isLoadingCodes,
allOptionsLength: allOptions.length, codeOptionsLength: codeOptions.length,
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })), tableName: component.tableName,
columnName: component.columnName,
}); });
// 드롭다운을 열 때 전역 상태 새로고침 // React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
if (!isOpen) {
console.log(`🖱️ [${component.id}] 셀렉트박스 클릭 - 전역 상태 새로고침`);
// 테이블 설정 캐시 무효화 후 재로드
if (component.tableName && component.columnName) {
const key = `${component.tableName}.${component.columnName}`;
globalState.tableCategories.delete(key);
// 현재 코드 카테고리의 캐시도 무효화
if (dynamicCodeCategory) {
globalState.codeOptions.delete(dynamicCodeCategory);
console.log(`🗑️ [${component.id}] 코드 옵션 캐시 무효화: ${dynamicCodeCategory}`);
// 강제로 새로운 API 호출 수행
console.log(`🔄 [${component.id}] 강제 코드 옵션 재로드 시작: ${dynamicCodeCategory}`);
loadCodeOptions(dynamicCodeCategory);
}
loadTableCodeCategory();
}
}
setIsOpen(!isOpen); setIsOpen(!isOpen);
}; };
@ -519,45 +266,19 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
}; };
}, [isOpen]); }, [isOpen]);
// 🚀 실시간 업데이트를 위한 이벤트 리스너 // ✅ React Query가 자동으로 처리하므로 수동 이벤트 리스너 불필요
useEffect(() => { // - refetchOnWindowFocus: true (기본값)
const handleFocus = () => { // - refetchOnReconnect: true (기본값)
console.log(`👁️ [${component.id}] 윈도우 포커스 - 전역 상태 새로고침`); // - staleTime으로 적절한 캐시 관리
if (component.tableName && component.columnName) {
const key = `${component.tableName}.${component.columnName}`;
globalState.tableCategories.delete(key); // 캐시 무효화
loadTableCodeCategory();
}
};
const handleVisibilityChange = () => {
if (!document.hidden) {
console.log(`👁️ [${component.id}] 페이지 가시성 변경 - 전역 상태 새로고침`);
if (component.tableName && component.columnName) {
const key = `${component.tableName}.${component.columnName}`;
globalState.tableCategories.delete(key); // 캐시 무효화
loadTableCodeCategory();
}
}
};
window.addEventListener("focus", handleFocus);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleFocus);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [component.tableName, component.columnName]);
// 모든 옵션 가져오기 // 모든 옵션 가져오기
const getAllOptions = () => { const getAllOptions = () => {
const configOptions = config.options || []; const configOptions = config.options || [];
console.log(`🔧 [${component.id}] 옵션 병합:`, { console.log(`🔧 [${component.id}] 옵션 병합:`, {
codeOptionsLength: codeOptions.length, codeOptionsLength: codeOptions.length,
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })), codeOptions: codeOptions.map((o: Option) => ({ value: o.value, label: o.label })),
configOptionsLength: configOptions.length, configOptionsLength: configOptions.length,
configOptions: configOptions.map((o) => ({ value: o.value, label: o.label })), configOptions: configOptions.map((o: Option) => ({ value: o.value, label: o.label })),
}); });
return [...codeOptions, ...configOptions]; return [...codeOptions, ...configOptions];
}; };
@ -649,7 +370,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
isDesignMode, isDesignMode,
isLoadingCodes, isLoadingCodes,
allOptionsLength: allOptions.length, allOptionsLength: allOptions.length,
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })), allOptions: allOptions.map((o: Option) => ({ value: o.value, label: o.label })),
}); });
return null; return null;
})()} })()}

View File

@ -1,11 +1,85 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types"; import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common"; import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache"; import { codeCache } from "@/lib/caching/codeCache";
// 전역 테이블 캐시
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
const tableInfoCache = new Map<string, { tables: any[]; timestamp: number }>();
const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분
// 캐시 정리 함수
const cleanupTableCache = () => {
const now = Date.now();
// 컬럼 캐시 정리
for (const [key, entry] of tableColumnCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableColumnCache.delete(key);
}
}
// 테이블 정보 캐시 정리
for (const [key, entry] of tableInfoCache.entries()) {
if (now - entry.timestamp > TABLE_CACHE_TTL) {
tableInfoCache.delete(key);
}
}
};
// 주기적으로 캐시 정리 (10분마다)
if (typeof window !== "undefined") {
setInterval(cleanupTableCache, 10 * 60 * 1000);
}
// 요청 디바운싱을 위한 전역 타이머
const debounceTimers = new Map<string, NodeJS.Timeout>();
// 진행 중인 요청 추적 (중복 요청 방지)
const activeRequests = new Map<string, Promise<any>>();
// 디바운싱된 API 호출 함수 (중복 요청 방지 포함)
const debouncedApiCall = <T extends any[], R>(key: string, fn: (...args: T) => Promise<R>, delay: number = 300) => {
return (...args: T): Promise<R> => {
// 이미 진행 중인 동일한 요청이 있으면 그 결과를 반환
const activeRequest = activeRequests.get(key);
if (activeRequest) {
console.log(`🔄 진행 중인 요청 재사용: ${key}`);
return activeRequest as Promise<R>;
}
return new Promise((resolve, reject) => {
// 기존 타이머 제거
const existingTimer = debounceTimers.get(key);
if (existingTimer) {
clearTimeout(existingTimer);
}
// 새 타이머 설정
const timer = setTimeout(async () => {
try {
// 요청 시작 시 활성 요청으로 등록
const requestPromise = fn(...args);
activeRequests.set(key, requestPromise);
const result = await requestPromise;
resolve(result);
} catch (error) {
reject(error);
} finally {
debounceTimers.delete(key);
activeRequests.delete(key);
}
}, delay);
debounceTimers.set(key, timer);
});
};
};
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization"; import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@ -90,12 +164,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} as TableListConfig; } as TableListConfig;
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동) // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const buttonColor = component.style?.labelColor || '#3b83f6'; // 기본 파란색 const buttonColor = component.style?.labelColor || "#3b83f6"; // 기본 파란색
const buttonTextColor = component.config?.buttonTextColor || '#ffffff'; const buttonTextColor = component.config?.buttonTextColor || "#ffffff";
const buttonStyle = { const buttonStyle = {
backgroundColor: buttonColor, backgroundColor: buttonColor,
color: buttonTextColor, color: buttonTextColor,
borderColor: buttonColor borderColor: buttonColor,
}; };
// 디버깅 로그 제거 (성능상 이유로) // 디버깅 로그 제거 (성능상 이유로)
@ -119,6 +193,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({}); const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리) const [columnMeta, setColumnMeta] = useState<Record<string, { webType?: string; codeCategory?: string }>>({}); // 🎯 컬럼 메타정보 (웹타입, 코드카테고리)
// 컬럼 정보 메모이제이션
const memoizedColumnInfo = useMemo(() => {
return {
labels: columnLabels,
meta: columnMeta,
visibleColumns: (tableConfig.columns || []).filter((col) => col.visible !== false),
};
}, [columnLabels, columnMeta, tableConfig.columns]);
// 고급 필터 관련 state // 고급 필터 관련 state
const [searchValues, setSearchValues] = useState<Record<string, any>>({}); const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@ -210,7 +293,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 디버깅 로그 제거 (성능상 이유로) // 디버깅 로그 제거 (성능상 이유로)
// 스타일 계산 (컨테이너에 맞춤) // 스타일 계산 (컨테이너에 맞춤)
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
width: "100%", // 컨테이너 전체 너비 사용 width: "100%", // 컨테이너 전체 너비 사용
@ -244,14 +326,36 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 자동 높이로 테이블 전체를 감쌈 // 자동 높이로 테이블 전체를 감쌈
} }
// 컬럼 라벨 정보 가져오기 // 컬럼 라벨 정보 가져오기 (캐싱 적용)
const fetchColumnLabels = async () => { const fetchColumnLabels = async () => {
if (!tableConfig.selectedTable) return; if (!tableConfig.selectedTable) return;
// 캐시 확인
const cacheKey = tableConfig.selectedTable;
const cached = tableColumnCache.get(cacheKey);
const now = Date.now();
let columns: any[] = [];
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
console.log(`🚀 테이블 컬럼 캐시 사용: ${cacheKey}`);
columns = cached.columns;
} else {
try { try {
console.log(`🔄 테이블 컬럼 API 호출: ${cacheKey}`);
const response = await tableTypeApi.getColumns(tableConfig.selectedTable); const response = await tableTypeApi.getColumns(tableConfig.selectedTable);
// API 응답 구조 확인 및 컬럼 배열 추출 // API 응답 구조 확인 및 컬럼 배열 추출
const columns = Array.isArray(response) ? response : (response as any).columns || []; columns = Array.isArray(response) ? response : (response as any).columns || [];
// 캐시 저장
tableColumnCache.set(cacheKey, { columns, timestamp: now });
console.log(`✅ 테이블 컬럼 캐시 저장: ${cacheKey} (${columns.length}개 컬럼)`);
} catch (error) {
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
return;
}
}
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
const meta: Record<string, { webType?: string; codeCategory?: string }> = {}; const meta: Record<string, { webType?: string; codeCategory?: string }> = {};
@ -263,9 +367,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (column.webType === "entity") { if (column.webType === "entity") {
// 우선 기준 테이블의 컬럼 라벨을 사용 // 우선 기준 테이블의 컬럼 라벨을 사용
displayLabel = column.displayName || column.columnName; displayLabel = column.displayName || column.columnName;
console.log( console.log(`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`);
`🎯 Entity 조인 컬럼 라벨 설정: ${column.columnName} → "${displayLabel}" (기준 테이블 라벨 사용)`,
);
} }
labels[column.columnName] = displayLabel; labels[column.columnName] = displayLabel;
@ -280,33 +382,61 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
setColumnMeta(meta); setColumnMeta(meta);
console.log("🔍 컬럼 라벨 설정 완료:", labels); console.log("🔍 컬럼 라벨 설정 완료:", labels);
console.log("🔍 컬럼 메타정보 설정 완료:", meta); console.log("🔍 컬럼 메타정보 설정 완료:", meta);
} catch (error) {
console.log("컬럼 라벨 정보를 가져올 수 없습니다:", error);
}
}; };
// 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용) // 🎯 전역 코드 캐시 사용으로 함수 제거 (codeCache.convertCodeToName 사용)
// 테이블 라벨명 가져오기 // 테이블 라벨명 가져오기 (캐싱 적용)
const fetchTableLabel = async () => { const fetchTableLabel = async () => {
if (!tableConfig.selectedTable) return; if (!tableConfig.selectedTable) return;
// 캐시 확인
const cacheKey = "all_tables";
const cached = tableInfoCache.get(cacheKey);
const now = Date.now();
let tables: any[] = [];
if (cached && now - cached.timestamp < TABLE_CACHE_TTL) {
console.log(`🚀 테이블 정보 캐시 사용: ${cacheKey}`);
tables = cached.tables;
} else {
try { try {
const tables = await tableTypeApi.getTables(); console.log(`🔄 테이블 정보 API 호출: ${cacheKey}`);
tables = await tableTypeApi.getTables();
// 캐시 저장
tableInfoCache.set(cacheKey, { tables, timestamp: now });
console.log(`✅ 테이블 정보 캐시 저장: ${cacheKey} (${tables.length}개 테이블)`);
} catch (error) {
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
setTableLabel(tableConfig.selectedTable);
return;
}
}
const table = tables.find((t: any) => t.tableName === tableConfig.selectedTable); const table = tables.find((t: any) => t.tableName === tableConfig.selectedTable);
if (table && table.displayName && table.displayName !== table.tableName) { if (table && table.displayName && table.displayName !== table.tableName) {
setTableLabel(table.displayName); setTableLabel(table.displayName);
} else { } else {
setTableLabel(tableConfig.selectedTable); setTableLabel(tableConfig.selectedTable);
} }
} catch (error) {
console.log("테이블 라벨 정보를 가져올 수 없습니다:", error);
setTableLabel(tableConfig.selectedTable);
}
}; };
// 테이블 데이터 가져오기 // 디바운싱된 테이블 데이터 가져오기
const fetchTableData = async () => { const fetchTableDataDebounced = useCallback(
debouncedApiCall(
`fetchTableData_${tableConfig.selectedTable}_${currentPage}_${localPageSize}`,
async () => {
return fetchTableDataInternal();
},
200, // 200ms 디바운스
),
[tableConfig.selectedTable, currentPage, localPageSize, searchTerm, sortColumn, sortDirection, searchValues],
);
// 실제 테이블 데이터 가져오기 함수
const fetchTableDataInternal = async () => {
if (!tableConfig.selectedTable) { if (!tableConfig.selectedTable) {
setData([]); setData([]);
return; return;
@ -494,15 +624,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
codeColumns.map(([col, meta]) => `${col}(${meta.codeCategory})`), codeColumns.map(([col, meta]) => `${col}(${meta.codeCategory})`),
); );
// 필요한 코드 카테고리들을 추출하여 배치 로드 // 필요한 코드 카테고리들을 추출하여 배치 로드 (중복 제거)
const categoryList = codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean) as string[]; const categoryList = [
...new Set(codeColumns.map(([, meta]) => meta.codeCategory).filter(Boolean)),
] as string[];
// 이미 캐시된 카테고리는 제외
const uncachedCategories = categoryList.filter((category) => !codeCache.getCodeSync(category));
if (uncachedCategories.length > 0) {
try { try {
await codeCache.preloadCodes(categoryList); console.log(`📋 새로운 코드 카테고리 로딩: ${uncachedCategories.join(", ")}`);
await codeCache.preloadCodes(uncachedCategories);
console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)"); console.log("📋 모든 코드 캐시 로드 완료 (전역 캐시)");
} catch (error) { } catch (error) {
console.error("❌ 코드 캐시 로드 중 오류:", error); console.error("❌ 코드 캐시 로드 중 오류:", error);
} }
} else {
console.log("📋 모든 코드 카테고리가 이미 캐시됨");
}
} }
// 🎯 Entity 조인된 컬럼 처리 - 사용자가 설정한 컬럼들만 사용 // 🎯 Entity 조인된 컬럼 처리 - 사용자가 설정한 컬럼들만 사용
@ -695,18 +835,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const handleAdvancedSearch = () => { const handleAdvancedSearch = () => {
setCurrentPage(1); setCurrentPage(1);
fetchTableData(); fetchTableDataDebounced();
}; };
const handleClearAdvancedFilters = () => { const handleClearAdvancedFilters = () => {
setSearchValues({}); setSearchValues({});
setCurrentPage(1); setCurrentPage(1);
fetchTableData(); fetchTableDataDebounced();
}; };
// 새로고침 // 새로고침
const handleRefresh = () => { const handleRefresh = () => {
fetchTableData(); fetchTableDataDebounced();
}; };
// 체크박스 핸들러들 // 체크박스 핸들러들
@ -808,7 +948,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
useEffect(() => { useEffect(() => {
if (tableConfig.autoLoad && !isDesignMode) { if (tableConfig.autoLoad && !isDesignMode) {
fetchTableData(); fetchTableDataDebounced();
} }
}, [ }, [
tableConfig.selectedTable, tableConfig.selectedTable,
@ -832,7 +972,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log("🔄 선택 상태 초기화 - 빈 배열 전달"); console.log("🔄 선택 상태 초기화 - 빈 배열 전달");
onSelectedRowsChange?.([], []); onSelectedRowsChange?.([], []);
// 테이블 데이터 새로고침 // 테이블 데이터 새로고침
fetchTableData(); fetchTableDataDebounced();
} }
}, [refreshKey]); }, [refreshKey]);
@ -987,7 +1127,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return null; return null;
} }
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" style={{ zIndex: 1 }} />; return (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
);
}; };
// 체크박스 셀 렌더링 // 체크박스 셀 렌더링
@ -1070,37 +1217,37 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 드래그 데이터에 그리드 정보 포함 // 드래그 데이터에 그리드 정보 포함
const dragData = { const dragData = {
...row, ...row,
_dragType: 'table-row', _dragType: "table-row",
_gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이) _gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이)
_snapToGrid: true _snapToGrid: true,
}; };
e.dataTransfer.setData('application/json', JSON.stringify(dragData)); e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'copy'; // move 대신 copy로 변경 e.dataTransfer.effectAllowed = "copy"; // move 대신 copy로 변경
// 드래그 이미지를 더 깔끔하게 // 드래그 이미지를 더 깔끔하게
const dragElement = e.currentTarget as HTMLElement; const dragElement = e.currentTarget as HTMLElement;
// 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일) // 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일)
const dragImage = document.createElement('div'); const dragImage = document.createElement("div");
dragImage.style.position = 'absolute'; dragImage.style.position = "absolute";
dragImage.style.top = '-1000px'; dragImage.style.top = "-1000px";
dragImage.style.left = '-1000px'; dragImage.style.left = "-1000px";
dragImage.style.background = 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'; dragImage.style.background = "linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)";
dragImage.style.color = 'white'; dragImage.style.color = "white";
dragImage.style.padding = '12px 16px'; dragImage.style.padding = "12px 16px";
dragImage.style.borderRadius = '8px'; dragImage.style.borderRadius = "8px";
dragImage.style.fontSize = '14px'; dragImage.style.fontSize = "14px";
dragImage.style.fontWeight = '600'; dragImage.style.fontWeight = "600";
dragImage.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)'; dragImage.style.boxShadow = "0 4px 12px rgba(59, 130, 246, 0.4)";
dragImage.style.display = 'flex'; dragImage.style.display = "flex";
dragImage.style.alignItems = 'center'; dragImage.style.alignItems = "center";
dragImage.style.gap = '8px'; dragImage.style.gap = "8px";
dragImage.style.minWidth = '200px'; dragImage.style.minWidth = "200px";
dragImage.style.whiteSpace = 'nowrap'; dragImage.style.whiteSpace = "nowrap";
// 아이콘과 텍스트 추가 // 아이콘과 텍스트 추가
const firstValue = Object.values(row)[0] || 'Row'; const firstValue = Object.values(row)[0] || "Row";
dragImage.innerHTML = ` dragImage.innerHTML = `
<div style=" <div style="
width: 20px; width: 20px;
@ -1150,12 +1297,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div style={componentStyle} className={className} {...domProps}>
<div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20"> <div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
<div className="text-center p-8"> <div className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center shadow-sm"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100 shadow-sm">
<TableIcon className="h-8 w-8 text-blue-600" /> <TableIcon className="h-8 w-8 text-blue-600" />
</div> </div>
<div className="text-lg font-semibold text-slate-700 mb-2"> </div> <div className="mb-2 text-lg font-semibold text-slate-700"> </div>
<div className="text-sm text-slate-500 bg-white/60 px-4 py-2 rounded-full"> <div className="rounded-full bg-white/60 px-4 py-2 text-sm text-slate-500">
</div> </div>
</div> </div>
@ -1166,36 +1313,34 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return ( return (
<div <div
style={{...componentStyle, zIndex: 10}} // 🎯 componentStyle + z-index 추가 style={{ ...componentStyle, zIndex: 10 }} // 🎯 componentStyle + z-index 추가
className={cn( className={cn(
"rounded-lg bg-white border border-gray-200 shadow-md shadow-blue-100/50", "rounded-lg border border-gray-200 bg-white shadow-md shadow-blue-100/50",
"overflow-hidden relative", // 🎯 항상 overflow-hidden 적용 + relative 추가 "relative overflow-hidden", // 🎯 항상 overflow-hidden 적용 + relative 추가
className className,
)} )}
{...domProps} {...domProps}
> >
{/* 헤더 */} {/* 헤더 */}
{tableConfig.showHeader && ( {tableConfig.showHeader && (
<div <div
className="flex items-center justify-between bg-gray-100/80 border-b border-gray-200 px-6 py-4" className="flex items-center justify-between border-b border-gray-200 bg-gray-100/80 px-6 py-4"
style={{ style={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
boxSizing: "border-box" boxSizing: "border-box",
}} }}
> >
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{(tableConfig.title || tableLabel) && ( {(tableConfig.title || tableLabel) && (
<h3 className="text-lg font-semibold text-gray-900"> <h3 className="text-lg font-semibold text-gray-900">{tableConfig.title || tableLabel}</h3>
{tableConfig.title || tableLabel}
</h3>
)} )}
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{/* 선택된 항목 정보 표시 */} {/* 선택된 항목 정보 표시 */}
{selectedRows.size > 0 && ( {selectedRows.size > 0 && (
<div className="flex items-center space-x-2 bg-blue-50 px-3 py-1 rounded-md"> <div className="flex items-center space-x-2 rounded-md bg-blue-50 px-3 py-1">
<span className="text-sm font-medium text-blue-700">{selectedRows.size} </span> <span className="text-sm font-medium text-blue-700">{selectedRows.size} </span>
</div> </div>
)} )}
@ -1207,17 +1352,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onClick={handleRefresh} onClick={handleRefresh}
disabled={loading} disabled={loading}
style={buttonStyle} style={buttonStyle}
className="group relative shadow-sm rounded-lg [&:hover]:opacity-90" className="group relative rounded-lg shadow-sm [&:hover]:opacity-90"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <div className="relative">
<RefreshCw className={cn( <RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} style={{ color: buttonTextColor }} />
"h-4 w-4", {loading && <div className="absolute -inset-1 animate-pulse rounded-full bg-blue-200/30"></div>}
loading && "animate-spin"
)} style={{ color: buttonTextColor }} />
{loading && (
<div className="absolute -inset-1 bg-blue-200/30 rounded-full animate-pulse"></div>
)}
</div> </div>
<span className="text-sm font-medium" style={{ color: buttonTextColor }}> <span className="text-sm font-medium" style={{ color: buttonTextColor }}>
{loading ? "새로고침 중..." : "새로고침"} {loading ? "새로고침 중..." : "새로고침"}
@ -1263,50 +1403,52 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
style={{ style={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
boxSizing: "border-box" boxSizing: "border-box",
}} }}
> >
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30"> <div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30">
<div className="text-center p-8"> <div className="p-8 text-center">
<div className="relative"> <div className="relative">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-100 to-indigo-100">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" /> <RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
</div> </div>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full animate-pulse"></div> <div className="absolute -top-1 -right-1 h-4 w-4 animate-pulse rounded-full bg-gradient-to-br from-blue-400 to-indigo-500"></div>
</div> </div>
<div className="text-sm font-medium text-slate-700"> ...</div> <div className="text-sm font-medium text-slate-700"> ...</div>
<div className="text-xs text-slate-500 mt-1"> </div> <div className="mt-1 text-xs text-slate-500"> </div>
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30"> <div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30">
<div className="text-center p-8"> <div className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-100 to-orange-100 rounded-2xl flex items-center justify-center"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-red-100 to-orange-100">
<div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-red-500 to-orange-500">
<span className="text-white text-sm font-bold">!</span> <span className="text-sm font-bold text-white">!</span>
</div> </div>
</div> </div>
<div className="text-sm font-medium text-red-700"> </div> <div className="text-sm font-medium text-red-700"> </div>
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div> <div className="mt-1 rounded-full bg-red-50 px-3 py-1 text-xs text-red-500">{error}</div>
</div> </div>
</div> </div>
) : tableConfig.displayMode === "card" ? ( ) : tableConfig.displayMode === "card" ? (
// 카드 모드 렌더링 // 카드 모드 렌더링
<div className="w-full h-full overflow-y-auto"> <div className="h-full w-full overflow-y-auto">
<CardModeRenderer <CardModeRenderer
data={data} data={data}
cardConfig={tableConfig.cardConfig || { cardConfig={
tableConfig.cardConfig || {
idColumn: "id", idColumn: "id",
titleColumn: "name", titleColumn: "name",
cardsPerRow: 3, cardsPerRow: 3,
cardSpacing: 16, cardSpacing: 16,
showActions: true, showActions: true,
}} }
}
visibleColumns={visibleColumns} visibleColumns={visibleColumns}
onRowClick={handleRowClick} onRowClick={handleRowClick}
onRowSelect={(row, selected) => { onRowSelect={(row, selected) => {
const rowIndex = data.findIndex(d => d === row); const rowIndex = data.findIndex((d) => d === row);
const rowKey = getRowKey(row, rowIndex); const rowKey = getRowKey(row, rowIndex);
handleRowSelection(rowKey, selected); handleRowSelection(rowKey, selected);
}} }}
@ -1343,20 +1485,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
style={{ style={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
tableLayout: "fixed" // 테이블 크기 고정 tableLayout: "fixed", // 테이블 크기 고정
}} }}
> >
<TableHeader className={cn( <TableHeader
className={cn(
tableConfig.stickyHeader ? "sticky top-0 z-20" : "", tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
"bg-gray-100/80 border-b border-gray-200" "border-b border-gray-200 bg-gray-100/80",
)}> )}
>
<TableRow <TableRow
style={{ style={{
minHeight: "48px !important", minHeight: "48px !important",
height: "48px !important", height: "48px !important",
lineHeight: "1", lineHeight: "1",
width: "100%", width: "100%",
maxWidth: "100%" maxWidth: "100%",
}} }}
className="border-none" className="border-none"
> >
@ -1374,12 +1518,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
textOverflow: "ellipsis", textOverflow: "ellipsis",
}} }}
className={cn( className={cn(
"h-12 align-middle px-4 py-3 text-sm font-semibold text-gray-800", "h-12 px-4 py-3 align-middle text-sm font-semibold text-gray-800",
column.columnName === "__checkbox__" column.columnName === "__checkbox__"
? "text-center" ? "text-center"
: "cursor-pointer whitespace-nowrap select-none", : "cursor-pointer whitespace-nowrap select-none",
`text-${column.align}`, `text-${column.align}`,
column.sortable && "hover:bg-orange-100 transition-colors duration-150" column.sortable && "transition-colors duration-150 hover:bg-orange-100",
)} )}
onClick={() => column.sortable && handleSort(column.columnName)} onClick={() => column.sortable && handleSort(column.columnName)}
> >
@ -1387,9 +1531,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
renderCheckboxHeader() renderCheckboxHeader()
) : ( ) : (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span> <span>{columnLabels[column.columnName] || column.displayName}</span>
{columnLabels[column.columnName] || column.displayName}
</span>
{column.sortable && ( {column.sortable && (
<div className="flex flex-col"> <div className="flex flex-col">
{sortColumn === column.columnName ? ( {sortColumn === column.columnName ? (
@ -1414,7 +1556,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableRow> <TableRow>
<TableCell colSpan={visibleColumns.length} className="py-12 text-center"> <TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center space-y-3"> <div className="flex flex-col items-center space-y-3">
<div className="w-12 h-12 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-slate-100 to-slate-200">
<TableIcon className="h-6 w-6 text-slate-400" /> <TableIcon className="h-6 w-6 text-slate-400" />
</div> </div>
<div className="text-sm font-medium text-slate-600"> </div> <div className="text-sm font-medium text-slate-600"> </div>
@ -1430,22 +1572,24 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onDragStart={(e) => handleRowDragStart(e, row, index)} onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd} onDragEnd={handleRowDragEnd}
className={cn( className={cn(
"group relative h-12 cursor-pointer transition-all duration-200 border-b border-gray-100", "group relative h-12 cursor-pointer border-b border-gray-100 transition-all duration-200",
// 기본 스타일 // 기본 스타일
tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm", tableConfig.tableStyle?.hoverEffect &&
"hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80", tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
// 드래그 상태 스타일 (미묘하게) // 드래그 상태 스타일 (미묘하게)
draggedRowIndex === index && "bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm border-blue-200", draggedRowIndex === index &&
"border-blue-200 bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm",
isDragging && draggedRowIndex !== index && "opacity-70", isDragging && draggedRowIndex !== index && "opacity-70",
// 드래그 가능 표시 // 드래그 가능 표시
!isDesignMode && "hover:cursor-grab active:cursor-grabbing" !isDesignMode && "hover:cursor-grab active:cursor-grabbing",
)} )}
style={{ style={{
minHeight: "48px", minHeight: "48px",
height: "48px", height: "48px",
lineHeight: "1", lineHeight: "1",
width: "100%", width: "100%",
maxWidth: "100%" maxWidth: "100%",
}} }}
onClick={() => handleRowClick(row)} onClick={() => handleRowClick(row)}
> >
@ -1453,8 +1597,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<TableCell <TableCell
key={column.columnName} key={column.columnName}
className={cn( className={cn(
"h-12 align-middle px-4 py-3 text-sm transition-all duration-200", "h-12 px-4 py-3 align-middle text-sm transition-all duration-200",
`text-${column.align}` `text-${column.align}`,
)} )}
style={{ style={{
minHeight: "48px", minHeight: "48px",
@ -1464,7 +1608,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
boxSizing: "border-box", boxSizing: "border-box",
overflow: "hidden", overflow: "hidden",
textOverflow: "ellipsis", textOverflow: "ellipsis",
whiteSpace: "nowrap" whiteSpace: "nowrap",
}} }}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__"
@ -1476,31 +1620,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (index === 0) { if (index === 0) {
// 디버깅 로그 제거 (성능상 이유로) // 디버깅 로그 제거 (성능상 이유로)
} }
const formattedValue = formatCellValue(cellValue, column.format, column.columnName) || "\u00A0"; const formattedValue =
formatCellValue(cellValue, column.format, column.columnName) || "\u00A0";
// 첫 번째 컬럼에 드래그 핸들과 아바타 추가 // 첫 번째 컬럼에 드래그 핸들과 아바타 추가
const isFirstColumn = colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0); const isFirstColumn =
colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0);
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{isFirstColumn && !isDesignMode && ( {isFirstColumn && !isDesignMode && (
<div className="opacity-60 cursor-grab active:cursor-grabbing mr-1"> <div className="mr-1 cursor-grab opacity-60 active:cursor-grabbing">
{/* 그리드 스냅 가이드 아이콘 */} {/* 그리드 스냅 가이드 아이콘 */}
<div className="flex space-x-0.5"> <div className="flex space-x-0.5">
<div className="flex flex-col space-y-0.5"> <div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div> <div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div> <div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
</div> </div>
<div className="flex flex-col space-y-0.5"> <div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div> <div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div> <div className="h-0.5 w-0.5 rounded-full bg-gray-400"></div>
</div> </div>
</div> </div>
</div> </div>
)} )}
<span className="font-medium text-gray-700"> <span className="font-medium text-gray-700">{formattedValue}</span>
{formattedValue}
</span>
</div> </div>
); );
})()} })()}
@ -1518,20 +1662,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 푸터/페이지네이션 */} {/* 푸터/페이지네이션 */}
{tableConfig.showFooter && tableConfig.pagination?.enabled && ( {tableConfig.showFooter && tableConfig.pagination?.enabled && (
<div <div
className="flex flex-col items-center justify-center bg-gray-100/80 border-t border-gray-200 p-6 space-y-4" className="flex flex-col items-center justify-center space-y-4 border-t border-gray-200 bg-gray-100/80 p-6"
style={{ style={{
width: "100%", width: "100%",
maxWidth: "100%", maxWidth: "100%",
boxSizing: "border-box" boxSizing: "border-box",
}} }}
> >
{/* 페이지 정보 - 가운데 정렬 */} {/* 페이지 정보 - 가운데 정렬 */}
{tableConfig.pagination?.showPageInfo && ( {tableConfig.pagination?.showPageInfo && (
<div className="flex items-center justify-center space-x-2 text-sm text-slate-600"> <div className="flex items-center justify-center space-x-2 text-sm text-slate-600">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div> <div className="h-2 w-2 rounded-full bg-blue-500"></div>
<span className="font-medium"> <span className="font-medium">
<span className="text-blue-600 font-semibold">{totalItems.toLocaleString()}</span> {" "} <span className="font-semibold text-blue-600">{totalItems.toLocaleString()}</span> {" "}
<span className="text-slate-800 font-semibold"> <span className="font-semibold text-slate-800">
{(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)} {(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)}
</span>{" "} </span>{" "}
@ -1578,7 +1722,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 데이터는 useEffect에서 자동으로 다시 로드됨 // 데이터는 useEffect에서 자동으로 다시 로드됨
}} }}
className="bg-white/80 border border-slate-200 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-white hover:border-slate-300 transition-colors" className="rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-slate-300 hover:bg-white"
> >
{(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => ( {(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
@ -1589,13 +1733,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)} )}
{/* 페이지네이션 버튼 */} {/* 페이지네이션 버튼 */}
<div className="flex items-center space-x-2 bg-white rounded-lg border border-gray-200 shadow-sm p-1"> <div className="flex items-center space-x-2 rounded-lg border border-gray-200 bg-white p-1 shadow-sm">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handlePageChange(1)} onClick={() => handlePageChange(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300" className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
> >
<ChevronsLeft className="h-4 w-4" /> <ChevronsLeft className="h-4 w-4" />
</Button> </Button>
@ -1604,19 +1748,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm" size="sm"
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300" className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<div className="flex items-center px-4 py-1 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-md border border-blue-100"> <div className="flex items-center rounded-md border border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 px-4 py-1">
<span className="text-sm font-semibold text-blue-800"> <span className="text-sm font-semibold text-blue-800">{currentPage}</span>
{currentPage} <span className="mx-2 font-light text-gray-400">/</span>
</span> <span className="text-sm font-medium text-gray-600">{totalPages}</span>
<span className="text-gray-400 mx-2 font-light">/</span>
<span className="text-sm font-medium text-gray-600">
{totalPages}
</span>
</div> </div>
<Button <Button
@ -1624,7 +1764,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm" size="sm"
onClick={() => handlePageChange(currentPage + 1)} onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300" className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
@ -1633,7 +1773,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm" size="sm"
onClick={() => handlePageChange(totalPages)} onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300" className="h-8 w-8 p-0 hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50"
> >
<ChevronsRight className="h-4 w-4" /> <ChevronsRight className="h-4 w-4" />
</Button> </Button>