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

390 lines
12 KiB
TypeScript
Raw Permalink Normal View History

2025-12-23 09:31:18 +09:00
"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;