- Added support for custom border, background, and text styles in V2Input and V2Select components, allowing for greater flexibility in styling based on user-defined configurations. - Updated the input and select components to conditionally apply styles based on the presence of custom properties, improving the overall user experience and visual consistency. - Refactored the FileUploadComponent to handle chunked file uploads, enhancing the file upload process by allowing multiple files to be uploaded in batches, improving performance and user feedback during uploads.
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;
|
|
|