2348800e68
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m22s
카테고리/캐스케이딩 시스템 (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>
234 lines
7.6 KiB
TypeScript
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;
|