381 lines
11 KiB
TypeScript
381 lines
11 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 } 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;
|
|
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
|
role?: "parent" | "child";
|
|
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
|
config?: CascadingDropdownConfig;
|
|
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
|
|
parentValue?: string | number | null;
|
|
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
|
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,
|
|
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
|
config,
|
|
parentValue,
|
|
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 || config?.enabled;
|
|
|
|
// 캐시 키 생성
|
|
const getCacheKey = useCallback(() => {
|
|
if (relationCode) {
|
|
// 부모 역할: 전체 옵션 캐시
|
|
if (role === "parent") {
|
|
return `relation:${relationCode}:parent:all`;
|
|
}
|
|
// 자식 역할: 부모 값별 캐시
|
|
if (!parentValue) return null;
|
|
return `relation:${relationCode}:child:${parentValue}`;
|
|
}
|
|
if (config) {
|
|
if (!parentValue) return null;
|
|
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
|
|
}
|
|
return null;
|
|
}, [relationCode, role, config, parentValue]);
|
|
|
|
// 🆕 부모 역할 옵션 로드 (관계의 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 || !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.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
|
|
|
|
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,
|
|
parentValue,
|
|
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, parentValue, 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 (relationCode) {
|
|
// 역할에 따라 다른 로드 함수 호출
|
|
if (role === "parent") {
|
|
loadParentOptions();
|
|
} else {
|
|
loadChildOptions();
|
|
}
|
|
} else if (config?.enabled) {
|
|
loadOptionsByConfig();
|
|
} else {
|
|
setOptions([]);
|
|
}
|
|
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
|
|
|
// 옵션 로드 트리거
|
|
useEffect(() => {
|
|
if (!isEnabled) {
|
|
setOptions([]);
|
|
return;
|
|
}
|
|
|
|
// 부모 역할: 즉시 전체 옵션 로드
|
|
if (role === "parent") {
|
|
loadOptions();
|
|
return;
|
|
}
|
|
|
|
// 자식 역할: 부모 값이 있을 때만 로드
|
|
// 부모 값이 변경되었는지 확인
|
|
const parentChanged = prevParentValueRef.current !== parentValue;
|
|
prevParentValueRef.current = parentValue;
|
|
|
|
if (parentValue) {
|
|
loadOptions();
|
|
} else {
|
|
// 부모 값이 없으면 옵션 초기화
|
|
setOptions([]);
|
|
}
|
|
}, [isEnabled, role, parentValue, loadOptions]);
|
|
|
|
// 옵션 새로고침
|
|
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;
|
|
|