ERP-node/frontend/components/v2/V2Select.tsx

1245 lines
45 KiB
TypeScript
Raw Normal View History

2025-12-19 15:44:38 +09:00
"use client";
/**
* V2Select
2025-12-19 15:44:38 +09:00
*
*
* - dropdown: 드롭다운
* - radio: 라디오
* - check: 체크박스
* - tag: 태그
* - toggle: 토글
* - swap: 스왑 ( )
*/
2025-12-22 13:45:08 +09:00
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
2025-12-19 15:44:38 +09:00
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";
2025-12-19 15:44:38 +09:00
import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react";
import { apiClient } from "@/lib/api/client";
import V2FormContext from "./V2FormContext";
2025-12-19 15:44:38 +09:00
/**
*
*/
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;
2025-12-19 15:44:38 +09:00
}>(({
options,
value,
onChange,
placeholder = "선택",
searchable,
multiple,
maxSelect,
allowClear = true,
disabled,
className,
style,
2025-12-19 15:44:38 +09:00
}, ref) => {
const [open, setOpen] = useState(false);
// 현재 선택된 값 존재 여부
const hasValue = useMemo(() => {
if (!value) return false;
if (Array.isArray(value)) return value.length > 0;
return value !== "";
}, [value]);
2025-12-19 15:44:38 +09:00
// 단일 선택 + 검색 불가능 → 기본 Select 사용
if (!searchable && !multiple) {
return (
<div className="relative w-full group">
<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 right-7 top-1/2 -translate-y-1/2 z-10 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 hover:opacity-100 transition-opacity" />
</span>
)}
</div>
2025-12-19 15:44:38 +09:00
);
}
// 검색 가능 또는 다중 선택 → Combobox 사용
// null/undefined value를 가진 옵션 필터링 (cmdk가 value={null}일 때 크래시 발생)
const safeOptions = useMemo(() =>
options.filter((o) => o.value != null && o.value !== ""),
[options]
);
2025-12-19 15:44:38 +09:00
const selectedValues = useMemo(() => {
if (!value) return [];
return Array.isArray(value) ? value : [value];
}, [value]);
const selectedLabels = useMemo(() => {
return selectedValues
.map((v) => safeOptions.find((o) => o.value === v)?.label)
2025-12-19 15:44:38 +09:00
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
2025-12-19 15:44:38 +09:00
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 체인 끊김) */}
2025-12-19 15:44:38 +09:00
<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}
2025-12-19 15:44:38 +09:00
>
<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 && (
<span
role="button"
tabIndex={-1}
2025-12-19 15:44:38 +09:00
onClick={handleClear}
onPointerDown={(e) => {
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
e.stopPropagation();
e.preventDefault();
}}
>
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
</span>
2025-12-19 15:44:38 +09:00
)}
<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) => {
2025-12-19 15:44:38 +09:00
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;
2025-12-19 15:44:38 +09:00
return 0;
}}
>
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{safeOptions.map((option) => {
2025-12-19 15:44:38 +09:00
const displayLabel = option.label || option.value || "(빈 값)";
return (
<CommandItem
key={option.value}
value={option.value}
2025-12-19 15:44:38 +09:00
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";
2025-12-19 15:44:38 +09:00
/**
* (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">
2025-12-19 15:44:38 +09:00
{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">
2025-12-19 15:44:38 +09:00
{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
2025-12-19 15:44:38 +09:00
*/
export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
2025-12-19 15:44:38 +09:00
(props, ref) => {
const {
id,
label,
required,
readonly,
disabled,
style,
size,
config: configProp,
value,
onChange,
onFormDataChange,
tableName,
columnName,
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
2025-12-19 15:44:38 +09:00
} = props;
// config가 없으면 기본값 사용
const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] };
// 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지
const allComponents = (props as any).allComponents as any[] | undefined;
2025-12-19 15:44:38 +09:00
const [options, setOptions] = useState<SelectOption[]>(config.options || []);
const [loading, setLoading] = useState(false);
2025-12-22 10:44:22 +09:00
const [optionsLoaded, setOptionsLoaded] = useState(false);
// 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용)
2025-12-22 13:45:08 +09:00
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;
2025-12-22 13:45:08 +09:00
2025-12-22 10:44:22 +09:00
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;
2025-12-22 13:45:08 +09:00
// 계층 코드 연쇄 선택 관련
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]);
2025-12-22 13:45:08 +09:00
// 부모 필드의 값 계산
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]);
2025-12-22 10:44:22 +09:00
// 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용)
2025-12-19 15:44:38 +09:00
useEffect(() => {
2025-12-22 13:45:08 +09:00
// 계층 구조인 경우 부모 값이 변경되면 다시 로드
if (hierarchical && source === "code") {
setOptionsLoaded(false);
}
}, [parentValue, hierarchical, source]);
// 필터 조건이 변경되면 옵션 다시 로드
useEffect(() => {
if (resolvedFiltersJson !== undefined) {
setOptionsLoaded(false);
}
}, [resolvedFiltersJson]);
2025-12-22 13:45:08 +09:00
useEffect(() => {
// 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외)
2025-12-22 10:44:22 +09:00
if (optionsLoaded && source !== "static") {
return;
}
2025-12-19 15:44:38 +09:00
const loadOptions = async () => {
2025-12-22 10:44:22 +09:00
if (source === "static") {
setOptions(staticOptions || []);
setOptionsLoaded(true);
2025-12-19 15:44:38 +09:00
return;
}
setLoading(true);
try {
let fetchedOptions: SelectOption[] = [];
2025-12-22 10:44:22 +09:00
if (source === "code" && codeGroup) {
2025-12-22 13:45:08 +09:00
// 계층 구조 사용 시 자식 코드만 로드
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 {
2025-12-23 10:49:28 +09:00
// 일반 공통코드에서 로드 (올바른 API 경로: /common-codes/categories/:categoryCode/options)
const response = await apiClient.get(`/common-codes/categories/${codeGroup}/options`);
2025-12-22 13:45:08 +09:00
const data = response.data;
if (data.success && data.data) {
2025-12-23 10:49:28 +09:00
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
2025-12-22 13:45:08 +09:00
}));
}
2025-12-19 15:44:38 +09:00
}
2025-12-22 10:44:22 +09:00
} else if (source === "db" && table) {
2025-12-19 15:44:38 +09:00
// DB 테이블에서 로드
const dbParams: Record<string, any> = {
value: valueColumn || "id",
label: labelColumn || "name",
};
if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson;
2025-12-22 10:44:22 +09:00
const response = await apiClient.get(`/entity/${table}/options`, {
params: dbParams,
2025-12-19 15:44:38 +09:00
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
2025-12-22 10:44:22 +09:00
} else if (source === "entity" && entityTable) {
2025-12-19 15:44:38 +09:00
// 엔티티(참조 테이블)에서 로드
2025-12-22 10:44:22 +09:00
const valueCol = entityValueColumn || "id";
const labelCol = entityLabelColumn || "name";
const entityParams: Record<string, any> = { value: valueCol, label: labelCol };
if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson;
2025-12-22 10:44:22 +09:00
const response = await apiClient.get(`/entity/${entityTable}/options`, {
params: entityParams,
2025-12-19 15:44:38 +09:00
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
2025-12-22 10:44:22 +09:00
} else if (source === "api" && apiEndpoint) {
2025-12-19 15:44:38 +09:00
// 외부 API에서 로드
2025-12-22 10:44:22 +09:00
const response = await apiClient.get(apiEndpoint);
2025-12-19 15:44:38 +09:00
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 값 조회
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이 없거나 유효하지 않으면 빈 옵션
}
2025-12-19 15:44:38 +09:00
}
// 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);
2025-12-22 10:44:22 +09:00
setOptionsLoaded(true);
2025-12-19 15:44:38 +09:00
} catch (error) {
console.error("옵션 로딩 실패:", error);
setOptions([]);
} finally {
setLoading(false);
}
};
2025-12-22 13:45:08 +09:00
loadOptions();
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
2025-12-19 15:44:38 +09:00
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
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]);
2025-12-19 15:44:38 +09:00
// 모드별 컴포넌트 렌더링
const renderSelect = () => {
if (loading) {
return <div className="h-full flex items-center text-sm text-muted-foreground"> ...</div>;
2025-12-19 15:44:38 +09:00
}
const isDisabled = disabled || readonly;
// 명시적 높이가 있을 때만 style 전달, 없으면 undefined (기본 높이 h-6 사용)
const heightStyle: React.CSSProperties | undefined = componentHeight
? { height: componentHeight }
: undefined;
2025-12-19 15:44:38 +09:00
// 🔧 디자인 모드용: 옵션이 없고 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>
);
}
2025-12-19 15:44:38 +09:00
switch (config.mode) {
case "dropdown":
case "combobox":
2025-12-19 15:44:38 +09:00
return (
<DropdownSelect
options={options}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
2025-12-19 15:44:38 +09:00
placeholder="선택"
searchable={config.mode === "combobox" ? true : config.searchable}
2025-12-19 15:44:38 +09:00
multiple={config.multiple}
maxSelect={config.maxSelect}
allowClear={config.allowClear}
disabled={isDisabled}
style={heightStyle}
2025-12-19 15:44:38 +09:00
/>
);
case "radio":
return (
<RadioSelect
options={options}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
2025-12-19 15:44:38 +09:00
disabled={isDisabled}
/>
);
case "check":
case "checkbox":
2025-12-19 15:44:38 +09:00
return (
<CheckSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
2025-12-19 15:44:38 +09:00
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
case "tag":
return (
<TagSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
2025-12-19 15:44:38 +09:00
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}
/>
);
2025-12-19 15:44:38 +09:00
case "toggle":
return (
<ToggleSelect
options={options}
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
onChange={(v) => handleChangeWithAutoFill(v)}
2025-12-19 15:44:38 +09:00
disabled={isDisabled}
/>
);
case "swap":
return (
<SwapSelect
options={options}
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
onChange={handleChangeWithAutoFill}
2025-12-19 15:44:38 +09:00
maxSelect={config.maxSelect}
disabled={isDisabled}
/>
);
default:
return (
<DropdownSelect
options={options}
value={resolvedValue}
onChange={handleChangeWithAutoFill}
2025-12-19 15:44:38 +09:00
disabled={isDisabled}
style={heightStyle}
2025-12-19 15:44:38 +09:00
/>
);
}
};
const showLabel = label && style?.labelDisplay !== false && style?.labelDisplay !== "false";
2025-12-19 15:44:38 +09:00
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: 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>
) : null;
const selectContent = (
<div
className={cn(
isHorizLabel ? "min-w-0 flex-1" : "h-full w-full",
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 : {}), ...(isHorizLabel ? { height: "100%" } : {}) }}
>
{renderSelect()}
</div>
);
if (isHorizLabel && showLabel) {
return (
<div
ref={ref}
id={id}
className={cn(isDesignMode && "pointer-events-none")}
style={{
width: componentWidth,
height: componentHeight,
display: "flex",
flexDirection: labelPos === "left" ? "row" : "row-reverse",
alignItems: "center",
gap: labelGapValue,
}}
>
{labelElement}
{selectContent}
</div>
);
}
2025-12-19 15:44:38 +09:00
return (
<div
ref={ref}
id={id}
className={cn("relative", isDesignMode && "pointer-events-none")}
2025-12-19 15:44:38 +09:00
style={{
width: componentWidth,
height: componentHeight,
2025-12-19 15:44:38 +09:00
}}
>
{labelElement}
{selectContent}
2025-12-19 15:44:38 +09:00
</div>
);
}
);
V2Select.displayName = "V2Select";
2025-12-19 15:44:38 +09:00
export default V2Select;
2025-12-19 15:44:38 +09:00