390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
"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;
|
|
|