공통코드 계층구조 구현
This commit is contained in:
457
frontend/components/common/HierarchicalCodeSelect.tsx
Normal file
457
frontend/components/common/HierarchicalCodeSelect.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
"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;
|
||||
|
||||
|
||||
389
frontend/components/common/MultiColumnHierarchySelect.tsx
Normal file
389
frontend/components/common/MultiColumnHierarchySelect.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 멀티 컬럼 계층구조 선택 컴포넌트
|
||||
*
|
||||
* 대분류, 중분류, 소분류를 각각 다른 컬럼에 저장하는 계층구조 선택 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* <MultiColumnHierarchySelect
|
||||
* categoryCode="PRODUCT_CATEGORY"
|
||||
* columns={{
|
||||
* large: { columnName: "category_large", label: "대분류" },
|
||||
* medium: { columnName: "category_medium", label: "중분류" },
|
||||
* small: { columnName: "category_small", label: "소분류" },
|
||||
* }}
|
||||
* values={{
|
||||
* large: formData.category_large,
|
||||
* medium: formData.category_medium,
|
||||
* small: formData.category_small,
|
||||
* }}
|
||||
* onChange={(role, columnName, value) => {
|
||||
* setFormData(prev => ({ ...prev, [columnName]: value }));
|
||||
* }}
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } 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 type HierarchyRole = "large" | "medium" | "small";
|
||||
|
||||
export interface HierarchyColumnConfig {
|
||||
columnName: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface MultiColumnHierarchySelectProps {
|
||||
/** 코드 카테고리 */
|
||||
categoryCode: string;
|
||||
|
||||
/** 각 계층별 컬럼 설정 */
|
||||
columns: {
|
||||
large?: HierarchyColumnConfig;
|
||||
medium?: HierarchyColumnConfig;
|
||||
small?: HierarchyColumnConfig;
|
||||
};
|
||||
|
||||
/** 현재 값들 */
|
||||
values?: {
|
||||
large?: string;
|
||||
medium?: string;
|
||||
small?: string;
|
||||
};
|
||||
|
||||
/** 값 변경 핸들러 (역할, 컬럼명, 값) */
|
||||
onChange?: (role: HierarchyRole, columnName: string, value: string) => void;
|
||||
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
|
||||
/** 메뉴 OBJID */
|
||||
menuObjid?: number;
|
||||
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
|
||||
/** 인라인 표시 (가로 배열) */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
interface LoadingState {
|
||||
large: boolean;
|
||||
medium: boolean;
|
||||
small: boolean;
|
||||
}
|
||||
|
||||
export function MultiColumnHierarchySelect({
|
||||
categoryCode,
|
||||
columns,
|
||||
values = {},
|
||||
onChange,
|
||||
disabled = false,
|
||||
menuObjid,
|
||||
className = "",
|
||||
inline = false,
|
||||
}: MultiColumnHierarchySelectProps) {
|
||||
// 각 단계별 옵션
|
||||
const [largeOptions, setLargeOptions] = useState<CodeInfo[]>([]);
|
||||
const [mediumOptions, setMediumOptions] = useState<CodeInfo[]>([]);
|
||||
const [smallOptions, setSmallOptions] = useState<CodeInfo[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState<LoadingState>({
|
||||
large: false,
|
||||
medium: false,
|
||||
small: false,
|
||||
});
|
||||
|
||||
// 대분류 로드 (depth = 1)
|
||||
const loadLargeOptions = useCallback(async () => {
|
||||
if (!categoryCode) return;
|
||||
|
||||
setLoading(prev => ({ ...prev, large: true }));
|
||||
try {
|
||||
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
null, // 부모 없음
|
||||
1, // depth = 1
|
||||
menuObjid
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setLargeOptions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("대분류 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, large: false }));
|
||||
}
|
||||
}, [categoryCode, menuObjid]);
|
||||
|
||||
// 중분류 로드 (대분류 선택 기준)
|
||||
const loadMediumOptions = useCallback(async (parentCodeValue: string) => {
|
||||
if (!categoryCode || !parentCodeValue) {
|
||||
setMediumOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, medium: true }));
|
||||
try {
|
||||
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentCodeValue,
|
||||
undefined,
|
||||
menuObjid
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setMediumOptions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중분류 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, medium: false }));
|
||||
}
|
||||
}, [categoryCode, menuObjid]);
|
||||
|
||||
// 소분류 로드 (중분류 선택 기준)
|
||||
const loadSmallOptions = useCallback(async (parentCodeValue: string) => {
|
||||
if (!categoryCode || !parentCodeValue) {
|
||||
setSmallOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, small: true }));
|
||||
try {
|
||||
const response = await commonCodeApi.hierarchy.getHierarchicalCodes(
|
||||
categoryCode,
|
||||
parentCodeValue,
|
||||
undefined,
|
||||
menuObjid
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setSmallOptions(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소분류 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, small: false }));
|
||||
}
|
||||
}, [categoryCode, menuObjid]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
loadLargeOptions();
|
||||
}, [loadLargeOptions]);
|
||||
|
||||
// 대분류 값이 있으면 중분류 로드
|
||||
useEffect(() => {
|
||||
if (values.large) {
|
||||
loadMediumOptions(values.large);
|
||||
} else {
|
||||
setMediumOptions([]);
|
||||
setSmallOptions([]);
|
||||
}
|
||||
}, [values.large, loadMediumOptions]);
|
||||
|
||||
// 중분류 값이 있으면 소분류 로드
|
||||
useEffect(() => {
|
||||
if (values.medium) {
|
||||
loadSmallOptions(values.medium);
|
||||
} else {
|
||||
setSmallOptions([]);
|
||||
}
|
||||
}, [values.medium, loadSmallOptions]);
|
||||
|
||||
// 대분류 변경
|
||||
const handleLargeChange = (codeValue: string) => {
|
||||
const columnName = columns.large?.columnName || "";
|
||||
if (onChange && columnName) {
|
||||
onChange("large", columnName, codeValue);
|
||||
}
|
||||
|
||||
// 하위 값 초기화
|
||||
if (columns.medium?.columnName && onChange) {
|
||||
onChange("medium", columns.medium.columnName, "");
|
||||
}
|
||||
if (columns.small?.columnName && onChange) {
|
||||
onChange("small", columns.small.columnName, "");
|
||||
}
|
||||
};
|
||||
|
||||
// 중분류 변경
|
||||
const handleMediumChange = (codeValue: string) => {
|
||||
const columnName = columns.medium?.columnName || "";
|
||||
if (onChange && columnName) {
|
||||
onChange("medium", columnName, codeValue);
|
||||
}
|
||||
|
||||
// 하위 값 초기화
|
||||
if (columns.small?.columnName && onChange) {
|
||||
onChange("small", columns.small.columnName, "");
|
||||
}
|
||||
};
|
||||
|
||||
// 소분류 변경
|
||||
const handleSmallChange = (codeValue: string) => {
|
||||
const columnName = columns.small?.columnName || "";
|
||||
if (onChange && columnName) {
|
||||
onChange("small", columnName, codeValue);
|
||||
}
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
// 설정된 컬럼만 렌더링
|
||||
const hasLarge = !!columns.large;
|
||||
const hasMedium = !!columns.medium;
|
||||
const hasSmall = !!columns.small;
|
||||
|
||||
return (
|
||||
<div className={`${containerClass} ${className}`}>
|
||||
{/* 대분류 */}
|
||||
{hasLarge && (
|
||||
<div className={selectItemClass}>
|
||||
<Label className="text-xs font-medium">
|
||||
{columns.large?.label || "대분류"}
|
||||
</Label>
|
||||
<Select
|
||||
value={values.large || ""}
|
||||
onValueChange={handleLargeChange}
|
||||
disabled={disabled || loading.large}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
{loading.large ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={columns.large?.placeholder || "대분류 선택"} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{largeOptions.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>
|
||||
)}
|
||||
|
||||
{/* 중분류 */}
|
||||
{hasMedium && (
|
||||
<div className={selectItemClass}>
|
||||
<Label className="text-xs font-medium">
|
||||
{columns.medium?.label || "중분류"}
|
||||
</Label>
|
||||
<Select
|
||||
value={values.medium || ""}
|
||||
onValueChange={handleMediumChange}
|
||||
disabled={disabled || loading.medium || !values.large}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
{loading.medium ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={values.large
|
||||
? (columns.medium?.placeholder || "중분류 선택")
|
||||
: "대분류를 먼저 선택하세요"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mediumOptions.length === 0 && values.large && !loading.medium ? (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
하위 항목이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
mediumOptions.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>
|
||||
)}
|
||||
|
||||
{/* 소분류 */}
|
||||
{hasSmall && (
|
||||
<div className={selectItemClass}>
|
||||
<Label className="text-xs font-medium">
|
||||
{columns.small?.label || "소분류"}
|
||||
</Label>
|
||||
<Select
|
||||
value={values.small || ""}
|
||||
onValueChange={handleSmallChange}
|
||||
disabled={disabled || loading.small || !values.medium}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
{loading.small ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={values.medium
|
||||
? (columns.small?.placeholder || "소분류 선택")
|
||||
: "중분류를 먼저 선택하세요"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{smallOptions.length === 0 && values.medium && !loading.small ? (
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">
|
||||
하위 항목이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
smallOptions.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 MultiColumnHierarchySelect;
|
||||
|
||||
Reference in New Issue
Block a user