Files
vexplor/frontend/components/v2/V2Select.tsx
syc0123 65026f14e4 docs: Add documentation for category dropdown depth separation
- Introduced new documents detailing the implementation of visual separation for three-level category dropdowns.
- Updated the `flattenTree` function in both `V2Select.tsx` and `UnifiedSelect.tsx` to use Non-Breaking Space (`\u00A0`) for indentation, ensuring proper visual hierarchy.
- Included a checklist to track the implementation progress and verification of the changes.
- Documented the rationale behind the changes, including the issues with HTML whitespace collapsing and the decisions made to enhance user experience.

These updates aim to improve the clarity and usability of the category selection interface in the application.
2026-03-11 15:53:01 +09:00

1351 lines
47 KiB
TypeScript

"use client";
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
/**
* V2Select
*
* 통합 선택 컴포넌트
* - dropdown: 드롭다운 선택
* - radio: 라디오 버튼 그룹
* - check: 체크박스 그룹
* - tag: 태그 선택
* - toggle: 토글 스위치
* - swap: 스왑 선택 (좌우 이동)
*/
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, 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, V2SelectFilter } 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 textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
const [hoverTooltip, setHoverTooltip] = 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 (
<div className="group relative w-full">
<Select
value={typeof value === "string" ? value : (value?.[0] ?? "")}
onValueChange={(v) => onChange?.(v)}
disabled={disabled}
>
{/* SelectTrigger에 style로 직접 height 전달 (Radix Select.Root는 DOM 없어서 h-full 체인 끊김) */}
<SelectTrigger
ref={ref}
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
style={style}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options
.filter((option) => option.value != null && option.value !== "")
.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 초기화 버튼 (값이 있을 때만 표시) */}
{allowClear && hasValue && !disabled && (
<span
role="button"
tabIndex={-1}
className="absolute top-1/2 right-7 z-10 -translate-y-1/2 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onChange?.("");
}}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-3.5 w-3.5 opacity-40 transition-opacity hover:opacity-100" />
</span>
)}
</div>
);
}
// 검색 가능 또는 다중 선택 → Combobox 사용
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
const safeOptions = useMemo(() => options.filter((o) => o.value != null && o.value !== ""), [options]);
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const selectedLabels = useMemo(() => {
return safeOptions
.filter((o) => selectedValues.includes(o.value))
.map((o) => o.label)
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
useEffect(() => {
const el = textRef.current;
if (el) {
setIsTruncated(el.scrollWidth > el.clientWidth);
}
}, [selectedLabels]);
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],
);
const displayText =
selectedLabels.length > 0 ? (multiple ? selectedLabels.join(", ") : selectedLabels[0]) : placeholder;
const isPlaceholder = selectedLabels.length === 0;
return (
<div
className="relative w-full"
onMouseEnter={() => {
if (isTruncated && multiple) setHoverTooltip(true);
}}
onMouseLeave={() => setHoverTooltip(false)}
>
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (isOpen) setHoverTooltip(false);
}}
>
<PopoverTrigger asChild>
<Button
ref={ref}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
"w-full justify-between font-normal",
"bg-transparent hover:bg-transparent",
"border-input shadow-xs",
"h-6 px-2 py-0 text-sm",
className,
)}
style={style}
>
<span
ref={textRef}
className="flex-1 truncate text-left"
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
>
{displayText}
</span>
<div className="ml-2 flex items-center gap-1">
{allowClear && selectedValues.length > 0 && (
<span
role="button"
tabIndex={-1}
onClick={handleClear}
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
)}
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command
filter={(itemValue, search) => {
if (!search) return 1;
const option = safeOptions.find((o) => o.value === itemValue);
const label = (option?.label || option?.value || "").toLowerCase();
if (label.includes(search.toLowerCase())) return 1;
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem key={option.value} value={option.value} onSelect={() => handleSelect(option.value)}>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0",
)}
/>
{displayLabel}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{hoverTooltip && !open && (
<div className="bg-popover animate-in fade-in-0 zoom-in-95 absolute bottom-full left-0 z-50 mb-1 rounded-md border px-3 py-1.5 shadow-md">
<div className="space-y-0.5 text-xs">
{selectedLabels.map((label, i) => (
<div key={i}>{label}</div>
))}
</div>
</div>
)}
</div>
);
},
);
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 (
<RadioGroup
ref={ref}
value={value ?? ""}
onValueChange={onChange}
disabled={disabled}
className={cn("flex flex-wrap gap-4", className)}
>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`radio-${option.value}`} />
<Label htmlFor={`radio-${option.value}`} className="cursor-pointer text-sm">
{option.label}
</Label>
</div>
))}
</RadioGroup>
);
});
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 (
<div ref={ref} className={cn("flex flex-wrap gap-4", className)}>
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<Checkbox
id={`check-${option.value}`}
checked={value.includes(option.value)}
onCheckedChange={(checked) => handleChange(option.value, checked as boolean)}
disabled={disabled || (maxSelect && value.length >= maxSelect && !value.includes(option.value))}
/>
<Label htmlFor={`check-${option.value}`} className="cursor-pointer text-sm">
{option.label}
</Label>
</div>
))}
</div>
);
});
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 (
<div ref={ref} className={cn("flex flex-wrap gap-2", className)}>
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
<Badge
key={option.value}
variant={isSelected ? "default" : "outline"}
className={cn("cursor-pointer transition-colors", disabled && "cursor-not-allowed opacity-50")}
onClick={() => !disabled && handleToggle(option.value)}
>
{option.label}
{isSelected && <X className="ml-1 h-3 w-3" />}
</Badge>
);
})}
</div>
);
});
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 (
<div ref={ref} className={cn("w-full max-w-full overflow-hidden", className)} style={{ width: style?.width }}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div
className={cn(
"border-input bg-background ring-offset-background flex w-full max-w-full cursor-pointer flex-wrap items-center gap-1.5 overflow-hidden rounded-md border px-3 py-2 text-sm",
"hover:border-primary/50 focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
disabled && "cursor-not-allowed opacity-50",
)}
style={triggerStyle}
>
{selectedOptions.length > 0 ? (
<>
{selectedOptions.map((option) => (
<Badge key={option.value} variant="secondary" className="flex items-center gap-1 px-2 py-0.5">
{option.label}
<X
className="hover:text-destructive h-3 w-3 cursor-pointer"
onClick={(e) => !disabled && handleRemove(e, option.value)}
/>
</Badge>
))}
</>
) : (
<span className="text-muted-foreground">{placeholder}</span>
)}
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</div>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="max-h-[300px] overflow-auto p-2">
{options.map((option) => {
const isSelected = value.includes(option.value);
return (
<div
key={option.value}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50",
)}
onClick={() => !disabled && handleToggle(option.value)}
>
<Checkbox checked={isSelected} disabled={disabled} className="pointer-events-none" />
<span>{option.label}</span>
</div>
);
})}
{options.length === 0 && (
<div className="text-muted-foreground py-2 text-center text-sm"> </div>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
});
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 (
<div ref={ref} className={cn("flex items-center gap-3", className)}>
<span className={cn("text-sm", !isOn && "font-medium")}>{offOption.label}</span>
<Switch
checked={isOn}
onCheckedChange={(checked) => onChange?.(checked ? onOption.value : offOption.value)}
disabled={disabled}
/>
<span className={cn("text-sm", isOn && "font-medium")}>{onOption.label}</span>
</div>
);
});
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 (
<div ref={ref} className={cn("flex items-stretch gap-2", className)}>
{/* 왼쪽: 선택 가능 */}
<div className="flex min-h-0 flex-1 flex-col rounded-md border">
<div className="bg-muted shrink-0 border-b p-2 text-xs font-medium"> </div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{available.map((option) => (
<div
key={option.value}
className={cn(
"hover:bg-accent cursor-pointer rounded p-2 text-sm",
disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !disabled && handleMoveRight(option.value)}
>
{option.label}
</div>
))}
{available.length === 0 && <div className="text-muted-foreground p-2 text-xs"> </div>}
</div>
</div>
{/* 중앙: 이동 버튼 */}
<div className="flex flex-col justify-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllRight}
disabled={disabled || available.length === 0}
>
<ArrowLeftRight className="h-4 w-4 rotate-180" />
</Button>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleMoveAllLeft}
disabled={disabled || selected.length === 0}
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
{/* 오른쪽: 선택됨 */}
<div className="flex min-h-0 flex-1 flex-col rounded-md border">
<div className="bg-primary/10 shrink-0 border-b p-2 text-xs font-medium"></div>
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{selected.map((option) => (
<div
key={option.value}
className={cn(
"hover:bg-accent flex cursor-pointer items-center justify-between rounded p-2 text-sm",
disabled && "cursor-not-allowed opacity-50",
)}
onClick={() => !disabled && handleMoveLeft(option.value)}
>
<span>{option.label}</span>
<X className="h-3 w-3 opacity-50" />
</div>
))}
{selected.length === 0 && <div className="text-muted-foreground p-2 text-xs"> </div>}
</div>
</div>
</div>
);
});
SwapSelect.displayName = "SwapSelect";
/**
* 메인 V2Select 컴포넌트
*/
export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
onFormDataChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
} = props;
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
const allComponents = (props as any).allComponents as any[] | undefined;
const [options, setOptions] = useState<SelectOption[]>(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 configFilters = config.filters;
// 계층 코드 연쇄 선택 관련
const hierarchical = config.hierarchical;
const parentField = config.parentField;
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
const formContext = useContext(V2FormContext);
/**
* 필터 조건을 API 전달용 JSON으로 변환
* field/user 타입은 런타임 값으로 치환
*/
const resolvedFiltersJson = useMemo(() => {
if (!configFilters || configFilters.length === 0) return undefined;
const resolved: Array<{ column: string; operator: string; value: unknown }> = [];
for (const f of configFilters) {
const vt = f.valueType || "static";
// isNull/isNotNull은 값 불필요
if (f.operator === "isNull" || f.operator === "isNotNull") {
resolved.push({ column: f.column, operator: f.operator, value: null });
continue;
}
let resolvedValue: unknown = f.value;
if (vt === "field" && f.fieldRef) {
// 다른 폼 필드 참조
if (formContext) {
resolvedValue = formContext.getValue(f.fieldRef);
} else {
const fd = (props as any).formData;
resolvedValue = fd?.[f.fieldRef];
}
// 참조 필드 값이 비어있으면 이 필터 건너뜀
if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue;
} else if (vt === "user" && f.userField) {
// 로그인 사용자 정보 참조 (props에서 가져옴)
const userMap: Record<string, string | undefined> = {
companyCode: (props as any).companyCode,
userId: (props as any).userId,
deptCode: (props as any).deptCode,
userName: (props as any).userName,
};
resolvedValue = userMap[f.userField];
if (!resolvedValue) continue;
}
resolved.push({ column: f.column, operator: f.operator, value: resolvedValue });
}
return resolved.length > 0 ? JSON.stringify(resolved) : undefined;
}, [configFilters, formContext, props]);
// 부모 필드의 값 계산
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(() => {
if (resolvedFiltersJson !== undefined) {
setOptionsLoaded(false);
}
}, [resolvedFiltersJson]);
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 dbParams: Record<string, any> = {
value: valueColumn || "id",
label: labelColumn || "name",
};
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${table}/options`, {
params: dbParams,
});
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 entityParams: Record<string, any> = { value: valueCol, label: labelCol };
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: entityParams,
});
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 ? "\u00A0\u00A0\u00A0".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 값 조회
const isValidColumnName = columnName && !columnName.startsWith("comp_");
if (tableName && isValidColumnName) {
const distinctParams: Record<string, any> = {};
if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson;
const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, {
params: distinctParams,
});
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이 없거나 유효하지 않으면 빈 옵션
}
}
// null/undefined value 필터링 (cmdk 크래시 방지)
const sanitized = fetchedOptions
.filter((o) => o.value != null && String(o.value) !== "")
.map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) }));
setOptions(sanitized);
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,
resolvedFiltersJson,
]);
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
const resolvedValue = useMemo(() => {
if (!value || options.length === 0) return value;
const resolveOne = (v: string): string => {
if (options.some((o) => o.value === v)) return v;
const trimmed = v.trim();
const match = options.find((o) => {
const cleanLabel = o.label.replace(/^[\s└]+/, "").trim();
return cleanLabel === trimmed;
});
return match ? match.value : v;
};
if (Array.isArray(value)) {
const resolved = value.map(resolveOne);
return resolved.every((v, i) => v === value[i]) ? value : resolved;
}
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
if (typeof value === "string" && value.includes(",")) {
const parts = value.split(",");
const resolved = parts.map((p) => resolveOne(p.trim()));
const result = resolved.join(",");
return result === value ? value : result;
}
return resolveOne(value);
}, [value, options]);
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
useEffect(() => {
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
onChange(resolvedValue as string | string[]);
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
const autoFillTargets = useMemo(() => {
if (source !== "entity" || !entityTable || !allComponents) return [];
const targets: Array<{ sourceField: string; targetColumnName: string }> = [];
for (const comp of allComponents) {
if (comp.id === id) continue;
// overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음)
const ov = (comp as any).overrides || {};
const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || "";
// 방법1: entityJoinTable 속성이 있는 경우
const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable;
const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn;
if (joinTable === entityTable && joinColumn) {
targets.push({ sourceField: joinColumn, targetColumnName: compColumnName });
continue;
}
// 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit)
if (compColumnName.includes(".")) {
const [prefix, actualColumn] = compColumnName.split(".");
if (prefix === entityTable && actualColumn) {
targets.push({ sourceField: actualColumn, targetColumnName: compColumnName });
}
}
}
return targets;
}, [source, entityTable, allComponents, id]);
// 엔티티 autoFill 적용 래퍼
const handleChangeWithAutoFill = useCallback(
(newValue: string | string[]) => {
onChange?.(newValue);
if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return;
const selectedKey = typeof newValue === "string" ? newValue : newValue[0];
if (!selectedKey) return;
const valueCol = entityValueColumn || "id";
apiClient
.get(`/table-management/tables/${entityTable}/data-with-joins`, {
params: {
page: 1,
size: 1,
search: JSON.stringify({ [valueCol]: selectedKey }),
autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }),
},
})
.then((res) => {
const responseData = res.data?.data;
const rows = responseData?.data || responseData?.rows || [];
if (rows.length > 0) {
const fullData = rows[0];
for (const target of autoFillTargets) {
const sourceValue = fullData[target.sourceField];
if (sourceValue !== undefined) {
onFormDataChange(target.targetColumnName, sourceValue);
}
}
}
})
.catch((err) => console.error("autoFill 조회 실패:", err));
},
[onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn],
);
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
return <div className="text-muted-foreground flex h-full items-center text-sm"> ...</div>;
}
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<string, string> = {
radio: "라디오",
check: "체크박스",
checkbox: "체크박스",
tag: "태그",
tagbox: "태그박스",
toggle: "토글",
swap: "스왑",
};
const modeName = modeNames[config.mode || ""] || config.mode;
return (
<div className="text-muted-foreground flex h-full items-center justify-center rounded border border-dashed p-2 text-xs">
<span className="opacity-70">
[{modeName}] {sourceInfo}
</span>
</div>
);
}
switch (config.mode) {
case "dropdown":
case "combobox":
return (
<DropdownSelect
options={options}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable}
multiple={config.multiple}
maxSelect={config.maxSelect}
allowClear={config.allowClear}
disabled={isDisabled}
style={heightStyle}
/>
);
case "radio":
return (
<RadioSelect
options={options}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
case "check":
case "checkbox":
return (
<CheckSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "tag":
return (
<TagSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "tagbox":
return (
<TagboxSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
placeholder={config.placeholder || "선택하세요"}
maxSelect={config.maxSelect}
disabled={isDisabled}
style={heightStyle}
/>
);
case "toggle":
return (
<ToggleSelect
options={options}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
disabled={isDisabled}
/>
);
case "swap":
return (
<SwapSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
default:
return (
<DropdownSelect
options={options}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
disabled={isDisabled}
style={heightStyle}
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false;
const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height;
// 라벨 위치 및 높이 계산
const labelPos = style?.labelPosition || "top";
const isHorizLabel = labelPos === "left" || labelPos === "right";
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
const labelGapValue = style?.labelGap || "8px";
// 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
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;
const labelElement = showLabel ? (
<Label
htmlFor={id}
style={{
...(labelPos === "top" ? { position: "absolute" as const, top: `-${estimatedLabelHeight}px`, left: 0 } : {}),
...(labelPos === "bottom"
? { position: "absolute" as const, bottom: `-${estimatedLabelHeight}px`, left: 0 }
: {}),
fontSize: style?.labelFontSize || "14px",
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{label}
{required && <span className="ml-0.5 text-amber-500">*</span>}
</Label>
) : null;
const selectContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
hasCustomBorder && "**:data-[slot=select-trigger]:border-0! [&_.border]:border-0! [&_button]:border-0!",
(hasCustomBorder || hasCustomRadius) &&
"**:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none! [&_button]:rounded-none!",
hasCustomBackground && "**:data-[slot=select-trigger]:bg-transparent! [&_button]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderSelect()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(
"flex flex-col gap-1",
labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center",
isDesignMode && "pointer-events-none",
)}
style={{
width: componentWidth,
height: componentHeight,
gap: labelGapValue,
}}
>
<Label
htmlFor={id}
style={{
fontSize: style?.labelFontSize || "14px",
color: getAdaptiveLabelColor(style?.labelColor),
fontWeight: style?.labelFontWeight || "500",
}}
className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0"
>
{label}
{required && <span className="ml-0.5 text-amber-500">*</span>}
</Label>
<div
className={cn(
"w-full min-w-0 flex-1",
hasCustomBorder && "**:data-[slot=select-trigger]:border-0! [&_.border]:border-0! [&_button]:border-0!",
(hasCustomBorder || hasCustomRadius) &&
"**:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none! [&_button]:rounded-none!",
hasCustomBackground && "**:data-[slot=select-trigger]:bg-transparent! [&_button]:bg-transparent!",
)}
style={{ ...(hasCustomText ? customTextStyle : {}) }}
>
{renderSelect()}
</div>
</div>
);
}
return (
<div
ref={ref}
id={id}
className={cn("relative", isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,
}}
>
{labelElement}
{selectContent}
</div>
);
});
V2Select.displayName = "V2Select";
export default V2Select;