/** * πŸ”— 연쇄 λ“œλ‘­λ‹€μš΄(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(); const CACHE_TTL = 5 * 60 * 1000; // 5λΆ„ export function useCascadingDropdown({ relationCode, role = "child", // 기본값은 μžμ‹ μ—­ν•  (κΈ°μ‘΄ λ™μž‘ μœ μ§€) config, parentValue, 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 || 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;