- 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.
815 lines
26 KiB
TypeScript
815 lines
26 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* UnifiedSelect
|
|
*
|
|
* 통합 선택 컴포넌트
|
|
* - 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 { UnifiedSelectProps, SelectOption } from "@/types/unified-components";
|
|
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import UnifiedFormContext from "./UnifiedFormContext";
|
|
|
|
/**
|
|
* 드롭다운 선택 컴포넌트
|
|
*/
|
|
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;
|
|
}
|
|
>(
|
|
(
|
|
{
|
|
options,
|
|
value,
|
|
onChange,
|
|
placeholder = "선택",
|
|
searchable,
|
|
multiple,
|
|
maxSelect,
|
|
allowClear = true,
|
|
disabled,
|
|
className,
|
|
},
|
|
ref,
|
|
) => {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
|
if (!searchable && !multiple) {
|
|
return (
|
|
<Select
|
|
value={typeof value === "string" ? value : (value?.[0] ?? "")}
|
|
onValueChange={(v) => onChange?.(v)}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger ref={ref} className={cn("h-10", className)}>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
// 검색 가능 또는 다중 선택 → 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 (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
ref={ref}
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
disabled={disabled}
|
|
className={cn("h-10 w-full justify-between font-normal", className)}
|
|
>
|
|
<span className="flex-1 truncate text-left">
|
|
{selectedLabels.length > 0
|
|
? multiple
|
|
? `${selectedLabels.length}개 선택됨`
|
|
: selectedLabels[0]
|
|
: placeholder}
|
|
</span>
|
|
<div className="ml-2 flex items-center gap-1">
|
|
{allowClear && selectedValues.length > 0 && (
|
|
<X className="h-4 w-4 opacity-50 hover:opacity-100" onClick={handleClear} />
|
|
)}
|
|
<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={(value, search) => {
|
|
// 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 && <CommandInput placeholder="검색..." className="h-9" />}
|
|
<CommandList>
|
|
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{options.map((option) => {
|
|
const displayLabel = option.label || option.value || "(빈 값)";
|
|
return (
|
|
<CommandItem key={option.value} value={displayLabel} 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>
|
|
);
|
|
},
|
|
);
|
|
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";
|
|
|
|
/**
|
|
* 토글 선택 컴포넌트 (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-1 rounded-md border">
|
|
<div className="bg-muted border-b p-2 text-xs font-medium">선택 가능</div>
|
|
<div className="max-h-40 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-1 rounded-md border">
|
|
<div className="bg-primary/10 border-b p-2 text-xs font-medium">선택됨</div>
|
|
<div className="max-h-40 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";
|
|
|
|
/**
|
|
* 메인 UnifiedSelect 컴포넌트
|
|
*/
|
|
export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((props, ref) => {
|
|
const {
|
|
id,
|
|
label,
|
|
required,
|
|
readonly,
|
|
disabled,
|
|
style,
|
|
size,
|
|
config: configProp,
|
|
value,
|
|
onChange,
|
|
tableName,
|
|
columnName,
|
|
} = props;
|
|
|
|
// config가 없으면 기본값 사용
|
|
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
|
|
|
|
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 hierarchical = config.hierarchical;
|
|
const parentField = config.parentField;
|
|
|
|
// FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null)
|
|
const formContext = useContext(UnifiedFormContext);
|
|
|
|
// 부모 필드의 값 계산
|
|
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로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
|
|
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: String(item.valueId), // valueId를 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이 없거나 유효하지 않으면 빈 옵션
|
|
console.warn("UnifiedSelect: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", {
|
|
tableName,
|
|
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 <div className="text-muted-foreground flex h-10 items-center text-sm">로딩 중...</div>;
|
|
}
|
|
|
|
const isDisabled = disabled || readonly;
|
|
|
|
switch (config.mode) {
|
|
case "dropdown":
|
|
return (
|
|
<DropdownSelect
|
|
options={options}
|
|
value={value}
|
|
onChange={onChange}
|
|
placeholder="선택"
|
|
searchable={config.searchable}
|
|
multiple={config.multiple}
|
|
maxSelect={config.maxSelect}
|
|
allowClear={config.allowClear}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "radio":
|
|
return (
|
|
<RadioSelect
|
|
options={options}
|
|
value={typeof value === "string" ? value : value?.[0]}
|
|
onChange={(v) => onChange?.(v)}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "check":
|
|
return (
|
|
<CheckSelect
|
|
options={options}
|
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
|
onChange={onChange}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "tag":
|
|
return (
|
|
<TagSelect
|
|
options={options}
|
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
|
onChange={onChange}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "toggle":
|
|
return (
|
|
<ToggleSelect
|
|
options={options}
|
|
value={typeof value === "string" ? value : value?.[0]}
|
|
onChange={(v) => onChange?.(v)}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "swap":
|
|
return (
|
|
<SwapSelect
|
|
options={options}
|
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
|
onChange={onChange}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return <DropdownSelect options={options} value={value} onChange={onChange} disabled={isDisabled} />;
|
|
}
|
|
};
|
|
|
|
const showLabel = label && style?.labelDisplay !== false;
|
|
const componentWidth = size?.width || style?.width;
|
|
const componentHeight = size?.height || style?.height;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className="flex flex-col"
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
fontSize: style?.labelFontSize,
|
|
color: style?.labelColor,
|
|
fontWeight: style?.labelFontWeight,
|
|
marginBottom: style?.labelMarginBottom,
|
|
}}
|
|
className="flex-shrink-0 text-sm font-medium"
|
|
>
|
|
{label}
|
|
{required && <span className="ml-0.5 text-amber-500">*</span>}
|
|
</Label>
|
|
)}
|
|
<div className="min-h-0 flex-1">{renderSelect()}</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
UnifiedSelect.displayName = "UnifiedSelect";
|
|
|
|
export default UnifiedSelect;
|