Files
invyone/frontend/components/common/MultiColumnHierarchySelect.tsx
T
DDD1542 2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
refactor(common-code): 마스터-디테일 재설계 — code_info(그룹) + code_detail(재귀 트리)
카테고리/캐스케이딩 시스템 (B/C/D) 전부 폐기:
- BE: mapper/Service/Controller 9세트 삭제 (cascading*, categoryTree, tableCategoryValue, categoryValueCascading, codeMerge)
- FE: 페이지 3 + API 8 + hooks 2 + 폐기 컴포넌트 6 삭제, 14곳 의존성 정리
- DB: 12 테이블 DROP, TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename

신설 commonCode 마스터-디테일:
- code_info: 1레벨 그룹 마스터
- code_detail: 2~∞ depth 재귀 트리 (parent_detail_id self-FK, depth 자동 계산)
- API: /api/common-codes/{info,detail}
- CodeCategoryFormModal/Panel → CodeInfoFormModal/Panel rename
- code_category 컬럼명 전부 code_info 로 치환 (mapper/Java/FE)
- 옛 commonCode API URL (/categories/...) → getCodeOptions 어댑터 + /detail?code_info=... 전환

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:50 +09:00

234 lines
7.6 KiB
TypeScript

"use client";
/**
* 멀티 컬럼 계층구조 선택 컴포넌트 — 대/중/소 분류를 다른 컬럼에 저장하는 패턴.
*
* 2026-05-15 새 마스터-디테일 스키마 기준:
* - 그룹의 전체 트리를 한 번 받아 로컬에서 depth + parent_detail_id 로 필터
* - 대분류 = depth 2, 중분류 = depth 3, 소분류 = depth 4
*/
import React, { useEffect, useMemo, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { getCodeDetailTree } from "@/lib/api/commonCode";
import { Loader2 } from "lucide-react";
import type { CodeDetail } from "@/types/commonCode";
export type HierarchyRole = "large" | "medium" | "small";
export interface HierarchyColumnConfig {
columnName: string;
label?: string;
placeholder?: string;
}
export interface MultiColumnHierarchySelectProps {
/** 그룹 코드 (code_info) */
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;
/** 현재 미사용 — 호환용 */
menuObjid?: number;
className?: string;
inline?: boolean;
}
export function MultiColumnHierarchySelect({
categoryCode,
columns,
values = {},
onChange,
disabled = false,
className = "",
inline = false,
}: MultiColumnHierarchySelectProps) {
const [allRows, setAllRows] = useState<CodeDetail[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!categoryCode) {
setAllRows([]);
return;
}
let cancelled = false;
setLoading(true);
getCodeDetailTree({ code_info: categoryCode, is_active: true })
.then((res) => {
if (!cancelled) setAllRows(res.data || []);
})
.catch((err) => {
console.error("계층 코드 로드 실패:", err);
if (!cancelled) setAllRows([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [categoryCode]);
const largeOptions = useMemo(
() => allRows.filter((r) => (r.depth ?? 2) === 2),
[allRows],
);
const mediumOptions = useMemo(() => {
if (!values.large) return [];
const parent = allRows.find((r) => r.code_value === values.large && (r.depth ?? 2) === 2);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 3 && r.parent_detail_id === parent.code_detail_id,
);
}, [allRows, values.large]);
const smallOptions = useMemo(() => {
if (!values.medium) return [];
const parent = allRows.find(
(r) => r.code_value === values.medium && (r.depth ?? 2) === 3,
);
if (!parent) return [];
return allRows.filter(
(r) => (r.depth ?? 2) === 4 && r.parent_detail_id === parent.code_detail_id,
);
}, [allRows, values.medium]);
const handleLargeChange = (value: string) => {
if (onChange) {
if (columns.large?.columnName) onChange("large", columns.large.columnName, value);
if (columns.medium?.columnName) onChange("medium", columns.medium.columnName, "");
if (columns.small?.columnName) onChange("small", columns.small.columnName, "");
}
};
const handleMediumChange = (value: string) => {
if (onChange) {
if (columns.medium?.columnName) onChange("medium", columns.medium.columnName, value);
if (columns.small?.columnName) onChange("small", columns.small.columnName, "");
}
};
const handleSmallChange = (value: string) => {
if (onChange && columns.small?.columnName) {
onChange("small", columns.small.columnName, value);
}
};
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}`}>
{columns.large && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">{columns.large?.label || "대분류"}</Label>
<Select
value={values.large || ""}
onValueChange={handleLargeChange}
disabled={disabled || loading}
>
<SelectTrigger className="h-9 text-sm">
{loading ? (
<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((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{columns.medium && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">{columns.medium?.label || "중분류"}</Label>
<Select
value={values.medium || ""}
onValueChange={handleMediumChange}
disabled={disabled || loading || !values.large}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue
placeholder={
values.large
? columns.medium?.placeholder || "중분류 선택"
: "대분류를 먼저 선택하세요"
}
/>
</SelectTrigger>
<SelectContent>
{mediumOptions.length === 0 && values.large ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
mediumOptions.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{columns.small && (
<div className={selectItemClass}>
<Label className="text-xs font-medium">{columns.small?.label || "소분류"}</Label>
<Select
value={values.small || ""}
onValueChange={handleSmallChange}
disabled={disabled || loading || !values.medium}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue
placeholder={
values.medium
? columns.small?.placeholder || "소분류 선택"
: "중분류를 먼저 선택하세요"
}
/>
</SelectTrigger>
<SelectContent>
{smallOptions.length === 0 && values.medium ? (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
</div>
) : (
smallOptions.map((row) => (
<SelectItem key={String(row.code_detail_id)} value={row.code_value}>
{row.code_name || row.code_value}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
</div>
);
}
export default MultiColumnHierarchySelect;