"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 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); // 단일 선택 + 검색 불가능 → 기본 Select 사용 if (!searchable && !multiple) { return ( ); } // 검색 가능 또는 다중 선택 → 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 체인 끊김) */} { // value는 CommandItem의 value (라벨) // search는 검색어 if (!search) return 1; const normalizedValue = value.toLowerCase(); const normalizedSearch = search.toLowerCase(); if (normalizedValue.includes(normalizedSearch)) 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 void; disabled?: boolean; className?: string; }>(({ options, value, onChange, disabled, className }, ref) => { return ( {options.map((option) => (
))}
); }); RadioSelect.displayName = "RadioSelect"; /** * 체크박스 선택 컴포넌트 */ const CheckSelect = forwardRef 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 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 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 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 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;