코드 셀렉트박스 기능 구현

This commit is contained in:
kjs 2025-09-15 15:38:48 +09:00
parent c243137a91
commit d609cc89b9
8 changed files with 758 additions and 150 deletions

View File

@ -306,6 +306,10 @@ export class TableManagementService {
}, },
}); });
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`);
} catch (error) { } catch (error) {
logger.error( logger.error(
@ -354,6 +358,10 @@ export class TableManagementService {
} }
}); });
// 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제
cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`); logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
} catch (error) { } catch (error) {
logger.error( logger.error(

View File

@ -13,6 +13,7 @@ import { toast } from "sonner";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement"; import { TABLE_MANAGEMENT_KEYS, WEB_TYPE_OPTIONS_WITH_KEYS } from "@/constants/tableManagement";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode";
// 가상화 스크롤링을 위한 간단한 구현 // 가상화 스크롤링을 위한 간단한 구현
interface TableInfo { interface TableInfo {
@ -112,14 +113,41 @@ export default function TableManagementPage() {
...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })), ...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })),
]; ];
// 공통 코드 옵션 (예시 - 실제로는 API에서 가져와야 함) // 공통 코드 카테고리 목록 상태
const [commonCodeCategories, setCommonCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
// 공통 코드 옵션
const commonCodeOptions = [ const commonCodeOptions = [
{ value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_CODE_PLACEHOLDER, "코드 선택") }, { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_CODE_PLACEHOLDER, "코드 선택") },
{ value: "USER_STATUS", label: "사용자 상태" }, ...commonCodeCategories,
{ value: "DEPT_TYPE", label: "부서 유형" },
{ value: "PRODUCT_CATEGORY", label: "제품 카테고리" },
]; ];
// 공통코드 카테고리 목록 로드
const loadCommonCodeCategories = async () => {
try {
const response = await commonCodeApi.categories.getList({ isActive: true });
console.log("🔍 공통코드 카테고리 API 응답:", response);
if (response.success && response.data) {
console.log("📋 공통코드 카테고리 데이터:", response.data);
const categories = response.data.map((category) => {
console.log("🏷️ 카테고리 항목:", category);
return {
value: category.category_code,
label: category.category_name || category.category_code,
};
});
console.log("✅ 매핑된 카테고리 옵션:", categories);
setCommonCodeCategories(categories);
}
} catch (error) {
console.error("공통코드 카테고리 로드 실패:", error);
// 에러는 로그만 남기고 사용자에게는 알리지 않음 (선택적 기능)
}
};
// 테이블 목록 로드 // 테이블 목록 로드
const loadTables = async () => { const loadTables = async () => {
setLoading(true); setLoading(true);
@ -408,6 +436,7 @@ export default function TableManagementPage() {
useEffect(() => { useEffect(() => {
loadTables(); loadTables();
loadCommonCodeCategories();
}, []); }, []);
// 더 많은 데이터 로드 // 더 많은 데이터 로드
@ -568,6 +597,7 @@ export default function TableManagementPage() {
<div className="w-48 px-4"></div> <div className="w-48 px-4"></div>
<div className="w-32 px-4">DB </div> <div className="w-32 px-4">DB </div>
<div className="w-48 px-4"> </div> <div className="w-48 px-4"> </div>
<div className="w-48 px-4"> </div>
<div className="flex-1 px-4"></div> <div className="flex-1 px-4"></div>
</div> </div>
@ -620,6 +650,50 @@ export default function TableManagementPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="w-48 px-4">
{/* 웹 타입이 'code'인 경우 공통코드 선택 */}
{column.webType === "code" && (
<Select
value={column.codeCategory || "none"}
onValueChange={(value) => handleDetailSettingsChange(column.columnName, "code", value)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="공통코드 선택" />
</SelectTrigger>
<SelectContent>
{commonCodeOptions.map((option, index) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.webType === "entity" && (
<Select
value={column.referenceTable || "none"}
onValueChange={(value) =>
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="참조 테이블 선택" />
</SelectTrigger>
<SelectContent>
{referenceTableOptions.map((option, index) => (
<SelectItem key={`entity-${option.value}-${index}`} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* 다른 웹 타입인 경우 빈 공간 */}
{column.webType !== "code" && column.webType !== "entity" && (
<div className="flex h-8 items-center text-xs text-gray-400">-</div>
)}
</div>
<div className="flex-1 px-4"> <div className="flex-1 px-4">
<Input <Input
value={column.description || ""} value={column.description || ""}

View File

@ -39,6 +39,7 @@ import {
FolderOpen, FolderOpen,
} from "lucide-react"; } from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCurrentUser, UserInfo } from "@/lib/api/client"; import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen"; import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -121,6 +122,40 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [selectedColumnForFiles, setSelectedColumnForFiles] = useState<DataTableColumn | null>(null); // 선택된 컬럼 정보 const [selectedColumnForFiles, setSelectedColumnForFiles] = useState<DataTableColumn | null>(null); // 선택된 컬럼 정보
const [linkedFiles, setLinkedFiles] = useState<any[]>([]); const [linkedFiles, setLinkedFiles] = useState<any[]>([]);
// 공통코드 관리 상태
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ value: string; label: string }>>>({});
// 공통코드 옵션 가져오기
const loadCodeOptions = useCallback(
async (categoryCode: string) => {
if (codeOptions[categoryCode]) {
return codeOptions[categoryCode]; // 이미 로드된 경우 캐시된 데이터 사용
}
try {
const response = await commonCodeApi.options.getOptions(categoryCode);
if (response.success && response.data) {
const options = response.data.map((code) => ({
value: code.value,
label: code.label,
}));
setCodeOptions((prev) => ({
...prev,
[categoryCode]: options,
}));
return options;
}
} catch (error) {
console.error(`공통코드 옵션 로드 실패: ${categoryCode}`, error);
}
return [];
},
[codeOptions],
);
// 파일 상태 확인 함수 // 파일 상태 확인 함수
const checkFileStatus = useCallback( const checkFileStatus = useCallback(
async (rowData: Record<string, any>) => { async (rowData: Record<string, any>) => {
@ -336,6 +371,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
[component.columns, tableColumns], [component.columns, tableColumns],
); );
// 컬럼의 코드 카테고리 가져오기
const getColumnCodeCategory = useCallback(
(columnName: string) => {
const column = component.columns.find((col) => col.columnName === columnName);
// webTypeConfig가 CodeTypeConfig인 경우 codeCategory 반환
const webTypeConfig = column?.webTypeConfig as any;
return webTypeConfig?.codeCategory || column?.codeCategory;
},
[component.columns],
);
// 그리드 컬럼 계산 // 그리드 컬럼 계산
const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0); const totalGridColumns = visibleColumns.reduce((sum, col) => sum + (col.gridColumns || 2), 0);
@ -1351,6 +1397,43 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div> </div>
); );
case "code":
// 코드 카테고리에서 코드 옵션 가져오기
const codeCategory = getColumnCodeCategory(column.columnName);
if (codeCategory) {
const codeOptionsForCategory = codeOptions[codeCategory] || [];
// 코드 옵션이 없으면 로드
if (codeOptionsForCategory.length === 0) {
loadCodeOptions(codeCategory);
}
return (
<div>
<Select value={value} onValueChange={(newValue) => handleAddFormChange(column.columnName, newValue)}>
<SelectTrigger className={commonProps.className}>
<SelectValue placeholder={`${column.label} 선택...`} />
</SelectTrigger>
<SelectContent>
{codeOptionsForCategory.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
} else {
return (
<div>
<Input {...commonProps} placeholder={`${column.label} (코드 카테고리 설정 필요)`} readOnly />
{advancedConfig?.helpText && <p className="mt-1 text-xs text-gray-500">{advancedConfig.helpText}</p>}
</div>
);
}
case "file": case "file":
return ( return (
<div> <div>

View File

@ -669,6 +669,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO", required: col.required !== undefined ? col.required : col.isNullable === "NO" || col.is_nullable === "NO",
columnDefault: col.columnDefault || col.column_default, columnDefault: col.columnDefault || col.column_default,
characterMaximumLength: col.characterMaximumLength || col.character_maximum_length, characterMaximumLength: col.characterMaximumLength || col.character_maximum_length,
// 코드 카테고리 정보 추가
codeCategory: col.codeCategory || col.code_category,
codeValue: col.codeValue || col.code_value,
})); }));
const tableInfo: TableInfo = { const tableInfo: TableInfo = {
@ -1753,14 +1756,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
case "code": case "code":
return { return {
language: "javascript", codeCategory: "", // 기본값, 실제로는 컬럼 정보에서 가져옴
theme: "light", placeholder: "선택하세요",
fontSize: 14, options: [], // 기본 빈 배열, 실제로는 API에서 로드
lineNumbers: true,
wordWrap: false,
readOnly: false,
autoFormat: true,
placeholder: "코드를 입력하세요...",
}; };
case "entity": case "entity":
return { return {
@ -1808,6 +1806,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
position: { x: relativeX, y: relativeY, z: 1 } as Position, position: { x: relativeX, y: relativeY, z: 1 } as Position,
size: { width: defaultWidth, height: 40 }, size: { width: defaultWidth, height: 40 },
gridColumns: 1, gridColumns: 1,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: { style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px", labelFontSize: "12px",
@ -1819,6 +1822,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
type: componentId, // text-input, number-input 등 type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
}, },
}; };
} else { } else {
@ -1841,6 +1849,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
position: { x, y, z: 1 } as Position, position: { x, y, z: 1 } as Position,
size: { width: defaultWidth, height: 40 }, size: { width: defaultWidth, height: 40 },
gridColumns: 1, gridColumns: 1,
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
style: { style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px", labelFontSize: "12px",
@ -1852,6 +1865,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
type: componentId, // text-input, number-input 등 type: componentId, // text-input, number-input 등
webType: column.widgetType, // 원본 웹타입 보존 webType: column.widgetType, // 원본 웹타입 보존
...getDefaultWebTypeConfig(column.widgetType), ...getDefaultWebTypeConfig(column.widgetType),
// 코드 타입인 경우 코드 카테고리 정보 추가
...(column.widgetType === "code" &&
column.codeCategory && {
codeCategory: column.codeCategory,
}),
}, },
}; };
} }

View File

@ -1,154 +1,571 @@
"use client"; import React, { useState, useEffect, useRef } from "react";
import { commonCodeApi } from "../../../api/commonCode";
import { tableTypeApi } from "../../../api/screen";
import React from "react"; interface Option {
import { ComponentRendererProps } from "@/types/component"; value: string;
import { SelectBasicConfig } from "./types"; label: string;
export interface SelectBasicComponentProps extends ComponentRendererProps {
config?: SelectBasicConfig;
} }
/** export interface SelectBasicComponentProps {
* SelectBasic component: any;
* select-basic componentConfig: any;
*/ screenId?: number;
export const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({ onUpdate?: (field: string, value: any) => void;
isSelected?: boolean;
isDesignMode?: boolean;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
[key: string]: any;
}
// 🚀 전역 상태 관리: 모든 컴포넌트가 공유하는 상태
interface GlobalState {
tableCategories: Map<string, string>; // tableName.columnName -> codeCategory
codeOptions: Map<string, { options: Option[]; timestamp: number }>; // codeCategory -> options
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.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> = ({
component, component,
isDesignMode = false, componentConfig,
screenId,
onUpdate,
isSelected = false, isSelected = false,
isInteractive = false, isDesignMode = false,
className,
style,
onClick, onClick,
onDragStart, onDragStart,
onDragEnd, onDragEnd,
config,
className,
style,
formData,
onFormDataChange,
...props ...props
}) => { }) => {
// 컴포넌트 설정 const [isOpen, setIsOpen] = useState(false);
const componentConfig = { const [selectedValue, setSelectedValue] = useState(componentConfig?.value || "");
...config, const [selectedLabel, setSelectedLabel] = useState("");
...component.config, const [codeOptions, setCodeOptions] = useState<Option[]>([]);
} as SelectBasicConfig; const [isLoadingCodes, setIsLoadingCodes] = useState(false);
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null);
const [globalStateVersion, setGlobalStateVersion] = useState(0); // 전역 상태 변경 감지용
const selectRef = useRef<HTMLDivElement>(null);
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) // 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리
const componentStyle: React.CSSProperties = { const codeCategory = dynamicCodeCategory || componentConfig?.codeCategory;
width: "100%",
height: "100%", // 🚀 전역 상태 구독 및 동기화
...component.style, useEffect(() => {
...style, const updateFromGlobalState = () => {
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);
}
}; };
// 디자인 모드 스타일 // 🔧 코드 옵션 로드 (전역 상태 사용)
if (isDesignMode) { const loadCodeOptions = async (category: string) => {
componentStyle.border = "1px dashed #cbd5e1"; if (!category || category === "none") {
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; setCodeOptions([]);
} setIsLoadingCodes(false);
return;
}
// 이벤트 핸들러 try {
const handleClick = (e: React.MouseEvent) => { setIsLoadingCodes(true);
e.stopPropagation(); console.log(`🔄 [${component.id}] 전역 코드 옵션 로딩: ${category}`);
onClick?.();
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);
}
}; };
// DOM에 전달하면 안 되는 React-specific props 필터링 // 초기 테이블 코드 카테고리 로드
const { useEffect(() => {
selectedScreen, loadTableCodeCategory();
onZoneComponentDrop, }, [component.tableName, component.columnName]);
onZoneClick,
componentConfig: _componentConfig, // 전역 상태 변경 시 동기화
component: _component, useEffect(() => {
isSelected: _isSelected, if (component.tableName && component.columnName) {
onClick: _onClick, const key = `${component.tableName}.${component.columnName}`;
onDragStart: _onDragStart, const cachedCategory = globalState.tableCategories.get(key);
onDragEnd: _onDragEnd,
size: _size, if (cachedCategory && cachedCategory !== dynamicCodeCategory) {
position: _position, console.log(`🔄 [${component.id}] 전역 상태 동기화: ${dynamicCodeCategory}${cachedCategory}`);
style: _style, setDynamicCodeCategory(cachedCategory || null);
screenId: _screenId, }
tableName: _tableName, }
onRefresh: _onRefresh, }, [globalStateVersion, component.tableName, component.columnName]);
onClose: _onClose,
...domProps // 코드 카테고리 변경 시 옵션 로드
} = props; 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(() => {
const getAllOptions = () => {
const configOptions = componentConfig.options || [];
return [...codeOptions, ...configOptions];
};
const options = getAllOptions();
const selectedOption = options.find((option) => option.value === selectedValue);
const newLabel = selectedOption?.label || "";
if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel);
}
}, [selectedValue, codeOptions, componentConfig.options]);
// 클릭 이벤트 핸들러 (전역 상태 새로고침)
const handleToggle = () => {
if (isDesignMode) return;
console.log(`🖱️ [${component.id}] 드롭다운 토글: ${isOpen}${!isOpen}`);
console.log(`📊 [${component.id}] 현재 상태:`, {
isDesignMode,
isLoadingCodes,
allOptionsLength: allOptions.length,
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })),
});
// 드롭다운을 열 때 전역 상태 새로고침
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);
};
// 옵션 선택 핸들러
const handleOptionSelect = (value: string, label: string) => {
setSelectedValue(value);
setSelectedLabel(label);
setIsOpen(false);
if (onUpdate) {
onUpdate("value", value);
}
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
};
// 외부 클릭 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (selectRef.current && !selectRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
// 🚀 실시간 업데이트를 위한 이벤트 리스너
useEffect(() => {
const handleFocus = () => {
console.log(`👁️ [${component.id}] 윈도우 포커스 - 전역 상태 새로고침`);
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 configOptions = componentConfig.options || [];
console.log(`🔧 [${component.id}] 옵션 병합:`, {
codeOptionsLength: codeOptions.length,
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })),
configOptionsLength: configOptions.length,
configOptions: configOptions.map((o) => ({ value: o.value, label: o.label })),
});
return [...codeOptions, ...configOptions];
};
const allOptions = getAllOptions();
const placeholder = componentConfig.placeholder || "선택하세요";
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div
{/* 라벨 렌더링 */} ref={selectRef}
{component.label && ( className={`relative w-full ${className || ""}`}
<label style={style}
style={{ onClick={onClick}
position: "absolute", onDragStart={onDragStart}
top: "-25px", onDragEnd={onDragEnd}
left: "0px", {...props}
fontSize: component.style?.labelFontSize || "14px", >
color: component.style?.labelColor || "#374151", {/* 커스텀 셀렉트 박스 */}
fontWeight: "500", <div
// isInteractive 모드에서는 사용자 스타일 우선 적용 className={`flex w-full cursor-pointer items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-gray-400"} ${isSelected ? "ring-2 ring-blue-500" : ""} ${isOpen ? "border-blue-500" : ""} `}
...(isInteractive && component.style ? component.style : {}), onClick={handleToggle}
}} style={{
> pointerEvents: isDesignMode ? "none" : "auto",
{component.label}
{component.required && <span style={{color: "#ef4444",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>*</span>}
</label>
)}
<select
value={component.value || ""}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
multiple={componentConfig.multiple || false}
style={{width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
padding: "8px 12px",
fontSize: "14px",
outline: "none",
backgroundColor: "white",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.value);
}
}} }}
> >
{componentConfig.placeholder && ( <span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<option value="" disabled>
{componentConfig.placeholder} {/* 드롭다운 아이콘 */}
</option> <svg
)} className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
{(componentConfig.options || []).map((option, index) => ( fill="none"
<option key={index} value={option.value}> stroke="currentColor"
{option.label} viewBox="0 0 24 24"
</option> >
))} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
{(!componentConfig.options || componentConfig.options.length === 0) && ( </svg>
<> </div>
<option value="option1"> 1</option>
<option value="option2"> 2</option> {/* 드롭다운 옵션 */}
<option value="option3"> 3</option> {isOpen && !isDesignMode && (
</> <div
)} className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
</select> style={{
backgroundColor: "white",
color: "black",
zIndex: 9999,
}}
>
{(() => {
console.log(`🎨 [${component.id}] 드롭다운 렌더링:`, {
isOpen,
isDesignMode,
isLoadingCodes,
allOptionsLength: allOptions.length,
allOptions: allOptions.map((o) => ({ value: o.value, label: o.label })),
});
return null;
})()}
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
style={{
color: "black",
backgroundColor: "white",
minHeight: "32px",
border: "1px solid #e5e7eb",
}}
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label || option.value || `옵션 ${index + 1}`}
</div>
))
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div> </div>
); );
}; };
/** // Wrapper 컴포넌트 (기존 호환성을 위해)
* SelectBasic export const SelectBasicWrapper = SelectBasicComponent;
*
*/ // 기본 export
export const SelectBasicWrapper: React.FC<SelectBasicComponentProps> = (props) => { export { SelectBasicComponent };
return <SelectBasicComponent {...props} />;
};

View File

@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component";
* SelectBasic * SelectBasic
*/ */
export interface SelectBasicConfig extends ComponentConfig { export interface SelectBasicConfig extends ComponentConfig {
// select 관련 설정 // select 관련 설정
placeholder?: string; placeholder?: string;
options?: Array<{ value: string; label: string }>;
multiple?: boolean;
// 코드 관련 설정
codeCategory?: string;
// 공통 설정 // 공통 설정
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
placeholder?: string;
helperText?: string; helperText?: string;
// 스타일 관련 // 스타일 관련
variant?: "default" | "outlined" | "filled"; variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
// 이벤트 관련 // 이벤트 관련
onChange?: (value: any) => void; onChange?: (value: any) => void;
onFocus?: () => void; onFocus?: () => void;
@ -37,7 +41,7 @@ export interface SelectBasicProps {
config?: SelectBasicConfig; config?: SelectBasicConfig;
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
// 이벤트 핸들러 // 이벤트 핸들러
onChange?: (value: any) => void; onChange?: (value: any) => void;
onFocus?: () => void; onFocus?: () => void;

View File

@ -47,8 +47,8 @@ export const WEB_TYPE_COMPONENT_MAPPING: Record<string, string> = {
// 기타 // 기타
label: "text-display", label: "text-display",
code: "text-input", // 임시로 텍스트 입력 사용 code: "select-basic", // 코드 타입은 선택상자 사용
entity: "select-basic", // 임시로 선택상자 사용 entity: "select-basic", // 엔티티 타입은 선택상자 사용
}; };
/** /**

View File

@ -388,6 +388,10 @@ export interface DataTableColumn {
searchable: boolean; // 검색 대상 여부 searchable: boolean; // 검색 대상 여부
webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정 webTypeConfig?: WebTypeConfig; // 컬럼별 상세 설정
// 레거시 지원용 (테이블 타입 관리에서 설정된 값)
codeCategory?: string; // 코드 카테고리 (코드 타입용)
referenceTable?: string; // 참조 테이블 (엔티티 타입용)
// 가상 파일 컬럼 관련 속성 // 가상 파일 컬럼 관련 속성
isVirtualFileColumn?: boolean; // 가상 파일 컬럼인지 여부 isVirtualFileColumn?: boolean; // 가상 파일 컬럼인지 여부
fileColumnConfig?: { fileColumnConfig?: {