189 lines
5.0 KiB
TypeScript
189 lines
5.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 🔗 연쇄 드롭다운(Cascading Dropdown) 컴포넌트
|
|
*
|
|
* 부모 필드의 값에 따라 옵션이 동적으로 변경되는 드롭다운입니다.
|
|
*
|
|
* @example
|
|
* // 창고 → 위치 연쇄 드롭다운
|
|
* <CascadingDropdown
|
|
* config={{
|
|
* enabled: true,
|
|
* parentField: "warehouse_code",
|
|
* sourceTable: "warehouse_location",
|
|
* parentKeyColumn: "warehouse_id",
|
|
* valueColumn: "location_code",
|
|
* labelColumn: "location_name",
|
|
* }}
|
|
* parentValue={formData.warehouse_code}
|
|
* value={formData.location_code}
|
|
* onChange={(value) => setFormData({ ...formData, location_code: value })}
|
|
* placeholder="위치 선택"
|
|
* />
|
|
*/
|
|
|
|
import React, { useEffect, useRef } from "react";
|
|
import { Loader2 } from "lucide-react";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { useCascadingDropdown, CascadingOption } from "@/hooks/useCascadingDropdown";
|
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface CascadingDropdownProps {
|
|
/** 연쇄 드롭다운 설정 */
|
|
config: CascadingDropdownConfig;
|
|
|
|
/** 부모 필드의 현재 값 */
|
|
parentValue?: string | number | null;
|
|
|
|
/** 현재 선택된 값 */
|
|
value?: string;
|
|
|
|
/** 값 변경 핸들러 */
|
|
onChange?: (value: string, option?: CascadingOption) => void;
|
|
|
|
/** 플레이스홀더 */
|
|
placeholder?: string;
|
|
|
|
/** 비활성화 여부 */
|
|
disabled?: boolean;
|
|
|
|
/** 읽기 전용 여부 */
|
|
readOnly?: boolean;
|
|
|
|
/** 필수 입력 여부 */
|
|
required?: boolean;
|
|
|
|
/** 추가 클래스명 */
|
|
className?: string;
|
|
|
|
/** 추가 스타일 */
|
|
style?: React.CSSProperties;
|
|
|
|
/** 검색 가능 여부 */
|
|
searchable?: boolean;
|
|
}
|
|
|
|
export function CascadingDropdown({
|
|
config,
|
|
parentValue,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
disabled = false,
|
|
readOnly = false,
|
|
required = false,
|
|
className,
|
|
style,
|
|
searchable = false,
|
|
}: CascadingDropdownProps) {
|
|
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
|
|
|
const {
|
|
options,
|
|
loading,
|
|
error,
|
|
getLabelByValue,
|
|
} = useCascadingDropdown({
|
|
config,
|
|
parentValue,
|
|
});
|
|
|
|
// 부모 값 변경 시 자동 초기화
|
|
useEffect(() => {
|
|
if (config.clearOnParentChange !== false) {
|
|
if (prevParentValueRef.current !== undefined &&
|
|
prevParentValueRef.current !== parentValue &&
|
|
value) {
|
|
// 부모 값이 변경되면 현재 값 초기화
|
|
onChange?.("");
|
|
}
|
|
}
|
|
prevParentValueRef.current = parentValue;
|
|
}, [parentValue, config.clearOnParentChange, value, onChange]);
|
|
|
|
// 부모 값이 없을 때 메시지
|
|
const getPlaceholder = () => {
|
|
if (!parentValue) {
|
|
return config.emptyParentMessage || "상위 항목을 먼저 선택하세요";
|
|
}
|
|
if (loading) {
|
|
return config.loadingMessage || "로딩 중...";
|
|
}
|
|
if (options.length === 0) {
|
|
return config.noOptionsMessage || "선택 가능한 항목이 없습니다";
|
|
}
|
|
return placeholder || "선택하세요";
|
|
};
|
|
|
|
// 값 변경 핸들러
|
|
const handleValueChange = (newValue: string) => {
|
|
if (readOnly) return;
|
|
|
|
const selectedOption = options.find((opt) => opt.value === newValue);
|
|
onChange?.(newValue, selectedOption);
|
|
};
|
|
|
|
// 비활성화 상태 계산
|
|
const isDisabled = disabled || readOnly || !parentValue || loading;
|
|
|
|
return (
|
|
<div className={cn("relative", className)} style={style}>
|
|
<Select
|
|
value={value || ""}
|
|
onValueChange={handleValueChange}
|
|
disabled={isDisabled}
|
|
>
|
|
<SelectTrigger
|
|
className={cn(
|
|
"w-full",
|
|
!parentValue && "text-muted-foreground",
|
|
error && "border-destructive"
|
|
)}
|
|
>
|
|
{loading ? (
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
<span className="text-muted-foreground text-sm">
|
|
{config.loadingMessage || "로딩 중..."}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<SelectValue placeholder={getPlaceholder()} />
|
|
)}
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.length === 0 ? (
|
|
<div className="text-muted-foreground px-2 py-4 text-center text-sm">
|
|
{!parentValue
|
|
? config.emptyParentMessage || "상위 항목을 먼저 선택하세요"
|
|
: config.noOptionsMessage || "선택 가능한 항목이 없습니다"}
|
|
</div>
|
|
) : (
|
|
options.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))
|
|
)}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 에러 메시지 */}
|
|
{error && (
|
|
<p className="text-destructive mt-1 text-xs">{error}</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CascadingDropdown;
|
|
|