547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
/**
|
|
* 🔗 연쇄 드롭다운(Cascading Dropdown) 훅
|
|
*
|
|
* 부모 필드의 값에 따라 자식 드롭다운의 옵션을 동적으로 로드합니다.
|
|
*
|
|
* @example
|
|
* // 방법 1: 관계 코드 사용 (권장)
|
|
* const { options, loading, error } = useCascadingDropdown({
|
|
* relationCode: "WAREHOUSE_LOCATION",
|
|
* parentValue: formData.warehouse_code,
|
|
* });
|
|
*
|
|
* @example
|
|
* // 방법 2: 직접 설정 (레거시)
|
|
* const { options, loading, error } = useCascadingDropdown({
|
|
* config: {
|
|
* enabled: true,
|
|
* parentField: "warehouse_code",
|
|
* sourceTable: "warehouse_location",
|
|
* parentKeyColumn: "warehouse_id",
|
|
* valueColumn: "location_code",
|
|
* labelColumn: "location_name",
|
|
* },
|
|
* parentValue: formData.warehouse_code,
|
|
* });
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
|
|
|
export interface CascadingOption {
|
|
value: string;
|
|
label: string;
|
|
[key: string]: any; // 추가 데이터
|
|
}
|
|
|
|
export interface UseCascadingDropdownProps {
|
|
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
|
relationCode?: string;
|
|
/** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */
|
|
categoryRelationCode?: string;
|
|
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
|
role?: "parent" | "child";
|
|
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
|
config?: CascadingDropdownConfig;
|
|
/** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */
|
|
parentValue?: string | number | null;
|
|
/** 🆕 다중 부모값 (배열) - parentValue보다 우선 */
|
|
parentValues?: (string | number)[];
|
|
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
|
initialOptions?: CascadingOption[];
|
|
}
|
|
|
|
export interface UseCascadingDropdownResult {
|
|
/** 드롭다운 옵션 목록 */
|
|
options: CascadingOption[];
|
|
/** 로딩 상태 */
|
|
loading: boolean;
|
|
/** 에러 메시지 */
|
|
error: string | null;
|
|
/** 옵션 새로고침 */
|
|
refresh: () => void;
|
|
/** 옵션 초기화 */
|
|
clear: () => void;
|
|
/** 특정 값의 라벨 가져오기 */
|
|
getLabelByValue: (value: string) => string | undefined;
|
|
/** API에서 가져온 관계 설정 (relationCode 사용 시) */
|
|
relationConfig: CascadingDropdownConfig | null;
|
|
}
|
|
|
|
// 글로벌 캐시 (컴포넌트 간 공유)
|
|
const optionsCache = new Map<string, { options: CascadingOption[]; timestamp: number }>();
|
|
const CACHE_TTL = 5 * 60 * 1000; // 5분
|
|
|
|
export function useCascadingDropdown({
|
|
relationCode,
|
|
categoryRelationCode,
|
|
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
|
config,
|
|
parentValue,
|
|
parentValues,
|
|
initialOptions = [],
|
|
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
|
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [relationConfig, setRelationConfig] = useState<CascadingDropdownConfig | null>(null);
|
|
|
|
// 이전 부모 값 추적 (변경 감지용)
|
|
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
|
|
|
// 관계 코드 또는 직접 설정 중 하나라도 있는지 확인
|
|
const isEnabled = !!relationCode || !!categoryRelationCode || config?.enabled;
|
|
|
|
// 유효한 부모값 배열 계산 (다중 또는 단일) - 메모이제이션으로 불필요한 리렌더 방지
|
|
const effectiveParentValues: string[] = useMemo(() => {
|
|
if (parentValues && parentValues.length > 0) {
|
|
return parentValues.map(v => String(v));
|
|
}
|
|
if (parentValue !== null && parentValue !== undefined) {
|
|
return [String(parentValue)];
|
|
}
|
|
return [];
|
|
}, [parentValues, parentValue]);
|
|
|
|
// 부모값 배열의 문자열 키 (의존성 비교용)
|
|
const parentValuesKey = useMemo(() => JSON.stringify(effectiveParentValues), [effectiveParentValues]);
|
|
|
|
// 캐시 키 생성
|
|
const getCacheKey = useCallback(() => {
|
|
if (categoryRelationCode) {
|
|
// 카테고리 값 연쇄관계
|
|
if (role === "parent") {
|
|
return `category-value:${categoryRelationCode}:parent:all`;
|
|
}
|
|
if (effectiveParentValues.length === 0) return null;
|
|
const sortedValues = [...effectiveParentValues].sort().join(',');
|
|
return `category-value:${categoryRelationCode}:child:${sortedValues}`;
|
|
}
|
|
if (relationCode) {
|
|
// 부모 역할: 전체 옵션 캐시
|
|
if (role === "parent") {
|
|
return `relation:${relationCode}:parent:all`;
|
|
}
|
|
// 자식 역할: 부모 값별 캐시 (다중 부모값 지원)
|
|
if (effectiveParentValues.length === 0) return null;
|
|
const sortedValues = [...effectiveParentValues].sort().join(',');
|
|
return `relation:${relationCode}:child:${sortedValues}`;
|
|
}
|
|
if (config) {
|
|
if (effectiveParentValues.length === 0) return null;
|
|
const sortedValues = [...effectiveParentValues].sort().join(',');
|
|
return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`;
|
|
}
|
|
return null;
|
|
}, [categoryRelationCode, relationCode, role, config, effectiveParentValues]);
|
|
|
|
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
|
const loadParentOptions = useCallback(async () => {
|
|
if (!relationCode) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = getCacheKey();
|
|
|
|
// 캐시 확인
|
|
if (cacheKey) {
|
|
const cached = optionsCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
setOptions(cached.options);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 부모 역할용 API 호출 (전체 옵션)
|
|
const response = await apiClient.get(`/cascading-relations/parent-options/${relationCode}`);
|
|
|
|
if (response.data?.success) {
|
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
|
setOptions(loadedOptions);
|
|
|
|
// 캐시 저장
|
|
if (cacheKey) {
|
|
optionsCache.set(cacheKey, {
|
|
options: loadedOptions,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
console.log("✅ Parent options 로드 완료:", {
|
|
relationCode,
|
|
count: loadedOptions.length,
|
|
});
|
|
} else {
|
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
|
}
|
|
} catch (err: any) {
|
|
console.error("❌ Parent options 로드 실패:", err);
|
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [relationCode, getCacheKey]);
|
|
|
|
// 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원
|
|
const loadChildOptions = useCallback(async () => {
|
|
if (!relationCode || effectiveParentValues.length === 0) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = getCacheKey();
|
|
|
|
// 캐시 확인
|
|
if (cacheKey) {
|
|
const cached = optionsCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
setOptions(cached.options);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 다중 부모값 지원: parentValues 파라미터 사용
|
|
let url: string;
|
|
if (effectiveParentValues.length === 1) {
|
|
// 단일 값 (기존 호환)
|
|
url = `/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
|
|
} else {
|
|
// 다중 값
|
|
const parentValuesParam = effectiveParentValues.join(',');
|
|
url = `/cascading-relations/options/${relationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
|
}
|
|
|
|
const response = await apiClient.get(url);
|
|
|
|
if (response.data?.success) {
|
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
|
setOptions(loadedOptions);
|
|
|
|
// 캐시 저장
|
|
if (cacheKey) {
|
|
optionsCache.set(cacheKey, {
|
|
options: loadedOptions,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
console.log("✅ Child options 로드 완료 (다중 부모값 지원):", {
|
|
relationCode,
|
|
parentValues: effectiveParentValues,
|
|
count: loadedOptions.length,
|
|
});
|
|
} else {
|
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
|
}
|
|
} catch (err: any) {
|
|
console.error("❌ Child options 로드 실패:", err);
|
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [relationCode, effectiveParentValues, getCacheKey]);
|
|
|
|
// 🆕 카테고리 값 연쇄관계 - 부모 옵션 로드
|
|
const loadCategoryParentOptions = useCallback(async () => {
|
|
if (!categoryRelationCode) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = getCacheKey();
|
|
|
|
// 캐시 확인
|
|
if (cacheKey) {
|
|
const cached = optionsCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
setOptions(cached.options);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const response = await apiClient.get(`/category-value-cascading/parent-options/${categoryRelationCode}`);
|
|
|
|
if (response.data?.success) {
|
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
|
setOptions(loadedOptions);
|
|
|
|
// 캐시 저장
|
|
if (cacheKey) {
|
|
optionsCache.set(cacheKey, {
|
|
options: loadedOptions,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
console.log("✅ Category parent options 로드 완료:", {
|
|
categoryRelationCode,
|
|
count: loadedOptions.length,
|
|
});
|
|
} else {
|
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
|
}
|
|
} catch (err: any) {
|
|
console.error("❌ Category parent options 로드 실패:", err);
|
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [categoryRelationCode, getCacheKey]);
|
|
|
|
// 🆕 카테고리 값 연쇄관계 - 자식 옵션 로드 (다중 부모값 지원)
|
|
const loadCategoryChildOptions = useCallback(async () => {
|
|
if (!categoryRelationCode || effectiveParentValues.length === 0) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = getCacheKey();
|
|
|
|
// 캐시 확인
|
|
if (cacheKey) {
|
|
const cached = optionsCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
setOptions(cached.options);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 다중 부모값 지원
|
|
let url: string;
|
|
if (effectiveParentValues.length === 1) {
|
|
url = `/category-value-cascading/options/${categoryRelationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`;
|
|
} else {
|
|
const parentValuesParam = effectiveParentValues.join(',');
|
|
url = `/category-value-cascading/options/${categoryRelationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`;
|
|
}
|
|
|
|
const response = await apiClient.get(url);
|
|
|
|
if (response.data?.success) {
|
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
|
setOptions(loadedOptions);
|
|
|
|
// 캐시 저장
|
|
if (cacheKey) {
|
|
optionsCache.set(cacheKey, {
|
|
options: loadedOptions,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
console.log("✅ Category child options 로드 완료 (다중 부모값 지원):", {
|
|
categoryRelationCode,
|
|
parentValues: effectiveParentValues,
|
|
count: loadedOptions.length,
|
|
});
|
|
} else {
|
|
throw new Error(response.data?.message || "옵션 로드 실패");
|
|
}
|
|
} catch (err: any) {
|
|
console.error("❌ Category child options 로드 실패:", err);
|
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [categoryRelationCode, effectiveParentValues, getCacheKey]);
|
|
|
|
// 옵션 로드 (직접 설정 방식 - 레거시)
|
|
const loadOptionsByConfig = useCallback(async () => {
|
|
if (!config?.enabled || !parentValue) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
const cacheKey = getCacheKey();
|
|
|
|
// 캐시 확인
|
|
if (cacheKey) {
|
|
const cached = optionsCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
setOptions(cached.options);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// API 호출하여 옵션 로드
|
|
const response = await apiClient.post(`/table-management/tables/${config.sourceTable}/data`, {
|
|
page: 1,
|
|
size: 1000, // 충분히 큰 값
|
|
search: {
|
|
[config.parentKeyColumn]: parentValue,
|
|
...config.additionalFilters,
|
|
},
|
|
autoFilter: true,
|
|
});
|
|
|
|
const items = response.data?.data?.data || response.data?.data || [];
|
|
|
|
const loadedOptions: CascadingOption[] = items.map((item: any) => ({
|
|
value: String(item[config.valueColumn] || ""),
|
|
label: String(item[config.labelColumn] || item[config.valueColumn] || ""),
|
|
...item, // 전체 데이터 보존
|
|
}));
|
|
|
|
setOptions(loadedOptions);
|
|
|
|
// 캐시 저장
|
|
if (cacheKey) {
|
|
optionsCache.set(cacheKey, {
|
|
options: loadedOptions,
|
|
timestamp: Date.now(),
|
|
});
|
|
}
|
|
|
|
console.log("✅ Cascading options 로드 완료 (직접설정):", {
|
|
sourceTable: config.sourceTable,
|
|
parentValue,
|
|
count: loadedOptions.length,
|
|
});
|
|
} catch (err: any) {
|
|
console.error("❌ Cascading options 로드 실패:", err);
|
|
setError(err.message || "옵션을 불러오는 데 실패했습니다.");
|
|
setOptions([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [config, parentValue, getCacheKey]);
|
|
|
|
// 통합 로드 함수
|
|
const loadOptions = useCallback(() => {
|
|
// 카테고리 값 연쇄관계 우선
|
|
if (categoryRelationCode) {
|
|
if (role === "parent") {
|
|
loadCategoryParentOptions();
|
|
} else {
|
|
loadCategoryChildOptions();
|
|
}
|
|
} else if (relationCode) {
|
|
// 역할에 따라 다른 로드 함수 호출
|
|
if (role === "parent") {
|
|
loadParentOptions();
|
|
} else {
|
|
loadChildOptions();
|
|
}
|
|
} else if (config?.enabled) {
|
|
loadOptionsByConfig();
|
|
} else {
|
|
setOptions([]);
|
|
}
|
|
}, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
|
|
|
// 옵션 로드 트리거
|
|
useEffect(() => {
|
|
if (!isEnabled) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 부모 역할: 즉시 전체 옵션 로드 (최초 1회만)
|
|
if (role === "parent") {
|
|
loadOptions();
|
|
return;
|
|
}
|
|
|
|
// 자식 역할: 부모 값이 있을 때만 로드
|
|
// 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교
|
|
const prevParentKey = prevParentValueRef.current;
|
|
|
|
if (prevParentKey !== parentValuesKey) {
|
|
prevParentValueRef.current = parentValuesKey as any;
|
|
|
|
if (effectiveParentValues.length > 0) {
|
|
loadOptions();
|
|
} else {
|
|
// 부모 값이 없으면 옵션 초기화
|
|
setOptions([]);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isEnabled, role, parentValuesKey]);
|
|
|
|
// 옵션 새로고침
|
|
const refresh = useCallback(() => {
|
|
const cacheKey = getCacheKey();
|
|
if (cacheKey) {
|
|
optionsCache.delete(cacheKey);
|
|
}
|
|
loadOptions();
|
|
}, [getCacheKey, loadOptions]);
|
|
|
|
// 옵션 초기화
|
|
const clear = useCallback(() => {
|
|
setOptions([]);
|
|
setError(null);
|
|
}, []);
|
|
|
|
// 값으로 라벨 찾기
|
|
const getLabelByValue = useCallback((value: string): string | undefined => {
|
|
const option = options.find((opt) => opt.value === value);
|
|
return option?.label;
|
|
}, [options]);
|
|
|
|
return {
|
|
options,
|
|
loading,
|
|
error,
|
|
refresh,
|
|
clear,
|
|
getLabelByValue,
|
|
relationConfig: relationConfig || config || null,
|
|
};
|
|
}
|
|
|
|
// 캐시 관리 유틸리티
|
|
export const cascadingDropdownCache = {
|
|
/** 특정 테이블의 캐시 삭제 */
|
|
invalidateTable: (tableName: string) => {
|
|
const keysToDelete: string[] = [];
|
|
optionsCache.forEach((_, key) => {
|
|
if (key.startsWith(`${tableName}:`)) {
|
|
keysToDelete.push(key);
|
|
}
|
|
});
|
|
keysToDelete.forEach((key) => optionsCache.delete(key));
|
|
},
|
|
|
|
/** 모든 캐시 삭제 */
|
|
invalidateAll: () => {
|
|
optionsCache.clear();
|
|
},
|
|
|
|
/** 캐시 상태 확인 */
|
|
getStats: () => ({
|
|
size: optionsCache.size,
|
|
keys: Array.from(optionsCache.keys()),
|
|
}),
|
|
};
|
|
|
|
export default useCascadingDropdown;
|
|
|