ERP-node/frontend/hooks/useCascadingDropdown.ts

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;