From ccbbf46faf6c1ecc87363273d22cc743abbf5044 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 14:30:29 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(universal-form-modal):=20=EC=98=B5?= =?UTF-8?q?=EC=85=94=EB=84=90=20=ED=95=84=EB=93=9C=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20Select=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옵셔널 필드 그룹: 섹션 내 선택적 필드 그룹 지원 (추가/제거, 연동 필드 자동 변경) - 카테고리 Select: table_column_category_values 테이블 값을 Select 옵션으로 사용 - 전체 카테고리 컬럼 조회 API: GET /api/table-categories/all-columns - RepeaterFieldGroup 저장 시 공통 필드 자동 병합 --- .../tableCategoryValueController.ts | 23 + .../controllers/tableManagementController.ts | 12 +- .../src/routes/tableCategoryValueRoutes.ts | 5 + .../src/services/commonCodeService.ts | 24 +- .../src/services/tableCategoryValueService.ts | 76 ++++ .../components/split-panel-layout2/README.md | 1 + .../SplitPanelLayout2Renderer.tsx | 1 + .../UniversalFormModalComponent.tsx | 252 ++++++++++- .../UniversalFormModalConfigPanel.tsx | 29 +- .../components/universal-form-modal/config.ts | 21 + .../modals/FieldDetailSettingsModal.tsx | 94 +++- .../modals/SaveSettingsModal.tsx | 1 + .../modals/SectionLayoutModal.tsx | 427 +++++++++++++++++- .../components/universal-form-modal/types.ts | 31 +- frontend/lib/utils/buttonActions.ts | 20 +- 15 files changed, 964 insertions(+), 53 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 75e225e6..60a0af08 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -30,6 +30,29 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons } }; +/** + * 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) + */ +export const getAllCategoryColumns = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + + const columns = await tableCategoryValueService.getAllCategoryColumns(companyCode); + + return res.json({ + success: true, + data: columns, + }); + } catch (error: any) { + logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "전체 카테고리 컬럼 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 카테고리 값 목록 조회 (메뉴 스코프 적용) * diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 66c70a77..e57c856e 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -877,7 +877,17 @@ export async function addTableData( const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { data.company_code = companyCode; - logger.info(`🔒 멀티테넌시: company_code 자동 추가 - ${companyCode}`); + logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`); + } + } + + // 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우) + const userId = req.user?.userId; + if (userId && !data.writer) { + const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer"); + if (hasWriterColumn) { + data.writer = userId; + logger.info(`writer 자동 추가 - ${userId}`); } } diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index e59d9b9d..b905a3f2 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { getCategoryColumns, + getAllCategoryColumns, getCategoryValues, addCategoryValue, updateCategoryValue, @@ -22,6 +23,10 @@ const router = Router(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +// 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) +// 주의: 더 구체적인 라우트보다 먼저 와야 함 +router.get("/all-columns", getAllCategoryColumns); + // 테이블의 카테고리 컬럼 목록 조회 router.get("/:tableName/columns", getCategoryColumns); diff --git a/backend-node/src/services/commonCodeService.ts b/backend-node/src/services/commonCodeService.ts index 8cbd8a29..db19adc3 100644 --- a/backend-node/src/services/commonCodeService.ts +++ b/backend-node/src/services/commonCodeService.ts @@ -86,11 +86,12 @@ export class CommonCodeService { } // 회사별 필터링 (최고 관리자가 아닌 경우) + // company_code = '*'인 공통 데이터도 함께 조회 if (userCompanyCode && userCompanyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); + whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`); values.push(userCompanyCode); paramIndex++; - logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode}`); + logger.info(`회사별 코드 카테고리 필터링: ${userCompanyCode} (공통 데이터 포함)`); } else if (userCompanyCode === "*") { // 최고 관리자는 모든 데이터 조회 가능 logger.info(`최고 관리자: 모든 코드 카테고리 조회`); @@ -116,7 +117,7 @@ export class CommonCodeService { const offset = (page - 1) * size; - // 카테고리 조회 + // code_category 테이블에서만 조회 (comm_code 제거) const categories = await query( `SELECT * FROM code_category ${whereClause} @@ -134,7 +135,7 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `카테고리 조회 완료: ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` + `카테고리 조회 완료: code_category ${categories.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})` ); return { @@ -224,7 +225,7 @@ export class CommonCodeService { paramIndex, }); - // 코드 조회 + // code_info 테이블에서만 코드 조회 (comm_code fallback 제거) const codes = await query( `SELECT * FROM code_info ${whereClause} @@ -242,20 +243,9 @@ export class CommonCodeService { const total = parseInt(countResult?.count || "0"); logger.info( - `✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` + `코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})` ); - logger.info(`📊 [getCodes] 조회된 코드 상세:`, { - categoryCode, - menuObjid, - codes: codes.map((c) => ({ - code_value: c.code_value, - code_name: c.code_name, - menu_objid: c.menu_objid, - company_code: c.company_code, - })), - }); - return { data: codes, total }; } catch (error) { logger.error(`코드 조회 중 오류 (${categoryCode}):`, error); diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index cdf1b838..1638a417 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -79,6 +79,82 @@ class TableCategoryValueService { } } + /** + * 모든 테이블의 카테고리 컬럼 목록 조회 (Select 옵션 설정용) + * 테이블 선택 없이 등록된 모든 카테고리 컬럼을 조회합니다. + */ + async getAllCategoryColumns( + companyCode: string + ): Promise { + try { + logger.info("전체 카테고리 컬럼 목록 조회", { companyCode }); + + const pool = getPool(); + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 카테고리 컬럼 조회 (중복 제거) + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COALESCE(cv_count.cnt, 0) AS "valueCount" + FROM ( + SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order + FROM table_type_columns + WHERE input_type = 'category' + GROUP BY table_name, column_name + ) tc + LEFT JOIN ( + SELECT table_name, column_name, COUNT(*) as cnt + FROM table_column_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 + ORDER BY tc.table_name, tc.display_order, tc.column_name + `; + params = []; + } else { + // 일반 회사: 자신의 카테고리 값만 카운트 (중복 제거) + query = ` + SELECT + tc.table_name AS "tableName", + tc.column_name AS "columnName", + tc.column_name AS "columnLabel", + COALESCE(cv_count.cnt, 0) AS "valueCount" + FROM ( + SELECT DISTINCT table_name, column_name, MIN(display_order) as display_order + FROM table_type_columns + WHERE input_type = 'category' + GROUP BY table_name, column_name + ) tc + LEFT JOIN ( + SELECT table_name, column_name, COUNT(*) as cnt + FROM table_column_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 + ORDER BY tc.table_name, tc.display_order, tc.column_name + `; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`전체 카테고리 컬럼 ${result.rows.length}개 조회 완료`, { + companyCode, + }); + + return result.rows; + } catch (error: any) { + logger.error(`전체 카테고리 컬럼 조회 실패: ${error.message}`); + throw error; + } + } + /** * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프) * diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md index 4e5debe8..26d2a669 100644 --- a/frontend/lib/registry/components/split-panel-layout2/README.md +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -101,3 +101,4 @@ - [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx index 21e70b13..642de9a2 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -41,3 +41,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index ef5b1ef9..6f4ef0fc 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -19,7 +19,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { ChevronDown, ChevronUp, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react"; +import { ChevronDown, ChevronUp, ChevronRight, Plus, Trash2, RefreshCw, Loader2 } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -35,6 +35,7 @@ import { FormDataState, RepeatSectionItem, SelectOptionConfig, + OptionalFieldGroupConfig, } from "./types"; import { defaultConfig, generateUniqueId } from "./config"; @@ -177,6 +178,9 @@ export function UniversalFormModalComponent({ // 섹션 접힘 상태 const [collapsedSections, setCollapsedSections] = useState>(new Set()); + // 옵셔널 필드 그룹 활성화 상태 (섹션ID-그룹ID 조합) + const [activatedOptionalFieldGroups, setActivatedOptionalFieldGroups] = useState>(new Set()); + // Select 옵션 캐시 const [selectOptionsCache, setSelectOptionsCache] = useState<{ [key: string]: { value: string; label: string }[]; @@ -575,6 +579,49 @@ export function UniversalFormModalComponent({ }); }, []); + // 옵셔널 필드 그룹 활성화 + const activateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => { + const section = config.sections.find((s) => s.id === sectionId); + const group = section?.optionalFieldGroups?.find((g) => g.id === groupId); + if (!group) return; + + const key = `${sectionId}-${groupId}`; + setActivatedOptionalFieldGroups((prev) => { + const newSet = new Set(prev); + newSet.add(key); + return newSet; + }); + + // 연동 필드 값 변경 (추가 시) + if (group.triggerField && group.triggerValueOnAdd !== undefined) { + handleFieldChange(group.triggerField, group.triggerValueOnAdd); + } + }, [config, handleFieldChange]); + + // 옵셔널 필드 그룹 비활성화 + const deactivateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => { + const section = config.sections.find((s) => s.id === sectionId); + const group = section?.optionalFieldGroups?.find((g) => g.id === groupId); + if (!group) return; + + const key = `${sectionId}-${groupId}`; + setActivatedOptionalFieldGroups((prev) => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + + // 연동 필드 값 변경 (제거 시) + if (group.triggerField && group.triggerValueOnRemove !== undefined) { + handleFieldChange(group.triggerField, group.triggerValueOnRemove); + } + + // 옵셔널 필드 그룹 필드 값 초기화 + group.fields.forEach((field) => { + handleFieldChange(field.columnName, field.defaultValue || ""); + }); + }, [config, handleFieldChange]); + // Select 옵션 로드 const loadSelectOptions = useCallback( async (fieldId: string, optionConfig: SelectOptionConfig): Promise<{ value: string; label: string }[]> => { @@ -587,9 +634,10 @@ export function UniversalFormModalComponent({ try { if (optionConfig.type === "static") { + // 직접 입력: 설정된 정적 옵션 사용 options = optionConfig.staticOptions || []; } else if (optionConfig.type === "table" && optionConfig.tableName) { - // POST 방식으로 테이블 데이터 조회 (autoFilter 포함) + // 테이블 참조: POST 방식으로 테이블 데이터 조회 (autoFilter 포함) const response = await apiClient.post(`/table-management/tables/${optionConfig.tableName}/data`, { page: 1, size: 1000, @@ -613,13 +661,21 @@ export function UniversalFormModalComponent({ value: String(row[optionConfig.valueColumn || "id"]), label: String(row[optionConfig.labelColumn || "name"]), })); - } else if (optionConfig.type === "code" && optionConfig.codeCategory) { - const response = await apiClient.get(`/common-code/${optionConfig.codeCategory}`); - if (response.data?.success && response.data?.data) { - options = response.data.data.map((code: any) => ({ - value: code.code_value || code.codeValue, - label: code.code_name || code.codeName, - })); + } else if (optionConfig.type === "code" && optionConfig.categoryKey) { + // 공통코드(카테고리 컬럼): table_column_category_values 테이블에서 조회 + // categoryKey 형식: "tableName.columnName" + const [categoryTable, categoryColumn] = optionConfig.categoryKey.split("."); + if (categoryTable && categoryColumn) { + const response = await apiClient.get( + `/table-categories/${categoryTable}/${categoryColumn}/values` + ); + if (response.data?.success && response.data?.data) { + // 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장) + options = response.data.data.map((item: any) => ({ + value: item.valueLabel || item.value_label, + label: item.valueLabel || item.value_label, + })); + } } } @@ -1500,6 +1556,15 @@ export function UniversalFormModalComponent({ ), )} + + {/* 옵셔널 필드 그룹 렌더링 */} + {section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && ( +
+ {section.optionalFieldGroups.map((group) => + renderOptionalFieldGroup(section, group, sectionColumns) + )} +
+ )} )} @@ -1507,6 +1572,175 @@ export function UniversalFormModalComponent({ ); }; + // 옵셔널 필드 그룹 접힘 상태 관리 + const [collapsedOptionalGroups, setCollapsedOptionalGroups] = useState>(() => { + // 초기 접힘 상태 설정 + const initialCollapsed = new Set(); + config.sections.forEach((section) => { + section.optionalFieldGroups?.forEach((group) => { + if (group.defaultCollapsed) { + initialCollapsed.add(`${section.id}-${group.id}`); + } + }); + }); + return initialCollapsed; + }); + + // 옵셔널 필드 그룹 렌더링 + const renderOptionalFieldGroup = ( + section: FormSectionConfig, + group: OptionalFieldGroupConfig, + sectionColumns: number + ) => { + const key = `${section.id}-${group.id}`; + const isActivated = activatedOptionalFieldGroups.has(key); + const isCollapsed = collapsedOptionalGroups.has(key); + const groupColumns = group.columns || sectionColumns; + const addButtonText = group.addButtonText || `+ ${group.title} 추가`; + const removeButtonText = group.removeButtonText || "제거"; + + // 비활성화 상태: 추가 버튼만 표시 + if (!isActivated) { + return ( +
+
+
+

{group.title}

+ {group.description && ( +

{group.description}

+ )} +
+ +
+
+ ); + } + + // 활성화 상태: 필드 그룹 표시 + // collapsible 설정에 따라 접기/펼치기 지원 + if (group.collapsible) { + return ( + { + setCollapsedOptionalGroups((prev) => { + const newSet = new Set(prev); + if (open) { + newSet.delete(key); + } else { + newSet.add(key); + } + return newSet; + }); + }} + className="border-primary/30 bg-muted/10 rounded-lg border" + > +
+ + + + +
+ +
+ {group.fields.map((field) => + renderFieldWithColumns( + field, + formData[field.columnName], + (value) => handleFieldChange(field.columnName, value), + `${section.id}-${group.id}-${field.id}`, + groupColumns + ) + )} +
+
+
+ ); + } + + // 접기 비활성화: 일반 표시 + return ( +
+
+
+

{group.title}

+ {group.description && ( +

{group.description}

+ )} +
+ +
+
+ {group.fields.map((field) => + renderFieldWithColumns( + field, + formData[field.columnName], + (value) => handleFieldChange(field.columnName, value), + `${section.id}-${group.id}-${field.id}`, + groupColumns + ) + )} +
+
+ ); + }; + // 반복 섹션 렌더링 const renderRepeatableSection = (section: FormSectionConfig, isCollapsed: boolean) => { const items = repeatSections[section.id] || []; diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx index 656f3f1a..98cbc248 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx @@ -499,7 +499,10 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor onOpenChange={setSectionLayoutModalOpen} section={selectedSection} onSave={(updates) => { + // config 업데이트 updateSection(selectedSection.id, updates); + // selectedSection 상태도 업데이트 (최신 상태 유지) + setSelectedSection({ ...selectedSection, ...updates }); setSectionLayoutModalOpen(false); }} onOpenFieldDetail={(field) => { @@ -522,18 +525,30 @@ export function UniversalFormModalConfigPanel({ config, onChange }: UniversalFor } }} field={selectedField} - onSave={(updates) => { + onSave={(updatedField) => { + // updatedField는 FieldDetailSettingsModal에서 전달된 전체 필드 객체 + const updatedSection = { + ...selectedSection, + // 기본 필드 목록에서 업데이트 + fields: selectedSection.fields.map((f) => (f.id === updatedField.id ? updatedField : f)), + // 옵셔널 필드 그룹 내 필드도 업데이트 + optionalFieldGroups: selectedSection.optionalFieldGroups?.map((group) => ({ + ...group, + fields: group.fields.map((f) => (f.id === updatedField.id ? updatedField : f)), + })), + }; + + // config 업데이트 onChange({ ...config, sections: config.sections.map((s) => - s.id === selectedSection.id - ? { - ...s, - fields: s.fields.map((f) => (f.id === selectedField.id ? { ...f, ...updates } : f)), - } - : s, + s.id === selectedSection.id ? updatedSection : s ), }); + + // selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영) + setSelectedSection(updatedSection); + setSelectedField(updatedField as FormFieldConfig); setFieldDetailModalOpen(false); setSectionLayoutModalOpen(true); }} diff --git a/frontend/lib/registry/components/universal-form-modal/config.ts b/frontend/lib/registry/components/universal-form-modal/config.ts index 66644576..85a3e3d9 100644 --- a/frontend/lib/registry/components/universal-form-modal/config.ts +++ b/frontend/lib/registry/components/universal-form-modal/config.ts @@ -91,9 +91,30 @@ export const defaultSectionConfig = { itemTitle: "항목 {index}", confirmRemove: false, }, + optionalFieldGroups: [], linkedFieldGroups: [], }; +// 기본 옵셔널 필드 그룹 설정 +export const defaultOptionalFieldGroupConfig = { + id: "", + fields: [], + // 섹션 스타일 설정 + title: "옵셔널 그룹", + description: "", + columns: undefined, // undefined면 부모 섹션 columns 상속 + collapsible: false, + defaultCollapsed: false, + // 버튼 설정 + addButtonText: "", + removeButtonText: "제거", + confirmRemove: false, + // 연동 필드 설정 + triggerField: "", + triggerValueOnAdd: "", + triggerValueOnRemove: "", +}; + // 기본 연동 필드 그룹 설정 export const defaultLinkedFieldGroupConfig = { id: "", 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 751ac2c6..d53d6e00 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -19,6 +19,17 @@ import { SELECT_OPTION_TYPE_OPTIONS, LINKED_FIELD_DISPLAY_FORMAT_OPTIONS, } from "../types"; +import { apiClient } from "@/lib/api/client"; + +// 카테고리 컬럼 타입 (table_column_category_values 용) +interface CategoryColumnOption { + tableName: string; + columnName: string; + columnLabel: string; + valueCount: number; + // 조합키: tableName.columnName + key: string; +} // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( @@ -48,6 +59,10 @@ export function FieldDetailSettingsModal({ }: FieldDetailSettingsModalProps) { // 로컬 상태로 필드 설정 관리 const [localField, setLocalField] = useState(field); + + // 전체 카테고리 컬럼 목록 상태 + const [categoryColumns, setCategoryColumns] = useState([]); + const [loadingCategoryColumns, setLoadingCategoryColumns] = useState(false); // open이 변경될 때마다 필드 데이터 동기화 useEffect(() => { @@ -55,6 +70,49 @@ export function FieldDetailSettingsModal({ setLocalField(field); } }, [open, field]); + + // 모든 카테고리 컬럼 목록 로드 (모달 열릴 때) + useEffect(() => { + const loadAllCategoryColumns = async () => { + if (!open) return; + + setLoadingCategoryColumns(true); + try { + // /api/table-categories/all-columns API 호출 + const response = await apiClient.get("/table-categories/all-columns"); + if (response.data?.success && response.data?.data) { + // 중복 제거를 위해 Map 사용 + const uniqueMap = new Map(); + response.data.data.forEach((col: any) => { + const tableName = col.tableName || col.table_name; + const columnName = col.columnName || col.column_name; + const key = `${tableName}.${columnName}`; + + // 이미 존재하는 경우 valueCount가 더 큰 것을 유지 + if (!uniqueMap.has(key)) { + uniqueMap.set(key, { + tableName, + columnName, + columnLabel: col.columnLabel || col.column_label || columnName, + valueCount: parseInt(col.valueCount || col.value_count || "0"), + key, + }); + } + }); + + setCategoryColumns(Array.from(uniqueMap.values())); + } else { + setCategoryColumns([]); + } + } catch (error) { + setCategoryColumns([]); + } finally { + setLoadingCategoryColumns(false); + } + }; + + loadAllCategoryColumns(); + }, [open]); // 필드 업데이트 함수 const updateField = (updates: Partial) => { @@ -107,7 +165,7 @@ export function FieldDetailSettingsModal({ }); }; - // 소스 테이블 컬럼 목록 + // 소스 테이블 컬럼 목록 (연결 필드용) const sourceTableColumns = localField.linkedFieldGroup?.sourceTable ? tableColumns[localField.linkedFieldGroup.sourceTable] || [] : []; @@ -248,7 +306,7 @@ export function FieldDetailSettingsModal({ Select 옵션 설정 {localField.selectOptions?.type && ( - ({localField.selectOptions.type === "table" ? "테이블 참조" : localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"}) + ({localField.selectOptions.type === "code" ? "공통코드" : "직접 입력"}) )} @@ -264,7 +322,7 @@ export function FieldDetailSettingsModal({ updateField({ selectOptions: { ...localField.selectOptions, - type: value as "static" | "table" | "code", + type: value as "static" | "code", }, }) } @@ -463,23 +521,32 @@ export function FieldDetailSettingsModal({ {localField.selectOptions?.type === "code" && (
- 공통코드: 시스템 공통코드에서 옵션을 가져옵니다. + 공통코드: 코드설정에서 등록한 카테고리 값을 가져옵니다.
- - + + + 코드설정에서 등록한 카테고리를 선택하세요
)} @@ -841,3 +908,4 @@ export function FieldDetailSettingsModal({ } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 27ee00ff..94bdf3af 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -795,3 +795,4 @@ export function SaveSettingsModal({ } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index dfdecbc0..057502c9 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -13,8 +13,8 @@ import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings as SettingsIcon } from "lucide-react"; import { cn } from "@/lib/utils"; -import { FormSectionConfig, FormFieldConfig, FIELD_TYPE_OPTIONS } from "../types"; -import { defaultFieldConfig, generateFieldId } from "../config"; +import { FormSectionConfig, FormFieldConfig, OptionalFieldGroupConfig, FIELD_TYPE_OPTIONS } from "../types"; +import { defaultFieldConfig, generateFieldId, generateUniqueId } from "../config"; // 도움말 텍스트 컴포넌트 const HelpText = ({ children }: { children: React.ReactNode }) => ( @@ -36,6 +36,7 @@ export function SectionLayoutModal({ onSave, onOpenFieldDetail, }: SectionLayoutModalProps) { + // 로컬 상태로 섹션 관리 const [localSection, setLocalSection] = useState(section); @@ -46,6 +47,7 @@ export function SectionLayoutModal({ } }, [open, section]); + // 섹션 업데이트 함수 const updateSection = (updates: Partial) => { setLocalSection((prev) => ({ ...prev, ...updates })); @@ -497,6 +499,427 @@ export function SectionLayoutModal({ )} + + {/* 옵셔널 필드 그룹 */} +
+
+
+

옵셔널 필드 그룹

+ + {localSection.optionalFieldGroups?.length || 0}개 + +
+ +
+ + + 섹션 내에서 "추가" 버튼을 눌러야 표시되는 필드 그룹입니다. +
+ 예: 해외 판매 정보 (인코텀즈, 결제조건, 통화 등) +
+ + {(!localSection.optionalFieldGroups || localSection.optionalFieldGroups.length === 0) ? ( +
+

옵셔널 필드 그룹이 없습니다

+
+ ) : ( +
+ {localSection.optionalFieldGroups.map((group, groupIndex) => ( +
+ {/* 그룹 헤더 */} +
+
+ 그룹 {groupIndex + 1} + {group.title} + + {group.fields.length}개 필드 + +
+ +
+ + {/* 그룹 기본 설정 */} +
+ {/* 제목 및 설명 */} +
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, title: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-6 text-[9px]" + placeholder="예: 해외 판매 정보" + /> +
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, description: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-6 text-[9px]" + placeholder="해외 판매 시 추가 정보 입력" + /> +
+
+ + {/* 레이아웃 및 옵션 */} +
+
+ + +
+
+ { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, collapsible: checked } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="scale-50" + /> + 접기 가능 +
+
+ { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, defaultCollapsed: checked } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + disabled={!group.collapsible} + className="scale-50" + /> + 기본 접힘 +
+
+ { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, confirmRemove: checked } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="scale-50" + /> + 제거 확인 +
+
+ + + + {/* 버튼 텍스트 설정 */} +
+ +
+
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, addButtonText: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-5 text-[8px]" + placeholder="+ 해외 판매 설정 추가" + /> +
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, removeButtonText: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-5 text-[8px]" + placeholder="제거" + /> +
+
+ + + + {/* 연동 필드 설정 */} +
+ + 추가/제거 시 다른 필드의 값을 자동으로 변경합니다 +
+
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, triggerField: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-5 text-[8px]" + placeholder="sales_type" + /> +
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, triggerValueOnAdd: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-5 text-[8px]" + placeholder="해외" + /> +
+
+ + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id ? { ...g, triggerValueOnRemove: e.target.value } : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="mt-0.5 h-5 text-[8px]" + placeholder="국내" + /> +
+
+ + + + {/* 그룹 내 필드 목록 */} +
+ + +
+ + {group.fields.length === 0 ? ( +
+

필드를 추가하세요

+
+ ) : ( +
+ {group.fields.map((field, fieldIndex) => ( +
+
+ { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id ? { ...f, label: e.target.value } : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="h-5 text-[8px]" + placeholder="라벨" + /> + { + const newGroups = localSection.optionalFieldGroups?.map((g) => + g.id === group.id + ? { + ...g, + fields: g.fields.map((f) => + f.id === field.id ? { ...f, columnName: e.target.value } : f + ), + } + : g + ); + updateSection({ optionalFieldGroups: newGroups }); + }} + className="h-5 text-[8px]" + placeholder="컬럼명" + /> + + +
+ + +
+ ))} +
+ )} +
+
+ ))} +
+ )} +
diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index beef7f56..3b5801c2 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -16,8 +16,9 @@ export interface SelectOptionConfig { labelColumn?: string; // 표시할 컬럼 (화면에 보여줄 텍스트) saveColumn?: string; // 저장할 컬럼 (실제로 DB에 저장할 값, 미지정 시 valueColumn 사용) filterCondition?: string; - // 공통코드 기반 옵션 - codeCategory?: string; + // 카테고리 컬럼 기반 옵션 (table_column_category_values 테이블) + // 형식: "tableName.columnName" (예: "sales_order_mng.incoterms") + categoryKey?: string; } // 채번규칙 설정 @@ -153,6 +154,29 @@ export interface RepeatSectionConfig { confirmRemove?: boolean; // 삭제 시 확인 (기본: false) } +// 옵셔널 필드 그룹 설정 (섹션 내에서 추가 버튼으로 표시되는 필드 그룹) +export interface OptionalFieldGroupConfig { + id: string; // 그룹 고유 ID + fields: FormFieldConfig[]; // 그룹에 포함된 필드들 + + // 섹션 스타일 설정 (활성화 시 표시되는 영역) + title: string; // 그룹 제목 (예: "해외 판매 정보") + description?: string; // 그룹 설명 + columns?: number; // 필드 배치 컬럼 수 (기본: 부모 섹션 columns 상속) + collapsible?: boolean; // 접을 수 있는지 (기본: false) + defaultCollapsed?: boolean; // 기본 접힘 상태 (기본: false) + + // 버튼 설정 + addButtonText?: string; // 추가 버튼 텍스트 (기본: "+ {title} 추가") + removeButtonText?: string; // 제거 버튼 텍스트 (기본: "제거") + confirmRemove?: boolean; // 제거 시 확인 (기본: false) + + // 연동 필드 설정 (추가/제거 시 다른 필드 값 변경) + triggerField?: string; // 값을 변경할 필드 (columnName) + triggerValueOnAdd?: any; // 추가 시 설정할 값 (예: "해외") + triggerValueOnRemove?: any; // 제거 시 설정할 값 (예: "국내") +} + // 섹션 설정 export interface FormSectionConfig { id: string; @@ -166,6 +190,9 @@ export interface FormSectionConfig { repeatable?: boolean; repeatConfig?: RepeatSectionConfig; + // 옵셔널 필드 그룹 (섹션 내에서 추가 버튼으로 표시) + optionalFieldGroups?: OptionalFieldGroupConfig[]; + // 연동 필드 그룹 (부서코드/부서명 등 연동 저장) linkedFieldGroups?: LinkedFieldGroup[]; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 892de458..19a41a52 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -973,6 +973,20 @@ export class ButtonActionExecutor { itemCount: parsedData.length, }); + // 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등) + // "범용_폼_모달" 키에서 공통 필드를 가져옴 + const universalFormData = context.formData["범용_폼_모달"] as Record | undefined; + const commonFields: Record = {}; + if (universalFormData && typeof universalFormData === "object") { + // 공통 필드 복사 (내부 메타 필드 제외) + for (const [key, value] of Object.entries(universalFormData)) { + if (!key.startsWith("_") && !key.endsWith("_numberingRuleId") && value !== undefined && value !== "") { + commonFields[key] = value; + } + } + console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields); + } + for (const item of parsedData) { // 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리) @@ -990,9 +1004,11 @@ export class ButtonActionExecutor { delete dataToSave.id; } - // 사용자 정보 추가 + // 🆕 공통 필드 병합 + 사용자 정보 추가 + // 공통 필드를 먼저 넣고, 개별 항목 데이터로 덮어씀 (개별 항목이 우선) const dataWithMeta: Record = { - ...dataToSave, + ...commonFields, // 범용 폼 모달의 공통 필드 (order_no, manager_id 등) + ...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, From 0810debd2ba08d1648a16079b880fb4dd0c5f07a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 15:00:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(universal-form-modal):=20=EC=98=B5?= =?UTF-8?q?=EC=85=94=EB=84=90=20=ED=95=84=EB=93=9C=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=ED=95=84=EB=93=9C=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모달 초기화 시 optionalFieldGroups의 triggerField에 기본값 설정 - triggerValueOnRemove 값을 기본값으로 사용 (비활성화 상태 기본값) - 수정 모드에서는 기존 데이터 값 유지 --- .../UniversalFormModalComponent.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 6f4ef0fc..3c47a1ba 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -390,6 +390,19 @@ export function UniversalFormModalComponent({ newFormData[field.columnName] = value; } + + // 옵셔널 필드 그룹의 연동 필드 기본값 설정 + // triggerValueOnRemove 값을 기본값으로 사용 (옵셔널 그룹이 비활성화 상태일 때의 기본값) + if (section.optionalFieldGroups) { + for (const group of section.optionalFieldGroups) { + if (group.triggerField && group.triggerValueOnRemove !== undefined) { + // effectiveInitialData에 해당 값이 없는 경우에만 기본값 설정 + if (!effectiveInitialData || effectiveInitialData[group.triggerField] === undefined) { + newFormData[group.triggerField] = group.triggerValueOnRemove; + } + } + } + } } } From 132cf4cd7d1ffa7d01bc702b6f1117cc518bf740 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 15:27:28 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix(universal-form-modal):=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=AA=A8=EB=93=9C=EC=97=90=EC=84=9C=20=EC=98=B5?= =?UTF-8?q?=EC=85=94=EB=84=90=20=ED=95=84=EB=93=9C=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 데이터의 triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화 - 활성화된 그룹의 필드값도 기존 데이터로 초기화 - 신규 등록 모드에서는 기존대로 비활성화 상태 유지 --- .../UniversalFormModalComponent.tsx | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 3c47a1ba..68a1553c 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -355,6 +355,7 @@ export function UniversalFormModalComponent({ const newFormData: FormDataState = {}; const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {}; const newCollapsed = new Set(); + const newActivatedGroups = new Set(); // 섹션별 초기화 for (const section of config.sections) { @@ -391,10 +392,31 @@ export function UniversalFormModalComponent({ newFormData[field.columnName] = value; } - // 옵셔널 필드 그룹의 연동 필드 기본값 설정 - // triggerValueOnRemove 값을 기본값으로 사용 (옵셔널 그룹이 비활성화 상태일 때의 기본값) + // 옵셔널 필드 그룹 처리 if (section.optionalFieldGroups) { for (const group of section.optionalFieldGroups) { + const key = `${section.id}-${group.id}`; + + // 수정 모드: triggerField 값이 triggerValueOnAdd와 일치하면 그룹 자동 활성화 + if (effectiveInitialData && group.triggerField && group.triggerValueOnAdd !== undefined) { + const triggerValue = effectiveInitialData[group.triggerField]; + if (triggerValue === group.triggerValueOnAdd) { + newActivatedGroups.add(key); + console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`); + + // 활성화된 그룹의 필드값도 초기화 + for (const field of group.fields) { + let value = field.defaultValue ?? ""; + const parentField = field.parentFieldName || field.columnName; + if (effectiveInitialData[parentField] !== undefined) { + value = effectiveInitialData[parentField]; + } + newFormData[field.columnName] = value; + } + } + } + + // 신규 등록 모드: triggerValueOnRemove를 기본값으로 설정 if (group.triggerField && group.triggerValueOnRemove !== undefined) { // effectiveInitialData에 해당 값이 없는 경우에만 기본값 설정 if (!effectiveInitialData || effectiveInitialData[group.triggerField] === undefined) { @@ -409,6 +431,7 @@ export function UniversalFormModalComponent({ setFormData(newFormData); setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); + setActivatedOptionalFieldGroups(newActivatedGroups); setOriginalData(effectiveInitialData || {}); // 채번규칙 자동 생성 From 52db6fd43cd7afc960fa4f49867794dff6226ad3 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 16:36:10 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(backend):=20created=5Fdate/updated=5Fd?= =?UTF-8?q?ate=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tableManagementService: insertData()에 created_date 자동 설정 - tableManagementService: updateData()에 updated_date 자동 설정 - dynamicFormService: updateFormRecord()에 updated_date 자동 설정 - 레거시 테이블(sales_order_mng 등) 날짜 컬럼 지원 --- backend-node/src/services/dynamicFormService.ts | 5 +++++ .../src/services/tableManagementService.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 7ec95626..68c30252 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -854,6 +854,11 @@ export class DynamicFormService { if (tableColumns.includes("updated_at")) { changedFields.updated_at = new Date(); } + // updated_date 컬럼도 지원 (sales_order_mng 등) + if (tableColumns.includes("updated_date")) { + changedFields.updated_date = new Date(); + console.log("📅 updated_date 자동 추가:", changedFields.updated_date); + } console.log("🎯 실제 업데이트할 필드들:", changedFields); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index e2f26138..b714b186 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -2289,6 +2289,13 @@ export class TableManagementService { logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); + // created_date 컬럼이 있고 값이 없으면 자동으로 현재 시간 추가 + const hasCreatedDate = columnTypeMap.has("created_date"); + if (hasCreatedDate && !data.created_date) { + data.created_date = new Date().toISOString(); + logger.info(`created_date 자동 추가: ${data.created_date}`); + } + // 컬럼명과 값을 분리하고 타입에 맞게 변환 const columns = Object.keys(data); const values = Object.values(data).map((value, index) => { @@ -2394,6 +2401,13 @@ export class TableManagementService { logger.info(`컬럼 타입 정보:`, Object.fromEntries(columnTypeMap)); logger.info(`PRIMARY KEY 컬럼들:`, primaryKeys); + // updated_date 컬럼이 있으면 자동으로 현재 시간 추가 + const hasUpdatedDate = columnTypeMap.has("updated_date"); + if (hasUpdatedDate && !updatedData.updated_date) { + updatedData.updated_date = new Date().toISOString(); + logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); + } + // SET 절 생성 (수정할 데이터) - 먼저 생성 const setConditions: string[] = []; const setValues: any[] = []; From ae38e0f249758da8b9fc5c8c2756db7e85863040 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 17 Dec 2025 16:38:12 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=97=90=EB=9F=AC=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=EC=83=89=EC=83=81=EC=97=86=EC=9D=8C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/tableManagementController.ts | 9 +++++---- .../screen/InteractiveDataTable.tsx | 8 ++++---- .../components/webtypes/RepeaterInput.tsx | 6 +++--- frontend/lib/api/dynamicForm.ts | 19 ++++++++++++++++++- .../table-list/TableListComponent.tsx | 10 ++++++---- 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 66c70a77..0c6b805a 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -767,11 +767,12 @@ export async function getTableData( const tableManagementService = new TableManagementService(); - // 🆕 현재 사용자 필터 적용 + // 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용) let enhancedSearch = { ...search }; - if (autoFilter?.enabled && req.user) { - const filterColumn = autoFilter.filterColumn || "company_code"; - const userField = autoFilter.userField || "companyCode"; + const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true + if (shouldApplyAutoFilter && req.user) { + const filterColumn = autoFilter?.filterColumn || "company_code"; + const userField = autoFilter?.userField || "companyCode"; const userValue = (req.user as any)[userField]; if (userValue) { diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index a1015ac6..5e4bda2e 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -2201,12 +2201,12 @@ export const InteractiveDataTable: React.FC = ({ const mapping = categoryMappings[column.columnName]; const categoryData = mapping?.[String(value)]; - // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 + // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시 const displayLabel = categoryData?.label || String(value); - const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 + const displayColor = categoryData?.color; - // 배지 없음 옵션: color가 "none"이면 텍스트만 표시 - if (displayColor === "none") { + // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 + if (!displayColor || displayColor === "none" || !categoryData) { return {displayLabel}; } diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 6118e073..db9e1d6b 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -418,7 +418,7 @@ export const RepeaterInput: React.FC = ({ const valueStr = String(value); // 값을 문자열로 변환 const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; - const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) + const displayColor = categoryData?.color; console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { fieldName: field.name, @@ -429,8 +429,8 @@ export const RepeaterInput: React.FC = ({ displayColor, }); - // 색상이 "none"이면 일반 텍스트로 표시 - if (displayColor === "none") { + // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 + if (!displayColor || displayColor === "none" || !categoryData) { return {displayLabel}; } diff --git a/frontend/lib/api/dynamicForm.ts b/frontend/lib/api/dynamicForm.ts index a66d8f99..a5a3b2eb 100644 --- a/frontend/lib/api/dynamicForm.ts +++ b/frontend/lib/api/dynamicForm.ts @@ -426,12 +426,29 @@ export class DynamicFormApi { sortBy?: string; sortOrder?: "asc" | "desc"; filters?: Record; + autoFilter?: { + enabled: boolean; + filterColumn?: string; + userField?: string; + }; }, ): Promise> { try { console.log("📊 테이블 데이터 조회 요청:", { tableName, params }); - const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params || {}); + // autoFilter가 없으면 기본값으로 멀티테넌시 필터 적용 + // pageSize를 size로 변환 (백엔드 파라미터명 호환) + const requestParams = { + ...params, + size: params?.pageSize || params?.size || 100, // 기본값 100 + autoFilter: params?.autoFilter ?? { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, + }; + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestParams); console.log("✅ 테이블 데이터 조회 성공 (원본):", response.data); console.log("🔍 response.data 상세:", { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index bed2b795..703bf2b0 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -4233,9 +4233,10 @@ export const TableListComponent: React.FC = ({ if (values.length === 1) { const categoryData = mapping?.[values[0]]; const displayLabel = categoryData?.label || values[0]; - const displayColor = categoryData?.color || "#64748b"; + const displayColor = categoryData?.color; - if (displayColor === "none") { + // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 + if (!displayColor || displayColor === "none" || !categoryData) { return {displayLabel}; } @@ -4258,9 +4259,10 @@ export const TableListComponent: React.FC = ({ {values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; - const displayColor = categoryData?.color || "#64748b"; + const displayColor = categoryData?.color; - if (displayColor === "none") { + // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 + if (!displayColor || displayColor === "none" || !categoryData) { return ( {displayLabel} From 6dcace3135ce5c56b8884b08c5eb3d9358e8bd4e Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 17:04:45 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix(RepeaterTable):=20=EC=88=AB=EC=9E=90=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=ED=8F=AC=EB=A7=B7=ED=8C=85=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정수/소수점 자동 구분 처리 - 천 단위 구분자(toLocaleString) 적용 - null/undefined/NaN 예외 처리 추가 --- .../modal-repeater-table/RepeaterTable.tsx | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 4d6c9086..9604e7d2 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -347,12 +347,23 @@ export function RepeaterTable({ // 계산 필드는 편집 불가 if (column.calculated || !column.editable) { + // 숫자 포맷팅 함수: 정수/소수점 자동 구분 + const formatNumber = (val: any): string => { + if (val === undefined || val === null || val === "") return "0"; + const num = typeof val === "number" ? val : parseFloat(val); + if (isNaN(num)) return "0"; + // 정수면 소수점 없이, 소수면 소수점 유지 + if (Number.isInteger(num)) { + return num.toLocaleString("ko-KR"); + } else { + return num.toLocaleString("ko-KR"); + } + }; + return (
{column.type === "number" - ? typeof value === "number" - ? value.toLocaleString() - : value || "0" + ? formatNumber(value) : value || "-"}
); @@ -361,10 +372,23 @@ export function RepeaterTable({ // 편집 가능한 필드 switch (column.type) { case "number": + // 숫자 표시: 정수/소수점 자동 구분 + const displayValue = (() => { + if (value === undefined || value === null || value === "") return ""; + const num = typeof value === "number" ? value : parseFloat(value); + if (isNaN(num)) return ""; + // 정수면 소수점 없이, 소수면 소수점 유지 + if (Number.isInteger(num)) { + return num.toString(); + } else { + return num.toString(); + } + })(); + return ( handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0) } From 3589e4a5b90dc9788a6308b588406b25e9299fa2 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 17 Dec 2025 17:41:29 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=ED=91=9C=EC=8B=9C=EC=84=A4=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/cascadingAutoFillRoutes.ts | 1 + .../src/routes/cascadingConditionRoutes.ts | 1 + .../src/routes/cascadingHierarchyRoutes.ts | 1 + .../routes/cascadingMutualExclusionRoutes.ts | 1 + .../src/services/entityJoinService.ts | 9 +- docs/노드플로우_개선사항.md | 1 + docs/메일발송_기능_사용_가이드.md | 1 + docs/즉시저장_버튼_액션_구현_계획서.md | 1 + frontend/app/(main)/admin/tableMng/page.tsx | 117 ++++++++++-------- frontend/contexts/ActiveTabContext.tsx | 1 + frontend/hooks/useAutoFill.ts | 1 + .../table-list/TableListComponent.tsx | 11 +- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 15 files changed, 90 insertions(+), 59 deletions(-) diff --git a/backend-node/src/routes/cascadingAutoFillRoutes.ts b/backend-node/src/routes/cascadingAutoFillRoutes.ts index 7aa1d825..92cd1bbc 100644 --- a/backend-node/src/routes/cascadingAutoFillRoutes.ts +++ b/backend-node/src/routes/cascadingAutoFillRoutes.ts @@ -52,3 +52,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingConditionRoutes.ts b/backend-node/src/routes/cascadingConditionRoutes.ts index 5f57c6ca..5745511b 100644 --- a/backend-node/src/routes/cascadingConditionRoutes.ts +++ b/backend-node/src/routes/cascadingConditionRoutes.ts @@ -48,3 +48,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingHierarchyRoutes.ts b/backend-node/src/routes/cascadingHierarchyRoutes.ts index b0e3c79a..92da4019 100644 --- a/backend-node/src/routes/cascadingHierarchyRoutes.ts +++ b/backend-node/src/routes/cascadingHierarchyRoutes.ts @@ -64,3 +64,4 @@ export default router; + diff --git a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts index 0cec35d2..451fe973 100644 --- a/backend-node/src/routes/cascadingMutualExclusionRoutes.ts +++ b/backend-node/src/routes/cascadingMutualExclusionRoutes.ts @@ -52,3 +52,4 @@ export default router; + diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 5557d8b5..25d96927 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -186,8 +186,13 @@ export class EntityJoinService { } } - // 별칭 컬럼명 생성 (writer -> writer_name) - const aliasColumn = `${column.column_name}_name`; + // 🎯 별칭 컬럼명 생성 - 사용자가 선택한 displayColumns 기반으로 동적 생성 + // 단일 컬럼: manager + user_name → manager_user_name + // 여러 컬럼: 첫 번째 컬럼 기준 (나머지는 개별 alias로 처리됨) + const firstDisplayColumn = displayColumns[0] || "name"; + const aliasColumn = `${column.column_name}_${firstDisplayColumn}`; + + logger.info(`🔧 별칭 컬럼명 생성: ${column.column_name} + ${firstDisplayColumn} → ${aliasColumn}`); const joinConfig: EntityJoinConfig = { sourceTable: tableName, diff --git a/docs/노드플로우_개선사항.md b/docs/노드플로우_개선사항.md index a181ac21..b19c7092 100644 --- a/docs/노드플로우_개선사항.md +++ b/docs/노드플로우_개선사항.md @@ -584,3 +584,4 @@ const result = await executeNodeFlow(flowId, { + diff --git a/docs/메일발송_기능_사용_가이드.md b/docs/메일발송_기능_사용_가이드.md index 916fbc54..f0805640 100644 --- a/docs/메일발송_기능_사용_가이드.md +++ b/docs/메일발송_기능_사용_가이드.md @@ -357,3 +357,4 @@ + diff --git a/docs/즉시저장_버튼_액션_구현_계획서.md b/docs/즉시저장_버튼_액션_구현_계획서.md index 6ce86286..69e34f5a 100644 --- a/docs/즉시저장_버튼_액션_구현_계획서.md +++ b/docs/즉시저장_버튼_액션_구현_계획서.md @@ -343,3 +343,4 @@ const getComponentValue = (componentId: string) => { 3. **조건부 저장**: 특정 조건 만족 시에만 저장 4. **연쇄 저장**: 한 번의 클릭으로 여러 테이블에 저장 + diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index cd0c462d..b554dff1 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -93,7 +93,7 @@ export default function TableManagementPage() { const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); const [ddlLogViewerOpen, setDdlLogViewerOpen] = useState(false); - + // 테이블 복제 관련 상태 const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateSourceTable, setDuplicateSourceTable] = useState(null); @@ -109,7 +109,7 @@ export default function TableManagementPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); - + // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); @@ -459,11 +459,39 @@ export default function TableManagementPage() { if (!selectedTable) return; try { + // 🎯 Entity 타입인 경우 detailSettings에 엔티티 설정을 JSON으로 포함 + let finalDetailSettings = column.detailSettings || ""; + + if (column.inputType === "entity" && column.referenceTable) { + // 기존 detailSettings를 파싱하거나 새로 생성 + let existingSettings: Record = {}; + if (typeof column.detailSettings === "string" && column.detailSettings.trim().startsWith("{")) { + try { + existingSettings = JSON.parse(column.detailSettings); + } catch { + existingSettings = {}; + } + } + + // 엔티티 설정 추가 + const entitySettings = { + ...existingSettings, + entityTable: column.referenceTable, + entityCodeColumn: column.referenceColumn || "id", + entityLabelColumn: column.displayColumn || "name", + placeholder: (existingSettings.placeholder as string) || "항목을 선택하세요", + searchable: existingSettings.searchable ?? true, + }; + + finalDetailSettings = JSON.stringify(entitySettings); + console.log("🔧 Entity 설정 JSON 생성:", entitySettings); + } + const columnSetting = { columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) columnLabel: column.displayName, // 사용자가 입력한 표시명 inputType: column.inputType || "text", - detailSettings: column.detailSettings || "", + detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", @@ -487,7 +515,7 @@ export default function TableManagementPage() { if (response.data.success) { console.log("✅ 컬럼 설정 저장 성공"); - + // 🆕 Category 타입인 경우 컬럼 매핑 처리 console.log("🔍 카테고리 조건 체크:", { isCategory: column.inputType === "category", @@ -547,7 +575,7 @@ export default function TableManagementPage() { } else if (successCount > 0 && failCount > 0) { toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); } else if (failCount > 0) { - toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + toast.error("컬럼 설정 저장 성공. 메뉴 매핑 생성 실패."); } } else { toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); @@ -680,9 +708,7 @@ export default function TableManagementPage() { console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); if (totalSuccessCount > 0) { - toast.success( - `테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.` - ); + toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`); } else if (totalFailCount > 0) { toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); } else { @@ -1000,14 +1026,15 @@ export default function TableManagementPage() { .filter( (table) => table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + (table.displayName && + table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), ) .every((table) => selectedTableIds.has(table.tableName)) } onCheckedChange={handleSelectAll} aria-label="전체 선택" /> - + {selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`} @@ -1047,9 +1074,9 @@ export default function TableManagementPage() {
e.stopPropagation()} /> )} -
handleTableSelect(table.tableName)} - > +
handleTableSelect(table.tableName)}>

{table.displayName || table.tableName}

{table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} @@ -1147,7 +1171,10 @@ export default function TableManagementPage() { ) : (

{/* 컬럼 헤더 (고정) */} -
+
컬럼명
라벨
입력 타입
@@ -1171,7 +1198,7 @@ export default function TableManagementPage() { className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }} > -
+
{column.columnName}
@@ -1226,9 +1253,9 @@ export default function TableManagementPage() { -
+
{secondLevelMenus.length === 0 ? ( -

+

2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.

) : ( @@ -1236,7 +1263,7 @@ export default function TableManagementPage() { // menuObjid를 숫자로 변환하여 비교 const menuObjidNum = Number(menu.menuObjid); const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - + return (
col.columnName === column.columnName ? { ...col, categoryMenus: newMenus } - : col - ) + : col, + ), ); }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + className="text-primary focus:ring-ring h-4 w-4 rounded border-gray-300 focus:ring-2" /> @@ -1282,9 +1309,7 @@ export default function TableManagementPage() { <> {/* 참조 테이블 */}
- + @@ -1361,9 +1379,7 @@ export default function TableManagementPage() { column.referenceColumn && column.referenceColumn !== "none" && (
- +