ERP-node/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx

676 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useRef, useMemo } from "react";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles";
interface Option {
value: string;
label: string;
}
export interface SelectBasicComponentProps {
component: any;
componentConfig: any;
screenId?: number;
onUpdate?: (field: string, value: any) => void;
isSelected?: boolean;
isDesignMode?: boolean;
isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
value?: any; // 외부에서 전달받는 값
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
[key: string]: any;
}
// ✅ React Query를 사용하여 중복 요청 방지 및 자동 캐싱 처리
// - 동일한 queryKey에 대해서는 자동으로 중복 요청 제거
// - 10분 staleTime으로 적절한 캐시 관리
// - 30분 gcTime으로 메모리 효율성 확보
const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
component,
componentConfig,
screenId,
onUpdate,
isSelected = false,
isDesignMode = false,
isInteractive = false,
onFormDataChange,
className,
style,
onClick,
onDragStart,
onDragEnd,
value: externalValue, // 명시적으로 value prop 받기
menuObjid, // 🆕 메뉴 OBJID
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
const config = (props as any).webTypeConfig || componentConfig || {};
// webType에 따른 세부 타입 결정 (TextInputComponent와 동일한 방식)
const webType = component.componentConfig?.webType || "select";
// 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
const [selectedLabel, setSelectedLabel] = useState("");
// multiselect의 경우 배열로 관리 (콤마 구분자로 파싱)
const [selectedValues, setSelectedValues] = useState<string[]>(() => {
const initialValue = externalValue || config?.value || "";
if (config?.multiple && typeof initialValue === "string" && initialValue) {
return initialValue.split(",").map(v => v.trim()).filter(v => v);
}
return [];
});
// autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState("");
const selectRef = useRef<HTMLDivElement>(null);
// 안정적인 쿼리 키를 위한 메모이제이션
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;
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, menuObjid);
// 디버깅: menuObjid가 제대로 전달되는지 확인
useEffect(() => {
if (codeCategory && codeCategory !== "none") {
console.log(`🎯 [SelectBasicComponent ${component.id}] 코드 옵션 로드:`, {
codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
isCodeCategoryValid,
codeOptionsCount: codeOptions.length,
isLoading: isLoadingCodes,
});
}
}, [component.id, codeCategory, menuObjid, codeOptions.length, isLoadingCodes, isCodeCategoryValid]);
// 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => {
const newValue = externalValue || config?.value || "";
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
if (newValue !== selectedValue) {
setSelectedValue(newValue);
// 다중선택 모드인 경우 selectedValues도 업데이트
if (config?.multiple && typeof newValue === "string" && newValue) {
const values = newValue.split(",").map(v => v.trim()).filter(v => v);
setSelectedValues(values);
}
}
}, [externalValue, config?.value, config?.multiple]);
// ✅ React Query가 자동으로 처리하므로 복잡한 전역 상태 관리 제거
// - 캐싱: React Query가 자동 관리 (10분 staleTime, 30분 gcTime)
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
// 선택된 값에 따른 라벨 업데이트
useEffect(() => {
const getAllOptions = () => {
const configOptions = config.options || [];
return [...codeOptions, ...configOptions];
};
const options = getAllOptions();
const selectedOption = options.find((option) => option.value === selectedValue);
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
let newLabel = selectedOption?.label || "";
// selectedOption이 없고 selectedValue가 있다면, 코드명으로도 검색해보기
if (!selectedOption && selectedValue && codeOptions.length > 0) {
// 1) selectedValue가 코드명인 경우 (예: "국내")
const labelMatch = options.find((option) => option.label === selectedValue);
if (labelMatch) {
newLabel = labelMatch.label;
} else {
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
}
}
if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel);
}
}, [selectedValue, codeOptions, config.options]);
// 클릭 이벤트 핸들러 (React Query로 간소화)
const handleToggle = () => {
if (isDesignMode) return;
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen);
};
// 옵션 선택 핸들러
const handleOptionSelect = (value: string, label: string) => {
setSelectedValue(value);
setSelectedLabel(label);
setIsOpen(false);
// 디자인 모드에서의 컴포넌트 속성 업데이트
if (onUpdate) {
onUpdate("value", value);
}
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, value);
}
};
// 외부 클릭 시 드롭다운 닫기
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]);
// ✅ React Query가 자동으로 처리하므로 수동 이벤트 리스너 불필요
// - refetchOnWindowFocus: true (기본값)
// - refetchOnReconnect: true (기본값)
// - staleTime으로 적절한 캐시 관리
// 모든 옵션 가져오기
const getAllOptions = () => {
const configOptions = config.options || [];
return [...codeOptions, ...configOptions];
};
const allOptions = getAllOptions();
const placeholder = componentConfig.placeholder || "선택하세요";
// DOM props에서 React 전용 props 필터링
const {
component: _component,
componentConfig: _componentConfig,
screenId: _screenId,
onUpdate: _onUpdate,
isSelected: _isSelected,
isDesignMode: _isDesignMode,
className: _className,
style: _style,
onClick: _onClick,
onDragStart: _onDragStart,
onDragEnd: _onDragEnd,
...otherProps
} = props;
const safeDomProps = filterDOMProps(otherProps);
// 세부 타입별 렌더링
const renderSelectByWebType = () => {
// code-radio: 라디오 버튼으로 코드 선택
if (webType === "code-radio") {
return (
<div className="flex flex-col gap-2">
{allOptions.map((option, index) => (
<label key={index} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={component.id || "code-radio-group"}
value={option.value}
checked={selectedValue === option.value}
onChange={() => handleOptionSelect(option.value, option.label)}
disabled={isDesignMode}
className="border-input text-primary focus:ring-ring h-4 w-4"
/>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
);
}
// code-autocomplete: 코드 자동완성
if (webType === "code-autocomplete") {
const filteredOptions = allOptions.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="w-full">
<input
type="text"
value={searchQuery || selectedLabel}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder="코드 또는 코드명 입력..."
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
)}
readOnly={isDesignMode}
/>
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 hover:bg-gray-100"
onClick={() => {
setSearchQuery("");
handleOptionSelect(option.value, option.label);
}}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-900">{option.label}</span>
<span className="text-xs text-gray-500">{option.value}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
// code: 기본 코드 선택박스 (select와 동일)
if (webType === "code") {
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{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"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label}
</div>
))
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// multiselect: 여러 항목 선택 (태그 형식)
if (webType === "multiselect") {
return (
<div className="w-full">
<div
className={cn(
"box-border flex h-full w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={() => {
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newVals.join(","));
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
<input
type="text"
placeholder={selectedValues.length > 0 ? "" : placeholder}
className="min-w-[100px] flex-1 border-none bg-transparent outline-none"
onClick={() => setIsOpen(true)}
readOnly={isDesignMode}
/>
</div>
</div>
);
}
// autocomplete: 검색 기능 포함
if (webType === "autocomplete") {
const filteredOptions = allOptions.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
);
return (
<div className="w-full">
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder={placeholder}
className={cn(
"h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 transition-all outline-none",
!isDesignMode && "hover:border-orange-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-200",
isSelected && "ring-2 ring-orange-500",
)}
readOnly={isDesignMode}
/>
{isOpen && !isDesignMode && filteredOptions.length > 0 && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{filteredOptions.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => {
setSearchQuery(option.label);
setSelectedValue(option.value);
setSelectedLabel(option.label);
setIsOpen(false);
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, option.value);
}
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
);
}
// dropdown (검색 선택박스): 기본 select와 유사하지만 검색 가능
if (webType === "dropdown") {
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 w-full rounded-md border border-gray-300 bg-white shadow-lg">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="검색..."
className="w-full border-b border-gray-300 px-3 py-2 outline-none"
/>
<div className="max-h-60 overflow-auto">
{allOptions
.filter(
(opt) =>
opt.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
opt.value.toLowerCase().includes(searchQuery.toLowerCase()),
)
.map((option, index) => (
<div
key={`${option.value}-${index}`}
className="cursor-pointer bg-white px-3 py-2 text-gray-900 hover:bg-gray-100"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label || option.value}
</div>
))}
</div>
</div>
)}
</div>
);
}
// select (기본 선택박스)
// 다중선택 모드인 경우
if (config?.multiple) {
return (
<div className="w-full">
<div
className={cn(
"box-border flex h-full min-h-[40px] w-full flex-wrap gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
)}
onClick={() => !isDesignMode && setIsOpen(true)}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
{selectedValues.map((val, idx) => {
const opt = allOptions.find((o) => o.value === val);
return (
<span key={idx} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800">
{opt?.label || val}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
const newVals = selectedValues.filter((v) => v !== val);
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
className="ml-1 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
);
})}
{selectedValues.length === 0 && (
<span className="text-gray-500">{placeholder}</span>
)}
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{isLoadingCodes ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
const isSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
const newVals = isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
setSelectedValues(newVals);
const newValue = newVals.join(",");
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="h-4 w-4"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
})
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
}
// 단일선택 모드
return (
<div className="w-full">
<div
className={cn(
"flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2",
!isDesignMode && "hover:border-orange-400",
isSelected && "ring-2 ring-orange-500",
isOpen && "border-orange-500",
)}
onClick={handleToggle}
style={{ pointerEvents: isDesignMode ? "none" : "auto" }}
>
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
<svg
className={`h-4 w-4 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
{isOpen && !isDesignMode && (
<div className="absolute z-[99999] mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
{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"
onClick={() => handleOptionSelect(option.value, option.label)}
>
{option.label || option.value}
</div>
))
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}
</div>
)}
</div>
);
};
return (
<div
ref={selectRef}
className={`relative w-full ${className || ""}`}
style={style}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...safeDomProps}
>
{/* 라벨 렌더링 */}
{component.label && (component.style?.labelDisplay ?? true) && (
<label className="absolute -top-6 left-0 text-sm font-medium text-slate-600">
{component.label}
{component.required && <span className="text-red-500">*</span>}
</label>
)}
{/* 세부 타입별 UI 렌더링 */}
{renderSelectByWebType()}
</div>
);
};
// Wrapper 컴포넌트 (기존 호환성을 위해)
export const SelectBasicWrapper = SelectBasicComponent;
// 기본 export
export { SelectBasicComponent };