From ccbbf46faf6c1ecc87363273d22cc743abbf5044 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 17 Dec 2025 14:30:29 +0900 Subject: [PATCH] =?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,