458 lines
15 KiB
TypeScript
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;
|
|
|
|
|