ERP-node/frontend/components/common/HierarchicalCodeSelect.tsx

458 lines
15 KiB
TypeScript

"use client";
/**
* 계층구조 코드 선택 컴포넌트 (1단계, 2단계, 3단계 셀렉트박스)
*
* @example
* // 기본 사용
* <HierarchicalCodeSelect
* categoryCode="PRODUCT_CATEGORY"
* maxDepth={3}
* value={selectedCode}
* onChange={(code) => setSelectedCode(code)}
* />
*
* @example
* // 특정 depth까지만 선택
* <HierarchicalCodeSelect
* categoryCode="LOCATION"
* maxDepth={2}
* value={selectedCode}
* onChange={(code) => setSelectedCode(code)}
* />
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { commonCodeApi } from "@/lib/api/commonCode";
import { Loader2 } from "lucide-react";
import type { CodeInfo } from "@/types/commonCode";
export interface HierarchicalCodeSelectProps {
/** 코드 카테고리 */
categoryCode: string;
/** 최대 깊이 (1, 2, 3) */
maxDepth?: 1 | 2 | 3;
/** 현재 선택된 값 (최종 선택된 코드값) */
value?: string;
/** 값 변경 핸들러 */
onChange?: (codeValue: string, codeInfo?: CodeInfo, fullPath?: CodeInfo[]) => void;
/** 각 단계별 라벨 */
labels?: [string, string?, string?];
/** 각 단계별 placeholder */
placeholders?: [string, string?, string?];
/** 비활성화 */
disabled?: boolean;
/** 필수 입력 */
required?: boolean;
/** 메뉴 OBJID (메뉴 기반 필터링) */
menuObjid?: number;
/** 추가 클래스 */
className?: string;
/** 인라인 표시 (가로 배열) */
inline?: boolean;
}
interface LoadingState {
level1: boolean;
level2: boolean;
level3: boolean;
}
export function HierarchicalCodeSelect({
categoryCode,
maxDepth = 3,
value,
onChange,
labels = ["1단계", "2단계", "3단계"],
placeholders = ["선택하세요", "선택하세요", "선택하세요"],
disabled = false,
required = false,
menuObjid,
className = "",
inline = false,
}: HierarchicalCodeSelectProps) {
// 각 단계별 옵션
const [level1Options, setLevel1Options] = useState<CodeInfo[]>([]);
const [level2Options, setLevel2Options] = useState<CodeInfo[]>([]);
const [level3Options, setLevel3Options] = useState<CodeInfo[]>([]);
// 각 단계별 선택값
const [selectedLevel1, setSelectedLevel1] = useState<string>("");
const [selectedLevel2, setSelectedLevel2] = useState<string>("");
const [selectedLevel3, setSelectedLevel3] = useState<string>("");
// 로딩 상태
const [loading, setLoading] = useState<LoadingState>({
level1: false,
level2: false,
level3: false,
});
// 모든 코드 데이터 (경로 추적용)
const [allCodes, setAllCodes] = useState<CodeInfo[]>([]);
// 1단계 코드 로드 (최상위)
const loadLevel1Codes = useCallback(async () => {
if (!categoryCode) return;
setLoading(prev => ({ ...prev, level1: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
null, // 부모 없음 (최상위)
1, // depth = 1
menuObjid
);
if (response.success && response.data) {
setLevel1Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 1);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("1단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level1: false }));
}
}, [categoryCode, menuObjid]);
// 2단계 코드 로드 (1단계 선택값 기준)
const loadLevel2Codes = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setLevel2Options([]);
return;
}
setLoading(prev => ({ ...prev, level2: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setLevel2Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 2 || (c.parentCodeValue || c.parent_code_value) !== parentCodeValue);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("2단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level2: false }));
}
}, [categoryCode, menuObjid]);
// 3단계 코드 로드 (2단계 선택값 기준)
const loadLevel3Codes = useCallback(async (parentCodeValue: string) => {
if (!categoryCode || !parentCodeValue) {
setLevel3Options([]);
return;
}
setLoading(prev => ({ ...prev, level3: true }));
try {
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
categoryCode,
parentCodeValue,
undefined,
menuObjid
);
if (response.success && response.data) {
setLevel3Options(response.data);
setAllCodes(prev => {
const filtered = prev.filter(c => c.depth !== 3 || (c.parentCodeValue || c.parent_code_value) !== parentCodeValue);
return [...filtered, ...response.data];
});
}
} catch (error) {
console.error("3단계 코드 로드 실패:", error);
} finally {
setLoading(prev => ({ ...prev, level3: false }));
}
}, [categoryCode, menuObjid]);
// 초기 로드 및 카테고리 변경 시
useEffect(() => {
loadLevel1Codes();
setSelectedLevel1("");
setSelectedLevel2("");
setSelectedLevel3("");
setLevel2Options([]);
setLevel3Options([]);
}, [loadLevel1Codes]);
// value prop 변경 시 역추적 (외부에서 값이 설정된 경우)
useEffect(() => {
if (!value || allCodes.length === 0) return;
// 선택된 코드 찾기
const selectedCode = allCodes.find(c =>
(c.codeValue || c.code_value) === value
);
if (!selectedCode) return;
const depth = selectedCode.depth || 1;
if (depth === 1) {
setSelectedLevel1(value);
setSelectedLevel2("");
setSelectedLevel3("");
} else if (depth === 2) {
const parentValue = selectedCode.parentCodeValue || selectedCode.parent_code_value || "";
setSelectedLevel1(parentValue);
setSelectedLevel2(value);
setSelectedLevel3("");
loadLevel2Codes(parentValue);
} else if (depth === 3) {
const parentValue = selectedCode.parentCodeValue || selectedCode.parent_code_value || "";
// 2단계 부모 찾기
const level2Code = allCodes.find(c => (c.codeValue || c.code_value) === parentValue);
const level1Value = level2Code?.parentCodeValue || level2Code?.parent_code_value || "";
setSelectedLevel1(level1Value);
setSelectedLevel2(parentValue);
setSelectedLevel3(value);
loadLevel2Codes(level1Value);
loadLevel3Codes(parentValue);
}
}, [value, allCodes]);
// 1단계 선택 변경
const handleLevel1Change = (codeValue: string) => {
setSelectedLevel1(codeValue);
setSelectedLevel2("");
setSelectedLevel3("");
setLevel2Options([]);
setLevel3Options([]);
if (codeValue && maxDepth > 1) {
loadLevel2Codes(codeValue);
}
// 최대 깊이가 1이면 즉시 onChange 호출
if (maxDepth === 1 && onChange) {
const selectedCodeInfo = level1Options.find(c => (c.codeValue || c.code_value) === codeValue);
onChange(codeValue, selectedCodeInfo, selectedCodeInfo ? [selectedCodeInfo] : []);
}
};
// 2단계 선택 변경
const handleLevel2Change = (codeValue: string) => {
setSelectedLevel2(codeValue);
setSelectedLevel3("");
setLevel3Options([]);
if (codeValue && maxDepth > 2) {
loadLevel3Codes(codeValue);
}
// 최대 깊이가 2이면 onChange 호출
if (maxDepth === 2 && onChange) {
const level1Code = level1Options.find(c => (c.codeValue || c.code_value) === selectedLevel1);
const level2Code = level2Options.find(c => (c.codeValue || c.code_value) === codeValue);
const fullPath = [level1Code, level2Code].filter(Boolean) as CodeInfo[];
onChange(codeValue, level2Code, fullPath);
}
};
// 3단계 선택 변경
const handleLevel3Change = (codeValue: string) => {
setSelectedLevel3(codeValue);
if (onChange) {
const level1Code = level1Options.find(c => (c.codeValue || c.code_value) === selectedLevel1);
const level2Code = level2Options.find(c => (c.codeValue || c.code_value) === selectedLevel2);
const level3Code = level3Options.find(c => (c.codeValue || c.code_value) === codeValue);
const fullPath = [level1Code, level2Code, level3Code].filter(Boolean) as CodeInfo[];
onChange(codeValue, level3Code, fullPath);
}
};
// 최종 선택값 계산
const finalValue = useMemo(() => {
if (maxDepth >= 3 && selectedLevel3) return selectedLevel3;
if (maxDepth >= 2 && selectedLevel2) return selectedLevel2;
if (selectedLevel1) return selectedLevel1;
return "";
}, [maxDepth, selectedLevel1, selectedLevel2, selectedLevel3]);
// 최종 선택값이 변경되면 onChange 호출 (maxDepth 제한 없이)
useEffect(() => {
if (!onChange) return;
// 현재 선택된 깊이 확인
if (selectedLevel3 && maxDepth >= 3) {
// 3단계까지 선택됨
return; // handleLevel3Change에서 처리
}
if (selectedLevel2 && maxDepth >= 2 && !selectedLevel3 && level3Options.length === 0) {
// 2단계까지 선택되고 3단계 옵션이 없음
const level1Code = level1Options.find(c => (c.codeValue || c.code_value) === selectedLevel1);
const level2Code = level2Options.find(c => (c.codeValue || c.code_value) === selectedLevel2);
const fullPath = [level1Code, level2Code].filter(Boolean) as CodeInfo[];
onChange(selectedLevel2, level2Code, fullPath);
} else if (selectedLevel1 && maxDepth >= 1 && !selectedLevel2 && level2Options.length === 0) {
// 1단계까지 선택되고 2단계 옵션이 없음
const level1Code = level1Options.find(c => (c.codeValue || c.code_value) === selectedLevel1);
onChange(selectedLevel1, level1Code, level1Code ? [level1Code] : []);
}
}, [level2Options, level3Options]);
const containerClass = inline
? "flex flex-wrap gap-4 items-end"
: "space-y-4";
const selectItemClass = inline
? "flex-1 min-w-[150px] space-y-1"
: "space-y-1";
return (
<div className={`${containerClass} ${className}`}>
{/* 1단계 선택 */}
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[0]}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
<Select
value={selectedLevel1}
onValueChange={handleLevel1Change}
disabled={disabled || loading.level1}
>
<SelectTrigger className="h-9 text-sm">
{loading.level1 ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue placeholder={placeholders[0]} />
)}
</SelectTrigger>
<SelectContent>
{level1Options.map((code) => {
const codeValue = code.codeValue || code.code_value || "";
const codeName = code.codeName || code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* 2단계 선택 */}
{maxDepth >= 2 && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[1] || "2단계"}
</Label>
<Select
value={selectedLevel2}
onValueChange={handleLevel2Change}
disabled={disabled || loading.level2 || !selectedLevel1}
>
<SelectTrigger className="h-9 text-sm">
{loading.level2 ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue placeholder={selectedLevel1 ? (placeholders[1] || "선택하세요") : "1단계를 먼저 선택하세요"} />
)}
</SelectTrigger>
<SelectContent>
{level2Options.length === 0 && selectedLevel1 && !loading.level2 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
level2Options.map((code) => {
const codeValue = code.codeValue || code.code_value || "";
const codeName = code.codeName || code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
)}
{/* 3단계 선택 */}
{maxDepth >= 3 && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">
{labels[2] || "3단계"}
</Label>
<Select
value={selectedLevel3}
onValueChange={handleLevel3Change}
disabled={disabled || loading.level3 || !selectedLevel2}
>
<SelectTrigger className="h-9 text-sm">
{loading.level3 ? (
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
<span className="text-muted-foreground"> ...</span>
</div>
) : (
<SelectValue placeholder={selectedLevel2 ? (placeholders[2] || "선택하세요") : "2단계를 먼저 선택하세요"} />
)}
</SelectTrigger>
<SelectContent>
{level3Options.length === 0 && selectedLevel2 && !loading.level3 ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
level3Options.map((code) => {
const codeValue = code.codeValue || code.code_value || "";
const codeName = code.codeName || code.code_name || "";
return (
<SelectItem key={codeValue} value={codeValue}>
{codeName}
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
)}
</div>
);
}
export default HierarchicalCodeSelect;