From 27558787b05778aa18d4ce5d4234206c7a9cae6c Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 5 Mar 2026 23:32:40 +0900 Subject: [PATCH 1/6] feat: Enhance CategoryValueManagerTree with input focus management and modal improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added refs for input fields in the CategoryValueManagerTree component to manage focus transitions between the name and description inputs. - Updated the modal behavior to reset form data without closing the modal, allowing for continuous input. - Changed the button label from "취소" to "닫기" for better clarity in the modal interface. - Included debug logging for cascading roles in the SelectBasicComponent to assist with troubleshooting. These enhancements improve user experience and maintainability of the component. --- .../CategoryValueManagerTree.tsx | 34 +++++++++++++++++-- .../select-basic/SelectBasicComponent.tsx | 16 +++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index 07965ce2..b3329a41 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -6,7 +6,7 @@ * - 체크박스를 통한 다중 선택 및 일괄 삭제 지원 */ -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { ChevronRight, ChevronDown, @@ -291,6 +291,10 @@ export const CategoryValueManagerTree: React.FC = const [editingValue, setEditingValue] = useState(null); const [deletingValue, setDeletingValue] = useState(null); + // 추가 모달 input ref + const addNameRef = useRef(null); + const addDescRef = useRef(null); + // 폼 상태 const [formData, setFormData] = useState({ valueCode: "", @@ -508,7 +512,15 @@ export const CategoryValueManagerTree: React.FC = const response = await createCategoryValue(input); if (response.success) { toast.success("카테고리가 추가되었습니다"); - setIsAddModalOpen(false); + // 폼 초기화 (모달은 닫지 않고 연속 입력) + setFormData((prev) => ({ + ...prev, + valueCode: "", + valueLabel: "", + description: "", + color: "", + })); + setTimeout(() => addNameRef.current?.focus(), 50); // 기존 펼침 상태 유지하면서 데이터 새로고침 await loadTree(true); // 부모 노드만 펼치기 (하위 추가 시) @@ -746,9 +758,17 @@ export const CategoryValueManagerTree: React.FC = 이름 * setFormData({ ...formData, valueLabel: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + addDescRef.current?.focus(); + } + }} placeholder="카테고리 이름을 입력하세요" className="h-9 text-sm" /> @@ -759,9 +779,17 @@ export const CategoryValueManagerTree: React.FC = 설명 setFormData({ ...formData, description: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + handleAdd(); + } + }} placeholder="선택 사항" className="h-9 text-sm" /> @@ -784,7 +812,7 @@ export const CategoryValueManagerTree: React.FC = onClick={() => setIsAddModalOpen(false)} className="h-9 flex-1 text-sm sm:flex-none" > - 취소 + 닫기 + + + + + + + {emptyText} + + + {items.map((item) => ( + { + onSelect(item.value === value ? "" : item.value); + setOpen(false); + }} + className="text-xs" + > + +
+ {item.label} + {item.sublabel && ( + + {item.sublabel} + + )} +
+
+ ))} +
+
+
+
+ + ); +}; + +export const StatusCountConfigPanel: React.FC = ({ + config, + onChange, +}) => { + const items = config.items || []; + const [tables, setTables] = useState>([]); + const [columns, setColumns] = useState>([]); + const [entityJoins, setEntityJoins] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingJoins, setLoadingJoins] = useState(false); + + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const result = await tableTypeApi.getTables(); + setTables( + (result || []).map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.tableName || t.table_name, + })) + ); + } catch (err) { + console.error("테이블 목록 로드 실패:", err); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + useEffect(() => { + if (!config.tableName) { + setColumns([]); + setEntityJoins([]); + return; + } + + const loadColumns = async () => { + setLoadingColumns(true); + try { + const result = await tableTypeApi.getColumns(config.tableName); + setColumns( + (result || []).map((c: any) => ({ + columnName: c.columnName || c.column_name, + columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name, + })) + ); + } catch (err) { + console.error("컬럼 목록 로드 실패:", err); + } finally { + setLoadingColumns(false); + } + }; + + const loadEntityJoins = async () => { + setLoadingJoins(true); + try { + const result = await entityJoinApi.getEntityJoinConfigs(config.tableName); + setEntityJoins(result?.joinConfigs || []); + } catch (err) { + console.error("엔티티 조인 설정 로드 실패:", err); + setEntityJoins([]); + } finally { + setLoadingJoins(false); + } + }; + + loadColumns(); + loadEntityJoins(); + }, [config.tableName]); + + const handleChange = (key: keyof StatusCountConfig, value: any) => { + onChange({ [key]: value }); + }; + + const handleItemChange = (index: number, key: keyof StatusCountItem, value: string) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [key]: value }; + handleChange("items", newItems); + }; + + const addItem = () => { + handleChange("items", [ + ...items, + { value: "", label: "새 상태", color: "gray" }, + ]); + }; + + const removeItem = (index: number) => { + handleChange( + "items", + items.filter((_: StatusCountItem, i: number) => i !== index) + ); + }; + + const tableComboItems = tables.map((t) => ({ + value: t.tableName, + label: t.displayName, + sublabel: t.displayName !== t.tableName ? t.tableName : undefined, + })); + + const columnComboItems = columns.map((c) => ({ + value: c.columnName, + label: c.columnLabel, + sublabel: c.columnLabel !== c.columnName ? c.columnName : undefined, + })); + + const relationComboItems = entityJoins.map((ej) => { + const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable; + return { + value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`, + label: `${ej.sourceColumn} -> ${refTableLabel}`, + sublabel: `${ej.referenceTable}.${ej.referenceColumn}`, + }; + }); + + const currentRelationValue = config.relationColumn && config.parentColumn + ? relationComboItems.find((item) => { + const [srcCol] = item.value.split("::"); + return srcCol === config.relationColumn; + })?.value || "" + : ""; + + return ( +
+
상태별 카운트 설정
+ +
+ + handleChange("title", e.target.value)} + placeholder="일련번호 현황" + className="h-8 text-xs" + /> +
+ +
+ + { + onChange({ tableName: v, statusColumn: "", relationColumn: "", parentColumn: "" }); + }} + items={tableComboItems} + placeholder="테이블 선택" + searchPlaceholder="테이블명 또는 라벨 검색..." + emptyText="테이블을 찾을 수 없습니다" + loading={loadingTables} + /> +
+ +
+ + handleChange("statusColumn", v)} + items={columnComboItems} + placeholder={config.tableName ? "상태 컬럼 선택" : "테이블을 먼저 선택"} + searchPlaceholder="컬럼명 또는 라벨 검색..." + emptyText="컬럼을 찾을 수 없습니다" + disabled={!config.tableName} + loading={loadingColumns} + /> +
+ +
+ + {loadingJoins ? ( +
+ 로딩중... +
+ ) : entityJoins.length > 0 ? ( + { + if (!v) { + onChange({ relationColumn: "", parentColumn: "" }); + return; + } + const [sourceCol, refPart] = v.split("::"); + const [refTable, refCol] = refPart.split("."); + onChange({ relationColumn: sourceCol, parentColumn: refCol }); + }} + items={relationComboItems} + placeholder="엔티티 관계 선택" + searchPlaceholder="관계 검색..." + emptyText="엔티티 관계가 없습니다" + disabled={!config.tableName} + /> + ) : ( +
+

+ {config.tableName ? "설정된 엔티티 관계가 없습니다" : "테이블을 먼저 선택하세요"} +

+
+ )} + {config.relationColumn && config.parentColumn && ( +

+ 자식 FK: {config.relationColumn} + {" -> "} + 부모 매칭: {config.parentColumn} +

+ )} +
+ +
+ + +
+ +
+
+ + +
+ + {items.map((item: StatusCountItem, i: number) => ( +
+
+ handleItemChange(i, "value", e.target.value)} + placeholder="상태값 (예: IN_USE)" + className="h-7 text-xs" + /> + +
+
+ handleItemChange(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 text-xs" + /> + +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx new file mode 100644 index 00000000..feec6b82 --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2StatusCountDefinition } from "./index"; +import { StatusCountComponent } from "./StatusCountComponent"; + +export class StatusCountRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2StatusCountDefinition; + + render(): React.ReactElement { + return ; + } +} + +StatusCountRenderer.registerSelf(); diff --git a/frontend/lib/registry/components/v2-status-count/index.ts b/frontend/lib/registry/components/v2-status-count/index.ts new file mode 100644 index 00000000..27495f0c --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/index.ts @@ -0,0 +1,38 @@ +"use client"; + +import { createComponentDefinition } from "../../utils/createComponentDefinition"; +import { ComponentCategory } from "@/types/component"; +import { StatusCountWrapper } from "./StatusCountComponent"; +import { StatusCountConfigPanel } from "./StatusCountConfigPanel"; + +export const V2StatusCountDefinition = createComponentDefinition({ + id: "v2-status-count", + name: "상태별 카운트", + nameEng: "Status Count", + description: "관련 테이블의 상태별 데이터 건수를 카드 형태로 표시하는 범용 컴포넌트", + category: ComponentCategory.DISPLAY, + webType: "text", + component: StatusCountWrapper, + configPanel: StatusCountConfigPanel, + defaultConfig: { + title: "상태 현황", + tableName: "", + statusColumn: "status", + relationColumn: "", + parentColumn: "", + items: [ + { value: "ACTIVE", label: "사용중", color: "blue" }, + { value: "STANDBY", label: "대기", color: "green" }, + { value: "REPAIR", label: "수리중", color: "orange" }, + { value: "DISPOSED", label: "폐기", color: "red" }, + ], + cardSize: "md", + }, + defaultSize: { width: 800, height: 100 }, + icon: "BarChart3", + tags: ["상태", "카운트", "통계", "현황", "v2"], + version: "1.0.0", + author: "개발팀", +}); + +export type { StatusCountConfig, StatusCountItem } from "./types"; diff --git a/frontend/lib/registry/components/v2-status-count/types.ts b/frontend/lib/registry/components/v2-status-count/types.ts new file mode 100644 index 00000000..b1e4985a --- /dev/null +++ b/frontend/lib/registry/components/v2-status-count/types.ts @@ -0,0 +1,29 @@ +import { ComponentConfig } from "@/types/component"; + +export interface StatusCountItem { + value: string; + label: string; + color: string; // "green" | "blue" | "orange" | "red" | "gray" | "purple" | hex color +} + +export interface StatusCountConfig extends ComponentConfig { + title?: string; + tableName: string; + statusColumn: string; + relationColumn: string; + parentColumn?: string; + items: StatusCountItem[]; + showTotal?: boolean; + cardSize?: "sm" | "md" | "lg"; +} + +export const STATUS_COLOR_MAP: Record = { + green: { bg: "bg-emerald-50", text: "text-emerald-600", border: "border-emerald-200" }, + blue: { bg: "bg-blue-50", text: "text-blue-600", border: "border-blue-200" }, + orange: { bg: "bg-orange-50", text: "text-orange-500", border: "border-orange-200" }, + red: { bg: "bg-red-50", text: "text-red-500", border: "border-red-200" }, + gray: { bg: "bg-gray-50", text: "text-gray-500", border: "border-gray-200" }, + purple: { bg: "bg-purple-50", text: "text-purple-600", border: "border-purple-200" }, + yellow: { bg: "bg-yellow-50", text: "text-yellow-600", border: "border-yellow-200" }, + cyan: { bg: "bg-cyan-50", text: "text-cyan-600", border: "border-cyan-200" }, +}; -- 2.43.0 From f6a02b5182a4f0b3900dc8da9188ad995dcce1e7 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Mar 2026 13:46:38 +0900 Subject: [PATCH 3/6] refactor: Update references from table_column_category_values to category_values - Changed all occurrences of `table_column_category_values` to `category_values` in the backend services and controllers to standardize the terminology. - Updated SQL queries to reflect the new table name, ensuring proper data retrieval and management. - Adjusted comments and documentation to clarify the purpose of the `category_values` table in the context of category management. These changes enhance code clarity and maintain consistency across the application. --- .../categoryValueCascadingController.ts | 8 +- .../src/controllers/entityJoinController.ts | 4 +- .../src/services/entityJoinService.ts | 27 +-- backend-node/src/services/menuCopyService.ts | 6 +- .../src/services/tableCategoryValueService.ts | 158 +++++++++++------- .../src/services/tableManagementService.ts | 6 +- .../screen/InteractiveDataTable.tsx | 2 +- .../components/webtypes/RepeaterInput.tsx | 4 +- .../card-display/CardDisplayComponent.tsx | 2 +- .../modal-repeater-table/RepeaterTable.tsx | 2 +- .../SplitPanelLayoutComponent.tsx | 4 +- .../table-list/TableListComponent.tsx | 5 +- .../UniversalFormModalComponent.tsx | 2 +- .../modals/FieldDetailSettingsModal.tsx | 2 +- .../modals/TableSectionSettingsModal.tsx | 2 +- .../components/universal-form-modal/types.ts | 2 +- .../BomItemEditorComponent.tsx | 2 +- .../v2-bom-tree/BomTreeComponent.tsx | 2 +- .../v2-card-display/CardDisplayComponent.tsx | 2 +- .../SplitPanelLayoutComponent.tsx | 4 +- .../v2-table-list/TableListComponent.tsx | 5 +- 21 files changed, 144 insertions(+), 107 deletions(-) diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts index 66250bf9..f57b6822 100644 --- a/backend-node/src/controllers/categoryValueCascadingController.ts +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -818,13 +818,13 @@ export const getCategoryValueCascadingParentOptions = async ( const group = groupResult.rows[0]; - // 부모 카테고리 값 조회 (table_column_category_values에서) + // 부모 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -916,13 +916,13 @@ export const getCategoryValueCascadingChildOptions = async ( const group = groupResult.rows[0]; - // 자식 카테고리 값 조회 (table_column_category_values에서) + // 자식 카테고리 값 조회 (category_values에서) let optionsQuery = ` SELECT value_code as value, value_label as label, value_order as display_order - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 15e05473..de9ee95f 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -417,10 +417,10 @@ export class EntityJoinController { // 1. 현재 테이블의 Entity 조인 설정 조회 const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode); - // 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외 + // 🆕 화면 디자이너용: category_values는 카테고리 드롭다운용이므로 제외 // 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨 const joinConfigs = allJoinConfigs.filter( - (config) => config.referenceTable !== "table_column_category_values" + (config) => config.referenceTable !== "category_values" ); if (joinConfigs.length === 0) { diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index a37942e1..1f345727 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -92,7 +92,7 @@ export class EntityJoinService { if (column.input_type === "category") { // 카테고리 타입: reference 정보가 비어있어도 자동 설정 - referenceTable = referenceTable || "table_column_category_values"; + referenceTable = referenceTable || "category_values"; referenceColumn = referenceColumn || "value_code"; displayColumn = displayColumn || "value_label"; @@ -308,7 +308,7 @@ export class EntityJoinService { const usedAliasesForColumns = new Set(); // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성 - // (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) + // (category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요) const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { if ( !acc.some( @@ -336,7 +336,7 @@ export class EntityJoinService { counter++; } usedAliasesForColumns.add(alias); - // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응) + // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (category_values 대응) const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; aliasMap.set(aliasKey, alias); logger.info( @@ -455,9 +455,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링) - if (config.referenceTable === "table_column_category_values") { - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } // user_info는 전역 테이블이므로 company_code 조건 없이 조인 @@ -528,10 +529,10 @@ export class EntityJoinService { return "join"; } - // table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 캐시 불가 + if (config.referenceTable === "category_values") { logger.info( - `🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}` + `🎯 category_values는 캐시 전략 불가: ${config.sourceColumn}` ); return "join"; } @@ -723,10 +724,10 @@ export class EntityJoinService { const aliasKey = `${config.referenceTable}:${config.sourceColumn}`; const alias = aliasMap.get(aliasKey); - // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만) - if (config.referenceTable === "table_column_category_values") { - // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외) - return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`; + // category_values는 특별한 조인 조건 필요 (회사별 필터링만) + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 + if (config.referenceTable === "category_values") { + return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code`; } return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`; diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index f67e09a3..af755316 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -3098,7 +3098,7 @@ export class MenuCopyService { } const allValuesResult = await client.query( - `SELECT * FROM table_column_category_values + `SELECT * FROM category_values WHERE company_code = $1 AND (${columnConditions.join(" OR ")}) ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, @@ -3115,7 +3115,7 @@ export class MenuCopyService { // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 const existingValuesResult = await client.query( `SELECT value_id, table_name, column_name, value_code - FROM table_column_category_values WHERE company_code = $1`, + FROM category_values WHERE company_code = $1`, [targetCompanyCode] ); const existingValueKeys = new Map( @@ -3194,7 +3194,7 @@ export class MenuCopyService { }); const insertResult = await client.query( - `INSERT INTO table_column_category_values ( + `INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, created_at, created_by, company_code, menu_objid diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index dd2f73a9..96efdfbb 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -31,7 +31,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -50,7 +50,7 @@ class TableCategoryValueService { tc.column_name AS "columnLabel", COUNT(cv.value_id) AS "valueCount" FROM table_type_columns tc - LEFT JOIN table_column_category_values cv + LEFT JOIN category_values cv ON tc.table_name = cv.table_name AND tc.column_name = cv.column_name AND cv.is_active = true @@ -110,7 +110,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -133,7 +133,7 @@ class TableCategoryValueService { ) tc LEFT JOIN ( SELECT table_name, column_name, COUNT(*) as cnt - FROM table_column_category_values + FROM category_values WHERE is_active = true AND company_code = $1 GROUP BY table_name, column_name ) cv_count ON tc.table_name = cv_count.table_name AND tc.column_name = cv_count.column_name @@ -207,7 +207,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", - NULL::numeric AS "menuObjid", + menu_objid AS "menuObjid", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", @@ -289,7 +289,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 회사에서 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -300,7 +300,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` SELECT value_id - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 @@ -316,8 +316,41 @@ class TableCategoryValueService { throw new Error("이미 존재하는 코드입니다"); } + // 라벨 중복 체크 (같은 테이블+컬럼+회사에서 동일한 라벨명 방지) + let labelDupQuery: string; + let labelDupParams: any[]; + + if (companyCode === "*") { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel]; + } else { + labelDupQuery = ` + SELECT value_id + FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + `; + labelDupParams = [value.tableName, value.columnName, value.valueLabel, companyCode]; + } + + const labelDupResult = await pool.query(labelDupQuery, labelDupParams); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${value.valueLabel}"`); + } + const insertQuery = ` - INSERT INTO table_column_category_values ( + INSERT INTO category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_objid, created_by @@ -425,6 +458,32 @@ class TableCategoryValueService { values.push(updates.isDefault); } + // 라벨 수정 시 중복 체크 (자기 자신 제외) + if (updates.valueLabel !== undefined) { + const currentRow = await pool.query( + `SELECT table_name, column_name, company_code FROM category_values WHERE value_id = $1`, + [valueId] + ); + + if (currentRow.rows.length > 0) { + const { table_name, column_name, company_code } = currentRow.rows[0]; + const labelDupResult = await pool.query( + `SELECT value_id FROM category_values + WHERE table_name = $1 + AND column_name = $2 + AND value_label = $3 + AND company_code = $4 + AND is_active = true + AND value_id != $5`, + [table_name, column_name, updates.valueLabel, company_code, valueId] + ); + + if (labelDupResult.rows.length > 0) { + throw new Error(`이미 동일한 이름의 카테고리 값이 존재합니다: "${updates.valueLabel}"`); + } + } + } + setClauses.push(`updated_at = NOW()`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); @@ -436,7 +495,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 수정 가능 values.push(valueId); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} RETURNING @@ -459,7 +518,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값만 수정 가능 values.push(valueId, companyCode); updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} AND company_code = $${paramIndex++} @@ -516,14 +575,14 @@ class TableCategoryValueService { if (companyCode === "*") { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 `; valueParams = [valueId]; } else { valueQuery = ` SELECT table_name, column_name, value_code - FROM table_column_category_values + FROM category_values WHERE value_id = $1 AND company_code = $2 `; @@ -635,10 +694,10 @@ class TableCategoryValueService { if (companyCode === "*") { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values WHERE parent_value_id = $1 + SELECT value_id FROM category_values WHERE parent_value_id = $1 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id ) SELECT value_id FROM category_tree @@ -647,11 +706,11 @@ class TableCategoryValueService { } else { query = ` WITH RECURSIVE category_tree AS ( - SELECT value_id FROM table_column_category_values + SELECT value_id FROM category_values WHERE parent_value_id = $1 AND company_code = $2 UNION ALL SELECT cv.value_id - FROM table_column_category_values cv + FROM category_values cv INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id WHERE cv.company_code = $2 ) @@ -697,10 +756,10 @@ class TableCategoryValueService { let labelParams: any[]; if (companyCode === "*") { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1`; labelParams = [id]; } else { - labelQuery = `SELECT value_label FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + labelQuery = `SELECT value_label FROM category_values WHERE value_id = $1 AND company_code = $2`; labelParams = [id, companyCode]; } @@ -730,10 +789,10 @@ class TableCategoryValueService { let deleteParams: any[]; if (companyCode === "*") { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1`; deleteParams = [id]; } else { - deleteQuery = `DELETE FROM table_column_category_values WHERE value_id = $1 AND company_code = $2`; + deleteQuery = `DELETE FROM category_values WHERE value_id = $1 AND company_code = $2`; deleteParams = [id, companyCode]; } @@ -770,7 +829,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $2 WHERE value_id = ANY($1::int[]) `; @@ -778,7 +837,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 일괄 삭제 가능 deleteQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET is_active = false, updated_at = NOW(), updated_by = $3 WHERE value_id = ANY($1::int[]) AND company_code = $2 @@ -819,7 +878,7 @@ class TableCategoryValueService { if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 `; @@ -827,7 +886,7 @@ class TableCategoryValueService { } else { // 일반 회사: 자신의 카테고리 값만 순서 변경 가능 updateQuery = ` - UPDATE table_column_category_values + UPDATE category_values SET value_order = $1, updated_at = NOW() WHERE value_id = $2 AND company_code = $3 @@ -1379,48 +1438,23 @@ class TableCategoryValueService { let query: string; let params: any[]; + // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 if (companyCode === "*") { - // 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합) - // 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n - const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", "); query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) `; - params = [...valueCodes, ...valueCodes]; + params = [...valueCodes]; } else { - // 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회 - // 첫 번째: $1~$n (valueCodes), $n+1 (companyCode) - // 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode) - const companyIdx1 = n + 1; - const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", "); - const companyIdx2 = 2 * n + 2; - + const companyIdx = n + 1; query = ` - SELECT value_code, value_label FROM ( - SELECT value_code, value_label - FROM table_column_category_values - WHERE value_code IN (${placeholders1}) - AND is_active = true - AND (company_code = $${companyIdx1} OR company_code = '*') - UNION ALL - SELECT value_code, value_label - FROM category_values - WHERE value_code IN (${placeholders2}) - AND is_active = true - AND (company_code = $${companyIdx2} OR company_code = '*') - ) combined + SELECT DISTINCT value_code, value_label + FROM category_values + WHERE value_code IN (${placeholders1}) + AND (company_code = $${companyIdx} OR company_code = '*') `; - params = [...valueCodes, companyCode, ...valueCodes, companyCode]; + params = [...valueCodes, companyCode]; } const result = await pool.query(query, params); @@ -1488,7 +1522,7 @@ class TableCategoryValueService { // 최고 관리자: 모든 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true @@ -1498,7 +1532,7 @@ class TableCategoryValueService { // 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회 query = ` SELECT value_code, value_label - FROM table_column_category_values + FROM category_values WHERE table_name = $1 AND column_name = $2 AND is_active = true diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index ed7ad460..0cff4f6b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3505,7 +3505,7 @@ export class TableManagementService { const referenceTableColumns = new Map(); const uniqueRefTables = new Set( joinConfigs - .filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외 + .filter((c) => c.referenceTable !== "category_values") // 카테고리는 제외 .map((c) => `${c.referenceTable}:${c.sourceColumn}`) ); @@ -4310,8 +4310,8 @@ export class TableManagementService { ]; for (const config of joinConfigs) { - // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 - if (config.referenceTable === "table_column_category_values") { + // category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 + if (config.referenceTable === "category_values") { dbJoins.push(config); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); continue; diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 8efde578..2de4d0df 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -378,7 +378,7 @@ export const InteractiveDataTable: React.FC = ({ for (const col of categoryColumns) { try { // menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용) - const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : ""; + const queryParams = menuObjid ? `?menuObjid=${menuObjid}&includeInactive=true` : "?includeInactive=true"; const response = await apiClient.get( `/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`, ); diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 050b386b..0349c6c3 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -796,7 +796,7 @@ export const RepeaterInput: React.FC = ({ console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -838,7 +838,7 @@ export const RepeaterInput: React.FC = ({ try { console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); - const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index e8afb3b3..1ff0088f 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -367,7 +367,7 @@ export const CardDisplayComponent: React.FC = ({ for (const columnName of categoryColumns) { try { - const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 532881b7..3fbfdf71 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -133,7 +133,7 @@ export function RepeaterTable({ continue; } - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); if (response.data?.success && response.data.data) { const options = response.data.data.map((item: any) => ({ diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index bc4ba2ba..a12e4bce 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1588,7 +1588,7 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { const valueMap: Record = {}; @@ -1650,7 +1650,7 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { const valueMap: Record = {}; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index c14f28f6..a01e88ed 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1298,7 +1298,8 @@ export const TableListComponent: React.FC = ({ targetColumn = parts[1]; } - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); + // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1381,7 +1382,7 @@ export const TableListComponent: React.FC = ({ // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 94149e4f..d0961021 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -1362,7 +1362,7 @@ export function UniversalFormModalComponent({ label: String(row[optionConfig.labelColumn || "name"]), })); } else if (optionConfig.type === "code" && optionConfig.categoryKey) { - // 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회 + // 공통코드(카테고리 컬럼): category_values 테이블에서 조회 // categoryKey 형식: "tableName.columnName" const [categoryTable, categoryColumn] = optionConfig.categoryKey.split("."); if (categoryTable && categoryColumn) { diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index afec5b6c..954d0192 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -31,7 +31,7 @@ import { import { apiClient } from "@/lib/api/client"; import { getCascadingRelations, getCascadingRelationByCode, CascadingRelation } from "@/lib/api/cascadingRelation"; -// 카테고리 컬럼 타입 (table_column_category_values 용) +// 카테고리 컬럼 타입 (category_values 용) interface CategoryColumnOption { tableName: string; columnName: string; diff --git a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx index 7bda67b2..3fc81fa3 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/TableSectionSettingsModal.tsx @@ -2526,7 +2526,7 @@ interface TableSectionSettingsModalProps { tables: { table_name: string; comment?: string }[]; tableColumns: Record; onLoadTableColumns: (tableName: string) => void; - // 카테고리 목록 (table_column_category_values에서 가져옴) + // 카테고리 목록 (category_values에서 가져옴) categoryList?: { tableName: string; columnName: string; displayName?: string }[]; onLoadCategoryList?: () => void; // 전체 섹션 목록 (다른 섹션 필드 참조용) diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index c6673d8d..8047d4f1 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -16,7 +16,7 @@ export interface SelectOptionConfig { labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트) saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용) filterCondition?: string; - // 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블) + // 카테고리 컬럼 기반 옵션 (category_values 테이블) // 형식: "tableName.columnName" (예: "sales_order_mng.incoterms") categoryKey?: string; diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index bd5f3d92..097c42c4 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -540,7 +540,7 @@ export function BomItemEditorComponent({ if (alreadyLoaded) continue; try { - const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); + const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values?includeInactive=true`); if (response.data?.success && response.data.data) { const options = response.data.data.map((item: any) => ({ value: item.valueCode || item.value_code, diff --git a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx index e98dbf88..8d70f5c1 100644 --- a/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx @@ -146,7 +146,7 @@ export function BomTreeComponent({ useEffect(() => { const loadLabels = async () => { try { - const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`); + const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values?includeInactive=true`); const vals = res.data?.data || []; if (vals.length > 0) { const map: Record = {}; diff --git a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx index ad6c88db..12eff3ab 100644 --- a/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/v2-card-display/CardDisplayComponent.tsx @@ -367,7 +367,7 @@ export const CardDisplayComponent: React.FC = ({ for (const columnName of categoryColumns) { try { - const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 865e6c44..2bfc7b19 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1894,7 +1894,7 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${leftTableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { const valueMap: Record = {}; @@ -1972,7 +1972,7 @@ export const SplitPanelLayoutComponent: React.FC for (const col of categoryColumns) { const columnName = col.columnName || col.column_name; try { - const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values?includeInactive=true`); if (response.data.success && response.data.data) { const valueMap: Record = {}; diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index eae50795..4087be04 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1441,7 +1441,8 @@ export const TableListComponent: React.FC = ({ targetColumn = parts[1]; } - const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values`); + // 비활성화된 카테고리도 라벨로 표시하기 위해 includeInactive=true + const response = await apiClient.get(`/table-categories/${targetTable}/${targetColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; @@ -1524,7 +1525,7 @@ export const TableListComponent: React.FC = ({ // inputType이 category인 경우 카테고리 매핑 로드 if (inputTypeInfo?.inputType === "category" && !mappings[col.columnName]) { try { - const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values`); + const response = await apiClient.get(`/table-categories/${joinedTable}/${col.actualColumn}/values?includeInactive=true`); if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; -- 2.43.0 From 2b4b7819c59e8255296ab9fc786f946a51137ea9 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Mar 2026 14:10:08 +0900 Subject: [PATCH 4/6] feat: Add Numbering Rule APIs and Frontend Integration - Implemented a new API endpoint to retrieve numbering rules based on table and column names, enhancing the flexibility of numbering rule management. - Added a new service method to handle the retrieval of numbering columns specific to a company, ensuring proper company code filtering. - Updated the frontend to load and display numbering columns, allowing users to select and manage numbering rules effectively. - Refactored existing logic to improve the handling of numbering rules, including fallback mechanisms for legacy data. These changes enhance the functionality and user experience in managing numbering rules within the application. --- .../controllers/numberingRuleController.ts | 24 + .../controllers/tableManagementController.ts | 69 +++ .../src/routes/tableManagementRoutes.ts | 7 + .../src/services/masterDetailExcelService.ts | 95 ++-- .../src/services/numberingRuleService.ts | 51 +- .../admin/systemMng/tableMngList/page.tsx | 167 +------ .../numbering-rule/NumberingRuleDesigner.tsx | 440 ++++++------------ 7 files changed, 350 insertions(+), 503 deletions(-) diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 8a9f6b56..a3887ab8 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -405,6 +405,30 @@ router.post( } ); +// 테이블+컬럼 기반 채번 규칙 조회 (메인 API) +router.get( + "/by-column/:tableName/:columnName", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + + try { + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + tableName, + columnName + ); + return res.json({ success: true, data: rule }); + } catch (error: any) { + logger.error("테이블+컬럼 기반 채번 규칙 조회 실패", { + error: error.message, + }); + return res.status(500).json({ success: false, error: error.message }); + } + } +); + // ==================== 테스트 테이블용 API ==================== // [테스트] 테스트 테이블에서 채번 규칙 목록 조회 diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index b8436176..0c35fdbd 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -3019,3 +3019,72 @@ export async function toggleColumnUnique( }); } } + +/** + * 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일) + * + * @route GET /api/table-management/numbering-columns + */ +export async function getNumberingColumnsByCompany( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user?.companyCode; + + logger.info("회사별 채번 컬럼 조회 요청", { companyCode }); + + if (!companyCode) { + res.status(400).json({ + success: false, + message: "회사 코드를 확인할 수 없습니다.", + }); + return; + } + + const { getPool } = await import("../database/db"); + const pool = getPool(); + + const targetCompanyCode = companyCode === "*" ? "*" : companyCode; + + const columnsQuery = ` + SELECT DISTINCT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ttc.column_name AS "columnName", + COALESCE( + ttc.column_label, + initcap(replace(ttc.column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM table_type_columns ttc + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ttc.input_type = 'numbering' + AND ttc.company_code = $1 + ORDER BY ttc.table_name, ttc.column_name + `; + + const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]); + + logger.info("채번 컬럼 조회 완료", { + companyCode, + rowCount: columnsResult.rows.length, + }); + + res.json({ + success: true, + data: columnsResult.rows, + }); + } catch (error: any) { + logger.error("채번 컬럼 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "채번 컬럼 조회 중 오류가 발생했습니다.", + error: error.message, + }); + } +} diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index a8964e99..92449cf6 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -25,6 +25,7 @@ import { toggleLogTable, getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회 getCategoryColumnsByCompany, // 🆕 회사별 카테고리 컬럼 조회 + getNumberingColumnsByCompany, // 채번 타입 컬럼 조회 multiTableSave, // 🆕 범용 다중 테이블 저장 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 @@ -254,6 +255,12 @@ router.post("/tables/:tableName/log/toggle", toggleLogTable); */ router.get("/category-columns", getCategoryColumnsByCompany); +/** + * 회사 기준 모든 채번 타입 컬럼 조회 + * GET /api/table-management/numbering-columns + */ +router.get("/numbering-columns", getNumberingColumnsByCompany); + /** * 메뉴의 형제 메뉴들이 사용하는 모든 테이블의 카테고리 타입 컬럼 조회 * GET /api/table-management/menu/:menuObjid/category-columns diff --git a/backend-node/src/services/masterDetailExcelService.ts b/backend-node/src/services/masterDetailExcelService.ts index 40cd58e3..a0370ed6 100644 --- a/backend-node/src/services/masterDetailExcelService.ts +++ b/backend-node/src/services/masterDetailExcelService.ts @@ -494,7 +494,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환 - * 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback + * numbering_rules 테이블에서 table_name + column_name + company_code로 직접 조회 */ private async detectNumberingRuleForColumn( tableName: string, @@ -502,32 +502,58 @@ class MasterDetailExcelService { companyCode?: string ): Promise<{ numberingRuleId: string } | null> { try { - // 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저) + // 1. table_type_columns에서 numbering 타입인지 확인 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($3, '*')` : `AND company_code = '*'`; - const params = companyCode && companyCode !== "*" + const ttcParams = companyCode && companyCode !== "*" ? [tableName, columnName, companyCode] : [tableName, columnName]; - const result = await query( - `SELECT input_type, detail_settings, company_code - FROM table_type_columns + const ttcResult = await query( + `SELECT input_type FROM table_type_columns WHERE table_name = $1 AND column_name = $2 ${companyCondition} - ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, - params + AND input_type = 'numbering' LIMIT 1`, + ttcParams ); - // 채번 타입인 행 찾기 (회사별 우선) - for (const row of result) { - if (row.input_type === "numbering") { - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - - if (settings?.numberingRuleId) { - return { numberingRuleId: settings.numberingRuleId }; - } + if (ttcResult.length === 0) return null; + + // 2. numbering_rules에서 table_name + column_name으로 규칙 조회 + const ruleCompanyCondition = companyCode && companyCode !== "*" + ? `AND company_code IN ($3, '*')` + : `AND company_code = '*'`; + const ruleParams = companyCode && companyCode !== "*" + ? [tableName, columnName, companyCode] + : [tableName, columnName]; + + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${ruleCompanyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + ruleParams + ); + + if (ruleResult.length > 0) { + return { numberingRuleId: ruleResult[0].rule_id }; + } + + // 3. fallback: detail_settings.numberingRuleId (하위 호환) + const fallbackResult = await query( + `SELECT detail_settings FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + AND input_type = 'numbering' + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + ttcParams + ); + + for (const row of fallbackResult) { + const settings = typeof row.detail_settings === "string" + ? JSON.parse(row.detail_settings || "{}") + : row.detail_settings; + if (settings?.numberingRuleId) { + return { numberingRuleId: settings.numberingRuleId }; } } @@ -540,7 +566,7 @@ class MasterDetailExcelService { /** * 특정 테이블의 모든 채번 컬럼을 한 번에 조회 - * 회사별 설정 우선, 공통(*) 설정 fallback + * numbering_rules 테이블에서 table_name + column_name으로 직접 조회 * @returns Map */ private async detectAllNumberingColumns( @@ -549,6 +575,7 @@ class MasterDetailExcelService { ): Promise> { const numberingCols = new Map(); try { + // 1. table_type_columns에서 numbering 타입 컬럼 목록 조회 const companyCondition = companyCode && companyCode !== "*" ? `AND company_code IN ($2, '*')` : `AND company_code = '*'`; @@ -556,22 +583,26 @@ class MasterDetailExcelService { ? [tableName, companyCode] : [tableName]; - const result = await query( - `SELECT column_name, detail_settings, company_code - FROM table_type_columns - WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition} - ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + const ttcResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}`, params ); - // 컬럼별로 회사 설정 우선 적용 - for (const row of result) { - if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵 - const settings = typeof row.detail_settings === "string" - ? JSON.parse(row.detail_settings || "{}") - : row.detail_settings; - if (settings?.numberingRuleId) { - numberingCols.set(row.column_name, settings.numberingRuleId); + // 2. 각 컬럼에 대해 numbering_rules에서 규칙 조회 + for (const row of ttcResult) { + const ruleResult = await query( + `SELECT rule_id FROM numbering_rules + WHERE table_name = $1 AND column_name = $2 ${companyCondition} + ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END + LIMIT 1`, + companyCode && companyCode !== "*" + ? [tableName, row.column_name, companyCode] + : [tableName, row.column_name] + ); + + if (ruleResult.length > 0) { + numberingCols.set(row.column_name, ruleResult[0].rule_id); } } diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 6f6fe81c..34acc44f 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -1747,7 +1747,53 @@ class NumberingRuleService { `; const params = [companyCode, tableName, columnName]; - const result = await pool.query(query, params); + let result = await pool.query(query, params); + + // fallback: column_name이 비어있는 레거시 규칙 검색 + if (result.rows.length === 0) { + const fallbackQuery = ` + SELECT + r.rule_id AS "ruleId", + r.rule_name AS "ruleName", + r.description, + r.separator, + r.reset_period AS "resetPeriod", + r.current_sequence AS "currentSequence", + r.table_name AS "tableName", + r.column_name AS "columnName", + r.company_code AS "companyCode", + r.category_column AS "categoryColumn", + r.category_value_id AS "categoryValueId", + cv.value_label AS "categoryValueLabel", + r.created_at AS "createdAt", + r.updated_at AS "updatedAt", + r.created_by AS "createdBy" + FROM numbering_rules r + LEFT JOIN category_values cv ON r.category_value_id = cv.value_id + WHERE r.company_code = $1 + AND r.table_name = $2 + AND (r.column_name IS NULL OR r.column_name = '') + AND r.category_value_id IS NULL + ORDER BY r.updated_at DESC + LIMIT 1 + `; + result = await pool.query(fallbackQuery, [companyCode, tableName]); + + // 찾으면 column_name 자동 업데이트 (레거시 데이터 마이그레이션) + if (result.rows.length > 0) { + const foundRule = result.rows[0]; + await pool.query( + `UPDATE numbering_rules SET column_name = $1 WHERE rule_id = $2 AND company_code = $3`, + [columnName, foundRule.ruleId, companyCode] + ); + result.rows[0].columnName = columnName; + logger.info("레거시 채번 규칙 자동 매핑 완료", { + ruleId: foundRule.ruleId, + tableName, + columnName, + }); + } + } if (result.rows.length === 0) { logger.info("테이블+컬럼 기반 채번 규칙을 찾을 수 없음", { @@ -1760,7 +1806,6 @@ class NumberingRuleService { const rule = result.rows[0]; - // 파트 정보 조회 (테스트 테이블) const partsQuery = ` SELECT id, @@ -1779,7 +1824,7 @@ class NumberingRuleService { ]); rule.parts = extractSeparatorAfterFromParts(partsResult.rows); - logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { + logger.info("테이블+컬럼 기반 채번 규칙 조회 성공", { ruleId: rule.ruleId, ruleName: rule.ruleName, }); diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index a8d58662..ec6aabae 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -669,38 +669,6 @@ export default function TableManagementPage() { console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings); } - // 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함 - console.log("🔍 Numbering 저장 체크:", { - inputType: column.inputType, - numberingRuleId: column.numberingRuleId, - hasNumberingRuleId: !!column.numberingRuleId, - }); - - if (column.inputType === "numbering") { - let existingSettings: Record = {}; - if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { - try { - existingSettings = JSON.parse(finalDetailSettings); - } catch { - existingSettings = {}; - } - } - - // numberingRuleId가 있으면 저장, 없으면 제거 - if (column.numberingRuleId) { - const numberingSettings = { - ...existingSettings, - numberingRuleId: column.numberingRuleId, - }; - finalDetailSettings = JSON.stringify(numberingSettings); - console.log("🔧 Numbering 설정 JSON 생성:", numberingSettings); - } else { - // numberingRuleId가 없으면 빈 객체 - finalDetailSettings = JSON.stringify(existingSettings); - console.log("🔧 Numbering 규칙 없이 저장:", existingSettings); - } - } - const columnSetting = { columnName: column.columnName, columnLabel: column.displayName, @@ -844,28 +812,6 @@ export default function TableManagementPage() { // detailSettings 계산 let finalDetailSettings = column.detailSettings || ""; - // 🆕 Numbering 타입인 경우 numberingRuleId를 detailSettings에 포함 - if (column.inputType === "numbering" && column.numberingRuleId) { - let existingSettings: Record = {}; - if (typeof finalDetailSettings === "string" && finalDetailSettings.trim().startsWith("{")) { - try { - existingSettings = JSON.parse(finalDetailSettings); - } catch { - existingSettings = {}; - } - } - const numberingSettings = { - ...existingSettings, - numberingRuleId: column.numberingRuleId, - }; - finalDetailSettings = JSON.stringify(numberingSettings); - console.log("🔧 전체저장 - Numbering 설정 JSON 생성:", { - columnName: column.columnName, - numberingRuleId: column.numberingRuleId, - finalDetailSettings, - }); - } - // 🆕 Entity 타입인 경우 detailSettings에 엔티티 설정 포함 if (column.inputType === "entity" && column.referenceTable) { let existingSettings: Record = {}; @@ -1987,118 +1933,7 @@ export default function TableManagementPage() { )} )} - {/* 입력 타입이 'numbering'인 경우 채번규칙 선택 */} - {column.inputType === "numbering" && ( -
- - - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: open, - })) - } - > - - - - - - - - - 채번규칙을 찾을 수 없습니다. - - - { - const columnIndex = columns.findIndex( - (c) => c.columnName === column.columnName, - ); - handleColumnChange(columnIndex, "numberingRuleId", undefined); - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: false, - })); - // 자동 저장 제거 - 전체 저장 버튼으로 저장 - }} - className="text-xs" - > - - -- 선택 안함 -- - - {numberingRules.map((rule) => ( - { - const columnIndex = columns.findIndex( - (c) => c.columnName === column.columnName, - ); - // 상태 업데이트만 (자동 저장 제거) - handleColumnChange(columnIndex, "numberingRuleId", rule.ruleId); - setNumberingComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: false, - })); - // 전체 저장 버튼으로 저장 - }} - className="text-xs" - > - -
- {rule.ruleName} - {rule.tableName && ( - - {rule.tableName}.{rule.columnName} - - )} -
-
- ))} -
-
-
-
-
- {column.numberingRuleId && ( -
- - 규칙 설정됨 -
- )} -
- )} + {/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */}
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index fbae903f..377869cc 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -1,36 +1,30 @@ "use client"; import React, { useState, useCallback, useEffect } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; +import { Plus, Save, Edit2, FolderTree } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; -import { - saveNumberingRuleToTest, - deleteNumberingRuleFromTest, - getNumberingRulesFromTest, -} from "@/lib/api/numberingRule"; -import { getCategoryTree, getAllCategoryKeys } from "@/lib/api/categoryTree"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { saveNumberingRuleToTest } from "@/lib/api/numberingRule"; +import { apiClient } from "@/lib/api/client"; import { cn } from "@/lib/utils"; -// 카테고리 값 트리 노드 타입 -interface CategoryValueNode { - valueId: number; - valueCode: string; - valueLabel: string; - depth: number; - path: string; - parentValueId: number | null; - children?: CategoryValueNode[]; +interface NumberingColumn { + tableName: string; + tableLabel: string; + columnName: string; + columnLabel: string; +} + +interface GroupedColumns { + tableLabel: string; + columns: NumberingColumn[]; } interface NumberingRuleDesignerProps { @@ -54,138 +48,100 @@ export const NumberingRuleDesigner: React.FC = ({ currentTableName, menuObjid, }) => { - const [savedRules, setSavedRules] = useState([]); - const [selectedRuleId, setSelectedRuleId] = useState(null); + const [numberingColumns, setNumberingColumns] = useState([]); + const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); const [currentRule, setCurrentRule] = useState(null); const [loading, setLoading] = useState(false); - const [leftTitle, setLeftTitle] = useState("저장된 규칙 목록"); + const [columnSearch, setColumnSearch] = useState(""); const [rightTitle, setRightTitle] = useState("규칙 편집"); - const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); // 구분자 관련 상태 (개별 파트 사이 구분자) const [separatorTypes, setSeparatorTypes] = useState>({}); const [customSeparators, setCustomSeparators] = useState>({}); - // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 - interface CategoryOption { - tableName: string; - columnName: string; - displayName: string; // "테이블명.컬럼명" 형식 - } - const [allCategoryOptions, setAllCategoryOptions] = useState([]); - const [selectedCategoryKey, setSelectedCategoryKey] = useState(""); // "tableName.columnName" - const [categoryValues, setCategoryValues] = useState([]); - const [categoryKeyOpen, setCategoryKeyOpen] = useState(false); - const [categoryValueOpen, setCategoryValueOpen] = useState(false); - const [loadingCategories, setLoadingCategories] = useState(false); - + // 좌측: 채번 타입 컬럼 목록 로드 useEffect(() => { - loadRules(); - loadAllCategoryOptions(); // 전체 카테고리 옵션 로드 + loadNumberingColumns(); }, []); - // currentRule의 categoryColumn이 변경되면 selectedCategoryKey 동기화 - useEffect(() => { - if (currentRule?.categoryColumn) { - setSelectedCategoryKey(currentRule.categoryColumn); - } else { - setSelectedCategoryKey(""); - } - }, [currentRule?.categoryColumn]); - - // 카테고리 키 선택 시 해당 카테고리 값 로드 - useEffect(() => { - if (selectedCategoryKey) { - const [tableName, columnName] = selectedCategoryKey.split("."); - if (tableName && columnName) { - loadCategoryValues(tableName, columnName); - } - } else { - setCategoryValues([]); - } - }, [selectedCategoryKey]); - - // 전체 카테고리 옵션 로드 (모든 테이블의 category 타입 컬럼) - const loadAllCategoryOptions = async () => { - try { - // category_values 테이블에서 고유한 테이블.컬럼 조합 조회 - const response = await getAllCategoryKeys(); - if (response.success && response.data) { - const options: CategoryOption[] = response.data.map((item) => ({ - tableName: item.tableName, - columnName: item.columnName, - displayName: `${item.tableName}.${item.columnName}`, - })); - setAllCategoryOptions(options); - console.log("전체 카테고리 옵션 로드:", options); - } - } catch (error) { - console.error("카테고리 옵션 목록 조회 실패:", error); - } - }; - - // 특정 카테고리 컬럼의 값 트리 조회 - const loadCategoryValues = async (tableName: string, columnName: string) => { - setLoadingCategories(true); - try { - const response = await getCategoryTree(tableName, columnName); - if (response.success && response.data) { - setCategoryValues(response.data); - console.log("카테고리 값 로드:", { tableName, columnName, count: response.data.length }); - } else { - setCategoryValues([]); - } - } catch (error) { - console.error("카테고리 값 트리 조회 실패:", error); - setCategoryValues([]); - } finally { - setLoadingCategories(false); - } - }; - - // 카테고리 값을 플랫 리스트로 변환 (UI에서 선택용) - const flattenCategoryValues = (nodes: CategoryValueNode[], result: CategoryValueNode[] = []): CategoryValueNode[] => { - for (const node of nodes) { - result.push(node); - if (node.children && node.children.length > 0) { - flattenCategoryValues(node.children, result); - } - } - return result; - }; - - const flatCategoryValues = flattenCategoryValues(categoryValues); - - const loadRules = useCallback(async () => { + const loadNumberingColumns = async () => { setLoading(true); try { - console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작 (test 테이블):", { - menuObjid, - hasMenuObjid: !!menuObjid, - }); - - // test 테이블에서 조회 - const response = await getNumberingRulesFromTest(menuObjid); - - console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답 (test 테이블):", { - menuObjid, - success: response.success, - rulesCount: response.data?.length || 0, - rules: response.data, - }); - - if (response.success && response.data) { - setSavedRules(response.data); - } else { - toast.error(response.error || "규칙 목록을 불러올 수 없습니다"); + const response = await apiClient.get("/table-management/numbering-columns"); + if (response.data.success && response.data.data) { + setNumberingColumns(response.data.data); } } catch (error: any) { - toast.error(`로딩 실패: ${error.message}`); + console.error("채번 컬럼 목록 로드 실패:", error); } finally { setLoading(false); } - }, [menuObjid]); + }; + + // 컬럼 선택 시 해당 컬럼의 채번 규칙 로드 + const handleSelectColumn = async (tableName: string, columnName: string) => { + setSelectedColumn({ tableName, columnName }); + setLoading(true); + try { + const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); + if (response.data.success && response.data.data) { + const rule = response.data.data as NumberingRuleConfig; + setCurrentRule(JSON.parse(JSON.stringify(rule))); + } else { + // 규칙 없으면 신규 생성 모드 + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: `${columnName} 채번`, + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "table", + tableName, + columnName, + }; + setCurrentRule(newRule); + } + } catch { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: `${columnName} 채번`, + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "table", + tableName, + columnName, + }; + setCurrentRule(newRule); + } finally { + setLoading(false); + } + }; + + // 테이블별로 그룹화 + const groupedColumns = numberingColumns.reduce>((acc, col) => { + if (!acc[col.tableName]) { + acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] }; + } + acc[col.tableName].columns.push(col); + return acc; + }, {}); + + // 검색 필터 적용 + const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => { + if (!columnSearch) return true; + const search = columnSearch.toLowerCase(); + return ( + tableName.toLowerCase().includes(search) || + group.tableLabel.toLowerCase().includes(search) || + group.columns.some( + (c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search) + ) + ); + }); useEffect(() => { if (currentRule) { @@ -343,60 +299,20 @@ export const NumberingRuleDesigner: React.FC = ({ return part; }); - // 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정 - // menuObjid가 있으면 menu 스코프, 없으면 기존 scopeType 유지 - const effectiveMenuObjid = menuObjid || currentRule.menuObjid || null; - const effectiveScopeType = effectiveMenuObjid ? "menu" : (currentRule.scopeType || "global"); - const ruleToSave = { ...currentRule, parts: partsWithDefaults, - scopeType: effectiveScopeType as "menu" | "global", // menuObjid 유무에 따라 결정 - tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 (참고용) - menuObjid: effectiveMenuObjid, // 메뉴 OBJID (필터링 기준) + scopeType: "table" as const, + tableName: selectedColumn?.tableName || currentRule.tableName || "", + columnName: selectedColumn?.columnName || currentRule.columnName || "", }; - console.log("💾 채번 규칙 저장:", { - currentTableName, - menuObjid, - "currentRule.tableName": currentRule.tableName, - "currentRule.menuObjid": currentRule.menuObjid, - "ruleToSave.tableName": ruleToSave.tableName, - "ruleToSave.menuObjid": ruleToSave.menuObjid, - "ruleToSave.scopeType": ruleToSave.scopeType, - ruleToSave, - }); - // 테스트 테이블에 저장 (numbering_rules) const response = await saveNumberingRuleToTest(ruleToSave); if (response.success && response.data) { - // 깊은 복사하여 savedRules와 currentRule이 다른 객체를 참조하도록 함 const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; - - // setSavedRules 내부에서 prev를 사용해서 existing 확인 (클로저 문제 방지) - setSavedRules((prev) => { - const savedData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; - const existsInPrev = prev.some((r) => r.ruleId === ruleToSave.ruleId); - - console.log("🔍 [handleSave] setSavedRules:", { - ruleId: ruleToSave.ruleId, - existsInPrev, - prevCount: prev.length, - }); - - if (existsInPrev) { - // 기존 규칙 업데이트 - return prev.map((r) => (r.ruleId === ruleToSave.ruleId ? savedData : r)); - } else { - // 새 규칙 추가 - return [...prev, savedData]; - } - }); - setCurrentRule(currentData); - setSelectedRuleId(response.data.ruleId); - await onSave?.(response.data); toast.success("채번 규칙이 저장되었습니다"); } else { @@ -407,143 +323,62 @@ export const NumberingRuleDesigner: React.FC = ({ } finally { setLoading(false); } - }, [currentRule, onSave, currentTableName, menuObjid]); - - const handleSelectRule = useCallback((rule: NumberingRuleConfig) => { - console.log("🔍 [handleSelectRule] 규칙 선택:", { - ruleId: rule.ruleId, - ruleName: rule.ruleName, - partsCount: rule.parts?.length || 0, - parts: rule.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })), - }); - - setSelectedRuleId(rule.ruleId); - // 깊은 복사하여 객체 참조 분리 (좌측 목록과 편집 영역의 객체가 공유되지 않도록) - const ruleCopy = JSON.parse(JSON.stringify(rule)) as NumberingRuleConfig; - - console.log("🔍 [handleSelectRule] 깊은 복사 후:", { - ruleId: ruleCopy.ruleId, - partsCount: ruleCopy.parts?.length || 0, - parts: ruleCopy.parts?.map(p => ({ id: p.id, order: p.order, partType: p.partType })), - }); - - setCurrentRule(ruleCopy); - toast.info(`"${rule.ruleName}" 규칙을 불러왔습니다`); - }, []); - - const handleDeleteSavedRule = useCallback( - async (ruleId: string) => { - setLoading(true); - try { - const response = await deleteNumberingRuleFromTest(ruleId); - - if (response.success) { - setSavedRules((prev) => prev.filter((r) => r.ruleId !== ruleId)); - - if (selectedRuleId === ruleId) { - setSelectedRuleId(null); - setCurrentRule(null); - } - - toast.success("규칙이 삭제되었습니다"); - } else { - showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." }); - } - } catch (error: any) { - showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); - } finally { - setLoading(false); - } - }, - [selectedRuleId], - ); - - const handleNewRule = useCallback(() => { - console.log("📋 새 규칙 생성:", { currentTableName, menuObjid }); - - const newRule: NumberingRuleConfig = { - ruleId: `rule-${Date.now()}`, - ruleName: "새 채번 규칙", - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지 - tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정 - menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용) - }; - - console.log("📋 생성된 규칙 정보:", newRule); - - setSelectedRuleId(newRule.ruleId); - setCurrentRule(newRule); - - toast.success("새 규칙이 생성되었습니다"); - }, [currentTableName, menuObjid]); + }, [currentRule, onSave, selectedColumn]); return (
- {/* 좌측: 저장된 규칙 목록 */} -
-
- {editingLeftTitle ? ( - setLeftTitle(e.target.value)} - onBlur={() => setEditingLeftTitle(false)} - onKeyDown={(e) => e.key === "Enter" && setEditingLeftTitle(false)} - className="h-8 text-sm font-semibold" - autoFocus - /> - ) : ( -

{leftTitle}

- )} - -
+ {/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */} +
+

채번 컬럼

- + setColumnSearch(e.target.value)} + placeholder="검색..." + className="h-8 text-xs" + /> -
- {loading ? ( +
+ {loading && numberingColumns.length === 0 ? (

로딩 중...

- ) : savedRules.length === 0 ? ( + ) : filteredGroups.length === 0 ? (
-

저장된 규칙이 없습니다

+

+ {numberingColumns.length === 0 + ? "채번 타입 컬럼이 없습니다" + : "검색 결과가 없습니다"} +

) : ( - savedRules.map((rule) => ( - handleSelectRule(rule)} - > - -
-
- {rule.ruleName} -
- -
-
-
+ {col.columnLabel} +
+ ); + })} +
)) )}
@@ -557,8 +392,9 @@ export const NumberingRuleDesigner: React.FC = ({ {!currentRule ? (
-

규칙을 선택해주세요

-

좌측에서 규칙을 선택하거나 새로 생성하세요

+ +

컬럼을 선택해주세요

+

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

) : ( -- 2.43.0 From 4d6783e5083742c33b03af4d5a0261ea31c0cb5a Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Mar 2026 15:34:31 +0900 Subject: [PATCH 5/6] feat: Implement automatic serial number generation and reference handling in mold management - Enhanced the `createMoldSerial` function to automatically generate serial numbers based on defined numbering rules when the serial number is not provided. - Integrated error handling for the automatic numbering process, ensuring robust logging for success and failure cases. - Updated the `NumberingRuleService` to support reference column handling, allowing for dynamic prefix generation based on related data. - Modified the frontend components to accommodate new reference configurations, improving user experience in managing numbering rules. These changes significantly enhance the mold management functionality by automating serial number generation and improving the flexibility of numbering rules. --- .../src/controllers/moldController.ts | 33 +++++- .../src/services/numberingRuleService.ts | 34 ++++++ .../src/services/tableManagementService.ts | 26 +++++ .../numbering-rule/AutoConfigPanel.tsx | 105 ++++++++++++++++++ .../numbering-rule/NumberingRuleCard.tsx | 4 + .../numbering-rule/NumberingRuleDesigner.tsx | 1 + frontend/components/v2/V2Input.tsx | 55 +++++---- .../v2-status-count/StatusCountComponent.tsx | 16 +-- .../StatusCountConfigPanel.tsx | 96 +++++++++++++++- frontend/types/numbering-rule.ts | 15 ++- 10 files changed, 334 insertions(+), 51 deletions(-) diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index 4683dd75..ee500f5e 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -233,8 +233,35 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response) const { moldCode } = req.params; const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; - if (!serial_number) { - res.status(400).json({ success: false, message: "일련번호는 필수입니다." }); + let finalSerialNumber = serial_number; + + // 일련번호가 비어있으면 채번 규칙으로 자동 생성 + if (!finalSerialNumber) { + try { + const { numberingRuleService } = await import("../services/numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn( + companyCode, + "mold_serial", + "serial_number" + ); + + if (rule) { + // formData에 mold_code를 포함 (reference 파트에서 참조) + const formData = { mold_code: moldCode, ...req.body }; + finalSerialNumber = await numberingRuleService.allocateCode( + rule.ruleId, + companyCode, + formData + ); + logger.info("일련번호 자동 채번 완료", { serialNumber: finalSerialNumber, ruleId: rule.ruleId }); + } + } catch (numError: any) { + logger.error("일련번호 자동 채번 실패", { error: numError.message }); + } + } + + if (!finalSerialNumber) { + res.status(400).json({ success: false, message: "일련번호를 생성할 수 없습니다. 채번 규칙을 확인해주세요." }); return; } @@ -244,7 +271,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response) RETURNING * `; const params = [ - companyCode, moldCode, serial_number, status || "STORED", + companyCode, moldCode, finalSerialNumber, status || "STORED", progress || 0, work_description || null, manager || null, completion_date || null, remarks || null, userId, ]; diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 34acc44f..91ae4cb5 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -172,6 +172,16 @@ class NumberingRuleService { break; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + prefixParts.push(String(formData[refColumn])); + } else { + prefixParts.push(""); + } + break; + } + default: break; } @@ -1245,6 +1255,14 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return "REF"; + } + default: logger.warn("알 수 없는 파트 타입", { partType: part.partType }); return ""; @@ -1375,6 +1393,13 @@ class NumberingRuleService { return catMapping2?.format || "CATEGORY"; } + case "reference": { + const refCol2 = autoConfig.referenceColumnName; + if (refCol2 && formData && formData[refCol2]) { + return String(formData[refCol2]); + } + return "REF"; + } default: return ""; } @@ -1524,6 +1549,15 @@ class NumberingRuleService { return ""; } + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + logger.warn("reference 파트: 참조 컬럼 값 없음", { refColumn, formDataKeys: formData ? Object.keys(formData) : [] }); + return ""; + } + default: return ""; } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 0cff4f6b..d07c02d2 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2691,6 +2691,32 @@ export class TableManagementService { logger.info(`created_date 자동 추가: ${data.created_date}`); } + // 채번 자동 적용: input_type = 'numbering'인 컬럼에 값이 비어있으면 자동 채번 + try { + const companyCode = data.company_code || "*"; + const numberingColsResult = await query( + `SELECT DISTINCT column_name FROM table_type_columns + WHERE table_name = $1 AND input_type = 'numbering' + AND company_code IN ($2, '*')`, + [tableName, companyCode] + ); + + for (const row of numberingColsResult) { + const col = row.column_name; + if (!data[col] || data[col] === "" || data[col] === "자동 생성됩니다") { + const { numberingRuleService } = await import("./numberingRuleService"); + const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, tableName, col); + if (rule) { + const generatedCode = await numberingRuleService.allocateCode(rule.ruleId, companyCode, data); + data[col] = generatedCode; + logger.info(`채번 자동 적용: ${tableName}.${col} = ${generatedCode}`); + } + } + } + } catch (numErr: any) { + logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index b51ea500..544eae9d 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -18,6 +18,7 @@ interface AutoConfigPanelProps { config?: any; onChange: (config: any) => void; isPreview?: boolean; + tableName?: string; } interface TableInfo { @@ -37,6 +38,7 @@ export const AutoConfigPanel: React.FC = ({ config = {}, onChange, isPreview = false, + tableName, }) => { // 1. 순번 (자동 증가) if (partType === "sequence") { @@ -161,6 +163,18 @@ export const AutoConfigPanel: React.FC = ({ ); } + // 6. 참조 (마스터-디테일 분번) + if (partType === "reference") { + return ( + + ); + } + return null; }; @@ -1088,3 +1102,94 @@ const CategoryConfigPanel: React.FC = ({
); }; + +function ReferenceConfigSection({ + config, + onChange, + isPreview, + tableName, +}: { + config: any; + onChange: (c: any) => void; + isPreview: boolean; + tableName?: string; +}) { + const [columns, setColumns] = useState([]); + const [loadingCols, setLoadingCols] = useState(false); + + useEffect(() => { + if (!tableName) return; + setLoadingCols(true); + + const loadEntityColumns = async () => { + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/screen-management/tables/${tableName}/columns` + ); + const allCols = response.data?.data || response.data || []; + const entityCols = allCols.filter( + (c: any) => + (c.inputType || c.input_type) === "entity" || + (c.inputType || c.input_type) === "numbering" + ); + setColumns( + entityCols.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: + c.columnLabel || c.column_label || c.columnName || c.column_name, + dataType: c.dataType || c.data_type || "", + inputType: c.inputType || c.input_type || "", + })) + ); + } catch { + setColumns([]); + } finally { + setLoadingCols(false); + } + }; + + loadEntityColumns(); + }, [tableName]); + + return ( +
+
+ + +

+ 마스터 테이블과 연결된 엔티티/채번 컬럼의 값을 코드에 포함합니다 +

+
+
+ ); +} diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index e9731017..e3dbc3ab 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -16,6 +16,7 @@ interface NumberingRuleCardProps { onUpdate: (updates: Partial) => void; onDelete: () => void; isPreview?: boolean; + tableName?: string; } export const NumberingRuleCard: React.FC = ({ @@ -23,6 +24,7 @@ export const NumberingRuleCard: React.FC = ({ onUpdate, onDelete, isPreview = false, + tableName, }) => { return ( @@ -57,6 +59,7 @@ export const NumberingRuleCard: React.FC = ({ date: { dateFormat: "YYYYMMDD" }, text: { textValue: "CODE" }, category: { categoryKey: "", categoryMappings: [] }, + reference: { referenceColumnName: "" }, }; onUpdate({ partType: newPartType, @@ -105,6 +108,7 @@ export const NumberingRuleCard: React.FC = ({ config={part.autoConfig} onChange={(autoConfig) => onUpdate({ autoConfig })} isPreview={isPreview} + tableName={tableName} /> ) : ( = ({ onUpdate={(updates) => handleUpdatePart(part.order, updates)} onDelete={() => handleDeletePart(part.order)} isPreview={isPreview} + tableName={selectedColumn?.tableName} /> {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} {index < currentRule.parts.length - 1 && ( diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 219fa275..6bd19c5d 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -619,45 +619,40 @@ export const V2Input = forwardRef((props, ref) => try { // 채번 규칙 ID 캐싱 (한 번만 조회) if (!numberingRuleIdRef.current) { - const { getTableColumns } = await import("@/lib/api/tableManagement"); - const columnsResponse = await getTableColumns(tableName); + // table_name + column_name 기반으로 채번 규칙 조회 + try { + const { apiClient } = await import("@/lib/api/client"); + const ruleResponse = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); + if (ruleResponse.data?.success && ruleResponse.data?.data?.ruleId) { + numberingRuleIdRef.current = ruleResponse.data.data.ruleId; - if (!columnsResponse.success || !columnsResponse.data) { - console.warn("테이블 컬럼 정보 조회 실패:", columnsResponse); - return; - } - - const columns = columnsResponse.data.columns || columnsResponse.data; - const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName); - - if (!targetColumn) { - console.warn("컬럼 정보를 찾을 수 없습니다:", columnName); - return; - } - - // detailSettings에서 numberingRuleId 추출 - if (targetColumn.detailSettings) { - try { - // 문자열이면 파싱, 객체면 그대로 사용 - const parsed = typeof targetColumn.detailSettings === "string" - ? JSON.parse(targetColumn.detailSettings) - : targetColumn.detailSettings; - numberingRuleIdRef.current = parsed.numberingRuleId || null; - - // 🆕 채번 규칙 ID를 formData에 저장 (저장 시 allocateCode 호출을 위해) - if (parsed.numberingRuleId && onFormDataChange && columnName) { - onFormDataChange(`${columnName}_numberingRuleId`, parsed.numberingRuleId); + if (onFormDataChange && columnName) { + onFormDataChange(`${columnName}_numberingRuleId`, ruleResponse.data.data.ruleId); } - } catch { - // JSON 파싱 실패 } + } catch { + // by-column 조회 실패 시 detailSettings fallback + try { + const { getTableColumns } = await import("@/lib/api/tableManagement"); + const columnsResponse = await getTableColumns(tableName); + if (columnsResponse.success && columnsResponse.data) { + const columns = columnsResponse.data.columns || columnsResponse.data; + const targetColumn = columns.find((col: { columnName: string }) => col.columnName === columnName); + if (targetColumn?.detailSettings) { + const parsed = typeof targetColumn.detailSettings === "string" + ? JSON.parse(targetColumn.detailSettings) + : targetColumn.detailSettings; + numberingRuleIdRef.current = parsed.numberingRuleId || null; + } + } + } catch { /* ignore */ } } } const numberingRuleId = numberingRuleIdRef.current; if (!numberingRuleId) { - console.warn("채번 규칙 ID가 설정되지 않았습니다. 테이블 관리에서 설정하세요.", { tableName, columnName }); + console.warn("채번 규칙을 찾을 수 없습니다. 옵션설정 > 채번설정에서 규칙을 생성하세요.", { tableName, columnName }); return; } diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx index fe25bd68..d0c388e3 100644 --- a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx +++ b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx @@ -35,14 +35,16 @@ export const StatusCountComponent: React.FC = ({ setLoading(true); try { - const res = await apiClient.get(`/table-management/data/${tableName}`, { - params: { - autoFilter: "true", - [relationColumn]: parentValue, - }, + const res = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: relationColumn ? { [relationColumn]: parentValue } : {}, }); - const rows: any[] = res.data?.data || res.data?.rows || res.data || []; + const responseData = res.data?.data; + const rows: any[] = Array.isArray(responseData) + ? responseData + : (responseData?.data || responseData?.rows || []); const grouped: Record = {}; for (const row of rows) { @@ -69,7 +71,7 @@ export const StatusCountComponent: React.FC = ({ }; const getCount = (item: StatusCountItem) => { - if (item.value === "__TOTAL__") { + if (item.value === "__TOTAL__" || item.value === "__ALL__") { return Object.values(counts).reduce((sum, c) => sum + c, 0); } const values = item.value.split(",").map((v) => v.trim()); diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx index bd029ab3..cee8a432 100644 --- a/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx @@ -233,6 +233,47 @@ export const StatusCountConfigPanel: React.FC = ({ ); }; + // 상태 컬럼의 카테고리 값 로드 + const [statusCategoryValues, setStatusCategoryValues] = useState>([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + + useEffect(() => { + if (!config.tableName || !config.statusColumn) { + setStatusCategoryValues([]); + return; + } + + const loadCategoryValues = async () => { + setLoadingCategoryValues(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-categories/${config.tableName}/${config.statusColumn}/values` + ); + if (response.data?.success && response.data?.data) { + const flatValues: Array<{ value: string; label: string }> = []; + const flatten = (items: any[]) => { + for (const item of items) { + flatValues.push({ + value: item.valueCode || item.value_code, + label: item.valueLabel || item.value_label, + }); + if (item.children?.length > 0) flatten(item.children); + } + }; + flatten(response.data.data); + setStatusCategoryValues(flatValues); + } + } catch { + setStatusCategoryValues([]); + } finally { + setLoadingCategoryValues(false); + } + }; + + loadCategoryValues(); + }, [config.tableName, config.statusColumn]); + const tableComboItems = tables.map((t) => ({ value: t.tableName, label: t.displayName, @@ -370,15 +411,52 @@ export const StatusCountConfigPanel: React.FC = ({
+ {loadingCategoryValues && ( +
+ 카테고리 값 로딩... +
+ )} + {items.map((item: StatusCountItem, i: number) => (
- handleItemChange(i, "value", e.target.value)} - placeholder="상태값 (예: IN_USE)" - className="h-7 text-xs" - /> + {statusCategoryValues.length > 0 ? ( + + ) : ( + handleItemChange(i, "value", e.target.value)} + placeholder="상태값 (예: IN_USE)" + className="h-7 text-xs" + /> + )}
))} + + {!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && ( +

+ 카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요. +

+ )}
); diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index 3b14a6bc..18e1e747 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -7,11 +7,12 @@ * 코드 파트 유형 (5가지) */ export type CodePartType = - | "sequence" // 순번 (자동 증가 숫자) - | "number" // 숫자 (고정 자릿수) - | "date" // 날짜 (다양한 날짜 형식) - | "text" // 문자 (텍스트) - | "category"; // 카테고리 (카테고리 값에 따른 형식) + | "sequence" // 순번 (자동 증가 숫자) + | "number" // 숫자 (고정 자릿수) + | "date" // 날짜 (다양한 날짜 형식) + | "text" // 문자 (텍스트) + | "category" // 카테고리 (카테고리 값에 따른 형식) + | "reference"; // 참조 (다른 컬럼의 값을 가져옴, 마스터-디테일 분번용) /** * 생성 방식 @@ -77,6 +78,9 @@ export interface NumberingRulePart { // 카테고리용 categoryKey?: string; // 카테고리 키 (테이블.컬럼 형식, 예: "item_info.type") categoryMappings?: CategoryFormatMapping[]; // 카테고리 값별 형식 매핑 + + // 참조용 (마스터-디테일 분번) + referenceColumnName?: string; // 참조할 컬럼명 (FK 컬럼 등, 해당 컬럼의 값을 코드에 포함) }; // 직접 입력 설정 @@ -132,6 +136,7 @@ export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, { value: "text", label: "문자", description: "텍스트 또는 코드" }, { value: "category", label: "카테고리", description: "카테고리 값에 따른 형식" }, + { value: "reference", label: "참조", description: "다른 컬럼 값 참조 (마스터 키 분번)" }, ]; export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ -- 2.43.0 From c98b2ccb4307f974ce14298c406eeed6106664cf Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 9 Mar 2026 18:05:00 +0900 Subject: [PATCH 6/6] feat: Add progress bar functionality to SplitPanelLayoutComponent and configuration options - Implemented a new progress bar rendering function in the SplitPanelLayoutComponent to visually represent the ratio of child to parent values. - Enhanced the SortableColumnRow component to support progress column configuration, allowing users to set current and maximum values through a popover interface. - Updated the AdditionalTabConfigPanel to include options for adding progress columns, improving user experience in managing data visualization. These changes significantly enhance the functionality and usability of the split panel layout by providing visual progress indicators and configuration options for users. --- .../SplitPanelLayoutComponent.tsx | 78 +++++++--- .../SplitPanelLayoutConfigPanel.tsx | 146 +++++++++++++++++- .../v2-status-count/StatusCountComponent.tsx | 10 +- 3 files changed, 210 insertions(+), 24 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 2bfc7b19..3439c220 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -929,6 +929,42 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); + // 프로그레스바 셀 렌더링 (부모 값 대비 자식 값 비율) + const renderProgressCell = useCallback( + (col: any, item: any, parentData: any) => { + const current = Number(item[col.numerator] || 0); + const max = Number(parentData?.[col.denominator] || item[col.denominator] || 0); + const percentage = max > 0 ? Math.round((current / max) * 100) : 0; + const barWidth = Math.min(percentage, 100); + const barColor = + percentage > 100 + ? "bg-red-600" + : percentage >= 90 + ? "bg-red-500" + : percentage >= 70 + ? "bg-amber-500" + : "bg-emerald-500"; + + return ( +
+
+
+
+
+
+ {current.toLocaleString()} / {max.toLocaleString()} +
+
+ {percentage}% +
+ ); + }, + [], + ); + // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) const formatCellValue = useCallback( ( @@ -3950,12 +3986,14 @@ export const SplitPanelLayoutComponent: React.FC > {tabSummaryColumns.map((col: any) => ( - {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} + {col.type === "progress" + ? renderProgressCell(col, item, selectedLeftItem) + : formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} ))} {hasTabActions && ( @@ -4064,12 +4102,14 @@ export const SplitPanelLayoutComponent: React.FC > {listSummaryColumns.map((col: any) => ( - {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} + {col.type === "progress" + ? renderProgressCell(col, item, selectedLeftItem) + : formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} ))} {hasTabActions && ( @@ -4486,12 +4526,14 @@ export const SplitPanelLayoutComponent: React.FC className="px-3 py-2 text-xs whitespace-nowrap" style={{ textAlign: col.align || "left" }} > - {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} + {col.type === "progress" + ? renderProgressCell(col, item, selectedLeftItem) + : formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index d77cb88d..c4c699cb 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities"; // 드래그 가능한 컬럼 아이템 function SortableColumnRow({ - id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, + id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, onProgressChange, availableChildColumns, availableParentColumns, }: { id: string; - col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean }; + col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean; type?: string; numerator?: string; denominator?: string }; index: number; isNumeric: boolean; isEntityJoin?: boolean; @@ -41,6 +41,9 @@ function SortableColumnRow({ onRemove: () => void; onShowInSummaryChange?: (checked: boolean) => void; onShowInDetailChange?: (checked: boolean) => void; + onProgressChange?: (updates: { numerator?: string; denominator?: string }) => void; + availableChildColumns?: Array<{ columnName: string; columnLabel: string }>; + availableParentColumns?: Array<{ columnName: string; columnLabel: string }>; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; @@ -53,12 +56,44 @@ function SortableColumnRow({ "flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5", isDragging && "z-50 opacity-50 shadow-md", isEntityJoin && "border-blue-200 bg-blue-50/30", + col.type === "progress" && "border-emerald-200 bg-emerald-50/30", )} >
- {isEntityJoin ? ( + {col.type === "progress" ? ( + + + + + +

프로그레스 설정

+
+ + +
+
+ + +
+
+
+ ) : isEntityJoin ? ( ) : ( #{index + 1} @@ -656,6 +691,13 @@ const AdditionalTabConfigPanel: React.FC = ({ newColumns[index] = { ...newColumns[index], showInDetail: checked }; updateTab({ columns: newColumns }); }} + onProgressChange={(updates) => { + const newColumns = [...selectedColumns]; + newColumns[index] = { ...newColumns[index], ...updates }; + updateTab({ columns: newColumns }); + }} + availableChildColumns={tabColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))} + availableParentColumns={leftTableColumns.map((c) => ({ columnName: c.columnName, columnLabel: c.columnLabel || c.columnName }))} /> ); })} @@ -685,6 +727,104 @@ const AdditionalTabConfigPanel: React.FC = ({ ))}
+ {/* 프로그레스 컬럼 추가 */} + {tab.tableName && ( +
+
+ + + 프로그레스 컬럼 추가 + +
+
+ + +
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+ )} + {/* Entity 조인 컬럼 - 아코디언 (접기/펼치기) */} {(() => { const joinData = tab.tableName ? entityJoinColumnsMap?.[tab.tableName] : null; diff --git a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx index d0c388e3..048ce076 100644 --- a/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx +++ b/frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx @@ -42,9 +42,13 @@ export const StatusCountComponent: React.FC = ({ }); const responseData = res.data?.data; - const rows: any[] = Array.isArray(responseData) - ? responseData - : (responseData?.data || responseData?.rows || []); + let rows: any[] = []; + if (Array.isArray(responseData)) { + rows = responseData; + } else if (responseData && typeof responseData === "object") { + rows = Array.isArray(responseData.data) ? responseData.data : + Array.isArray(responseData.rows) ? responseData.rows : []; + } const grouped: Record = {}; for (const row of rows) { -- 2.43.0