/** * πŸ”— 연쇄 λ“œλ‘­λ‹€μš΄(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(); const CACHE_TTL = 5 * 60 * 1000; // 5λΆ„ export function useCascadingDropdown({ relationCode, categoryRelationCode, role = "child", // 기본값은 μžμ‹ μ—­ν•  (κΈ°μ‘΄ λ™μž‘ μœ μ§€) config, parentValue, parentValues, initialOptions = [], }: UseCascadingDropdownProps): UseCascadingDropdownResult { const [options, setOptions] = useState(initialOptions); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [relationConfig, setRelationConfig] = useState(null); // 이전 λΆ€λͺ¨ κ°’ 좔적 (λ³€κ²½ κ°μ§€μš©) const prevParentValueRef = useRef(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;