1014 lines
36 KiB
TypeScript
1014 lines
36 KiB
TypeScript
"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);
|
|
|
|
// 단일 선택 + 검색 불가능 → 기본 Select 사용
|
|
if (!searchable && !multiple) {
|
|
return (
|
|
<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", className)} style={style}>
|
|
<SelectValue placeholder={placeholder} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options
|
|
.filter((option) => option.value !== "")
|
|
.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에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
|
|
<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", // 표준 Select와 동일한 투명 배경
|
|
"border-input shadow-xs", // 표준 Select와 동일한 테두리
|
|
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
|
|
className,
|
|
)}
|
|
style={style}
|
|
>
|
|
<span className="truncate flex-1 text-left">
|
|
{selectedLabels.length > 0
|
|
? multiple
|
|
? `${selectedLabels.length}개 선택됨`
|
|
: selectedLabels[0]
|
|
: placeholder}
|
|
</span>
|
|
<div className="flex items-center gap-1 ml-2">
|
|
{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="text-sm cursor-pointer">
|
|
{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="text-sm cursor-pointer">
|
|
{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 && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
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(
|
|
"flex w-full max-w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer overflow-hidden",
|
|
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
disabled && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
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="h-3 w-3 cursor-pointer hover:text-destructive"
|
|
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 items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer",
|
|
"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="py-2 text-center text-sm text-muted-foreground">
|
|
옵션이 없습니다
|
|
</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 gap-2 items-stretch", className)}>
|
|
{/* 왼쪽: 선택 가능 */}
|
|
<div className="flex-1 border rounded-md flex flex-col min-h-0">
|
|
<div className="p-2 bg-muted text-xs font-medium border-b shrink-0">선택 가능</div>
|
|
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
|
{available.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={cn(
|
|
"p-2 text-sm rounded cursor-pointer hover:bg-accent",
|
|
disabled && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
onClick={() => !disabled && handleMoveRight(option.value)}
|
|
>
|
|
{option.label}
|
|
</div>
|
|
))}
|
|
{available.length === 0 && (
|
|
<div className="text-xs text-muted-foreground p-2">항목 없음</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙: 이동 버튼 */}
|
|
<div className="flex flex-col gap-1 justify-center">
|
|
<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 border rounded-md flex flex-col min-h-0">
|
|
<div className="p-2 bg-primary/10 text-xs font-medium border-b shrink-0">선택됨</div>
|
|
<div className="p-2 space-y-1 flex-1 overflow-y-auto min-h-0">
|
|
{selected.map((option) => (
|
|
<div
|
|
key={option.value}
|
|
className={cn(
|
|
"p-2 text-sm rounded cursor-pointer hover:bg-accent flex justify-between items-center",
|
|
disabled && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
onClick={() => !disabled && handleMoveLeft(option.value)}
|
|
>
|
|
<span>{option.label}</span>
|
|
<X className="h-3 w-3 opacity-50" />
|
|
</div>
|
|
))}
|
|
{selected.length === 0 && (
|
|
<div className="text-xs text-muted-foreground p-2">선택 없음</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,
|
|
tableName,
|
|
columnName,
|
|
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
|
} = 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(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 <div className="h-full flex items-center text-sm text-muted-foreground">로딩 중...</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="h-full flex items-center justify-center text-xs text-muted-foreground border border-dashed rounded p-2">
|
|
<span className="opacity-70">[{modeName}] {sourceInfo}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
switch (config.mode) {
|
|
case "dropdown":
|
|
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
|
return (
|
|
<DropdownSelect
|
|
options={options}
|
|
value={value}
|
|
onChange={onChange}
|
|
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 value === "string" ? value : value?.[0]}
|
|
onChange={(v) => onChange?.(v)}
|
|
disabled={isDisabled}
|
|
/>
|
|
);
|
|
|
|
case "check":
|
|
case "checkbox": // 🔧 기존 저장된 값 호환
|
|
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 "tagbox":
|
|
return (
|
|
<TagboxSelect
|
|
options={options}
|
|
value={Array.isArray(value) ? value : value ? [value] : []}
|
|
onChange={onChange}
|
|
placeholder={config.placeholder || "선택하세요"}
|
|
maxSelect={config.maxSelect}
|
|
disabled={isDisabled}
|
|
style={heightStyle}
|
|
/>
|
|
);
|
|
|
|
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}
|
|
style={heightStyle}
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
ref={ref}
|
|
id={id}
|
|
className={cn("relative", isDesignMode && "pointer-events-none")}
|
|
style={{
|
|
width: componentWidth,
|
|
height: componentHeight,
|
|
}}
|
|
>
|
|
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */}
|
|
{showLabel && (
|
|
<Label
|
|
htmlFor={id}
|
|
style={{
|
|
position: "absolute",
|
|
top: `-${estimatedLabelHeight}px`,
|
|
left: 0,
|
|
fontSize: style?.labelFontSize || "14px",
|
|
color: style?.labelColor || "#64748b",
|
|
fontWeight: style?.labelFontWeight || "500",
|
|
}}
|
|
className="text-sm font-medium whitespace-nowrap"
|
|
>
|
|
{label}
|
|
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
|
</Label>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"h-full w-full",
|
|
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
|
|
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
|
|
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
|
|
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
|
|
// 커스텀 배경 설정 시, 내부 요소를 투명하게
|
|
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
|
|
)}
|
|
style={hasCustomText ? customTextStyle : undefined}
|
|
>
|
|
{renderSelect()}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
V2Select.displayName = "V2Select";
|
|
|
|
export default V2Select;
|
|
|