From b01efd293ca2f4e19fb9a3be4128374e7cd2bf59 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 22 Dec 2025 13:45:08 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=ED=86=B5=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/commonCodeController.ts | 119 +++++++++- backend-node/src/routes/commonCodeRoutes.ts | 16 ++ .../src/services/commonCodeService.ts | 223 +++++++++++++++++- frontend/components/admin/CodeFormModal.tsx | 60 ++++- .../components/admin/SortableCodeItem.tsx | 25 +- frontend/components/unified/UnifiedSelect.tsx | 83 +++++-- frontend/lib/api/commonCode.ts | 32 +++ frontend/types/commonCode.ts | 27 +++ frontend/types/unified-components.ts | 10 +- 9 files changed, 560 insertions(+), 35 deletions(-) diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index b0db2059..40579b7e 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -275,8 +275,22 @@ export class CommonCodeController { const { categoryCode } = req.params; const codeData: CreateCodeData = req.body; const userId = req.user?.userId || "SYSTEM"; + // 사용자의 회사 코드 사용 (회사별 코드 관리) const companyCode = req.user?.companyCode || "*"; - const menuObjid = req.body.menuObjid; + + // 카테고리 조회 - 존재 여부 확인 및 menuObjid 가져오기 + const category = await this.commonCodeService.getCategoryByCode(categoryCode); + if (!category) { + return res.status(404).json({ + success: false, + message: `카테고리를 찾을 수 없습니다: ${categoryCode}`, + }); + } + + // 카테고리의 menuObjid 사용 (카테고리는 공통, 코드는 회사별) + const menuObjid = category.menu_objid; + + logger.info(`코드 생성 - 사용자 회사로 저장: categoryCode=${categoryCode}, companyCode=${companyCode}, menuObjid=${menuObjid}`); // 입력값 검증 if (!codeData.codeValue || !codeData.codeName) { @@ -286,13 +300,6 @@ export class CommonCodeController { }); } - if (!menuObjid) { - return res.status(400).json({ - success: false, - message: "메뉴 OBJID는 필수입니다.", - }); - } - const code = await this.commonCodeService.createCode( categoryCode, codeData, @@ -588,4 +595,100 @@ export class CommonCodeController { }); } } + + /** + * 테이블.컬럼 기반 카테고리 옵션 조회 (레거시 호환용) + * GET /api/common-codes/category-options/:tableName/:columnName + * + * 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 + * 기존 API 호출이 동작하도록 호환성 제공 + */ + async getCategoryOptionsAsCode(req: AuthenticatedRequest, res: Response) { + try { + const { tableName, columnName } = req.params; + const userCompanyCode = req.user?.companyCode; + + logger.info(`카테고리 → 공통코드 호환 조회: ${tableName}.${columnName}`); + + const options = await this.commonCodeService.getCategoryOptionsAsCode( + tableName, + columnName, + userCompanyCode + ); + + return res.json({ + success: true, + data: options, + message: `카테고리 옵션 조회 성공 (${tableName}.${columnName})`, + }); + } catch (error) { + logger.error(`카테고리 옵션 조회 실패 (${req.params.tableName}.${req.params.columnName}):`, error); + return res.status(500).json({ + success: false, + message: "카테고리 옵션 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 계층 구조 코드 조회 (트리 형태) + * GET /api/common-codes/categories/:categoryCode/hierarchy + */ + async getCodesHierarchy(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const userCompanyCode = req.user?.companyCode; + + const hierarchy = await this.commonCodeService.getCodesHierarchy( + categoryCode, + userCompanyCode + ); + + return res.json({ + success: true, + data: hierarchy, + message: `계층 코드 조회 성공 (${categoryCode})`, + }); + } catch (error) { + logger.error(`계층 코드 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "계층 코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + /** + * 자식 코드 조회 (연쇄 선택용) + * GET /api/common-codes/categories/:categoryCode/children + * Query: parentCodeValue (optional) + */ + async getChildCodes(req: AuthenticatedRequest, res: Response) { + try { + const { categoryCode } = req.params; + const { parentCodeValue } = req.query; + const userCompanyCode = req.user?.companyCode; + + const children = await this.commonCodeService.getChildCodes( + categoryCode, + parentCodeValue as string | null, + userCompanyCode + ); + + return res.json({ + success: true, + data: children, + message: `자식 코드 조회 성공 (${categoryCode}, 부모: ${parentCodeValue || '최상위'})`, + }); + } catch (error) { + logger.error(`자식 코드 조회 실패 (${req.params.categoryCode}):`, error); + return res.status(500).json({ + success: false, + message: "자식 코드 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } diff --git a/backend-node/src/routes/commonCodeRoutes.ts b/backend-node/src/routes/commonCodeRoutes.ts index 6772a6e9..e6869e82 100644 --- a/backend-node/src/routes/commonCodeRoutes.ts +++ b/backend-node/src/routes/commonCodeRoutes.ts @@ -58,4 +58,20 @@ router.get("/categories/:categoryCode/options", (req, res) => commonCodeController.getCodeOptions(req, res) ); +// 계층 구조 코드 조회 (트리 형태) +router.get("/categories/:categoryCode/hierarchy", (req, res) => + commonCodeController.getCodesHierarchy(req, res) +); + +// 자식 코드 조회 (연쇄 선택용) +router.get("/categories/:categoryCode/children", (req, res) => + commonCodeController.getChildCodes(req, res) +); + +// 카테고리 → 공통코드 호환 API (레거시 지원) +// 기존 카테고리 타입이 공통코드로 마이그레이션된 후에도 동작 +router.get("/category-options/:tableName/:columnName", (req, res) => + commonCodeController.getCategoryOptionsAsCode(req, res) +); + export default router; diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index db19adc3..c59e17d3 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -25,6 +25,9 @@ export interface CodeInfo { is_active: string; company_code: string; menu_objid?: number | null; // 메뉴 기반 코드 관리용 + // 계층 구조 지원 + parent_code_value?: string | null; // 부모 코드 값 + depth?: number; // 계층 깊이 (1: 최상위) created_date?: Date | null; created_by?: string | null; updated_date?: Date | null; @@ -61,6 +64,9 @@ export interface CreateCodeData { description?: string; sortOrder?: number; isActive?: string; + // 계층 구조 지원 + parentCodeValue?: string; + depth?: number; } export class CommonCodeService { @@ -148,6 +154,22 @@ export class CommonCodeService { } } + /** + * 특정 카테고리 조회 (코드로) + */ + async getCategoryByCode(categoryCode: string): Promise { + try { + const category = await queryOne( + `SELECT * FROM code_category WHERE category_code = $1`, + [categoryCode] + ); + return category || null; + } catch (error) { + logger.error(`카테고리 조회 실패 (${categoryCode}):`, error); + return null; + } + } + /** * 카테고리별 코드 목록 조회 */ @@ -192,11 +214,12 @@ export class CommonCodeService { } // 회사별 필터링 (최고 관리자가 아닌 경우) + // company_code = '*'인 공통 데이터도 함께 조회 if (userCompanyCode && userCompanyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); + whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`); values.push(userCompanyCode); paramIndex++; - logger.info(`회사별 코드 필터링: ${userCompanyCode}`); + logger.info(`회사별 코드 필터링: ${userCompanyCode} (공통 데이터 포함)`); } else if (userCompanyCode === "*") { logger.info(`최고 관리자: 모든 코드 조회`); } @@ -405,11 +428,22 @@ export class CommonCodeService { menuObjid: number ) { try { + // 계층 구조 깊이 계산 + let depth = data.depth || 1; + if (data.parentCodeValue && !data.depth) { + // 부모 코드의 depth를 조회하여 +1 + const parentCode = await queryOne<{ depth: number }>( + `SELECT depth FROM code_info WHERE code_category = $1 AND code_value = $2`, + [categoryCode, data.parentCodeValue] + ); + depth = (parentCode?.depth || 0) + 1; + } + const code = await queryOne( `INSERT INTO code_info (code_category, code_value, code_name, code_name_eng, description, sort_order, - is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date) - VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW()) + is_active, menu_objid, company_code, parent_code_value, depth, created_by, updated_by, created_date, updated_date) + VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, $11, $12, NOW(), NOW()) RETURNING *`, [ categoryCode, @@ -420,13 +454,15 @@ export class CommonCodeService { data.sortOrder || 0, menuObjid, companyCode, + data.parentCodeValue || null, + depth, createdBy, createdBy, ] ); logger.info( - `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})` + `코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode}, 부모: ${data.parentCodeValue || '없음'}, depth: ${depth})` ); return code; } catch (error) { @@ -491,6 +527,25 @@ export class CommonCodeService { updateFields.push(`is_active = $${paramIndex++}`); values.push(activeValue); } + // 계층 구조 필드 + if (data.parentCodeValue !== undefined) { + updateFields.push(`parent_code_value = $${paramIndex++}`); + values.push(data.parentCodeValue || null); + + // depth 자동 계산 + if (data.parentCodeValue) { + const parentCode = await queryOne<{ depth: number }>( + `SELECT depth FROM code_info WHERE code_category = $1 AND code_value = $2`, + [categoryCode, data.parentCodeValue] + ); + const newDepth = (parentCode?.depth || 0) + 1; + updateFields.push(`depth = $${paramIndex++}`); + values.push(newDepth); + } else { + updateFields.push(`depth = $${paramIndex++}`); + values.push(1); // 부모 없으면 최상위 + } + } // WHERE 절 구성 let whereClause = `WHERE code_category = $${paramIndex++} AND code_value = $${paramIndex}`; @@ -594,6 +649,164 @@ export class CommonCodeService { } } + /** + * 계층 구조로 코드 조회 (트리 형태) + */ + async getCodesHierarchy( + categoryCode: string, + userCompanyCode?: string + ): Promise { + try { + let sql = ` + SELECT code_value, code_name, code_name_eng, description, sort_order, + is_active, parent_code_value, depth + FROM code_info + WHERE code_category = $1 AND is_active = 'Y' + `; + const values: any[] = [categoryCode]; + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + sql += ` AND company_code = $2`; + values.push(userCompanyCode); + } + + sql += ` ORDER BY depth ASC, sort_order ASC, code_value ASC`; + + const codes = await query<{ + code_value: string; + code_name: string; + code_name_eng: string | null; + description: string | null; + sort_order: number; + is_active: string; + parent_code_value: string | null; + depth: number; + }>(sql, values); + + // 트리 구조로 변환 + const codeMap = new Map(); + const roots: any[] = []; + + // 모든 코드를 맵에 저장 + for (const code of codes) { + codeMap.set(code.code_value, { + value: code.code_value, + label: code.code_name, + labelEng: code.code_name_eng, + description: code.description, + depth: code.depth || 1, + parentValue: code.parent_code_value, + children: [], + }); + } + + // 부모-자식 관계 구성 + for (const code of codes) { + const node = codeMap.get(code.code_value); + if (code.parent_code_value && codeMap.has(code.parent_code_value)) { + const parent = codeMap.get(code.parent_code_value); + parent.children.push(node); + } else { + roots.push(node); + } + } + + logger.info( + `계층 코드 조회 완료: ${categoryCode} - ${roots.length}개 루트, 전체 ${codes.length}개` + ); + return roots; + } catch (error) { + logger.error(`계층 코드 조회 중 오류 (${categoryCode}):`, error); + throw error; + } + } + + /** + * 특정 부모 코드의 자식 코드 목록 조회 (연쇄 선택용) + */ + async getChildCodes( + categoryCode: string, + parentCodeValue: string | null, + userCompanyCode?: string + ): Promise> { + try { + let sql = ` + SELECT + c.code_value, + c.code_name, + c.code_name_eng, + EXISTS(SELECT 1 FROM code_info c2 WHERE c2.parent_code_value = c.code_value AND c2.code_category = c.code_category) as has_children + FROM code_info c + WHERE c.code_category = $1 + AND c.is_active = 'Y' + `; + const values: any[] = [categoryCode]; + let paramIndex = 2; + + // 부모 코드 필터 + if (parentCodeValue) { + sql += ` AND c.parent_code_value = $${paramIndex++}`; + values.push(parentCodeValue); + } else { + sql += ` AND (c.parent_code_value IS NULL OR c.depth = 1)`; + } + + // 회사별 필터링 + if (userCompanyCode && userCompanyCode !== "*") { + sql += ` AND c.company_code = $${paramIndex++}`; + values.push(userCompanyCode); + } + + sql += ` ORDER BY c.sort_order ASC, c.code_value ASC`; + + const codes = await query<{ + code_value: string; + code_name: string; + code_name_eng: string | null; + has_children: boolean; + }>(sql, values); + + const result = codes.map((code) => ({ + value: code.code_value, + label: code.code_name, + hasChildren: code.has_children, + })); + + logger.info( + `자식 코드 조회 완료: ${categoryCode} (부모: ${parentCodeValue || '최상위'}) - ${result.length}개` + ); + return result; + } catch (error) { + logger.error(`자식 코드 조회 중 오류 (${categoryCode}, ${parentCodeValue}):`, error); + throw error; + } + } + + /** + * 테이블.컬럼 기반 카테고리 옵션 조회 (레거시 카테고리 호환용) + * + * 마이그레이션 후 category 타입이 code로 변환되었을 때 + * 기존 `tableName_columnName` 형식의 codeGroup으로 조회 + * + * @param tableName 테이블명 + * @param columnName 컬럼명 + * @param userCompanyCode 회사 코드 + */ + async getCategoryOptionsAsCode(tableName: string, columnName: string, userCompanyCode?: string) { + try { + // 카테고리 코드 그룹명 생성: TABLENAME_COLUMNNAME + const categoryCode = `${tableName.toUpperCase()}_${columnName.toUpperCase()}`; + + logger.info(`카테고리 → 코드 호환 조회: ${tableName}.${columnName} → ${categoryCode}`); + + return await this.getCodeOptions(categoryCode, userCompanyCode); + } catch (error) { + logger.error(`카테고리 호환 조회 중 오류 (${tableName}.${columnName}):`, error); + throw error; + } + } + /** * 코드 순서 변경 */ diff --git a/frontend/components/admin/CodeFormModal.tsx b/frontend/components/admin/CodeFormModal.tsx index 977e9e84..67d6c844 100644 --- a/frontend/components/admin/CodeFormModal.tsx +++ b/frontend/components/admin/CodeFormModal.tsx @@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { ValidationMessage } from "@/components/common/ValidationMessage"; @@ -83,6 +84,9 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code // 폼 스키마 선택 (생성/수정에 따라) const schema = isEditing ? updateCodeSchema : createCodeSchema; + // 부모 코드 선택 상태 + const [parentCodeValue, setParentCodeValue] = useState(""); + const form = useForm({ resolver: zodResolver(schema), mode: "onChange", // 실시간 검증 활성화 @@ -111,6 +115,9 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code // codeValue는 별도로 설정 (표시용) form.setValue("codeValue" as any, editingCode.codeValue || editingCode.code_value); + + // 부모 코드 설정 + setParentCodeValue(editingCode.parentCodeValue || editingCode.parent_code_value || ""); } else { // 새 코드 모드: 자동 순서 계산 const maxSortOrder = codes.length > 0 ? Math.max(...codes.map((c) => c.sortOrder || c.sort_order)) : 0; @@ -122,6 +129,7 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code description: "", sortOrder: maxSortOrder + 1, }); + setParentCodeValue(""); } } }, [isOpen, isEditing, editingCode, codes]); @@ -129,22 +137,29 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code const handleSubmit = form.handleSubmit(async (data) => { try { if (isEditing && editingCode) { - // 수정 + // 수정 - 부모 코드 포함 await updateCodeMutation.mutateAsync({ categoryCode, codeValue: editingCode.codeValue || editingCode.code_value, - data: data as UpdateCodeData, + data: { + ...data, + parentCodeValue: parentCodeValue || undefined, + } as UpdateCodeData, }); } else { - // 생성 + // 생성 - 부모 코드 포함 await createCodeMutation.mutateAsync({ categoryCode, - data: data as CreateCodeData, + data: { + ...data, + parentCodeValue: parentCodeValue || undefined, + } as CreateCodeData, }); } onClose(); form.reset(); + setParentCodeValue(""); } catch (error) { console.error("코드 저장 실패:", error); } @@ -269,6 +284,43 @@ export function CodeFormModal({ isOpen, onClose, categoryCode, editingCode, code )} + {/* 부모 코드 (계층 구조) */} +
+ + +

+ 계층 구조가 필요한 경우 부모 코드를 선택하세요. +

+
+ {/* 정렬 순서 */}
diff --git a/frontend/components/admin/SortableCodeItem.tsx b/frontend/components/admin/SortableCodeItem.tsx index 4f7a8215..d1f1af55 100644 --- a/frontend/components/admin/SortableCodeItem.tsx +++ b/frontend/components/admin/SortableCodeItem.tsx @@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Edit, Trash2 } from "lucide-react"; +import { Edit, Trash2, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { useUpdateCode } from "@/hooks/queries/useCodes"; import type { CodeInfo } from "@/types/commonCode"; @@ -61,20 +61,32 @@ export function SortableCodeItem({ } }; + // 계층 깊이 계산 (들여쓰기용) + const depth = code.depth || 1; + const indentPx = (depth - 1) * 20; // 깊이당 20px 들여쓰기 + return (
1 && "border-l-2 border-l-primary/30", )} >
+ {/* 계층 표시 아이콘 */} + {depth > 1 && ( + + )}

{code.codeName || code.code_name}

-

{code.codeValue || code.code_value}

+

+ {code.codeValue || code.code_value} + {code.parentCodeValue || code.parent_code_value ? ( + + (부모: {code.parentCodeValue || code.parent_code_value}) + + ) : null} +

{code.description &&

{code.description}

}
diff --git a/frontend/components/unified/UnifiedSelect.tsx b/frontend/components/unified/UnifiedSelect.tsx index 39da5d13..5efe619d 100644 --- a/frontend/components/unified/UnifiedSelect.tsx +++ b/frontend/components/unified/UnifiedSelect.tsx @@ -12,7 +12,7 @@ * - swap: 스왑 선택 (좌우 이동) */ -import React, { forwardRef, useCallback, useEffect, useMemo, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Label } from "@/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Checkbox } from "@/components/ui/checkbox"; @@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"; import { UnifiedSelectProps, SelectOption } from "@/types/unified-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; +import UnifiedFormContext from "./UnifiedFormContext"; /** * 드롭다운 선택 컴포넌트 @@ -463,20 +464,56 @@ export const UnifiedSelect = forwardRef( const [optionsLoaded, setOptionsLoaded] = useState(false); // 옵션 로딩에 필요한 값들만 추출 (객체 참조 대신 원시값 사용) - const source = config.source; + // category 소스는 code로 자동 변환 (카테고리 → 공통코드 통합) + const rawSource = config.source; + const categoryTable = (config as any).categoryTable; + const categoryColumn = (config as any).categoryColumn; + + // category 소스인 경우 code로 변환하고 codeGroup을 자동 생성 + const source = rawSource === "category" ? "code" : rawSource; + const codeGroup = rawSource === "category" && categoryTable && categoryColumn + ? `${categoryTable.toUpperCase()}_${categoryColumn.toUpperCase()}` + : config.codeGroup; + const entityTable = config.entityTable; const entityValueColumn = config.entityValueColumn || config.entityValueField; const entityLabelColumn = config.entityLabelColumn || config.entityLabelField; - const codeGroup = config.codeGroup; const table = config.table; const valueColumn = config.valueColumn; const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + + // 계층 코드 연쇄 선택 관련 + const hierarchical = config.hierarchical; + const parentField = config.parentField; + + // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) + const formContext = useContext(UnifiedFormContext); + + // 부모 필드의 값 계산 + const parentValue = useMemo(() => { + if (!hierarchical || !parentField) return null; + + // FormContext가 있으면 거기서 값 가져오기 + if (formContext) { + const val = formContext.getValue(parentField); + return val as string | null; + } + + return null; + }, [hierarchical, parentField, formContext]); // 데이터 소스에 따른 옵션 로딩 (원시값 의존성만 사용) useEffect(() => { - // 이미 로드된 경우 스킵 (static 제외) + // 계층 구조인 경우 부모 값이 변경되면 다시 로드 + if (hierarchical && source === "code") { + setOptionsLoaded(false); + } + }, [parentValue, hierarchical, source]); + + useEffect(() => { + // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { return; } @@ -493,14 +530,32 @@ export const UnifiedSelect = forwardRef( let fetchedOptions: SelectOption[] = []; if (source === "code" && codeGroup) { - // 공통코드에서 로드 - const response = await apiClient.get(`/common-codes/${codeGroup}/items`); - const data = response.data; - if (data.success && data.data) { - fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({ - value: item.code, - label: item.codeName, - })); + // 계층 구조 사용 시 자식 코드만 로드 + if (hierarchical) { + const params = new URLSearchParams(); + if (parentValue) { + params.append("parentCodeValue", parentValue); + } + const queryString = params.toString(); + const url = `/common-codes/categories/${codeGroup}/children${queryString ? `?${queryString}` : ""}`; + const response = await apiClient.get(url); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { value: string; label: string; hasChildren: boolean }) => ({ + value: item.value, + label: item.label, + })); + } + } else { + // 일반 공통코드에서 로드 + const response = await apiClient.get(`/common-codes/${codeGroup}/items`); + const data = response.data; + if (data.success && data.data) { + fetchedOptions = data.data.map((item: { code: string; codeName: string }) => ({ + value: item.code, + label: item.codeName, + })); + } } } else if (source === "db" && table) { // DB 테이블에서 로드 @@ -547,8 +602,8 @@ export const UnifiedSelect = forwardRef( } }; - loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded]); + loadOptions(); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); // 모드별 컴포넌트 렌더링 const renderSelect = () => { diff --git a/frontend/lib/api/commonCode.ts b/frontend/lib/api/commonCode.ts index c51edb11..9784e112 100644 --- a/frontend/lib/api/commonCode.ts +++ b/frontend/lib/api/commonCode.ts @@ -3,6 +3,8 @@ import { CodeCategory, CodeInfo, CodeOption, + CodeHierarchyNode, + CodeChildOption, CreateCategoryRequest, UpdateCategoryRequest, CreateCodeRequest, @@ -166,4 +168,34 @@ export const commonCodeApi = { return response.data; }, }, + + // 계층 구조 API + hierarchy: { + /** + * 계층 구조 코드 조회 (트리 형태) + */ + async getTree(categoryCode: string): Promise> { + const response = await apiClient.get(`/common-codes/categories/${categoryCode}/hierarchy`); + return response.data; + }, + + /** + * 자식 코드 조회 (연쇄 선택용) + * @param categoryCode 카테고리 코드 + * @param parentCodeValue 부모 코드 값 (null이면 최상위) + */ + async getChildren( + categoryCode: string, + parentCodeValue?: string | null + ): Promise> { + const params = new URLSearchParams(); + if (parentCodeValue) { + params.append("parentCodeValue", parentCodeValue); + } + const queryString = params.toString(); + const url = `/common-codes/categories/${categoryCode}/children${queryString ? `?${queryString}` : ""}`; + const response = await apiClient.get(url); + return response.data; + }, + }, }; diff --git a/frontend/types/commonCode.ts b/frontend/types/commonCode.ts index 046cdad7..e198ac02 100644 --- a/frontend/types/commonCode.ts +++ b/frontend/types/commonCode.ts @@ -25,6 +25,9 @@ export interface CodeInfo { sortOrder?: number; isActive?: string | boolean; useYn?: string; + // 계층 구조 필드 + parentCodeValue?: string | null; + depth?: number; // 기존 필드 (하위 호환성을 위해 유지) code_category?: string; @@ -33,6 +36,7 @@ export interface CodeInfo { code_name_eng?: string | null; sort_order?: number; is_active?: string; + parent_code_value?: string | null; created_date?: string | null; created_by?: string | null; updated_date?: string | null; @@ -61,6 +65,9 @@ export interface CreateCodeRequest { codeNameEng?: string; description?: string; sortOrder?: number; + // 계층 구조 필드 + parentCodeValue?: string; + depth?: number; } export interface UpdateCodeRequest { @@ -69,6 +76,8 @@ export interface UpdateCodeRequest { description?: string; sortOrder?: number; isActive?: "Y" | "N"; // 백엔드에서 기대하는 문자열 타입 + // 계층 구조 필드 + parentCodeValue?: string; } export interface CodeOption { @@ -77,6 +86,24 @@ export interface CodeOption { labelEng?: string | null; } +// 계층 구조 트리 노드 +export interface CodeHierarchyNode { + value: string; + label: string; + labelEng?: string | null; + description?: string | null; + depth: number; + parentValue?: string | null; + children: CodeHierarchyNode[]; +} + +// 자식 코드 (연쇄 선택용) +export interface CodeChildOption { + value: string; + label: string; + hasChildren: boolean; +} + export interface ReorderCodesRequest { codes: Array<{ codeValue: string; diff --git a/frontend/types/unified-components.ts b/frontend/types/unified-components.ts index ce3efeb3..2a394379 100644 --- a/frontend/types/unified-components.ts +++ b/frontend/types/unified-components.ts @@ -127,7 +127,7 @@ export interface UnifiedInputProps extends UnifiedBaseProps { // ===== UnifiedSelect ===== export type UnifiedSelectMode = "dropdown" | "radio" | "check" | "tag" | "toggle" | "swap"; -export type UnifiedSelectSource = "static" | "code" | "db" | "api" | "entity"; +export type UnifiedSelectSource = "static" | "code" | "db" | "api" | "entity" | "category"; export interface SelectOption { value: string; @@ -150,8 +150,13 @@ export interface UnifiedSelectConfig { entityTable?: string; entityValueField?: string; entityLabelField?: string; + entityValueColumn?: string; // alias for entityValueField + entityLabelColumn?: string; // alias for entityLabelField // API 연결 (source: api) apiEndpoint?: string; + // 카테고리 연결 (source: category) - 레거시, code로 자동 변환됨 + categoryTable?: string; + categoryColumn?: string; // 공통 옵션 searchable?: boolean; multiple?: boolean; @@ -161,6 +166,9 @@ export interface UnifiedSelectConfig { cascading?: CascadingConfig; // 상호 배제 mutualExclusion?: MutualExclusionConfig; + // 계층 코드 연쇄 선택 (source: code일 때 계층 구조 사용) + hierarchical?: boolean; // 계층 구조 사용 여부 + parentField?: string; // 부모 값을 참조할 필드 (다른 컴포넌트의 columnName) } export interface UnifiedSelectProps extends UnifiedBaseProps {