ERP-node/frontend/components/common/CascadingDropdown.tsx

189 lines
5.0 KiB
TypeScript
Raw Normal View History

2025-12-10 13:53:44 +09:00
"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;