"use client";
/**
* V2Select
*
* 통합 선택 컴포넌트
* - dropdown: 드롭다운 선택
* - radio: 라디오 버튼 그룹
* - check: 체크박스 그룹
* - tag: 태그 선택
* - toggle: 토글 스위치
* - swap: 스왑 선택 (좌우 이동)
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { V2SelectProps, SelectOption } from "@/types/v2-components";
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import V2FormContext from "./V2FormContext";
/**
* 드롭다운 선택 컴포넌트
*/
const DropdownSelect = forwardRef<
HTMLButtonElement,
{
options: SelectOption[];
value?: string | string[];
onChange?: (value: string | string[]) => void;
placeholder?: string;
searchable?: boolean;
multiple?: boolean;
maxSelect?: number;
allowClear?: boolean;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
>(
(
{
options,
value,
onChange,
placeholder = "선택",
searchable,
multiple,
maxSelect,
allowClear = true,
disabled,
className,
style,
},
ref,
) => {
const [open, setOpen] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
return value !== "";
}, [value]);
// 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) {
return (
{/* 초기화 버튼 (값이 있을 때만 표시) */}
{allowClear && hasValue && !disabled && (
{
e.stopPropagation();
e.preventDefault();
onChange?.("");
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
)}
);
}
// 검색 가능 또는 다중 선택 → Combobox 사용
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const selectedLabels = useMemo(() => {
return selectedValues.map((v) => options.find((o) => o.value === v)?.label).filter(Boolean) as string[];
}, [selectedValues, options]);
const handleSelect = useCallback(
(selectedValue: string) => {
if (multiple) {
const newValues = selectedValues.includes(selectedValue)
? selectedValues.filter((v) => v !== selectedValue)
: maxSelect && selectedValues.length >= maxSelect
? selectedValues
: [...selectedValues, selectedValue];
onChange?.(newValues);
} else {
onChange?.(selectedValue);
setOpen(false);
}
},
[multiple, selectedValues, maxSelect, onChange],
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onChange?.(multiple ? [] : "");
},
[multiple, onChange],
);
return (
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
{
if (!search) return 1;
const option = options.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && }
검색 결과가 없습니다.
{options.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
handleSelect(option.value)}>
{displayLabel}
);
})}
);
},
);
DropdownSelect.displayName = "DropdownSelect";
/**
* 라디오 선택 컴포넌트
*/
const RadioSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
>(({ options, value, onChange, disabled, className }, ref) => {
return (
{options.map((option) => (
))}
);
});
RadioSelect.displayName = "RadioSelect";
/**
* 체크박스 선택 컴포넌트
*/
const CheckSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}
>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleChange = useCallback(
(optionValue: string, checked: boolean) => {
if (checked) {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
} else {
onChange?.(value.filter((v) => v !== optionValue));
}
},
[value, maxSelect, onChange],
);
return (
{options.map((option) => (
handleChange(option.value, checked as boolean)}
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
/>
))}
);
});
CheckSelect.displayName = "CheckSelect";
/**
* 태그 선택 컴포넌트
*/
const TagSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}
>(({ options, value = [], onChange, maxSelect, disabled, className }, ref) => {
const handleToggle = useCallback(
(optionValue: string) => {
const isSelected = value.includes(optionValue);
if (isSelected) {
onChange?.(value.filter((v) => v !== optionValue));
} else {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
}
},
[value, maxSelect, onChange],
);
return (
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
!disabled && handleToggle(option.value)}
>
{option.label}
{isSelected && }
);
})}
);
});
TagSelect.displayName = "TagSelect";
/**
* 태그박스 선택 컴포넌트 (태그 형태 + 체크박스 드롭다운)
* - 선택된 값들이 태그(Badge)로 표시됨
* - 클릭하면 체크박스 목록이 드롭다운으로 열림
*/
const TagboxSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
maxSelect?: number;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
>(({ options, value = [], onChange, placeholder = "선택하세요", maxSelect, disabled, className, style }, ref) => {
const [open, setOpen] = useState(false);
// 선택된 옵션들의 라벨 가져오기
const selectedOptions = useMemo(() => options.filter((o) => value.includes(o.value)), [options, value]);
// 체크박스 토글 핸들러
const handleToggle = useCallback(
(optionValue: string) => {
const isSelected = value.includes(optionValue);
if (isSelected) {
onChange?.(value.filter((v) => v !== optionValue));
} else {
if (maxSelect && value.length >= maxSelect) return;
onChange?.([...value, optionValue]);
}
},
[value, maxSelect, onChange],
);
// 태그 제거 핸들러
const handleRemove = useCallback(
(e: React.MouseEvent, optionValue: string) => {
e.stopPropagation();
onChange?.(value.filter((v) => v !== optionValue));
},
[value, onChange],
);
// 🔧 높이 처리: style.height가 있으면 minHeight로 사용 (기본 40px 보장)
const triggerStyle: React.CSSProperties = {
minHeight: style?.height || 40,
height: style?.height || "auto",
maxWidth: "100%", // 🔧 부모 컨테이너를 넘지 않도록
};
return (
{selectedOptions.length > 0 ? (
<>
{selectedOptions.map((option) => (
{option.label}
!disabled && handleRemove(e, option.value)}
/>
))}
>
) : (
{placeholder}
)}
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
!disabled && handleToggle(option.value)}
>
{option.label}
);
})}
{options.length === 0 && (
옵션이 없습니다
)}
);
});
TagboxSelect.displayName = "TagboxSelect";
/**
* 토글 선택 컴포넌트 (Boolean용)
*/
const ToggleSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string;
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
>(({ options, value, onChange, disabled, className }, ref) => {
// 토글은 2개 옵션만 지원
const [offOption, onOption] =
options.length >= 2
? [options[0], options[1]]
: [
{ value: "false", label: "아니오" },
{ value: "true", label: "예" },
];
const isOn = value === onOption.value;
return (
{offOption.label}
onChange?.(checked ? onOption.value : offOption.value)}
disabled={disabled}
/>
{onOption.label}
);
});
ToggleSelect.displayName = "ToggleSelect";
/**
* 스왑 선택 컴포넌트 (좌우 이동 방식)
*/
const SwapSelect = forwardRef<
HTMLDivElement,
{
options: SelectOption[];
value?: string[];
onChange?: (value: string[]) => void;
maxSelect?: number;
disabled?: boolean;
className?: string;
}
>(({ options, value = [], onChange, disabled, className }, ref) => {
const available = useMemo(() => options.filter((o) => !value.includes(o.value)), [options, value]);
const selected = useMemo(() => options.filter((o) => value.includes(o.value)), [options, value]);
const handleMoveRight = useCallback(
(optionValue: string) => {
onChange?.([...value, optionValue]);
},
[value, onChange],
);
const handleMoveLeft = useCallback(
(optionValue: string) => {
onChange?.(value.filter((v) => v !== optionValue));
},
[value, onChange],
);
const handleMoveAllRight = useCallback(() => {
onChange?.(options.map((o) => o.value));
}, [options, onChange]);
const handleMoveAllLeft = useCallback(() => {
onChange?.([]);
}, [onChange]);
return (
{/* 왼쪽: 선택 가능 */}
선택 가능
{available.map((option) => (
!disabled && handleMoveRight(option.value)}
>
{option.label}
))}
{available.length === 0 &&
항목 없음
}
{/* 중앙: 이동 버튼 */}
{/* 오른쪽: 선택됨 */}
선택됨
{selected.map((option) => (
!disabled && handleMoveLeft(option.value)}
>
{option.label}
))}
{selected.length === 0 &&
선택 없음
}
);
});
SwapSelect.displayName = "SwapSelect";
/**
* 메인 V2Select 컴포넌트
*/
export const V2Select = forwardRef((props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
} = props;
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
const [options, setOptions] = useState(config.options || []);
const [loading, setLoading] = useState(false);
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
const rawSource = config.source;
const categoryTable = (config as any).categoryTable;
const categoryColumn = (config as any).categoryColumn;
// category 소스 유지 (category_values 테이블에서 로드)
const source = rawSource;
const codeGroup = config.codeGroup;
const entityTable = config.entityTable;
const entityValueColumn = config.entityValueColumn || config.entityValueField;
const entityLabelColumn = config.entityLabelColumn || config.entityLabelField;
const table = config.table;
const valueColumn = config.valueColumn;
const labelColumn = config.labelColumn;
const apiEndpoint = config.apiEndpoint;
const staticOptions = config.options;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
const parentField = config.parentField;
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(V2FormContext);
// 부모 필드의 값 계산
const parentValue = useMemo(() => {
if (!hierarchical || !parentField) return null;
// FormContext가 있으면 거기서 값 가져오기
if (formContext) {
const val = formContext.getValue(parentField);
return val as string | null;
}
return null;
}, [hierarchical, parentField, formContext]);
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
useEffect(() => {
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
if (hierarchical && source === "code") {
setOptionsLoaded(false);
}
}, [parentValue, hierarchical, source]);
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
if (optionsLoaded && source !== "static") {
return;
}
const loadOptions = async () => {
if (source === "static") {
setOptions(staticOptions || []);
setOptionsLoaded(true);
return;
}
setLoading(true);
try {
let fetchedOptions: SelectOption[] = [];
if (source === "code" && codeGroup) {
// 계층 구조 사용 시 자식 코드만 로드
if (hierarchical) {
const params = new URLSearchParams();
if (parentValue) {
params.append("parentCodeValue", parentValue);
}
const queryString = params.toString();
const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get(url);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({
value: item.value,
label: item.label,
}));
}
} else {
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
}
} else if (source === "db" && table) {
// DB 테이블에서 로드
const response = await apiClient.get(`/entity/${table}/options`, {
params: {
value: valueColumn || "id",
label: labelColumn || "name",
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "entity" && entityTable) {
// 엔티티(참조 테이블)에서 로드
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: {
value: valueCol,
label: labelCol,
},
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if (source === "api" && apiEndpoint) {
// 외부 API에서 로드
const response = await apiClient.get(apiEndpoint);
const data = response.data;
if (Array.isArray(data)) {
fetchedOptions = data;
}
} else if (source === "category") {
// 카테고리에서 로드 (category_values 테이블)
// tableName, columnName은 props에서 가져옴
const catTable = categoryTable || tableName;
const catColumn = categoryColumn || columnName;
if (catTable && catColumn) {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화하여 옵션으로 변환
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
const flattenTree = (
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
depth: number = 0,
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
}
}
return result;
};
fetchedOptions = flattenTree(data.data);
}
}
} else if (source === "select" || source === "distinct") {
// 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회
// tableName, columnName은 props에서 가져옴
// 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀
const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) {
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: String(item.value),
label: String(item.label),
}));
}
} else if (!isValidColumnName) {
// columnName이 없거나 유효하지 않으면 빈 옵션
}
}
setOptions(fetchedOptions);
setOptionsLoaded(true);
} catch (error) {
console.error("옵션 로딩 실패:", error);
setOptions([]);
} finally {
setLoading(false);
}
};
loadOptions();
}, [
source,
entityTable,
entityValueColumn,
entityLabelColumn,
codeGroup,
table,
valueColumn,
labelColumn,
apiEndpoint,
staticOptions,
optionsLoaded,
hierarchical,
parentValue,
]);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
return 로딩 중...
;
}
const isDisabled = disabled || readonly;
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
const heightStyle: React.CSSProperties | undefined = componentHeight ? { height: componentHeight } : undefined;
// 🔧 디자인 모드용: 옵션이 없고 dropdown/combobox가 아닌 모드일 때 source 정보 표시
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap"];
if (options.length === 0 && nonDropdownModes.includes(config.mode || "dropdown")) {
// 데이터 소스 정보 기반 메시지 생성
let sourceInfo = "";
if (source === "static") {
sourceInfo = "정적 옵션 설정 필요";
} else if (source === "code") {
sourceInfo = codeGroup ? `공통코드: ${codeGroup}` : "공통코드 설정 필요";
} else if (source === "entity") {
sourceInfo = entityTable ? `엔티티: ${entityTable}` : "엔티티 설정 필요";
} else if (source === "category") {
const catInfo = categoryTable || tableName || columnName;
sourceInfo = catInfo ? `카테고리: ${catInfo}` : "카테고리 설정 필요";
} else if (source === "db") {
sourceInfo = table ? `테이블: ${table}` : "테이블 설정 필요";
} else if (!source || source === "distinct") {
// distinct 또는 미설정인 경우 - 컬럼명 기반으로 표시
sourceInfo = columnName ? `컬럼: ${columnName}` : "데이터 소스 설정 필요";
} else {
sourceInfo = `소스: ${source}`;
}
// 모드 이름 한글화
const modeNames: Record = {
radio: "라디오",
check: "체크박스",
checkbox: "체크박스",
tag: "태그",
tagbox: "태그박스",
toggle: "토글",
swap: "스왑",
};
const modeName = modeNames[config.mode || ""] || config.mode;
return (
[{modeName}] {sourceInfo}
);
}
switch (config.mode) {
case "dropdown":
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
return (
);
case "radio":
return (
onChange?.(v)}
disabled={isDisabled}
/>
);
case "check":
case "checkbox": // 🔧 기존 저장된 값 호환
return (
);
case "tag":
return (
);
case "tagbox":
return (
);
case "toggle":
return (
onChange?.(v)}
disabled={isDisabled}
/>
);
case "swap":
return (
);
default:
return (
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
return (
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
{showLabel && (
)}
{renderSelect()}
);
});
V2Select.displayName = "V2Select";
export default V2Select;