From e305e78155bc308ad43dfaff75177c284569fdc9 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 11:43:26 +0900 Subject: [PATCH 01/11] Implement Comma Value Resolution in Entity Join Service - Added a new method `resolveCommaValues` in `EntityJoinService` to handle comma-separated values for entity joins, allowing for individual code resolution and label conversion. - Integrated the new method into `TableManagementService` to process data after executing join queries. - Enhanced the `DynamicComponentRenderer` to maintain entity label columns based on existing configurations. Made-with: Cursor --- .../src/services/entityJoinService.ts | 70 +++++++++++++++++++ .../src/services/tableManagementService.ts | 5 +- frontend/components/v2/V2Input.tsx | 2 +- .../lib/registry/DynamicComponentRenderer.tsx | 8 ++- 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 1f345727..1a28f67a 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -823,6 +823,76 @@ export class EntityJoinService { return []; } } + + /** + * 콤마 구분 다중값 해결 (겸직 부서 등) + * entity join이 NULL인데 소스값에 콤마가 있으면 개별 코드를 각각 조회해서 라벨로 변환 + */ + async resolveCommaValues( + data: Record[], + joinConfigs: EntityJoinConfig[] + ): Promise[]> { + if (!data.length || !joinConfigs.length) return data; + + for (const config of joinConfigs) { + const sourceCol = config.sourceColumn; + const displayCol = config.displayColumns?.[0] || config.displayColumn; + if (!displayCol || displayCol === "none") continue; + + const aliasCol = config.aliasColumn || `${sourceCol}_${displayCol}`; + const labelCol = `${sourceCol}_label`; + + const codesSet = new Set(); + const rowsToResolve: number[] = []; + + data.forEach((row, idx) => { + const srcVal = row[sourceCol]; + if (!srcVal || typeof srcVal !== "string" || !srcVal.includes(",")) return; + + const joinedVal = row[aliasCol] || row[labelCol]; + if (joinedVal && joinedVal !== "") return; + + rowsToResolve.push(idx); + srcVal.split(",").map((v: string) => v.trim()).filter(Boolean).forEach((code: string) => codesSet.add(code)); + }); + + if (codesSet.size === 0) continue; + + const codes = Array.from(codesSet); + const refCol = config.referenceColumn || "id"; + const placeholders = codes.map((_, i) => `$${i + 1}`).join(","); + try { + const result = await query>( + `SELECT "${refCol}"::TEXT as _key, "${displayCol}"::TEXT as _label + FROM ${config.referenceTable} + WHERE "${refCol}"::TEXT IN (${placeholders})`, + codes + ); + + const labelMap = new Map(); + result.forEach((r) => labelMap.set(r._key, r._label)); + + for (const idx of rowsToResolve) { + const srcVal = data[idx][sourceCol] as string; + const resolvedLabels = srcVal + .split(",") + .map((v: string) => v.trim()) + .filter(Boolean) + .map((code: string) => labelMap.get(code) || code) + .join(", "); + + data[idx][aliasCol] = resolvedLabels; + data[idx][labelCol] = resolvedLabels; + } + + logger.info(`콤마 구분 entity 값 해결: ${sourceCol} → ${codesSet.size}개 코드, ${rowsToResolve.length}개 행`); + } catch (err) { + logger.warn(`콤마 구분 entity 값 해결 실패: ${sourceCol}`, err); + } + } + + return data; + } } export const entityJoinService = new EntityJoinService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2ddae736..82b66438 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -3588,12 +3588,15 @@ export class TableManagementService { `✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행` ); - const data = Array.isArray(dataResult) ? dataResult : []; + let data = Array.isArray(dataResult) ? dataResult : []; const total = Array.isArray(countResult) && countResult.length > 0 ? Number((countResult[0] as any).total) : 0; + // 콤마 구분 다중값 후처리 (겸직 부서 등) + data = await entityJoinService.resolveCommaValues(data, joinConfigs); + const queryTime = Date.now() - startTime; return { diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index c8204faf..626bbbe3 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -1085,7 +1085,7 @@ export const V2Input = forwardRef((props, ref) => const hasCustomRadius = !!style?.borderRadius; const customTextStyle: React.CSSProperties = {}; - if (style?.color) customTextStyle.color = style.color; + if (style?.color) customTextStyle.color = getAdaptiveLabelColor(style.color); if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 859d136f..46b17fe2 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -102,12 +102,16 @@ function mergeColumnMeta(tableName: string | undefined, columnName: string | und if (dbInputType === "entity") { const refTable = meta.reference_table || meta.referenceTable; const refColumn = meta.reference_column || meta.referenceColumn; - const displayCol = meta.display_column || meta.displayColumn; + const rawDisplayCol = meta.display_column || meta.displayColumn; + const displayCol = rawDisplayCol && rawDisplayCol !== "none" && rawDisplayCol !== "" ? rawDisplayCol : undefined; if (refTable) { merged.source = "entity"; merged.entityTable = refTable; merged.entityValueColumn = refColumn || "id"; - merged.entityLabelColumn = displayCol || "name"; + // 화면 설정에 이미 entityLabelColumn이 있으면 유지, 없으면 DB 값 또는 기본값 사용 + if (!merged.entityLabelColumn) { + merged.entityLabelColumn = displayCol || "name"; + } merged.fieldType = "entity"; merged.inputType = "entity"; } -- 2.43.0 From dfd26e19335539596ac31f0e2e9a3640df0da4c2 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 16:28:42 +0900 Subject: [PATCH 02/11] 11 --- .../src/services/numberingRuleService.ts | 355 +++++++++++++++++- 1 file changed, 342 insertions(+), 13 deletions(-) diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 91ae4cb5..97d27e6c 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -227,6 +227,312 @@ class NumberingRuleService { ); return result.rows[0].current_sequence; } + + /** + * 카운터를 특정 값 이상으로 동기화 (GREATEST 사용) + * 테이블 내 실제 최대값이 카운터보다 높을 때 카운터를 맞춰줌 + */ + private async setSequenceForPrefix( + client: any, + ruleId: string, + companyCode: string, + prefixKey: string, + targetSequence: number + ): Promise { + const result = await client.query( + `INSERT INTO numbering_rule_sequences (rule_id, company_code, prefix_key, current_sequence, last_allocated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (rule_id, company_code, prefix_key) + DO UPDATE SET current_sequence = GREATEST(numbering_rule_sequences.current_sequence, $4), + last_allocated_at = NOW() + RETURNING current_sequence`, + [ruleId, companyCode, prefixKey, targetSequence] + ); + return result.rows[0].current_sequence; + } + + /** + * 대상 테이블에서 해당 회사의 최대 시퀀스 번호를 조회 + * 코드의 prefix/suffix 패턴을 기반으로 sequence 부분만 추출하여 MAX 계산 + */ + private async getMaxSequenceFromTable( + client: any, + tableName: string, + columnName: string, + codePrefix: string, + codeSuffix: string, + seqLength: number, + companyCode: string + ): Promise { + try { + // 테이블에 company_code 컬럼이 있는지 확인 + const colCheck = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + const hasCompanyCode = colCheck.rows.length > 0; + + // 대상 컬럼 존재 여부 확인 + const targetColCheck = await client.query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = $2`, + [tableName, columnName] + ); + if (targetColCheck.rows.length === 0) { + logger.warn(`getMaxSequenceFromTable: 컬럼 없음 ${tableName}.${columnName}`); + return 0; + } + + // prefix와 suffix 사이의 sequence 부분을 추출하기 위한 위치 계산 + const prefixLen = codePrefix.length; + const seqStart = prefixLen + 1; // SQL SUBSTRING은 1-based + + // LIKE 패턴: prefix + N자리 숫자 + suffix + const likePattern = codePrefix + "%" + codeSuffix; + + let sql: string; + let params: any[]; + + if (hasCompanyCode && companyCode !== "*") { + sql = ` + SELECT MAX( + CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + ) as max_seq + FROM "${tableName}" + WHERE "${columnName}" LIKE $3 + AND company_code = $4 + AND LENGTH("${columnName}") = $5 + AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + `; + params = [seqStart, seqLength, likePattern, companyCode, prefixLen + seqLength + codeSuffix.length]; + } else { + sql = ` + SELECT MAX( + CAST(SUBSTRING("${columnName}" FROM $1 FOR $2) AS INTEGER) + ) as max_seq + FROM "${tableName}" + WHERE "${columnName}" LIKE $3 + AND LENGTH("${columnName}") = $4 + AND SUBSTRING("${columnName}" FROM $1 FOR $2) ~ '^[0-9]+$' + `; + params = [seqStart, seqLength, likePattern, prefixLen + seqLength + codeSuffix.length]; + } + + const result = await client.query(sql, params); + const maxSeq = result.rows[0]?.max_seq ?? 0; + + logger.info("getMaxSequenceFromTable 결과", { + tableName, columnName, codePrefix, codeSuffix, + seqLength, companyCode, maxSeq, + }); + + return maxSeq; + } catch (error: any) { + logger.warn("getMaxSequenceFromTable 실패 (카운터 폴백)", { + tableName, columnName, error: error.message, + }); + return 0; + } + } + + /** + * 규칙의 파트 구성에서 sequence 파트 전후의 prefix/suffix를 계산 + * allocateCode/previewCode에서 비-sequence 파트 값이 이미 계산된 후 호출 + */ + private buildCodePrefixSuffix( + partValues: string[], + sortedParts: any[], + globalSeparator: string + ): { prefix: string; suffix: string; seqIndex: number; seqLength: number } | null { + const seqIndex = sortedParts.findIndex((p: any) => p.partType === "sequence"); + if (seqIndex === -1) return null; + + const seqLength = sortedParts[seqIndex].autoConfig?.sequenceLength || 3; + + // prefix: sequence 파트 이전의 모든 파트값 + 구분자 + let prefix = ""; + for (let i = 0; i < seqIndex; i++) { + prefix += partValues[i]; + const sep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator; + prefix += sep; + } + + // suffix: sequence 파트 이후의 모든 파트값 + 구분자 + let suffix = ""; + for (let i = seqIndex + 1; i < partValues.length; i++) { + const sep = sortedParts[i - 1].separatorAfter ?? sortedParts[i - 1].autoConfig?.separatorAfter ?? globalSeparator; + if (i === seqIndex + 1) { + // sequence 파트 바로 뒤 구분자 + const seqSep = sortedParts[seqIndex].separatorAfter ?? sortedParts[seqIndex].autoConfig?.separatorAfter ?? globalSeparator; + suffix += seqSep; + } + suffix += partValues[i]; + if (i < partValues.length - 1) { + const nextSep = sortedParts[i].separatorAfter ?? sortedParts[i].autoConfig?.separatorAfter ?? globalSeparator; + suffix += nextSep; + } + } + + return { prefix, suffix, seqIndex, seqLength }; + } + + /** + * 비-sequence 파트의 값을 계산하여 prefix/suffix 패턴 구축에 사용 + * sequence 파트는 빈 문자열로 반환 (이후 buildCodePrefixSuffix에서 처리) + */ + private async computeNonSequenceValues( + rule: NumberingRuleConfig, + formData?: Record + ): Promise { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + return Promise.all(sortedParts.map(async (part: any) => { + if (part.partType === "sequence") return ""; + if (part.generationMethod === "manual") return ""; + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "text": + return autoConfig.textValue || "TEXT"; + + case "number": { + const length = autoConfig.numberLength || 3; + const value = autoConfig.numberValue || 1; + return String(value).padStart(length, "0"); + } + + case "date": { + const dateFormat = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName && formData) { + const columnValue = formData[autoConfig.sourceColumnName]; + if (columnValue) { + const dateValue = columnValue instanceof Date ? columnValue : new Date(columnValue); + if (!isNaN(dateValue.getTime())) { + return this.formatDate(dateValue, dateFormat); + } + } + } + return this.formatDate(new Date(), dateFormat); + } + + case "category": { + const categoryKey = autoConfig.categoryKey; + const categoryMappings = autoConfig.categoryMappings || []; + if (!categoryKey || !formData) return ""; + + const colName = categoryKey.includes(".") ? categoryKey.split(".")[1] : categoryKey; + const selectedValue = formData[colName]; + if (!selectedValue) return ""; + + const selectedValueStr = String(selectedValue); + let mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === selectedValueStr) return true; + if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true; + if (m.categoryValueLabel === selectedValueStr) return true; + return false; + }); + + if (!mapping) { + try { + const pool = getPool(); + const [ct, cc] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey]; + const cvResult = await pool.query( + `SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`, + [ct, cc, selectedValueStr] + ); + if (cvResult.rows.length > 0) { + mapping = categoryMappings.find((m: any) => { + if (m.categoryValueId?.toString() === String(cvResult.rows[0].value_id)) return true; + if (m.categoryValueLabel === cvResult.rows[0].value_label) return true; + return false; + }); + } + } catch { /* ignore */ } + } + + return mapping?.format || ""; + } + + case "reference": { + const refColumn = autoConfig.referenceColumnName; + if (refColumn && formData && formData[refColumn]) { + return String(formData[refColumn]); + } + return ""; + } + + default: + return ""; + } + })); + } + + /** + * 대상 테이블 기반으로 실제 최대 시퀀스를 확인하고, + * 카운터와 비교하여 더 높은 값 + 1을 반환 + */ + private async resolveNextSequence( + client: any, + rule: NumberingRuleConfig, + companyCode: string, + ruleId: string, + prefixKey: string, + formData?: Record + ): Promise { + // 1. 현재 저장된 카운터 조회 + const currentCounter = await this.getSequenceForPrefix( + client, ruleId, companyCode, prefixKey + ); + + let baseSequence = currentCounter; + + // 2. 규칙에 tableName/columnName이 설정되어 있으면 대상 테이블에서 MAX 조회 + if (rule.tableName && rule.columnName) { + try { + const sortedParts = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const patternValues = await this.computeNonSequenceValues(rule, formData); + const psInfo = this.buildCodePrefixSuffix(patternValues, sortedParts, rule.separator || ""); + + if (psInfo) { + const maxFromTable = await this.getMaxSequenceFromTable( + client, rule.tableName, rule.columnName, + psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode + ); + + if (maxFromTable > baseSequence) { + logger.info("테이블 내 최대값이 카운터보다 높음 → 동기화", { + ruleId, companyCode, currentCounter, maxFromTable, + }); + baseSequence = maxFromTable; + } + } + } catch (error: any) { + logger.warn("테이블 기반 MAX 조회 실패, 카운터 기반 폴백", { + ruleId, error: error.message, + }); + } + } + + // 3. 다음 시퀀스 = base + 1 + const nextSequence = baseSequence + 1; + + // 4. 카운터를 동기화 (GREATEST 사용) + await this.setSequenceForPrefix(client, ruleId, companyCode, prefixKey, nextSequence); + + // 5. 호환성을 위해 numbering_rules.current_sequence도 업데이트 + await client.query( + "UPDATE numbering_rules SET current_sequence = GREATEST(COALESCE(current_sequence, 0), $3) WHERE rule_id = $1 AND company_code = $2", + [ruleId, companyCode, nextSequence] + ); + + logger.info("resolveNextSequence 완료", { + ruleId, companyCode, prefixKey, currentCounter, baseSequence, nextSequence, + }); + + return nextSequence; + } + /** * 규칙 목록 조회 (전체) */ @@ -1087,13 +1393,41 @@ class NumberingRuleService { const rule = await this.getRuleById(ruleId, companyCode); if (!rule) throw new Error("규칙을 찾을 수 없습니다"); - // prefix_key 기반 순번 조회 + // prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교 const prefixKey = await this.buildPrefixKey(rule, formData); const pool = getPool(); const currentSeq = await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); - logger.info("미리보기: prefix_key 기반 순번 조회", { - ruleId, prefixKey, currentSeq, + // 대상 테이블에서 실제 최대 시퀀스 조회 + let baseSeq = currentSeq; + if (rule.tableName && rule.columnName) { + try { + const sortedPartsForPattern = [...rule.parts].sort((a: any, b: any) => a.order - b.order); + const patternValues = await this.computeNonSequenceValues(rule, formData); + const psInfo = this.buildCodePrefixSuffix(patternValues, sortedPartsForPattern, rule.separator || ""); + + if (psInfo) { + const maxFromTable = await this.getMaxSequenceFromTable( + pool, rule.tableName, rule.columnName, + psInfo.prefix, psInfo.suffix, psInfo.seqLength, companyCode + ); + + if (maxFromTable > baseSeq) { + logger.info("미리보기: 테이블 내 최대값이 카운터보다 높음", { + ruleId, companyCode, currentSeq, maxFromTable, + }); + baseSeq = maxFromTable; + } + } + } catch (error: any) { + logger.warn("미리보기: 테이블 기반 MAX 조회 실패, 카운터 기반 폴백", { + ruleId, error: error.message, + }); + } + } + + logger.info("미리보기: 순번 조회 완료", { + ruleId, prefixKey, currentSeq, baseSeq, }); const parts = await Promise.all(rule.parts @@ -1108,7 +1442,7 @@ class NumberingRuleService { switch (part.partType) { case "sequence": { const length = autoConfig.sequenceLength || 3; - const nextSequence = currentSeq + 1; + const nextSequence = baseSeq + 1; return String(nextSequence).padStart(length, "0"); } @@ -1306,20 +1640,15 @@ class NumberingRuleService { const prefixKey = await this.buildPrefixKey(rule, formData); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); - // 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 + // 순번이 있으면 테이블 내 최대값과 카운터를 비교하여 다음 순번 결정 let allocatedSequence = 0; if (hasSequence) { - allocatedSequence = await this.incrementSequenceForPrefix( - client, ruleId, companyCode, prefixKey - ); - // 호환성을 위해 기존 current_sequence도 업데이트 - await client.query( - "UPDATE numbering_rules SET current_sequence = current_sequence + 1 WHERE rule_id = $1 AND company_code = $2", - [ruleId, companyCode] + allocatedSequence = await this.resolveNextSequence( + client, rule, companyCode, ruleId, prefixKey, formData ); } - logger.info("allocateCode: prefix_key 기반 순번 할당", { + logger.info("allocateCode: 테이블 기반 순번 할당", { ruleId, prefixKey, allocatedSequence, }); -- 2.43.0 From b4a5fb9aa3b896299097ced637bba6589b28f89d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 16:47:33 +0900 Subject: [PATCH 03/11] feat: enhance ButtonConfigPanel and V2ButtonConfigPanel with improved data handling - Updated the ButtonConfigPanel to fetch a larger set of screens by modifying the API call to include a size parameter. - Enhanced the V2ButtonConfigPanel with new state variables and effects for managing data transfer field mappings, including loading available tables and their columns. - Implemented multi-table mapping logic to support complex data transfer actions, improving the flexibility and usability of the component. - Added a dedicated section for field mapping in the UI, allowing users to configure data transfer settings more effectively. These updates aim to enhance the functionality and user experience of the button configuration panels within the ERP system, enabling better data management and transfer capabilities. Made-with: Cursor --- .../config-panels/ButtonConfigPanel-fixed.tsx | 2 +- .../v2/config-panels/V2ButtonConfigPanel.tsx | 675 +++++++++++++++++- frontend/lib/utils/buttonActions.ts | 31 +- 3 files changed, 703 insertions(+), 5 deletions(-) diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx index 51322c3e..30b93cb4 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel-fixed.tsx @@ -102,7 +102,7 @@ export const ButtonConfigPanel: React.FC = ({ component, const fetchScreens = async () => { try { setScreensLoading(true); - const response = await apiClient.get("/screen-management/screens"); + const response = await apiClient.get("/screen-management/screens?size=1000"); if (response.data.success && Array.isArray(response.data.data)) { const screenList = response.data.data.map((screen: any) => ({ diff --git a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx index 777c705b..a36cd8c9 100644 --- a/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2ButtonConfigPanel.tsx @@ -269,6 +269,13 @@ export const V2ButtonConfigPanel: React.FC = ({ const [modalScreenOpen, setModalScreenOpen] = useState(false); const [modalSearchTerm, setModalSearchTerm] = useState(""); + // 데이터 전달 필드 매핑 관련 + const [availableTables, setAvailableTables] = useState>([]); + const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState>>({}); + const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); + const [fieldMappingOpen, setFieldMappingOpen] = useState(false); + const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); + const showIconSettings = displayMode === "icon" || displayMode === "icon-text"; const currentActionIcons = actionIconMap[actionType] || []; const isNoIconAction = noIconActions.has(actionType); @@ -330,6 +337,76 @@ export const V2ButtonConfigPanel: React.FC = ({ setIconSize(config.icon?.size || "보통"); }, [config.icon?.name, config.icon?.type, config.icon?.size]); + // 테이블 목록 로드 (데이터 전달 액션용) + useEffect(() => { + if (actionType !== "transferData") return; + if (availableTables.length > 0) return; + + const loadTables = async () => { + try { + const response = await apiClient.get("/table-management/tables"); + if (response.data.success && response.data.data) { + const tables = response.data.data.map((t: any) => ({ + name: t.tableName || t.name, + label: t.displayName || t.tableLabel || t.label || t.tableName || t.name, + })); + setAvailableTables(tables); + } + } catch { + setAvailableTables([]); + } + }; + loadTables(); + }, [actionType, availableTables.length]); + + // 테이블 컬럼 로드 헬퍼 + const loadTableColumns = useCallback(async (tableName: string): Promise> => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + if (Array.isArray(columnData)) { + return columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + } + } + } catch { /* ignore */ } + return []; + }, []); + + // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 + useEffect(() => { + if (actionType !== "transferData") return; + + const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; + const targetTable = config.action?.dataTransfer?.targetTable; + + const loadAll = async () => { + const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); + const newMap: Record> = {}; + for (const tbl of sourceTableNames) { + if (!mappingSourceColumnsMap[tbl]) { + newMap[tbl] = await loadTableColumns(tbl); + } + } + if (Object.keys(newMap).length > 0) { + setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); + } + + if (targetTable) { + const cols = await loadTableColumns(targetTable); + setMappingTargetColumns(cols); + } else { + setMappingTargetColumns([]); + } + }; + loadAll(); + }, [actionType, config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.targetTable, loadTableColumns]); + // 화면 목록 로드 (모달 액션용) useEffect(() => { if (actionType !== "modal" && actionType !== "navigate") return; @@ -338,7 +415,7 @@ export const V2ButtonConfigPanel: React.FC = ({ const loadScreens = async () => { setScreensLoading(true); try { - const response = await apiClient.get("/screen-management/screens"); + const response = await apiClient.get("/screen-management/screens?size=1000"); if (response.data.success && response.data.data) { const screenList = response.data.data.map((s: any) => ({ id: s.id || s.screenId, @@ -521,6 +598,8 @@ export const V2ButtonConfigPanel: React.FC = ({ modalSearchTerm={modalSearchTerm} setModalSearchTerm={setModalSearchTerm} currentTableName={effectiveTableName} + allComponents={allComponents} + handleUpdateProperty={handleUpdateProperty} /> {/* ─── 아이콘 설정 (접기) ─── */} @@ -657,6 +736,26 @@ export const V2ButtonConfigPanel: React.FC = ({ )} + {/* 데이터 전달 필드 매핑 (transferData 액션 전용) */} + {actionType === "transferData" && ( + <> + + + + )} + {/* 제어 기능 */} {actionType !== "excel_upload" && actionType !== "multi_table_excel_upload" && ( <> @@ -699,6 +798,8 @@ const ActionDetailSection: React.FC<{ modalSearchTerm: string; setModalSearchTerm: (term: string) => void; currentTableName?: string; + allComponents?: ComponentData[]; + handleUpdateProperty?: (path: string, value: any) => void; }> = ({ actionType, config, @@ -711,6 +812,8 @@ const ActionDetailSection: React.FC<{ modalSearchTerm, setModalSearchTerm, currentTableName, + allComponents = [], + handleUpdateProperty, }) => { const action = config.action || {}; @@ -800,7 +903,7 @@ const ActionDetailSection: React.FC<{ - + {screens .filter((s) => + !modalSearchTerm || s.name.toLowerCase().includes(modalSearchTerm.toLowerCase()) || - s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) + s.description?.toLowerCase().includes(modalSearchTerm.toLowerCase()) || + String(s.id).includes(modalSearchTerm) ) .map((screen) => ( ); + case "transferData": + return ( +
+
+ + 데이터 전달 설정 +
+ + {/* 소스 컴포넌트 선택 */} +
+ + +
+ + {/* 타겟 타입 */} +
+ + +
+ + {/* 타겟 컴포넌트 선택 */} + {action.dataTransfer?.targetType === "component" && ( +
+ + +
+ )} + + {/* 데이터 전달 모드 */} +
+ + +
+ + {/* 전달 후 초기화 */} +
+
+

전달 후 소스 선택 초기화

+

데이터 전달 후 소스의 선택을 해제해요

+
+ { + const dt = { ...action.dataTransfer, clearAfterTransfer: checked }; + updateActionConfig("dataTransfer", dt); + }} + /> +
+ + {/* 전달 전 확인 */} +
+
+

전달 전 확인 메시지

+

전달 전 확인 다이얼로그를 표시해요

+
+ { + const dt = { ...action.dataTransfer, confirmBeforeTransfer: checked }; + updateActionConfig("dataTransfer", dt); + }} + /> +
+ + {action.dataTransfer?.confirmBeforeTransfer && ( +
+ + { + const dt = { ...action.dataTransfer, confirmMessage: e.target.value }; + updateActionConfig("dataTransfer", dt); + }} + placeholder="선택한 항목을 전달하시겠습니까?" + className="h-7 text-xs" + /> +
+ )} + + {commonMessageSection} +
+ ); + case "event": return (
@@ -1373,6 +1662,386 @@ const IconSettingsSection: React.FC<{ ); }; +// ─── 데이터 전달 필드 매핑 서브 컴포넌트 (고급 설정 내부) ─── +const TransferDataFieldMappingSection: React.FC<{ + config: Record; + onChange: (config: Record) => void; + availableTables: Array<{ name: string; label: string }>; + mappingSourceColumnsMap: Record>; + setMappingSourceColumnsMap: React.Dispatch>>>; + mappingTargetColumns: Array<{ name: string; label: string }>; + fieldMappingOpen: boolean; + setFieldMappingOpen: (open: boolean) => void; + activeMappingGroupIndex: number; + setActiveMappingGroupIndex: (index: number) => void; + loadTableColumns: (tableName: string) => Promise>; +}> = ({ + config, + onChange, + availableTables, + mappingSourceColumnsMap, + setMappingSourceColumnsMap, + mappingTargetColumns, + activeMappingGroupIndex, + setActiveMappingGroupIndex, + loadTableColumns, +}) => { + const [sourcePopoverOpen, setSourcePopoverOpen] = useState>({}); + const [targetPopoverOpen, setTargetPopoverOpen] = useState>({}); + + const dataTransfer = config.action?.dataTransfer || {}; + const multiTableMappings: Array<{ sourceTable: string; mappingRules: Array<{ sourceField: string; targetField: string }> }> = + dataTransfer.multiTableMappings || []; + + const updateDataTransfer = (field: string, value: any) => { + const currentAction = config.action || {}; + const currentDt = currentAction.dataTransfer || {}; + onChange({ + ...config, + action: { + ...currentAction, + dataTransfer: { ...currentDt, [field]: value }, + }, + }); + }; + + const activeGroup = multiTableMappings[activeMappingGroupIndex]; + const activeSourceTable = activeGroup?.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules = activeGroup?.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiTableMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + updateDataTransfer("multiTableMappings", mappings); + }; + + return ( +
+
+

필드 매핑

+

+ 레이어별로 소스 테이블이 다를 때 각각 매핑 규칙을 설정해요 +

+
+ + {/* 타겟 테이블 (공통) */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + updateDataTransfer("targetTable", table.name)} + className="text-xs" + > + +
+ {table.label} + {table.label !== table.name && {table.name}} +
+
+ ))} +
+
+
+
+
+
+ + {/* 소스 테이블 그룹 탭 + 추가 버튼 */} +
+
+ + +
+ + {!dataTransfer.targetTable ? ( +
+

타겟 테이블을 먼저 선택하세요

+
+ ) : multiTableMappings.length === 0 ? ( +
+

소스 테이블을 추가하세요

+
+ ) : ( +
+ {/* 그룹 탭 */} +
+ {multiTableMappings.map((group, gIdx) => ( +
+ + +
+ ))} +
+ + {/* 활성 그룹 편집 */} + {activeGroup && ( +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadTableColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + +
+ {table.label} + {table.label !== table.name && {table.name}} +
+
+ ))} +
+
+
+
+
+
+ + {/* 매핑 규칙 */} +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( +
+ {activeRules.map((rule: any, rIdx: number) => { + const keyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const keyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+ {/* 소스 필드 */} + setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: open }))} + > + + + + + + + + 찾을 수 없습니다 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setSourcePopoverOpen((prev) => ({ ...prev, [keyS]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + + + + + {/* 타겟 필드 */} + setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: open }))} + > + + + + + + + + 찾을 수 없습니다 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setTargetPopoverOpen((prev) => ({ ...prev, [keyT]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + + + {/* 삭제 */} + +
+ ); + })} +
+ )} +
+
+ )} +
+ )} +
+
+ ); +}; + V2ButtonConfigPanel.displayName = "V2ButtonConfigPanel"; export default V2ButtonConfigPanel; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1d8a3197..5ea616e2 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -6566,7 +6566,36 @@ export class ButtonActionExecutor { } // dataTransfer 설정이 있는 경우 - const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer; + const { targetType, targetComponentId, targetScreenId, receiveMode } = dataTransfer; + + // multiTableMappings 우선: 소스 테이블에 맞는 매핑 규칙 선택 + let mappingRules = dataTransfer.mappingRules; + const multiTableMappings = (dataTransfer as any).multiTableMappings as Array<{ + sourceTable: string; + mappingRules: Array<{ sourceField: string; targetField: string }>; + }> | undefined; + + if (multiTableMappings && multiTableMappings.length > 0) { + const sourceTableName = context.tableName || (dataTransfer as any).sourceTable; + const matchedGroup = multiTableMappings.find((g) => g.sourceTable === sourceTableName); + if (matchedGroup && matchedGroup.mappingRules?.length > 0) { + mappingRules = matchedGroup.mappingRules; + console.log("📋 [transferData] multiTableMappings 매핑 적용:", { + sourceTable: sourceTableName, + rules: matchedGroup.mappingRules, + }); + } else if (!mappingRules || mappingRules.length === 0) { + // 매칭되는 그룹이 없고 기존 mappingRules도 없으면 첫 번째 그룹 사용 + const fallback = multiTableMappings[0]; + if (fallback?.mappingRules?.length > 0) { + mappingRules = fallback.mappingRules; + console.log("📋 [transferData] multiTableMappings 폴백 매핑 적용:", { + sourceTable: fallback.sourceTable, + rules: fallback.mappingRules, + }); + } + } + } if (targetType === "component" && targetComponentId) { // 같은 화면 내 컴포넌트로 전달 + 레이어 활성화 이벤트 병행 -- 2.43.0 From 825f164bdeddcbfeeedbd28f2c172416b77cfc27 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:15:12 +0900 Subject: [PATCH 04/11] 22 --- .../screen/ResponsiveGridRenderer.tsx | 405 +----------------- frontend/package-lock.json | 42 +- 2 files changed, 47 insertions(+), 400 deletions(-) diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index c718c70e..1322ee99 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -2,8 +2,6 @@ import React, { useRef, useState, useEffect } from "react"; import { ComponentData } from "@/types/screen"; -import { useResponsive } from "@/lib/hooks/useResponsive"; -import { cn } from "@/lib/utils"; interface ResponsiveGridRendererProps { components: ComponentData[]; @@ -12,60 +10,6 @@ interface ResponsiveGridRendererProps { renderComponent: (component: ComponentData) => React.ReactNode; } -const FULL_WIDTH_TYPES = new Set([ - "table-list", - "v2-table-list", - "table-search-widget", - "v2-table-search-widget", - "conditional-container", - "split-panel-layout", - "split-panel-layout2", - "v2-split-panel-layout", - "screen-split-panel", - "v2-split-line", - "flow-widget", - "v2-tab-container", - "tab-container", - "tabs-widget", - "v2-tabs-widget", -]); - -const FLEX_GROW_TYPES = new Set([ - "table-list", - "v2-table-list", - "split-panel-layout", - "split-panel-layout2", - "v2-split-panel-layout", - "screen-split-panel", - "v2-tab-container", - "tab-container", - "tabs-widget", - "v2-tabs-widget", -]); - -function groupComponentsIntoRows( - components: ComponentData[], - threshold: number = 30 -): ComponentData[][] { - if (components.length === 0) return []; - const sorted = [...components].sort((a, b) => a.position.y - b.position.y); - const rows: ComponentData[][] = []; - let currentRow: ComponentData[] = []; - let currentRowY = -Infinity; - - for (const comp of sorted) { - if (comp.position.y - currentRowY > threshold) { - if (currentRow.length > 0) rows.push(currentRow); - currentRow = [comp]; - currentRowY = comp.position.y; - } else { - currentRow.push(comp); - } - } - if (currentRow.length > 0) rows.push(currentRow); - return rows.map((row) => row.sort((a, b) => a.position.x - b.position.x)); -} - function getComponentTypeId(component: ComponentData): string { const direct = (component as any).componentType || (component as any).widgetType; @@ -78,132 +22,10 @@ function getComponentTypeId(component: ComponentData): string { return component.type || ""; } -function isButtonComponent(component: ComponentData): boolean { - return getComponentTypeId(component).includes("button"); -} - -function isFullWidthComponent(component: ComponentData): boolean { - return FULL_WIDTH_TYPES.has(getComponentTypeId(component)); -} - -function shouldFlexGrow(component: ComponentData): boolean { - return FLEX_GROW_TYPES.has(getComponentTypeId(component)); -} - -function getPercentageWidth(componentWidth: number, canvasWidth: number): number { - const pct = (componentWidth / canvasWidth) * 100; - return pct >= 95 ? 100 : pct; -} - -function getRowGap(row: ComponentData[], canvasWidth: number): number { - if (row.length < 2) return 0; - const totalW = row.reduce((s, c) => s + (c.size?.width || 100), 0); - const gap = canvasWidth - totalW; - const cnt = row.length - 1; - if (gap <= 0 || cnt <= 0) return 8; - return Math.min(Math.max(Math.round(gap / cnt), 4), 24); -} - -interface ProcessedRow { - type: "normal" | "fullwidth"; - mainComponent?: ComponentData; - overlayComps: ComponentData[]; - normalComps: ComponentData[]; - rowMinY?: number; - rowMaxBottom?: number; -} - -function FullWidthOverlayRow({ - main, - overlayComps, - canvasWidth, - renderComponent, -}: { - main: ComponentData; - overlayComps: ComponentData[]; - canvasWidth: number; - renderComponent: (component: ComponentData) => React.ReactNode; -}) { - const containerRef = useRef(null); - const [containerW, setContainerW] = useState(0); - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const ro = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width; - if (w && w > 0) setContainerW(w); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - const compFlexGrow = shouldFlexGrow(main); - const mainY = main.position.y; - const scale = containerW > 0 ? containerW / canvasWidth : 1; - - const minButtonY = Math.min(...overlayComps.map((c) => c.position.y)); - const rawYOffset = minButtonY - mainY; - const maxBtnH = Math.max( - ...overlayComps.map((c) => c.size?.height || 40) - ); - const yOffset = rawYOffset + (maxBtnH / 2) * (1 - scale); - - return ( -
-
- {renderComponent(main)} -
- - {overlayComps.length > 0 && containerW > 0 && ( -
- {overlayComps.map((comp) => ( -
- {renderComponent(comp)} -
- ))} -
- )} -
- ); -} - +/** + * 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링. + * 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대. + */ function ProportionalRenderer({ components, canvasWidth, @@ -270,220 +92,13 @@ export function ResponsiveGridRenderer({ canvasHeight, renderComponent, }: ResponsiveGridRendererProps) { - const { isMobile } = useResponsive(); - - const topLevel = components.filter((c) => !c.parentId); - const hasFullWidthComponent = topLevel.some((c) => isFullWidthComponent(c)); - - if (!isMobile && !hasFullWidthComponent) { - return ( - - ); - } - - const rows = groupComponentsIntoRows(topLevel); - const processedRows: ProcessedRow[] = []; - - for (const row of rows) { - const fullWidthComps: ComponentData[] = []; - const normalComps: ComponentData[] = []; - - for (const comp of row) { - if (isFullWidthComponent(comp)) { - fullWidthComps.push(comp); - } else { - normalComps.push(comp); - } - } - - const allComps = [...fullWidthComps, ...normalComps]; - const rowMinY = allComps.length > 0 ? Math.min(...allComps.map(c => c.position.y)) : 0; - const rowMaxBottom = allComps.length > 0 ? Math.max(...allComps.map(c => c.position.y + (c.size?.height || 40))) : 0; - - if (fullWidthComps.length > 0 && normalComps.length > 0) { - for (const fwComp of fullWidthComps) { - processedRows.push({ - type: "fullwidth", - mainComponent: fwComp, - overlayComps: normalComps, - normalComps: [], - rowMinY, - rowMaxBottom, - }); - } - } else if (fullWidthComps.length > 0) { - for (const fwComp of fullWidthComps) { - processedRows.push({ - type: "fullwidth", - mainComponent: fwComp, - overlayComps: [], - normalComps: [], - rowMinY, - rowMaxBottom, - }); - } - } else { - processedRows.push({ - type: "normal", - overlayComps: [], - normalComps, - rowMinY, - rowMaxBottom, - }); - } - } - return ( -
- {processedRows.map((processedRow, rowIndex) => { - const rowMarginTop = (() => { - if (rowIndex === 0) return 0; - const prevRow = processedRows[rowIndex - 1]; - const prevBottom = prevRow.rowMaxBottom ?? 0; - const currTop = processedRow.rowMinY ?? 0; - const designGap = currTop - prevBottom; - if (designGap <= 0) return 0; - return Math.min(Math.max(Math.round(designGap * 0.5), 4), 48); - })(); - - if (processedRow.type === "fullwidth" && processedRow.mainComponent) { - return ( -
0 ? `${rowMarginTop}px` : undefined }}> - -
- ); - } - - const { normalComps } = processedRow; - const allButtons = normalComps.every((c) => isButtonComponent(c)); - - // 데스크톱에서 버튼만 있는 행: 디자이너의 x, width를 비율로 적용 - if (allButtons && normalComps.length > 0 && !isMobile) { - const rowHeight = Math.max(...normalComps.map(c => c.size?.height || 40)); - - return ( -
0 ? `${rowMarginTop}px` : undefined, - }} - > - {normalComps.map((component) => { - const typeId = getComponentTypeId(component); - const leftPct = (component.position.x / canvasWidth) * 100; - const widthPct = ((component.size?.width || 90) / canvasWidth) * 100; - - return ( -
- {renderComponent(component)} -
- ); - })} -
- ); - } - - const gap = isMobile ? 8 : getRowGap(normalComps, canvasWidth); - - const hasFlexHeightComp = normalComps.some((c) => { - const h = c.size?.height || 0; - return h / canvasHeight >= 0.8; - }); - - return ( -
0 ? `${rowMarginTop}px` : undefined }} - > - {normalComps.map((component) => { - const typeId = getComponentTypeId(component); - const isButton = isButtonComponent(component); - const isFullWidth = isMobile && !isButton; - - if (isButton) { - return ( -
- {renderComponent(component)} -
- ); - } - - const percentWidth = isFullWidth - ? 100 - : getPercentageWidth(component.size?.width || 100, canvasWidth); - const flexBasis = isFullWidth - ? "100%" - : `calc(${percentWidth}% - ${gap}px)`; - - const heightPct = (component.size?.height || 0) / canvasHeight; - const useFlexHeight = heightPct >= 0.8; - - return ( -
- {renderComponent(component)} -
- ); - })} -
- ); - })} -
+ ); } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 230d3139..85329c8b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -266,6 +266,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -307,6 +308,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -340,6 +342,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3055,6 +3058,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/react-reconciler": "^0.32.0", @@ -3708,6 +3712,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz", "integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.6" }, @@ -3802,6 +3807,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz", "integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4115,6 +4121,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz", "integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -6615,6 +6622,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -6625,6 +6633,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6667,6 +6676,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -6749,6 +6759,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -7381,6 +7392,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8531,7 +8543,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3": { "version": "7.9.0", @@ -8853,6 +8866,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -9612,6 +9626,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9700,6 +9715,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -9801,6 +9817,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10972,6 +10989,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -11752,7 +11770,8 @@ "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/levn": { "version": "0.4.1", @@ -13091,6 +13110,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13384,6 +13404,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -13413,6 +13434,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -13461,6 +13483,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz", "integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -13664,6 +13687,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13733,6 +13757,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -13783,6 +13808,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13815,7 +13841,8 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-leaflet": { "version": "5.0.0", @@ -14123,6 +14150,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14145,7 +14173,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/recharts/node_modules/redux-thunk": { "version": "3.1.0", @@ -15175,7 +15204,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -15263,6 +15293,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15611,6 +15642,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" -- 2.43.0 From a391918e5805e3717c6fa08ce9ab1493a5332d33 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:28:34 +0900 Subject: [PATCH 05/11] [agent-pipeline] pipe-20260316081628-53mz round-1 --- .../admin/systemMng/tableMngList/page.tsx | 507 +++--------------- .../admin/table-type/ColumnDetailPanel.tsx | 468 ++++++++++++++++ .../admin/table-type/ColumnGrid.tsx | 252 +++++++++ .../admin/table-type/TypeOverviewStrip.tsx | 114 ++++ frontend/components/admin/table-type/types.ts | 75 +++ 5 files changed, 969 insertions(+), 447 deletions(-) create mode 100644 frontend/components/admin/table-type/ColumnDetailPanel.tsx create mode 100644 frontend/components/admin/table-type/ColumnGrid.tsx create mode 100644 frontend/components/admin/table-type/TypeOverviewStrip.tsx create mode 100644 frontend/components/admin/table-type/types.ts diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 3064e4e5..79a57134 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -50,43 +50,10 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; - -interface TableInfo { - tableName: string; - displayName: string; - description: string; - columnCount: number; -} - -interface ColumnTypeInfo { - columnName: string; - displayName: string; - inputType: string; // webType → inputType 변경 - detailSettings: string; - description: string; - isNullable: string; - isUnique: string; - defaultValue?: string; - maxLength?: number; - numericPrecision?: number; - numericScale?: number; - codeCategory?: string; - codeValue?: string; - referenceTable?: string; - referenceColumn?: string; - displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 - categoryMenus?: number[]; - hierarchyRole?: "large" | "medium" | "small"; - numberingRuleId?: string; - categoryRef?: string | null; -} - -interface SecondLevelMenu { - menuObjid: number; - menuName: string; - parentMenuName: string; - screenCode?: string; -} +import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/admin/table-type/types"; +import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip"; +import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid"; +import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel"; export default function TableManagementPage() { const { userLang, getText } = useMultiLang({ companyCode: "*" }); @@ -164,6 +131,11 @@ export default function TableManagementPage() { // 선택된 테이블 목록 (체크박스) const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); + // 컬럼 그리드: 선택된 컬럼(우측 상세 패널 표시) + const [selectedColumn, setSelectedColumn] = useState(null); + // 타입 오버뷰 스트립: 타입 필터 (null = 전체) + const [typeFilter, setTypeFilter] = useState(null); + // 최고 관리자 여부 확인 (회사코드가 "*" AND userType이 "SUPER_ADMIN") const isSuperAdmin = user?.companyCode === "*" && user?.userType === "SUPER_ADMIN"; @@ -442,6 +414,8 @@ export default function TableManagementPage() { setSelectedTable(tableName); setCurrentPage(1); setColumns([]); + setSelectedColumn(null); + setTypeFilter(null); // 선택된 테이블 정보에서 라벨 설정 const tableInfo = tables.find((table) => table.tableName === tableName); @@ -1588,417 +1562,56 @@ export default function TableManagementPage() { {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
) : ( -
- {/* 컬럼 헤더 (고정) */} -
-
라벨
-
컬럼명
-
입력 타입
-
설명
-
Primary
-
NotNull
-
Index
-
Unique
-
- - {/* 컬럼 리스트 (스크롤 영역) */} -
{ - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 - if (scrollHeight - scrollTop <= clientHeight + 100) { - loadMoreColumns(); - } - }} - > - {columns.map((column, index) => { - const idxState = getColumnIndexState(column.columnName); - return ( -
-
- handleLabelChange(column.columnName, e.target.value)} - placeholder={column.columnName} - className="h-8 text-xs" - /> -
-
-
{column.columnName}
-
-
-
- {/* 입력 타입 선택 */} - - {/* 입력 타입이 'code'인 경우 공통코드 선택 */} - {column.inputType === "code" && ( - <> - - {/* 계층구조 역할 선택 */} - {column.codeCategory && column.codeCategory !== "none" && ( - - )} - - )} - {/* 카테고리 타입: 참조 설정 */} - {column.inputType === "category" && ( -
- - { - const val = e.target.value || null; - setColumns((prev) => - prev.map((c) => - c.columnName === column.columnName - ? { ...c, categoryRef: val } - : c - ) - ); - }} - placeholder="테이블명.컬럼명" - className="h-8 text-xs" - /> -

- 다른 테이블의 카테고리 값 참조 시 입력 -

-
- )} - {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.inputType === "entity" && ( - <> - {/* 참조 테이블 - 검색 가능한 Combobox */} -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { ...prev[column.columnName], table: open }, - })) - } - > - - - - - - - - - 테이블을 찾을 수 없습니다. - - - {referenceTableOptions.map((option) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity", - option.value, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - table: false, - }, - })); - }} - className="text-xs" - > - -
- {option.label} - {option.value !== "none" && ( - - {option.value} - - )} -
-
- ))} -
-
-
-
-
-
- - {/* 조인 컬럼 - 검색 가능한 Combobox */} - {column.referenceTable && column.referenceTable !== "none" && ( -
- - - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { ...prev[column.columnName], joinColumn: open }, - })) - } - > - - - - - - - - - 컬럼을 찾을 수 없습니다. - - - { - handleDetailSettingsChange( - column.columnName, - "entity_reference_column", - "none", - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - joinColumn: false, - }, - })); - }} - className="text-xs" - > - - -- 선택 안함 -- - - {referenceTableColumns[column.referenceTable]?.map((refCol) => ( - { - handleDetailSettingsChange( - column.columnName, - "entity_reference_column", - refCol.columnName, - ); - setEntityComboboxOpen((prev) => ({ - ...prev, - [column.columnName]: { - ...prev[column.columnName], - joinColumn: false, - }, - })); - }} - className="text-xs" - > - -
- {refCol.columnName} - {refCol.displayName && ( - - {refCol.displayName} - - )} -
-
- ))} -
-
-
-
-
-
- )} - - {/* 설정 완료 표시 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && ( -
- - 설정 완료 -
- )} - - )} - {/* 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요) */} -
-
-
- handleColumnChange(index, "description", e.target.value)} - placeholder="설명" - className="h-8 w-full text-xs" - /> -
- {/* PK 체크박스 */} -
- - handlePkToggle(column.columnName, checked as boolean) - } - aria-label={`${column.columnName} PK 설정`} - /> -
- {/* NN (NOT NULL) 체크박스 */} -
- - handleNullableToggle(column.columnName, column.isNullable) - } - aria-label={`${column.columnName} NOT NULL 설정`} - /> -
- {/* IDX 체크박스 */} -
- - handleIndexToggle(column.columnName, "index", checked as boolean) - } - aria-label={`${column.columnName} 인덱스 설정`} - /> -
- {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */} -
- - handleUniqueToggle(column.columnName, column.isUnique) - } - aria-label={`${column.columnName} 유니크 설정`} - /> -
-
- ); - })} - - {/* 로딩 표시 */} - {columnsLoading && ( -
- - 더 많은 컬럼 로딩 중... -
- )} -
- - {/* 페이지 정보 (고정 하단) */} -
- {columns.length} / {totalColumns} 컬럼 표시됨 +
+
+ + { + const idx = columns.findIndex((c) => c.columnName === columnName); + if (idx >= 0) handleColumnChange(idx, field, value); + }} + constraints={constraints} + typeFilter={typeFilter} + getColumnIndexState={getColumnIndexState} + />
+ {selectedColumn && ( +
+ c.columnName === selectedColumn) ?? null} + tables={tables} + referenceTableColumns={referenceTableColumns} + secondLevelMenus={secondLevelMenus} + numberingRules={numberingRules} + onColumnChange={(field, value) => { + if (!selectedColumn) return; + if (field === "inputType") { + handleInputTypeChange(selectedColumn, value as string); + return; + } + if (field === "referenceTable" && value) { + loadReferenceTableColumns(value as string); + } + setColumns((prev) => + prev.map((c) => + c.columnName === selectedColumn ? { ...c, [field]: value } : c, + ), + ); + }} + onClose={() => setSelectedColumn(null)} + onLoadReferenceColumns={loadReferenceTableColumns} + codeCategoryOptions={commonCodeOptions} + referenceTableOptions={referenceTableOptions} + /> +
+ )}
)} diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx new file mode 100644 index 00000000..39914dbe --- /dev/null +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -0,0 +1,468 @@ +"use client"; + +import React, { useMemo } from "react"; +import { X, Type, Settings2, Tag, ToggleLeft, ChevronDown, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Badge } from "@/components/ui/badge"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { ColumnTypeInfo, TableInfo, SecondLevelMenu } from "./types"; +import { INPUT_TYPE_COLORS } from "./types"; +import type { ReferenceTableColumn } from "@/lib/api/entityJoin"; +import type { NumberingRuleConfig } from "@/types/numbering-rule"; + +export interface ColumnDetailPanelProps { + column: ColumnTypeInfo | null; + tables: TableInfo[]; + referenceTableColumns: Record; + secondLevelMenus: SecondLevelMenu[]; + numberingRules: NumberingRuleConfig[]; + onColumnChange: (field: keyof ColumnTypeInfo, value: unknown) => void; + onClose: () => void; + onLoadReferenceColumns?: (tableName: string) => void; + /** 코드 카테고리 옵션 (value, label) */ + codeCategoryOptions?: Array<{ value: string; label: string }>; + /** 참조 테이블 옵션 (value, label) */ + referenceTableOptions?: Array<{ value: string; label: string }>; +} + +export function ColumnDetailPanel({ + column, + tables, + referenceTableColumns, + numberingRules, + onColumnChange, + onClose, + onLoadReferenceColumns, + codeCategoryOptions = [], + referenceTableOptions = [], +}: ColumnDetailPanelProps) { + const [advancedOpen, setAdvancedOpen] = React.useState(false); + const [entityTableOpen, setEntityTableOpen] = React.useState(false); + const [entityColumnOpen, setEntityColumnOpen] = React.useState(false); + const [numberingOpen, setNumberingOpen] = React.useState(false); + + const typeConf = column ? INPUT_TYPE_COLORS[column.inputType || "text"] : null; + const refColumns = column?.referenceTable + ? referenceTableColumns[column.referenceTable] ?? [] + : []; + + React.useEffect(() => { + if (column?.referenceTable && column.referenceTable !== "none") { + onLoadReferenceColumns?.(column.referenceTable); + } + }, [column?.referenceTable, onLoadReferenceColumns]); + + const advancedCount = useMemo(() => { + if (!column) return 0; + let n = 0; + if (column.defaultValue != null && column.defaultValue !== "") n++; + if (column.maxLength != null && column.maxLength > 0) n++; + return n; + }, [column]); + + if (!column) return null; + + const refTableOpts = referenceTableOptions.length + ? referenceTableOptions + : [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))]; + + return ( +
+ {/* 헤더 */} +
+
+ {typeConf && ( + + {typeConf.label} + + )} + {column.columnName} +
+ +
+ +
+ {/* [섹션 1] 데이터 타입 선택 */} +
+
+ + +
+
+ {Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => ( + + ))} +
+
+ + {/* [섹션 2] 타입별 상세 설정 */} + {column.inputType === "entity" && ( +
+
+ + +
+
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {refTableOpts.map((opt) => ( + { + onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); + if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); + setEntityTableOpen(false); + }} + className="text-xs" + > + + {opt.label} + + ))} + + + + + +
+ {column.referenceTable && column.referenceTable !== "none" && ( +
+ + + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + onColumnChange("referenceColumn", undefined); + setEntityColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {refColumns.map((refCol) => ( + { + onColumnChange("referenceColumn", refCol.columnName); + setEntityColumnOpen(false); + }} + className="text-xs" + > + + {refCol.columnName} + + ))} + + + + + +
+ )} +
+
+ )} + + {column.inputType === "code" && ( +
+
+ + +
+
+
+ + +
+ {column.codeCategory && column.codeCategory !== "none" && ( +
+ + +
+ )} +
+
+ )} + + {column.inputType === "category" && ( +
+
+ + +
+
+ + onColumnChange("categoryRef", e.target.value || null)} + placeholder="테이블명.컬럼명" + className="h-9 text-xs" + /> +
+
+ )} + + {column.inputType === "numbering" && ( +
+
+ + +
+ + + + + + + + + 규칙을 찾을 수 없습니다. + + { + onColumnChange("numberingRuleId", undefined); + setNumberingOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {numberingRules.map((r) => ( + { + onColumnChange("numberingRuleId", r.ruleId); + setNumberingOpen(false); + }} + className="text-xs" + > + + {r.ruleName} + + ))} + + + + + +
+ )} + + {/* [섹션 3] 표시 이름 */} +
+
+ + +
+ onColumnChange("displayName", e.target.value)} + placeholder={column.columnName} + className="h-9 text-sm" + /> +
+ + {/* [섹션 4] 표시 옵션 */} +
+
+ + +
+
+
+
+

필수 입력

+

비워두면 저장할 수 없어요.

+
+ onColumnChange("isNullable", checked ? "NO" : "YES")} + aria-label="필수 입력" + /> +
+
+
+

읽기 전용

+

편집할 수 없어요.

+
+ {}} + disabled + aria-label="읽기 전용 (향후 확장)" + /> +
+
+
+ + {/* [섹션 5] 고급 설정 */} + + + + + +
+
+ + onColumnChange("defaultValue", e.target.value)} + placeholder="기본값" + className="h-9 text-xs" + /> +
+
+ + { + const v = e.target.value; + onColumnChange("maxLength", v === "" ? undefined : Number(v)); + }} + placeholder="숫자" + className="h-9 text-xs" + /> +
+
+
+
+
+
+ ); +} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx new file mode 100644 index 00000000..e520fc43 --- /dev/null +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -0,0 +1,252 @@ +"use client"; + +import React, { useMemo } from "react"; +import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { ColumnTypeInfo } from "./types"; +import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; + +export interface ColumnGridConstraints { + primaryKey: { columns: string[] }; + indexes: Array<{ columns: string[]; isUnique: boolean }>; +} + +export interface ColumnGridProps { + columns: ColumnTypeInfo[]; + selectedColumn: string | null; + onSelectColumn: (columnName: string) => void; + onColumnChange: (columnName: string, field: keyof ColumnTypeInfo, value: unknown) => void; + constraints: ColumnGridConstraints; + typeFilter?: string | null; + getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; +} + +function getIndexState( + columnName: string, + constraints: ColumnGridConstraints, +): { isPk: boolean; hasIndex: boolean } { + const isPk = constraints.primaryKey.columns.includes(columnName); + const hasIndex = constraints.indexes.some( + (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, + ); + return { isPk, hasIndex }; +} + +/** 그룹 헤더 라벨 */ +const GROUP_LABELS: Record = { + basic: { icon: FileStack, label: "기본 정보" }, + reference: { icon: Layers, label: "참조 정보" }, + meta: { icon: Database, label: "메타 정보" }, +}; + +export function ColumnGrid({ + columns, + selectedColumn, + onSelectColumn, + constraints, + typeFilter = null, + getColumnIndexState: externalGetIndexState, +}: ColumnGridProps) { + const getIdxState = useMemo( + () => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)), + [constraints, externalGetIndexState], + ); + + /** typeFilter 적용 후 그룹별로 정렬 */ + const filteredAndGrouped = useMemo(() => { + const filtered = + typeFilter != null ? columns.filter((c) => (c.inputType || "text") === typeFilter) : columns; + const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] }; + for (const col of filtered) { + const group = getColumnGroup(col); + groups[group].push(col); + } + return groups; + }, [columns, typeFilter]); + + const totalFiltered = + filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length; + + return ( +
+
+ + 라벨 · 컬럼명 + 참조/설정 + 타입 + PK / NN / IDX / UQ + +
+ +
+ {totalFiltered === 0 ? ( +
+ {typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."} +
+ ) : ( + (["basic", "reference", "meta"] as const).map((groupKey) => { + const list = filteredAndGrouped[groupKey]; + if (list.length === 0) return null; + const { icon: Icon, label } = GROUP_LABELS[groupKey]; + return ( +
+
+ + + {label} + + + {list.length} + +
+ {list.map((column) => { + const typeConf = INPUT_TYPE_COLORS[column.inputType || "text"] || INPUT_TYPE_COLORS.text; + const idxState = getIdxState(column.columnName); + const isSelected = selectedColumn === column.columnName; + + return ( +
onSelectColumn(column.columnName)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectColumn(column.columnName); + } + }} + className={cn( + "grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors", + "grid-cols-[4px_140px_1fr_100px_160px_40px]", + "bg-card border-transparent hover:border-border hover:shadow-sm", + isSelected && "border-primary/30 bg-primary/5 shadow-sm", + )} + > + {/* 4px 색상바 */} +
+ + {/* 라벨 + 컬럼명 */} +
+
+ {column.displayName || column.columnName} +
+
+ {column.columnName} +
+
+ + {/* 참조/설정 칩 */} +
+ {column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && ( + <> + + {column.referenceTable} + + + + {column.referenceColumn || "—"} + + + )} + {column.inputType === "code" && ( + + {column.codeCategory ?? "—"} · {column.defaultValue ?? ""} + + )} + {column.inputType === "numbering" && column.numberingRuleId && ( + + {column.numberingRuleId} + + )} + {column.inputType !== "entity" && + column.inputType !== "code" && + column.inputType !== "numbering" && + (column.defaultValue ? ( + {column.defaultValue} + ) : ( + + ))} +
+ + {/* 타입 뱃지 */} +
+ + {typeConf.label} +
+ + {/* PK / NN / IDX / UQ (읽기 전용) */} +
+ + PK + + + NN + + + IDX + + + UQ + +
+ +
+ +
+
+ ); + })} +
+ ); + }) + )} +
+
+ ); +} diff --git a/frontend/components/admin/table-type/TypeOverviewStrip.tsx b/frontend/components/admin/table-type/TypeOverviewStrip.tsx new file mode 100644 index 00000000..bdb27f47 --- /dev/null +++ b/frontend/components/admin/table-type/TypeOverviewStrip.tsx @@ -0,0 +1,114 @@ +"use client"; + +import React, { useMemo } from "react"; +import { cn } from "@/lib/utils"; +import type { ColumnTypeInfo } from "./types"; +import { INPUT_TYPE_COLORS } from "./types"; + +export interface TypeOverviewStripProps { + columns: ColumnTypeInfo[]; + activeFilter?: string | null; + onFilterChange?: (type: string | null) => void; +} + +/** inputType별 카운트 계산 */ +function countByInputType(columns: ColumnTypeInfo[]): Record { + const counts: Record = {}; + for (const col of columns) { + const t = col.inputType || "text"; + counts[t] = (counts[t] || 0) + 1; + } + return counts; +} + +/** 도넛 차트용 비율 (0~1) 배열 및 라벨 순서 */ +function getDonutSegments(counts: Record, total: number): Array<{ type: string; ratio: number }> { + const order = Object.keys(INPUT_TYPE_COLORS); + return order + .filter((type) => (counts[type] || 0) > 0) + .map((type) => ({ type, ratio: (counts[type] || 0) / total })); +} + +export function TypeOverviewStrip({ + columns, + activeFilter = null, + onFilterChange, +}: TypeOverviewStripProps) { + const { counts, total, segments } = useMemo(() => { + const counts = countByInputType(columns); + const total = columns.length || 1; + const segments = getDonutSegments(counts, total); + return { counts, total, segments }; + }, [columns]); + + /** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */ + const circumference = 100; + let offset = 0; + const segmentPaths = segments.map(({ type, ratio }) => { + const length = ratio * circumference; + const dashArray = `${length} ${circumference - length}`; + const dashOffset = -offset; + offset += length; + const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" }; + return { + type, + dashArray, + dashOffset, + ...conf, + }; + }); + + return ( +
+ {/* SVG 도넛 (원형 stroke) */} +
+ + {segmentPaths.map((seg) => ( + + + + ))} + {segments.length === 0 && ( + + )} + +
+ + {/* 타입 칩 목록 (클릭 시 필터 토글) */} +
+ {Object.entries(counts) + .sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)) + .map(([type]) => { + const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type }; + const isActive = activeFilter === null || activeFilter === type; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/components/admin/table-type/types.ts b/frontend/components/admin/table-type/types.ts new file mode 100644 index 00000000..8adbcb62 --- /dev/null +++ b/frontend/components/admin/table-type/types.ts @@ -0,0 +1,75 @@ +/** + * 테이블 타입 관리 페이지 공통 타입 + * page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸 + */ + +export interface TableInfo { + tableName: string; + displayName: string; + description: string; + columnCount: number; +} + +export interface ColumnTypeInfo { + columnName: string; + displayName: string; + inputType: string; + detailSettings: string; + description: string; + isNullable: string; + isUnique: string; + defaultValue?: string; + maxLength?: number; + numericPrecision?: number; + numericScale?: number; + codeCategory?: string; + codeValue?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + categoryMenus?: number[]; + hierarchyRole?: "large" | "medium" | "small"; + numberingRuleId?: string; + categoryRef?: string | null; +} + +export interface SecondLevelMenu { + menuObjid: number; + menuName: string; + parentMenuName: string; + screenCode?: string; +} + +/** 컬럼 그룹 분류 */ +export type ColumnGroup = "basic" | "reference" | "meta"; + +/** 타입별 색상 매핑 (다크모드 호환 레이어 사용) */ +export interface TypeColorConfig { + color: string; + bgColor: string; + label: string; + icon?: string; +} + +/** 입력 타입별 색상 맵 - 배경/텍스트/보더는 다크에서 자동 변환 */ +export const INPUT_TYPE_COLORS: Record = { + text: { color: "text-slate-600", bgColor: "bg-slate-50", label: "텍스트" }, + number: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "숫자" }, + date: { color: "text-amber-600", bgColor: "bg-amber-50", label: "날짜" }, + code: { color: "text-emerald-600", bgColor: "bg-emerald-50", label: "코드" }, + entity: { color: "text-violet-600", bgColor: "bg-violet-50", label: "엔티티" }, + select: { color: "text-cyan-600", bgColor: "bg-cyan-50", label: "셀렉트" }, + checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", label: "체크박스" }, + numbering: { color: "text-orange-600", bgColor: "bg-orange-50", label: "채번" }, + category: { color: "text-teal-600", bgColor: "bg-teal-50", label: "카테고리" }, + textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "텍스트영역" }, + radio: { color: "text-rose-600", bgColor: "bg-rose-50", label: "라디오" }, +}; + +/** 컬럼 그룹 판별 */ +export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup { + const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"]; + if (metaCols.includes(col.columnName)) return "meta"; + if (["entity", "code", "category"].includes(col.inputType)) return "reference"; + return "basic"; +} -- 2.43.0 From 43aafb36c186c6b53f00e0c8f6a83046f79d6bb6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:58:37 +0900 Subject: [PATCH 06/11] feat: enhance table management page with improved filtering and UI updates - Implemented Korean prioritization in table filtering, allowing for better sorting of table names based on Korean characters. - Updated the UI to a more compact design with a top bar for better accessibility and user experience. - Added new button styles and functionalities for creating and duplicating tables, enhancing the overall management capabilities. - Improved the column detail panel with clearer labeling and enhanced interaction for selecting data types and reference tables. These changes aim to streamline the table management process and improve usability within the ERP system. --- .../admin/systemMng/tableMngList/page.tsx | 663 +++++++++--------- .../admin/table-type/ColumnDetailPanel.tsx | 230 +++--- .../admin/table-type/ColumnGrid.tsx | 71 +- frontend/components/admin/table-type/types.ts | 28 +- 4 files changed, 527 insertions(+), 465 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 79a57134..29886bbd 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -12,7 +12,7 @@ import { Search, Database, RefreshCw, - Settings, + Save, Plus, Activity, Trash2, @@ -21,7 +21,6 @@ import { ChevronsUpDown, Loader2, } from "lucide-react"; -import { ResponsiveSplitPanel } from "@/components/common/ResponsiveSplitPanel"; import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; @@ -969,16 +968,24 @@ export default function TableManagementPage() { return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedTable, columns.length]); - // 필터링된 테이블 목록 (메모이제이션) - const filteredTables = useMemo( - () => - tables.filter( - (table) => - table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - table.displayName.toLowerCase().includes(searchTerm.toLowerCase()), - ), - [tables, searchTerm], - ); + // 필터링 + 한글 우선 정렬 (ㄱ~ㅎ → a~z) + const filteredTables = useMemo(() => { + const filtered = tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + table.displayName.toLowerCase().includes(searchTerm.toLowerCase()), + ); + const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str); + return filtered.sort((a, b) => { + const nameA = a.displayName || a.tableName; + const nameB = b.displayName || b.tableName; + const aKo = isKorean(nameA); + const bKo = isKorean(nameB); + if (aKo && !bKo) return -1; + if (!aKo && bKo) return 1; + return nameA.localeCompare(nameB, aKo ? "ko" : "en"); + }); + }, [tables, searchTerm]); // 선택된 테이블 정보 const selectedTableInfo = tables.find((table) => table.tableName === selectedTable); @@ -1292,339 +1299,338 @@ export default function TableManagementPage() { }; return ( -
-
- {/* 페이지 헤더 */} -
-
-
-

- {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} -

-

- {getTextFromUI( - TABLE_MANAGEMENT_KEYS.PAGE_DESCRIPTION, - "데이터베이스 테이블과 컬럼의 타입을 관리합니다", - )} -

- {isSuperAdmin && ( -

- 최고 관리자 권한으로 새 테이블 생성 및 컬럼 추가가 가능합니다 -

- )} -
- -
- {/* DDL 기능 버튼들 (최고 관리자만) */} - {isSuperAdmin && ( - <> - - - - - {selectedTable && ( - - )} - - - - )} - +
+ {/* 컴팩트 탑바 (52px) */} +
+
+ +

+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} +

+ + {tables.length} 테이블 + +
+
+ {isSuperAdmin && ( + <> + + {selectedTable && ( + + )} + + + )} + +
+
+ + {/* 3패널 메인 */} +
+ {/* 좌측: 테이블 목록 (240px) */} +
+ {/* 검색 */} +
+
+ + setSearchTerm(e.target.value)} + className="bg-background h-[34px] pl-8 text-xs" + />
+ {isSuperAdmin && ( +
+
+ 0 && + filteredTables.every((table) => selectedTableIds.has(table.tableName)) + } + onCheckedChange={handleSelectAll} + aria-label="전체 선택" + className="h-3.5 w-3.5" + /> + + {selectedTableIds.size > 0 ? `${selectedTableIds.size}개` : "전체"} + +
+ {selectedTableIds.size > 0 && ( + + )} +
+ )} +
+ + {/* 테이블 리스트 */} +
+ {loading ? ( +
+ +
+ ) : filteredTables.length === 0 ? ( +
+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")} +
+ ) : ( + filteredTables.map((table, idx) => { + const isActive = selectedTable === table.tableName; + const prevTable = idx > 0 ? filteredTables[idx - 1] : null; + const isKo = /^[가-힣ㄱ-ㅎ]/.test(table.displayName || table.tableName); + const prevIsKo = prevTable ? /^[가-힣ㄱ-ㅎ]/.test(prevTable.displayName || prevTable.tableName) : null; + const showDivider = idx === 0 || (prevIsKo !== null && isKo !== prevIsKo); + + return ( +
+ {showDivider && ( +
+ {isKo ? "한글" : "ENGLISH"} +
+ )} +
handleTableSelect(table.tableName)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleTableSelect(table.tableName); + } + }} + > + {isActive && ( +
+ )} + {isSuperAdmin && ( + handleTableCheck(table.tableName, checked as boolean)} + aria-label={`${table.displayName || table.tableName} 선택`} + className="h-3.5 w-3.5 flex-shrink-0" + onClick={(e) => e.stopPropagation()} + /> + )} +
+
+ + {table.displayName || table.tableName} + +
+
+ {table.tableName} +
+
+ + {table.columnCount} + +
+
+ ); + }) + )} +
+ + {/* 하단 정보 */} +
+ {filteredTables.length} / {tables.length} 테이블
- - {/* 검색 */} -
-
- + {/* 중앙: 컬럼 그리드 */} +
+ {!selectedTable ? ( +
+ +

+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} +

+
+ ) : ( + <> + {/* 중앙 헤더: 테이블명 + 라벨 입력 + 저장 */} +
+
+
+ {tableLabel || selectedTable} +
+
+ {selectedTable} +
+
+
setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" + value={tableLabel} + onChange={(e) => setTableLabel(e.target.value)} + placeholder="표시명" + className="h-8 max-w-[160px] text-xs" + /> + setTableDescription(e.target.value)} + placeholder="설명" + className="h-8 max-w-[200px] text-xs" />
+
- {/* 테이블 목록 */} -
- {/* 전체 선택 및 일괄 삭제 (최고 관리자만) */} - {isSuperAdmin && ( -
-
- - table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), - ).length > 0 && - tables - .filter( - (table) => - table.tableName.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}개 선택됨`} - -
- {selectedTableIds.size > 0 && ( - - )} -
- )} - - {loading ? ( -
- - - {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_TABLES, "테이블 로딩 중...")} - -
- ) : tables.length === 0 ? ( -
- {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_TABLES, "테이블이 없습니다")} -
- ) : ( - tables - .filter( - (table) => - table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || - (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), - ) - .map((table) => ( -
-
- {/* 체크박스 (최고 관리자만) */} - {isSuperAdmin && ( - handleTableCheck(table.tableName, checked as boolean)} - aria-label={`${table.displayName || table.tableName} 선택`} - className="mt-0.5" - onClick={(e) => e.stopPropagation()} - /> - )} -
handleTableSelect(table.tableName)}> -

{table.displayName || table.tableName}

-

- {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} -

-
- 컬럼 - - {table.columnCount} - -
-
-
-
- )) - )} -
-
- } - right={ -
- {!selectedTable ? ( -
-
-

- {getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} -

-
+ {columnsLoading ? ( +
+ + + {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} + +
+ ) : columns.length === 0 ? ( +
+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
) : ( <> - {/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */} -
-
- setTableLabel(e.target.value)} - placeholder="테이블 표시명" - className="h-10 text-sm" - /> -
-
- setTableDescription(e.target.value)} - placeholder="테이블 설명" - className="h-10 text-sm" - /> -
- {/* 저장 버튼 (항상 보이도록 상단에 배치) */} - -
- - {columnsLoading ? ( -
- - - {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} - -
- ) : columns.length === 0 ? ( -
- {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} -
- ) : ( -
-
- - { - const idx = columns.findIndex((c) => c.columnName === columnName); - if (idx >= 0) handleColumnChange(idx, field, value); - }} - constraints={constraints} - typeFilter={typeFilter} - getColumnIndexState={getColumnIndexState} - /> -
- {selectedColumn && ( -
- c.columnName === selectedColumn) ?? null} - tables={tables} - referenceTableColumns={referenceTableColumns} - secondLevelMenus={secondLevelMenus} - numberingRules={numberingRules} - onColumnChange={(field, value) => { - if (!selectedColumn) return; - if (field === "inputType") { - handleInputTypeChange(selectedColumn, value as string); - return; - } - if (field === "referenceTable" && value) { - loadReferenceTableColumns(value as string); - } - setColumns((prev) => - prev.map((c) => - c.columnName === selectedColumn ? { ...c, [field]: value } : c, - ), - ); - }} - onClose={() => setSelectedColumn(null)} - onLoadReferenceColumns={loadReferenceTableColumns} - codeCategoryOptions={commonCodeOptions} - referenceTableOptions={referenceTableOptions} - /> -
- )} -
- )} + + { + const idx = columns.findIndex((c) => c.columnName === columnName); + if (idx >= 0) handleColumnChange(idx, field, value); + }} + constraints={constraints} + typeFilter={typeFilter} + getColumnIndexState={getColumnIndexState} + /> )} -
- } - leftTitle="테이블 목록" - leftWidth={20} - minLeftWidth={10} - maxLeftWidth={35} - height="100%" - className="flex-1 overflow-hidden" - /> + + )} +
+ + {/* 우측: 상세 패널 (selectedColumn 있을 때만) */} + {selectedColumn && ( +
+ c.columnName === selectedColumn) ?? null} + tables={tables} + referenceTableColumns={referenceTableColumns} + secondLevelMenus={secondLevelMenus} + numberingRules={numberingRules} + onColumnChange={(field, value) => { + if (!selectedColumn) return; + if (field === "inputType") { + handleInputTypeChange(selectedColumn, value as string); + return; + } + if (field === "referenceTable" && value) { + loadReferenceTableColumns(value as string); + } + setColumns((prev) => + prev.map((c) => + c.columnName === selectedColumn ? { ...c, [field]: value } : c, + ), + ); + }} + onClose={() => setSelectedColumn(null)} + onLoadReferenceColumns={loadReferenceTableColumns} + codeCategoryOptions={commonCodeOptions} + referenceTableOptions={referenceTableOptions} + /> +
+ )} +
{/* DDL 모달 컴포넌트들 */} {isSuperAdmin && ( @@ -1817,7 +1823,6 @@ export default function TableManagementPage() { {/* Scroll to Top 버튼 */} -
); } diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 39914dbe..77f5dedf 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -100,75 +100,152 @@ export function ColumnDetailPanel({
{/* [섹션 1] 데이터 타입 선택 */}
-
- - +
+

이 필드는 어떤 유형인가요?

+

유형에 따라 입력 방식이 바뀌어요

-
- {Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => ( - - ))} +
+ {Object.entries(INPUT_TYPE_COLORS).map(([type, conf]) => { + const isSelected = (column.inputType || "text") === type; + return ( + + ); + })}
{/* [섹션 2] 타입별 상세 설정 */} {column.inputType === "entity" && ( -
+
-
-
- - + + {/* 참조 테이블 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {refTableOpts.map((opt) => ( + { + onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); + if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); + setEntityTableOpen(false); + }} + className="text-xs" + > + + {opt.label} + + ))} + + + + + +
+ + {/* 조인 컬럼 */} + {column.referenceTable && column.referenceTable !== "none" && ( +
+ + - + - 테이블을 찾을 수 없습니다. + 컬럼을 찾을 수 없습니다. - {refTableOpts.map((opt) => ( + { + onColumnChange("referenceColumn", undefined); + setEntityColumnOpen(false); + }} + className="text-xs" + > + + 선택 안함 + + {refColumns.map((refCol) => ( { - onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); - if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); - setEntityTableOpen(false); + onColumnChange("referenceColumn", refCol.columnName); + setEntityColumnOpen(false); }} className="text-xs" > - {opt.label} + {refCol.columnName} ))} @@ -177,67 +254,20 @@ export function ColumnDetailPanel({
- {column.referenceTable && column.referenceTable !== "none" && ( -
- - - - - - - - - - 컬럼을 찾을 수 없습니다. - - { - onColumnChange("referenceColumn", undefined); - setEntityColumnOpen(false); - }} - className="text-xs" - > - - 선택 안함 - - {refColumns.map((refCol) => ( - { - onColumnChange("referenceColumn", refCol.columnName); - setEntityColumnOpen(false); - }} - className="text-xs" - > - - {refCol.columnName} - - ))} - - - - - -
- )} -
+ )} + + {/* 참조 요약 미니맵 */} + {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && ( +
+ + {column.referenceTable} + + + + {column.referenceColumn} + +
+ )}
)} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index e520fc43..5f339a8a 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -45,6 +45,7 @@ export function ColumnGrid({ columns, selectedColumn, onSelectColumn, + onColumnChange, constraints, typeFilter = null, getColumnIndexState: externalGetIndexState, @@ -128,8 +129,8 @@ export function ColumnGrid({ isSelected && "border-primary/30 bg-primary/5 shadow-sm", )} > - {/* 4px 색상바 */} -
+ {/* 4px 색상바 (타입별 진한 색) */} +
{/* 라벨 + 컬럼명 */}
@@ -180,48 +181,72 @@ export function ColumnGrid({ {typeConf.label}
- {/* PK / NN / IDX / UQ (읽기 전용) */} + {/* PK / NN / IDX / UQ (클릭 토글) */}
- { + e.stopPropagation(); + onColumnChange(column.columnName, "isPrimaryKey" as keyof ColumnTypeInfo, !idxState.isPk); + }} + title="Primary Key 토글" > PK - - +
diff --git a/frontend/components/admin/table-type/types.ts b/frontend/components/admin/table-type/types.ts index 8adbcb62..329b4049 100644 --- a/frontend/components/admin/table-type/types.ts +++ b/frontend/components/admin/table-type/types.ts @@ -47,23 +47,25 @@ export type ColumnGroup = "basic" | "reference" | "meta"; export interface TypeColorConfig { color: string; bgColor: string; + barColor: string; label: string; - icon?: string; + desc: string; + iconChar: string; } -/** 입력 타입별 색상 맵 - 배경/텍스트/보더는 다크에서 자동 변환 */ +/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */ export const INPUT_TYPE_COLORS: Record = { - text: { color: "text-slate-600", bgColor: "bg-slate-50", label: "텍스트" }, - number: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "숫자" }, - date: { color: "text-amber-600", bgColor: "bg-amber-50", label: "날짜" }, - code: { color: "text-emerald-600", bgColor: "bg-emerald-50", label: "코드" }, - entity: { color: "text-violet-600", bgColor: "bg-violet-50", label: "엔티티" }, - select: { color: "text-cyan-600", bgColor: "bg-cyan-50", label: "셀렉트" }, - checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", label: "체크박스" }, - numbering: { color: "text-orange-600", bgColor: "bg-orange-50", label: "채번" }, - category: { color: "text-teal-600", bgColor: "bg-teal-50", label: "카테고리" }, - textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", label: "텍스트영역" }, - radio: { color: "text-rose-600", bgColor: "bg-rose-50", label: "라디오" }, + text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" }, + number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" }, + date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" }, + code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" }, + entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" }, + select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" }, + checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" }, + numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" }, + category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" }, + textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" }, + radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" }, }; /** 컬럼 그룹 판별 */ -- 2.43.0 From 8da48bfe9c3cf253cd7cecfec8c08beb6d20b2c0 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 18:43:42 +0900 Subject: [PATCH 07/11] feat: enhance V2TableListConfigPanel with editable column locking feature - Added a button to toggle the editable state of columns in the V2TableListConfigPanel, allowing users to lock or unlock editing for specific columns. - Implemented visual indicators (lock/unlock icons) to represent the editable state of each column, improving user interaction and clarity. - Enhanced the button's tooltip to provide context on the current state (editable or locked) when hovered. These updates aim to improve the usability of the table configuration panel by providing users with more control over column editing capabilities. Made-with: Cursor --- .../config-panels/V2TableListConfigPanel.tsx | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/components/v2/config-panels/V2TableListConfigPanel.tsx b/frontend/components/v2/config-panels/V2TableListConfigPanel.tsx index a815b41c..8af58507 100644 --- a/frontend/components/v2/config-panels/V2TableListConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2TableListConfigPanel.tsx @@ -957,7 +957,38 @@ export const V2TableListConfigPanel: React.FC = ({ /> {column.columnLabel} - + {isAlreadyAdded && ( + + )} + {column.inputType || column.dataType}
-- 2.43.0 From 6a50e1e924a2f545123a70c29325ce62528bcc6f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 18:59:45 +0900 Subject: [PATCH 08/11] feat: add primary key and index toggle functionality to ColumnGrid component - Introduced `onPkToggle` and `onIndexToggle` props to the `ColumnGrid` component, allowing users to toggle primary key and index states directly from the UI. - Updated the `TableManagementPage` to handle these new toggle events, enhancing the interactivity and usability of the table management features. These changes aim to improve the table configuration process within the ERP system, providing users with more control over their table structures. --- frontend/app/(main)/admin/systemMng/tableMngList/page.tsx | 4 ++++ frontend/components/admin/table-type/ColumnGrid.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 29886bbd..44051e28 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1592,6 +1592,10 @@ export default function TableManagementPage() { constraints={constraints} typeFilter={typeFilter} getColumnIndexState={getColumnIndexState} + onPkToggle={handlePkToggle} + onIndexToggle={(columnName, checked) => + handleIndexToggle(columnName, "index", checked) + } /> )} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index 5f339a8a..c03c7516 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -21,6 +21,8 @@ export interface ColumnGridProps { constraints: ColumnGridConstraints; typeFilter?: string | null; getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; + onPkToggle?: (columnName: string, checked: boolean) => void; + onIndexToggle?: (columnName: string, checked: boolean) => void; } function getIndexState( @@ -49,6 +51,8 @@ export function ColumnGrid({ constraints, typeFilter = null, getColumnIndexState: externalGetIndexState, + onPkToggle, + onIndexToggle, }: ColumnGridProps) { const getIdxState = useMemo( () => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)), @@ -193,7 +197,7 @@ export function ColumnGrid({ )} onClick={(e) => { e.stopPropagation(); - onColumnChange(column.columnName, "isPrimaryKey" as keyof ColumnTypeInfo, !idxState.isPk); + onPkToggle?.(column.columnName, !idxState.isPk); }} title="Primary Key 토글" > @@ -225,7 +229,7 @@ export function ColumnGrid({ )} onClick={(e) => { e.stopPropagation(); - onColumnChange(column.columnName, "hasIndex" as keyof ColumnTypeInfo, !idxState.hasIndex); + onIndexToggle?.(column.columnName, !idxState.hasIndex); }} title="Index 토글" > -- 2.43.0 From cf4296b020d531d5483387b575a8a1e632cb0444 Mon Sep 17 00:00:00 2001 From: kmh Date: Tue, 17 Mar 2026 09:44:41 +0900 Subject: [PATCH 09/11] feat: implement pagination settings in split panel layout - Added pagination configuration options for both left and right panels in the SplitPanelLayoutComponent, allowing for server-side data retrieval in pages. - Introduced a new PaginationConfig interface to manage pagination settings, including page size. - Enhanced data loading functions to support pagination, improving data management and user experience. Made-with: Cursor --- .../screen/InteractiveScreenViewerDynamic.tsx | 1 - .../screen/RealtimePreviewDynamic.tsx | 2 +- frontend/components/v2/V2Input.tsx | 35 +- frontend/components/v2/V2Select.tsx | 6 +- .../V2SplitPanelLayoutConfigPanel.tsx | 77 +- .../lib/registry/DynamicComponentRenderer.tsx | 16 +- .../SplitPanelLayout2Component.tsx | 4 +- .../SplitPanelLayoutComponent.tsx | 717 +++++++++++------- .../components/v2-split-panel-layout/types.ts | 13 + 9 files changed, 594 insertions(+), 277 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 75e7248e..d6111b64 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -1338,7 +1338,6 @@ export const InteractiveScreenViewerDynamic: React.FC = ({ // - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용 const isV2HorizLabel = !!( componentStyle && - (componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") && + componentStyle.labelDisplay !== false && componentStyle.labelDisplay !== "false" && (componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right") ); const needsStripBorder = isV2HorizLabel || isButtonComponent; diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 2d7c3246..0fe033fa 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -33,7 +33,8 @@ const FORMAT_PATTERNS: Record((props, ref) => ref={ref} id={id} className={cn( - "flex flex-col gap-1", - labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", + "flex gap-1", + labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", )} style={{ width: componentWidth, @@ -1060,7 +1087,7 @@ export const V2Input = forwardRef((props, ref) => color: getAdaptiveLabelColor(style?.labelColor), fontWeight: style?.labelFontWeight || "500", }} - className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0" + className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0" > {actualLabel} {required && *} diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 9062e7bc..9ced9670 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -1291,8 +1291,8 @@ export const V2Select = forwardRef((props, ref) = ref={ref} id={id} className={cn( - "flex flex-col gap-1", - labelPos === "left" ? "sm:flex-row sm:items-center" : "sm:flex-row-reverse sm:items-center", + "flex gap-1", + labelPos === "left" ? "flex-row items-center" : "flex-row-reverse items-center", isDesignMode && "pointer-events-none", )} style={{ @@ -1308,7 +1308,7 @@ export const V2Select = forwardRef((props, ref) = color: getAdaptiveLabelColor(style?.labelColor), fontWeight: style?.labelFontWeight || "500", }} - className="w-full text-sm font-medium whitespace-nowrap sm:w-[120px] sm:shrink-0" + className="text-sm font-medium whitespace-nowrap w-[120px] shrink-0" > {label} {required && *} diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index ae5679b6..7895a3d5 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -82,9 +82,10 @@ import { arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { - SplitPanelLayoutConfig, - AdditionalTabConfig, +import { + MAX_LOAD_ALL_SIZE, + type SplitPanelLayoutConfig, + type AdditionalTabConfig, } from "@/lib/registry/components/v2-split-panel-layout/types"; import type { TableInfo, ColumnInfo } from "@/types/screen"; @@ -1158,6 +1159,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateLeftPanel({ showItemAddButton: checked }) } /> + + updateLeftPanel({ + pagination: { + ...config.leftPanel?.pagination, + enabled: checked, + pageSize: config.leftPanel?.pagination?.pageSize ?? 20, + }, + }) + } + /> + {config.leftPanel?.pagination?.enabled && ( +
+ + + updateLeftPanel({ + pagination: { + ...config.leftPanel?.pagination, + enabled: true, + pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)), + }, + }) + } + className="h-7 w-24 text-xs" + /> +
+ )}
{/* 좌측 패널 컬럼 설정 (접이식) */} @@ -1564,6 +1600,41 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< updateRightPanel({ showDelete: checked }) } /> + + updateRightPanel({ + pagination: { + ...config.rightPanel?.pagination, + enabled: checked, + pageSize: config.rightPanel?.pagination?.pageSize ?? 20, + }, + }) + } + /> + {config.rightPanel?.pagination?.enabled && ( +
+ + + updateRightPanel({ + pagination: { + ...config.rightPanel?.pagination, + enabled: true, + pageSize: Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 20)), + }, + }) + } + className="h-7 w-24 text-xs" + /> +
+ )}
{/* 우측 패널 컬럼 설정 (접이식) */} diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 873b7408..ece44692 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -440,7 +440,7 @@ export const DynamicComponentRenderer: React.FC = } : (component as any).style; const catSize = catNeedsExternalHorizLabel - ? { ...(component as any).size, width: undefined, height: undefined } + ? { ...(component as any).size, width: undefined } : (component as any).size; const rendererProps = { @@ -706,35 +706,33 @@ export const DynamicComponentRenderer: React.FC = componentType === "modal-repeater-table" || componentType === "v2-input"; - // 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true/"true"일 때만 라벨 표시) + // 🆕 v2-input 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크) const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay; const effectiveLabel = - labelDisplay === true || labelDisplay === "true" + labelDisplay !== false && labelDisplay !== "false" ? component.style?.labelText || (component as any).label || component.componentConfig?.label : undefined; - // 🔧 수평 라벨(left/right) 감지 → 외부 flex 컨테이너에서 라벨 처리 + // 🔧 수평 라벨(left/right) 감지 → 런타임에서만 외부 flex 컨테이너로 라벨 처리 + // 디자인 모드에서는 V2 컴포넌트가 자체적으로 라벨을 렌더링 (height 체인 문제 방지) const labelPosition = component.style?.labelPosition; const isV2Component = componentType?.startsWith("v2-"); const needsExternalHorizLabel = !!( + !props.isDesignMode && isV2Component && effectiveLabel && (labelPosition === "left" || labelPosition === "right") ); - // 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀 const mergedStyle = { - ...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저! - // CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고) + ...component.style, width: finalStyle.width, height: finalStyle.height, - // 수평 라벨 → V2 컴포넌트에는 라벨 비활성화 (외부에서 처리) ...(needsExternalHorizLabel ? { labelDisplay: false, labelPosition: "top" as const, width: "100%", - height: "100%", borderWidth: undefined, borderColor: undefined, borderStyle: undefined, diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a36836a6..d0fd3a5c 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -1751,7 +1751,7 @@ export const SplitPanelLayout2Component: React.FC {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} @@ -1952,7 +1952,7 @@ export const SplitPanelLayout2Component: React.FC )} {displayColumns.map((col, idx) => ( - + {col.label || col.name} ))} 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 41fa815e..36f978e8 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2,7 +2,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { ComponentRendererProps } from "../../types"; -import { SplitPanelLayoutConfig } from "./types"; +import { SplitPanelLayoutConfig, MAX_LOAD_ALL_SIZE } from "./types"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -16,6 +16,9 @@ import { ChevronUp, Save, ChevronRight, + ChevronLeft, + ChevronsLeft, + ChevronsRight, Pencil, Trash2, Settings, @@ -47,6 +50,66 @@ import { cn } from "@/lib/utils"; import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { BomExcelUploadModal } from "../v2-bom-tree/BomExcelUploadModal"; +/** 클라이언트 사이드 데이터 필터 (페이징 OFF 전용) */ +function applyClientSideFilter(data: any[], dataFilter: any): any[] { + if (!dataFilter?.enabled) return data; + + let result = data; + + if (dataFilter.filters?.length > 0) { + const matchFn = dataFilter.matchType === "any" ? "some" : "every"; + result = result.filter((item: any) => + dataFilter.filters[matchFn]((cond: any) => { + const val = item[cond.columnName]; + switch (cond.operator) { + case "equals": + return val === cond.value; + case "notEquals": + case "not_equals": + return val !== cond.value; + case "in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return arr.includes(val); + } + case "not_in": { + const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; + return !arr.includes(val); + } + case "contains": + return String(val || "").includes(String(cond.value)); + case "is_null": + return val === null || val === undefined || val === ""; + case "is_not_null": + return val !== null && val !== undefined && val !== ""; + default: + return true; + } + }), + ); + } + + // legacy conditions 형식 (하위 호환성) + if (dataFilter.conditions?.length > 0) { + result = result.filter((item: any) => + dataFilter.conditions.every((cond: any) => { + const val = item[cond.column]; + switch (cond.operator) { + case "equals": + return val === cond.value; + case "notEquals": + return val !== cond.value; + case "contains": + return String(val || "").includes(String(cond.value)); + default: + return true; + } + }), + ); + } + + return result; +} + export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props onUpdateComponent?: (component: any) => void; @@ -350,6 +413,22 @@ export const SplitPanelLayoutComponent: React.FC const [columnInputTypes, setColumnInputTypes] = useState>({}); const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 + // 🆕 페이징 상태 + const [leftCurrentPage, setLeftCurrentPage] = useState(1); + const [leftTotalPages, setLeftTotalPages] = useState(1); + const [leftTotal, setLeftTotal] = useState(0); + const [leftPageSize, setLeftPageSize] = useState(componentConfig.leftPanel?.pagination?.pageSize ?? 20); + const [rightCurrentPage, setRightCurrentPage] = useState(1); + const [rightTotalPages, setRightTotalPages] = useState(1); + const [rightTotal, setRightTotal] = useState(0); + const [rightPageSize, setRightPageSize] = useState(componentConfig.rightPanel?.pagination?.pageSize ?? 20); + const [tabsPagination, setTabsPagination] = useState>({}); + const [leftPageInput, setLeftPageInput] = useState("1"); + const [rightPageInput, setRightPageInput] = useState("1"); + + const leftPaginationEnabled = componentConfig.leftPanel?.pagination?.enabled ?? false; + const rightPaginationEnabled = componentConfig.rightPanel?.pagination?.enabled ?? false; + // 추가 탭 관련 상태 const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭 const [tabsData, setTabsData] = useState>({}); // 탭별 데이터 @@ -918,13 +997,24 @@ export const SplitPanelLayoutComponent: React.FC let columns = displayColumns; - // columnVisibility가 있으면 가시성 적용 + // columnVisibility가 있으면 가시성 + 너비 적용 if (leftColumnVisibility.length > 0) { - const visibilityMap = new Map(leftColumnVisibility.map((cv) => [cv.columnName, cv.visible])); - columns = columns.filter((col: any) => { - const colName = typeof col === "string" ? col : col.name || col.columnName; - return visibilityMap.get(colName) !== false; - }); + const visibilityMap = new Map( + leftColumnVisibility.map((cv) => [cv.columnName, cv]) + ); + columns = columns + .filter((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + return visibilityMap.get(colName)?.visible !== false; + }) + .map((col: any) => { + const colName = typeof col === "string" ? col : col.name || col.columnName; + const cv = visibilityMap.get(colName); + if (cv?.width && typeof col === "object") { + return { ...col, width: cv.width }; + } + return col; + }); } // 🔧 컬럼 순서 적용 @@ -1237,87 +1327,62 @@ export const SplitPanelLayoutComponent: React.FC return joinColumns.length > 0 ? joinColumns : undefined; }, []); - // 좌측 데이터 로드 - const loadLeftData = useCallback(async () => { + // 좌측 데이터 로드 (페이징 ON: page 파라미터 사용, OFF: 전체 로드) + const loadLeftData = useCallback(async (page?: number, pageSizeOverride?: number) => { const leftTableName = componentConfig.leftPanel?.tableName; if (!leftTableName || isDesignMode) return; setIsLoadingLeft(true); try { - // 🎯 필터 조건을 API에 전달 (entityJoinApi 사용) const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; - - // 🆕 좌측 패널 config의 Entity 조인 컬럼 추출 (헬퍼 함수 사용) const leftJoinColumns = extractAdditionalJoinColumns( componentConfig.leftPanel?.columns, leftTableName, ); - console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns); + if (leftPaginationEnabled) { + const currentPageToLoad = page ?? leftCurrentPage; + const effectivePageSize = pageSizeOverride ?? leftPageSize; + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { + page: currentPageToLoad, + size: effectivePageSize, + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, + }); - const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { - page: 1, - size: 100, - search: filters, - enableEntityJoin: true, - dataFilter: componentConfig.leftPanel?.dataFilter, - additionalJoinColumns: leftJoinColumns, - companyCodeOverride: companyCode, - }); + setLeftData(result.data || []); + setLeftCurrentPage(result.page || currentPageToLoad); + setLeftTotalPages(result.totalPages || 1); + setLeftTotal(result.total || 0); + setLeftPageInput(String(result.page || currentPageToLoad)); + } else { + const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { + page: 1, + size: MAX_LOAD_ALL_SIZE, + search: filters, + enableEntityJoin: true, + dataFilter: componentConfig.leftPanel?.dataFilter, + additionalJoinColumns: leftJoinColumns, + companyCodeOverride: companyCode, + }); - // 🔍 디버깅: API 응답 데이터의 키 확인 - if (result.data && result.data.length > 0) { - console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); - console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); - } + let filteredLeftData = applyClientSideFilter(result.data || [], componentConfig.leftPanel?.dataFilter); - // 좌측 패널 dataFilter 클라이언트 사이드 적용 - let filteredLeftData = result.data || []; - const leftDataFilter = componentConfig.leftPanel?.dataFilter; - if (leftDataFilter?.enabled && leftDataFilter.filters?.length > 0) { - const matchFn = leftDataFilter.matchType === "any" ? "some" : "every"; - filteredLeftData = filteredLeftData.filter((item: any) => { - return leftDataFilter.filters[matchFn]((cond: any) => { - const val = item[cond.columnName]; - switch (cond.operator) { - case "equals": - return val === cond.value; - case "not_equals": - return val !== cond.value; - case "in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return arr.includes(val); - } - case "not_in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return !arr.includes(val); - } - case "contains": - return String(val || "").includes(String(cond.value)); - case "is_null": - return val === null || val === undefined || val === ""; - case "is_not_null": - return val !== null && val !== undefined && val !== ""; - default: - return true; - } + const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; + if (leftColumn && filteredLeftData.length > 0) { + filteredLeftData.sort((a, b) => { + const aValue = String(a[leftColumn] || ""); + const bValue = String(b[leftColumn] || ""); + return aValue.localeCompare(bValue, "ko-KR"); }); - }); - } + } - // 가나다순 정렬 (좌측 패널의 표시 컬럼 기준) - const leftColumn = componentConfig.rightPanel?.relation?.leftColumn; - if (leftColumn && filteredLeftData.length > 0) { - filteredLeftData.sort((a, b) => { - const aValue = String(a[leftColumn] || ""); - const bValue = String(b[leftColumn] || ""); - return aValue.localeCompare(bValue, "ko-KR"); - }); + const hierarchicalData = buildHierarchy(filteredLeftData); + setLeftData(hierarchicalData); } - - // 계층 구조 빌드 - const hierarchicalData = buildHierarchy(filteredLeftData); - setLeftData(hierarchicalData); } catch (error) { console.error("좌측 데이터 로드 실패:", error); toast({ @@ -1333,15 +1398,25 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.leftPanel?.columns, componentConfig.leftPanel?.dataFilter, componentConfig.rightPanel?.relation?.leftColumn, + leftPaginationEnabled, + leftCurrentPage, + leftPageSize, isDesignMode, toast, buildHierarchy, searchValues, ]); - // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드) + const updateRightPaginationState = useCallback((result: any, fallbackPage: number) => { + setRightCurrentPage(result.page || fallbackPage); + setRightTotalPages(result.totalPages || 1); + setRightTotal(result.total || 0); + setRightPageInput(String(result.page || fallbackPage)); + }, []); + + // 우측 데이터 로드 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용) const loadRightData = useCallback( - async (leftItem: any) => { + async (leftItem: any, page?: number, pageSizeOverride?: number) => { const relationshipType = componentConfig.rightPanel?.relation?.type || "detail"; const rightTableName = componentConfig.rightPanel?.tableName; @@ -1355,70 +1430,33 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.columns, rightTableName, ); + const effectivePageSize = pageSizeOverride ?? rightPageSize; - const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { - enableEntityJoin: true, - size: 1000, - companyCodeOverride: companyCode, - additionalJoinColumns: rightJoinColumns, - dataFilter: componentConfig.rightPanel?.dataFilter, - }); - - // dataFilter 적용 - let filteredData = result.data || []; - const dataFilter = componentConfig.rightPanel?.dataFilter; - if (dataFilter?.enabled && dataFilter.filters?.length > 0) { - filteredData = filteredData.filter((item: any) => { - return dataFilter.filters.every((cond: any) => { - const value = item[cond.columnName]; - switch (cond.operator) { - case "equals": - return value === cond.value; - case "notEquals": - case "not_equals": - return value !== cond.value; - case "in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return arr.includes(value); - } - case "not_in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return !arr.includes(value); - } - case "contains": - return String(value || "").includes(String(cond.value)); - case "is_null": - return value === null || value === undefined || value === ""; - case "is_not_null": - return value !== null && value !== undefined && value !== ""; - default: - return true; - } - }); + if (rightPaginationEnabled) { + const currentPageToLoad = page ?? rightCurrentPage; + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + page: currentPageToLoad, + size: effectivePageSize, + enableEntityJoin: true, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, + dataFilter: componentConfig.rightPanel?.dataFilter, }); - } - // conditions 형식 dataFilter도 지원 (하위 호환성) - const dataFilterConditions = componentConfig.rightPanel?.dataFilter; - if (dataFilterConditions?.enabled && dataFilterConditions.conditions?.length > 0) { - filteredData = filteredData.filter((item: any) => { - return dataFilterConditions.conditions.every((cond: any) => { - const value = item[cond.column]; - switch (cond.operator) { - case "equals": - return value === cond.value; - case "notEquals": - return value !== cond.value; - case "contains": - return String(value || "").includes(String(cond.value)); - default: - return true; - } - }); + setRightData(result.data || []); + updateRightPaginationState(result, currentPageToLoad); + } else { + const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { + enableEntityJoin: true, + size: MAX_LOAD_ALL_SIZE, + companyCodeOverride: companyCode, + additionalJoinColumns: rightJoinColumns, + dataFilter: componentConfig.rightPanel?.dataFilter, }); - } - setRightData(filteredData); + const filteredData = applyClientSideFilter(result.data || [], componentConfig.rightPanel?.dataFilter); + setRightData(filteredData); + } } catch (error) { console.error("우측 전체 데이터 로드 실패:", error); } finally { @@ -1495,9 +1533,9 @@ export const SplitPanelLayoutComponent: React.FC const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, - size: 1000, + size: MAX_LOAD_ALL_SIZE, companyCodeOverride: companyCode, - additionalJoinColumns: rightJoinColumnsForGroup, // 🆕 Entity 조인 컬럼 전달 + additionalJoinColumns: rightJoinColumnsForGroup, }); if (result.data) { allResults.push(...result.data); @@ -1536,16 +1574,19 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); } - // 엔티티 조인 API로 데이터 조회 + const effectivePageSize = pageSizeOverride ?? rightPageSize; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, - size: 1000, + size: rightPaginationEnabled ? effectivePageSize : MAX_LOAD_ALL_SIZE, + page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumns, }); - console.log("🔗 [분할패널] 복합키 조회 결과:", result); + if (rightPaginationEnabled) { + updateRightPaginationState(result, page ?? rightCurrentPage); + } setRightData(result.data || []); } else { @@ -1572,14 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); } + const effectivePageSizeLegacy = pageSizeOverride ?? rightPageSize; const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, - size: 1000, + size: rightPaginationEnabled ? effectivePageSizeLegacy : MAX_LOAD_ALL_SIZE, + page: rightPaginationEnabled ? (page ?? rightCurrentPage) : undefined, companyCodeOverride: companyCode, additionalJoinColumns: rightJoinColumnsLegacy, }); + if (rightPaginationEnabled) { + updateRightPaginationState(result, page ?? rightCurrentPage); + } + setRightData(result.data || []); } } @@ -1600,14 +1647,18 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.leftPanel?.tableName, + rightPaginationEnabled, + rightCurrentPage, + rightPageSize, isDesignMode, toast, + updateRightPaginationState, ], ); - // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드) + // 추가 탭 데이터 로딩 함수 (leftItem이 null이면 전체 데이터 로드, page: 서버 페이징용) const loadTabData = useCallback( - async (tabIndex: number, leftItem: any) => { + async (tabIndex: number, leftItem: any, page?: number, pageSizeOverride?: number) => { const tabConfig = componentConfig.rightPanel?.additionalTabs?.[tabIndex - 1]; if (!tabConfig || isDesignMode) return; @@ -1619,109 +1670,73 @@ export const SplitPanelLayoutComponent: React.FC const keys = tabConfig.relation?.keys; const leftColumn = tabConfig.relation?.leftColumn || keys?.[0]?.leftColumn; const rightColumn = tabConfig.relation?.foreignKey || keys?.[0]?.rightColumn; - - // 탭 config의 Entity 조인 컬럼 추출 const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); - if (tabJoinColumns) { - console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); - } let resultData: any[] = []; - - // 탭의 dataFilter (API 전달용) + let apiResult: any = null; const tabDataFilterForApi = (tabConfig as any).dataFilter; - - // 탭의 relation type 확인 (detail이면 초기 전체 로드 안 함) const tabRelationType = tabConfig.relation?.type || "join"; + const tabPagState = tabsPagination[tabIndex]; + const currentTabPage = page ?? tabPagState?.currentPage ?? 1; + const currentTabPageSize = pageSizeOverride ?? tabPagState?.pageSize ?? rightPageSize; + const apiSize = rightPaginationEnabled ? currentTabPageSize : MAX_LOAD_ALL_SIZE; + const apiPage = rightPaginationEnabled ? currentTabPage : undefined; + + const commonApiParams = { + enableEntityJoin: true, + size: apiSize, + page: apiPage, + companyCodeOverride: companyCode, + additionalJoinColumns: tabJoinColumns, + dataFilter: tabDataFilterForApi, + }; + if (!leftItem) { - if (tabRelationType === "detail") { - // detail 모드: 선택 안 하면 아무것도 안 뜸 - resultData = []; - } else { - // join 모드: 좌측 미선택 시 전체 데이터 로드 (dataFilter는 API에 전달) - const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { - enableEntityJoin: true, - size: 1000, - companyCodeOverride: companyCode, - additionalJoinColumns: tabJoinColumns, - dataFilter: tabDataFilterForApi, - }); - resultData = result.data || []; + if (tabRelationType !== "detail") { + apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams); + resultData = apiResult.data || []; } } else if (leftColumn && rightColumn) { const searchConditions: Record = {}; - if (keys && keys.length > 0) { keys.forEach((key: any) => { if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) { - searchConditions[key.rightColumn] = { - value: leftItem[key.leftColumn], - operator: "equals", - }; + searchConditions[key.rightColumn] = { value: leftItem[key.leftColumn], operator: "equals" }; } }); } else { const leftValue = leftItem[leftColumn]; if (leftValue !== undefined) { - searchConditions[rightColumn] = { - value: leftValue, - operator: "equals", - }; + searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; } } - const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { + apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, { search: searchConditions, - enableEntityJoin: true, - size: 1000, - companyCodeOverride: companyCode, - additionalJoinColumns: tabJoinColumns, - dataFilter: tabDataFilterForApi, + ...commonApiParams, }); - resultData = result.data || []; + resultData = apiResult.data || []; } else { - const result = await entityJoinApi.getTableDataWithJoins(tabTableName, { - enableEntityJoin: true, - size: 1000, - companyCodeOverride: companyCode, - additionalJoinColumns: tabJoinColumns, - dataFilter: tabDataFilterForApi, - }); - resultData = result.data || []; + apiResult = await entityJoinApi.getTableDataWithJoins(tabTableName, commonApiParams); + resultData = apiResult.data || []; } - // 탭별 dataFilter 적용 - const tabDataFilter = (tabConfig as any).dataFilter; - if (tabDataFilter?.enabled && tabDataFilter.filters?.length > 0) { - resultData = resultData.filter((item: any) => { - return tabDataFilter.filters.every((cond: any) => { - const value = item[cond.columnName]; - switch (cond.operator) { - case "equals": - return value === cond.value; - case "notEquals": - case "not_equals": - return value !== cond.value; - case "in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return arr.includes(value); - } - case "not_in": { - const arr = Array.isArray(cond.value) ? cond.value : [cond.value]; - return !arr.includes(value); - } - case "contains": - return String(value || "").includes(String(cond.value)); - case "is_null": - return value === null || value === undefined || value === ""; - case "is_not_null": - return value !== null && value !== undefined && value !== ""; - default: - return true; - } - }); - }); + // 공통 페이징 상태 업데이트 + if (rightPaginationEnabled && apiResult) { + setTabsPagination((prev) => ({ + ...prev, + [tabIndex]: { + currentPage: apiResult.page || currentTabPage, + totalPages: apiResult.totalPages || 1, + total: apiResult.total || 0, + pageSize: currentTabPageSize, + }, + })); + } + + if (!rightPaginationEnabled) { + resultData = applyClientSideFilter(resultData, (tabConfig as any).dataFilter); } setTabsData((prev) => ({ ...prev, [tabIndex]: resultData })); @@ -1736,9 +1751,148 @@ export const SplitPanelLayoutComponent: React.FC setTabsLoading((prev) => ({ ...prev, [tabIndex]: false })); } }, - [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], + [componentConfig.rightPanel?.additionalTabs, rightPaginationEnabled, rightPageSize, tabsPagination, isDesignMode, toast], ); + // 🆕 좌측 페이지 변경 핸들러 + const handleLeftPageChange = useCallback((newPage: number) => { + if (newPage < 1 || newPage > leftTotalPages) return; + setLeftCurrentPage(newPage); + setLeftPageInput(String(newPage)); + loadLeftData(newPage); + }, [leftTotalPages, loadLeftData]); + + const commitLeftPageInput = useCallback(() => { + const parsed = parseInt(leftPageInput, 10); + if (!isNaN(parsed) && parsed >= 1 && parsed <= leftTotalPages) { + handleLeftPageChange(parsed); + } else { + setLeftPageInput(String(leftCurrentPage)); + } + }, [leftPageInput, leftTotalPages, leftCurrentPage, handleLeftPageChange]); + + // 🆕 좌측 페이지 크기 변경 + const handleLeftPageSizeChange = useCallback((newSize: number) => { + setLeftPageSize(newSize); + setLeftCurrentPage(1); + setLeftPageInput("1"); + loadLeftData(1, newSize); + }, [loadLeftData]); + + // 🆕 우측 페이지 변경 핸들러 + const handleRightPageChange = useCallback((newPage: number) => { + if (newPage < 1 || newPage > rightTotalPages) return; + setRightCurrentPage(newPage); + setRightPageInput(String(newPage)); + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem, newPage); + } else { + loadTabData(activeTabIndex, selectedLeftItem, newPage); + } + }, [rightTotalPages, activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); + + const commitRightPageInput = useCallback(() => { + const parsed = parseInt(rightPageInput, 10); + const tp = activeTabIndex === 0 ? rightTotalPages : (tabsPagination[activeTabIndex]?.totalPages ?? 1); + if (!isNaN(parsed) && parsed >= 1 && parsed <= tp) { + handleRightPageChange(parsed); + } else { + const cp = activeTabIndex === 0 ? rightCurrentPage : (tabsPagination[activeTabIndex]?.currentPage ?? 1); + setRightPageInput(String(cp)); + } + }, [rightPageInput, rightTotalPages, rightCurrentPage, activeTabIndex, tabsPagination, handleRightPageChange]); + + // 🆕 우측 페이지 크기 변경 + const handleRightPageSizeChange = useCallback((newSize: number) => { + setRightPageSize(newSize); + setRightCurrentPage(1); + setRightPageInput("1"); + setTabsPagination({}); + if (activeTabIndex === 0) { + loadRightData(selectedLeftItem, 1, newSize); + } else { + loadTabData(activeTabIndex, selectedLeftItem, 1, newSize); + } + }, [activeTabIndex, selectedLeftItem, loadRightData, loadTabData]); + + // 🆕 페이징 UI 컴포넌트 (공통) + const renderPaginationBar = useCallback((params: { + currentPage: number; + totalPages: number; + total: number; + pageSize: number; + pageInput: string; + setPageInput: (v: string) => void; + onPageChange: (p: number) => void; + onPageSizeChange: (s: number) => void; + commitPageInput: () => void; + loading: boolean; + }) => { + const { currentPage, totalPages, total, pageSize, pageInput, setPageInput, onPageChange, onPageSizeChange, commitPageInput: commitFn, loading } = params; + return ( +
+
+ 표시: + { + const v = Math.min(MAX_LOAD_ALL_SIZE, Math.max(1, Number(e.target.value) || 1)); + onPageSizeChange(v); + }} + className="border-input bg-background focus:ring-ring h-6 w-12 rounded border px-1 text-center text-[10px] focus:ring-1 focus:outline-none" + /> + / {total}건 +
+ +
+ + +
+ setPageInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { commitFn(); (e.target as HTMLInputElement).blur(); } }} + onBlur={commitFn} + onFocus={(e) => e.target.select()} + disabled={loading} + className="border-input bg-background focus:ring-ring h-6 w-8 rounded border px-1 text-center text-[10px] font-medium focus:ring-1 focus:outline-none" + /> + / + {totalPages || 1} +
+ + +
+
+ ); + }, []); + + // 우측/탭 페이징 상태 (IIFE 대신 useMemo로 사전 계산) + const rightPagState = useMemo(() => { + const isTab = activeTabIndex > 0; + const tabPag = isTab ? tabsPagination[activeTabIndex] : null; + return { + isTab, + currentPage: isTab ? (tabPag?.currentPage ?? 1) : rightCurrentPage, + totalPages: isTab ? (tabPag?.totalPages ?? 1) : rightTotalPages, + total: isTab ? (tabPag?.total ?? 0) : rightTotal, + pageSize: isTab ? (tabPag?.pageSize ?? rightPageSize) : rightPageSize, + }; + }, [activeTabIndex, tabsPagination, rightCurrentPage, rightTotalPages, rightTotal, rightPageSize]); + // 탭 변경 핸들러 const handleTabChange = useCallback( (newTabIndex: number) => { @@ -1775,12 +1929,18 @@ export const SplitPanelLayoutComponent: React.FC selectedLeftItem[leftPk] === item[leftPk]; if (isSameItem) { - // 선택 해제 setSelectedLeftItem(null); setCustomLeftSelectedData({}); setExpandedRightItems(new Set()); setTabsData({}); + // 우측/탭 페이지 리셋 + if (rightPaginationEnabled) { + setRightCurrentPage(1); + setRightPageInput("1"); + setTabsPagination({}); + } + const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; if (mainRelationType === "detail") { // "선택 시 표시" 모드: 선택 해제 시 데이터 비움 @@ -1805,15 +1965,21 @@ export const SplitPanelLayoutComponent: React.FC } setSelectedLeftItem(item); - setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달 - setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 - setTabsData({}); // 모든 탭 데이터 초기화 + setCustomLeftSelectedData(item); + setExpandedRightItems(new Set()); + setTabsData({}); + + // 우측/탭 페이지 리셋 + if (rightPaginationEnabled) { + setRightCurrentPage(1); + setRightPageInput("1"); + setTabsPagination({}); + } - // 현재 활성 탭에 따라 데이터 로드 if (activeTabIndex === 0) { - loadRightData(item); + loadRightData(item, 1); } else { - loadTabData(activeTabIndex, item); + loadTabData(activeTabIndex, item, 1); } // modalDataStore에 선택된 좌측 항목 저장 (단일 선택) @@ -1825,7 +1991,7 @@ export const SplitPanelLayoutComponent: React.FC }); } }, - [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem], + [loadRightData, loadTabData, activeTabIndex, componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.relation, componentConfig.rightPanel?.additionalTabs, isDesignMode, selectedLeftItem, rightPaginationEnabled], ); // 우측 항목 확장/축소 토글 @@ -3100,10 +3266,30 @@ export const SplitPanelLayoutComponent: React.FC // eslint-disable-next-line react-hooks/exhaustive-deps }, [isDesignMode, componentConfig.autoLoad]); - // 🔄 필터 변경 시 데이터 다시 로드 + // config에서 pageSize 변경 시 상태 동기화 + 페이지 리셋 + useEffect(() => { + const configLeftPageSize = componentConfig.leftPanel?.pagination?.pageSize ?? 20; + setLeftPageSize(configLeftPageSize); + setLeftCurrentPage(1); + setLeftPageInput("1"); + }, [componentConfig.leftPanel?.pagination?.pageSize]); + + useEffect(() => { + const configRightPageSize = componentConfig.rightPanel?.pagination?.pageSize ?? 20; + setRightPageSize(configRightPageSize); + setRightCurrentPage(1); + setRightPageInput("1"); + setTabsPagination({}); + }, [componentConfig.rightPanel?.pagination?.pageSize]); + + // 🔄 필터 변경 시 데이터 다시 로드 (페이지 1로 리셋) useEffect(() => { if (!isDesignMode && componentConfig.autoLoad !== false) { - loadLeftData(); + if (leftPaginationEnabled) { + setLeftCurrentPage(1); + setLeftPageInput("1"); + } + loadLeftData(1); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [leftFilters]); @@ -3543,12 +3729,6 @@ export const SplitPanelLayoutComponent: React.FC format: undefined, // 🆕 기본값 })); - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) - const leftTotalColWidth = columnsToShow.reduce((sum, col) => { - const w = col.width && col.width <= 100 ? col.width : 0; - return sum + w; - }, 0); - // 🔧 그룹화된 데이터 렌더링 const hasGroupedLeftActions = !isDesignMode && ( (componentConfig.leftPanel?.showEdit !== false) || @@ -3562,7 +3742,7 @@ export const SplitPanelLayoutComponent: React.FC
{group.groupKey} ({group.count}개)
- 100 ? `${leftTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -3570,7 +3750,7 @@ export const SplitPanelLayoutComponent: React.FC key={idx} className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ - width: col.width && col.width <= 100 ? `${col.width}%` : "auto", + minWidth: col.width ? `${col.width}px` : "80px", textAlign: col.align || "left", }} > @@ -3659,7 +3839,7 @@ export const SplitPanelLayoutComponent: React.FC ); return (
-
100 ? `${leftTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -3667,7 +3847,7 @@ export const SplitPanelLayoutComponent: React.FC key={idx} className="px-3 py-2 text-left text-xs font-medium tracking-wider text-muted-foreground uppercase whitespace-nowrap" style={{ - width: col.width && col.width <= 100 ? `${col.width}%` : "auto", + minWidth: col.width ? `${col.width}px` : "80px", textAlign: col.align || "left", }} > @@ -4004,6 +4184,22 @@ export const SplitPanelLayoutComponent: React.FC )} + + {/* 좌측 페이징 UI */} + {leftPaginationEnabled && !isDesignMode && ( + renderPaginationBar({ + currentPage: leftCurrentPage, + totalPages: leftTotalPages, + total: leftTotal, + pageSize: leftPageSize, + pageInput: leftPageInput, + setPageInput: setLeftPageInput, + onPageChange: handleLeftPageChange, + onPageSizeChange: handleLeftPageSizeChange, + commitPageInput: commitLeftPageInput, + loading: isLoadingLeft, + }) + )} @@ -4663,16 +4859,10 @@ export const SplitPanelLayoutComponent: React.FC })); } - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) - const rightTotalColWidth = columnsToShow.reduce((sum, col) => { - const w = col.width && col.width <= 100 ? col.width : 0; - return sum + w; - }, 0); - return (
-
100 ? `${rightTotalColWidth}%` : '100%' }}> +
{columnsToShow.map((col, idx) => ( @@ -4680,7 +4870,7 @@ export const SplitPanelLayoutComponent: React.FC key={idx} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap" style={{ - width: col.width && col.width <= 100 ? `${col.width}%` : "auto", + minWidth: col.width ? `${col.width}px` : "80px", textAlign: col.align || "left", }} > @@ -4793,12 +4983,6 @@ export const SplitPanelLayoutComponent: React.FC })); } - // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) - const displayTotalColWidth = columnsToDisplay.reduce((sum, col) => { - const w = col.width && col.width <= 100 ? col.width : 0; - return sum + w; - }, 0); - const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); const hasActions = hasEditButton || hasDeleteButton; @@ -4806,14 +4990,14 @@ export const SplitPanelLayoutComponent: React.FC return filteredData.length > 0 ? (
-
100 ? `${displayTotalColWidth}%` : '100%' }}> +
{columnsToDisplay.map((col) => ( @@ -5037,6 +5221,31 @@ export const SplitPanelLayoutComponent: React.FC )} + + {/* 우측/탭 페이징 UI */} + {rightPaginationEnabled && !isDesignMode && renderPaginationBar({ + currentPage: rightPagState.currentPage, + totalPages: rightPagState.totalPages, + total: rightPagState.total, + pageSize: rightPagState.pageSize, + pageInput: rightPageInput, + setPageInput: setRightPageInput, + onPageChange: (p) => { + if (rightPagState.isTab) { + setTabsPagination((prev) => ({ + ...prev, + [activeTabIndex]: { ...(prev[activeTabIndex] || { currentPage: 1, totalPages: 1, total: 0, pageSize: rightPageSize }), currentPage: p }, + })); + setRightPageInput(String(p)); + loadTabData(activeTabIndex, selectedLeftItem, p); + } else { + handleRightPageChange(p); + } + }, + onPageSizeChange: handleRightPageSizeChange, + commitPageInput: commitRightPageInput, + loading: isLoadingRight || (tabsLoading[activeTabIndex] ?? false), + })} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index ed41f578..6b3cfaa7 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -10,6 +10,15 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management" */ export type PanelInlineComponent = TabInlineComponent; +/** 페이징 처리 설정 (좌측/우측 패널 공통) */ +export interface PaginationConfig { + enabled: boolean; + pageSize?: number; +} + +/** 페이징 OFF 시 전체 데이터 로드에 사용하는 최대 건수 */ +export const MAX_LOAD_ALL_SIZE = 10000; + /** * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) */ @@ -224,6 +233,8 @@ export interface SplitPanelLayoutConfig { // 🆕 컬럼 값 기반 데이터 필터링 dataFilter?: DataFilterConfig; + + pagination?: PaginationConfig; }; // 우측 패널 설정 @@ -350,6 +361,8 @@ export interface SplitPanelLayoutConfig { // 🆕 추가 탭 설정 (멀티 테이블 탭) additionalTabs?: AdditionalTabConfig[]; + + pagination?: PaginationConfig; }; // 레이아웃 설정 -- 2.43.0 From 4ba931dc70b7085ff4ee26db8bb263d80be2b8bb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 09:54:44 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=E3=85=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/screen/ScreenSettingModal.tsx | 466 ++++++++++++------ .../TableSearchWidget.tsx | 19 + 2 files changed, 340 insertions(+), 145 deletions(-) diff --git a/frontend/components/screen/ScreenSettingModal.tsx b/frontend/components/screen/ScreenSettingModal.tsx index b0f85351..52f4ebd5 100644 --- a/frontend/components/screen/ScreenSettingModal.tsx +++ b/frontend/components/screen/ScreenSettingModal.tsx @@ -8,7 +8,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -240,14 +239,14 @@ export function ScreenSettingModal({ componentCount = 0, onSaveSuccess, }: ScreenSettingModalProps) { - const [activeTab, setActiveTab] = useState("overview"); const [loading, setLoading] = useState(false); const [dataFlows, setDataFlows] = useState([]); const [layoutItems, setLayoutItems] = useState([]); - const [iframeKey, setIframeKey] = useState(0); // iframe 새로고침용 키 - const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); // 화면 캔버스 크기 - const [showDesignerModal, setShowDesignerModal] = useState(false); // 화면 디자이너 모달 - const [showTableSettingModal, setShowTableSettingModal] = useState(false); // 테이블 설정 모달 + const [buttonControls, setButtonControls] = useState([]); + const [iframeKey, setIframeKey] = useState(0); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + const [showDesignerModal, setShowDesignerModal] = useState(false); + const [showTableSettingModal, setShowTableSettingModal] = useState(false); const [tableSettingTarget, setTableSettingTarget] = useState<{ tableName: string; tableLabel?: string } | null>(null); // 그룹 내 화면 목록 및 현재 선택된 화면 @@ -338,12 +337,56 @@ export function ScreenSettingModal({ if (layoutResponse.success && layoutResponse.data) { const screenLayout = layoutResponse.data[currentScreenId]; setLayoutItems(screenLayout?.layoutItems || []); - // 캔버스 크기 저장 (화면 프리뷰에 사용) setCanvasSize({ width: screenLayout?.canvasWidth || 0, height: screenLayout?.canvasHeight || 0, }); } + + // 3. 버튼 정보 추출 (읽기 전용 요약용) + try { + const rawLayout = await screenApi.getLayout(currentScreenId); + if (rawLayout?.components) { + const buttons: ButtonControlInfo[] = []; + const extractButtons = (components: any[]) => { + for (const comp of components) { + const config = comp.componentConfig || {}; + const isButton = + comp.widgetType === "button" || comp.webType === "button" || + comp.type === "button" || config.webType === "button" || + comp.componentType?.includes("button") || comp.componentKind?.includes("button"); + if (isButton) { + const webTypeConfig = comp.webTypeConfig || {}; + const action = config.action || {}; + buttons.push({ + id: comp.id || comp.componentId || `btn-${buttons.length}`, + label: config.text || comp.label || comp.title || comp.name || "버튼", + actionType: typeof action === "string" ? action : (action.type || "custom"), + confirmMessage: action.confirmationMessage || action.confirmMessage || config.confirmMessage, + confirmationEnabled: action.confirmationEnabled ?? (!!action.confirmationMessage || !!action.confirmMessage), + backgroundColor: webTypeConfig.backgroundColor || config.backgroundColor || comp.style?.backgroundColor, + textColor: webTypeConfig.textColor || config.textColor || comp.style?.color, + borderRadius: webTypeConfig.borderRadius || config.borderRadius || comp.style?.borderRadius, + linkedFlows: webTypeConfig.dataflowConfig?.flowConfigs?.map((fc: any) => ({ + id: fc.flowId, name: fc.flowName, timing: fc.executionTiming || "after", + })) || (webTypeConfig.dataflowConfig?.flowConfig ? [{ + id: webTypeConfig.dataflowConfig.flowConfig.flowId, + name: webTypeConfig.dataflowConfig.flowConfig.flowName, + timing: webTypeConfig.dataflowConfig.flowConfig.executionTiming || "after", + }] : []), + }); + } + if (comp.children && Array.isArray(comp.children)) extractButtons(comp.children); + if (comp.componentConfig?.children && Array.isArray(comp.componentConfig.children)) extractButtons(comp.componentConfig.children); + if (comp.items && Array.isArray(comp.items)) extractButtons(comp.items); + } + }; + extractButtons(rawLayout.components); + setButtonControls(buttons); + } + } catch (btnError) { + console.error("버튼 정보 추출 실패:", btnError); + } } catch (error) { console.error("데이터 로드 실패:", error); } finally { @@ -360,162 +403,295 @@ export function ScreenSettingModal({ // 새로고침 (데이터 + iframe) const handleRefresh = useCallback(() => { loadData(); - setIframeKey(prev => prev + 1); // iframe 새로고침 + setIframeKey(prev => prev + 1); }, [loadData]); + // 통계 계산 + const stats = useMemo(() => { + const totalJoins = filterTables.reduce((sum, ft) => sum + (ft.joinColumnRefs?.length || 0), 0); + const layoutColumnsSet = new Set(); + layoutItems.forEach((item) => { + if (item.usedColumns) item.usedColumns.forEach((col) => layoutColumnsSet.add(col)); + if (item.bindField) layoutColumnsSet.add(item.bindField); + }); + const inputCount = layoutItems.filter(i => !i.widgetType?.includes("button") && !i.componentKind?.includes("table")).length; + const gridCount = layoutItems.filter(i => i.componentKind?.includes("table") || i.componentKind?.includes("grid")).length; + return { + tableCount: 1 + filterTables.length, + fieldCount: layoutColumnsSet.size || fieldMappings.length, + joinCount: totalJoins, + flowCount: dataFlows.length, + inputCount, + gridCount, + buttonCount: buttonControls.length, + }; + }, [filterTables, fieldMappings, dataFlows, layoutItems, buttonControls]); + + // 연결된 플로우 총 개수 + const linkedFlowCount = useMemo(() => { + return buttonControls.reduce((sum, btn) => sum + (btn.linkedFlows?.length || 0), 0); + }, [buttonControls]); + return ( <> - - - - - 화면 설정: - {groupScreens.length > 1 ? ( - - ) : ( - {currentScreenName} + + {/* V3 Header */} + + + + {currentScreenName} + {groupScreens.length > 1 && ( + <> + + + )} + #{currentScreenId} + - - 화면의 필드 매핑, 테이블 연결, 데이터 흐름을 확인하고 설정합니다. - + 화면 정보 패널 - {/* 2컬럼 레이아웃: 왼쪽 탭(좁게) + 오른쪽 프리뷰(넓게) */} -
- {/* 왼쪽: 탭 컨텐츠 (40%) */} -
- -
- - - - 개요 - - - - 테이블 설정 - - - - 제어 관리 - - - - 데이터 흐름 - - -
- - + {/* V3 Body: Left Info Panel + Right Preview */} +
+ {/* 왼쪽: 정보 패널 (탭 없음, 단일 스크롤) */} +
+
+ + {/* 1. 내러티브 요약 */} +
+

+ {currentMainTable || "테이블 미연결"} + {stats.fieldCount > 0 && <> 테이블의 {stats.fieldCount}개 컬럼을 사용하고 있어요.} + {filterTables.length > 0 && <>
필터 테이블 {filterTables.length}개{stats.joinCount > 0 && <>, 엔티티 조인 {stats.joinCount}개}가 연결되어 있어요.} +

+
+ + {/* 2. 속성 테이블 */} +
+
+ 메인 테이블 + {currentMainTable || "-"} + {stats.fieldCount > 0 && {stats.fieldCount} 컬럼} +
+ {filterTables.map((ft, idx) => ( +
+ 필터 테이블 + {ft.tableName} + FK +
+ ))} + {filterTables.some(ft => ft.joinColumnRefs && ft.joinColumnRefs.length > 0) && ( +
+ 엔티티 조인 + + {filterTables.flatMap(ft => ft.joinColumnRefs || []).map((j, i) => ( + {i > 0 && ", "}{j.column}{j.refTable} + ))} + + {stats.joinCount}개 +
+ )} +
+ 컴포넌트 + + {stats.inputCount > 0 && <>입력 {stats.inputCount}} + {stats.gridCount > 0 && <>{stats.inputCount > 0 && " · "}그리드 {stats.gridCount}} + {stats.buttonCount > 0 && <>{(stats.inputCount > 0 || stats.gridCount > 0) && " · "}버튼 {stats.buttonCount}} + {stats.inputCount === 0 && stats.gridCount === 0 && stats.buttonCount === 0 && `${componentCount}개`} +
- {/* 탭 1: 화면 개요 */} - - - +
- {/* 탭 2: 테이블 설정 */} - - {mainTable && ( - {}} // 탭에서는 닫기 불필요 - tableName={mainTable} - tableLabel={mainTableLabel} - screenId={currentScreenId} - onSaveSuccess={handleRefresh} - isEmbedded={true} // 임베드 모드 - /> + {/* 3. 테이블 섹션 */} +
+
+
+ +
+ 테이블 + {stats.tableCount} +
+

컬럼 타입이나 조인을 변경하려면 "설정"을 눌러요

+
+ {currentMainTable && ( +
+
+
+
{currentMainTable}
+
메인 · {stats.fieldCount} 컬럼 사용중
+
+ +
+ )} + {filterTables.map((ft, idx) => ( +
+
+
+
{ft.tableName}
+
필터{ft.filterKeyMapping ? ` · FK: ${ft.filterKeyMapping.filterTableColumn}` : ""}
+
+ +
+ ))} +
+
+ +
+ + {/* 4. 버튼 섹션 (읽기 전용) */} +
+
+
+ +
+ 버튼 + {stats.buttonCount} +
+

버튼 편집은 화면 디자이너에서 해요

+ {buttonControls.length > 0 ? ( +
+ {buttonControls.map((btn) => ( +
+ {btn.label} +
+
{btn.actionType?.toUpperCase() || "CUSTOM"}
+ {btn.confirmMessage &&
"{btn.confirmMessage}"
} +
+ {btn.linkedFlows && btn.linkedFlows.length > 0 && ( + + 플로우 {btn.linkedFlows.length} + + )} +
+ ))} +
+ ) : ( +
버튼이 없어요
)} - +
- {/* 탭 3: 제어 관리 */} - - - +
- {/* 탭 3: 데이터 흐름 */} - - - - + {/* 5. 데이터 흐름 섹션 */} +
+
+
+ +
+ 데이터 흐름 + {stats.flowCount} +
+ {dataFlows.length > 0 ? ( +
+ {dataFlows.map((flow) => ( +
+
+
+
{flow.source_action || flow.flow_type} → {flow.target_screen_name || `화면 ${flow.target_screen_id}`}
+
{flow.flow_type}{flow.flow_label ? ` · ${flow.flow_label}` : ""}
+
+ +
+ ))} +
+ ) : ( +
+ +
데이터 흐름이 없어요
+
다른 화면으로 데이터를 전달하려면 추가해보세요
+
+ )} + +
+ +
+ + {/* 6. 플로우 연동 섹션 */} +
+
+
+ +
+ 플로우 연동 + {linkedFlowCount} +
+ {linkedFlowCount > 0 ? ( +
+ {buttonControls.filter(b => b.linkedFlows && b.linkedFlows.length > 0).flatMap(btn => + (btn.linkedFlows || []).map(flow => ( +
+
+
+
{flow.name || `플로우 #${flow.id}`}
+
{btn.label} 버튼 · {flow.timing === "before" ? "실행 전" : "실행 후"}
+
+
+ )) + )} +
+ ) : ( +
연동된 플로우가 없어요
+ )} +
+
+ + {/* CTA: 화면 디자이너 열기 */} +
+ +
- {/* 오른쪽: 화면 프리뷰 (60%, 항상 표시) */} -
- + + {/* 테이블 선택 드롭다운 (여러 테이블 + showTableSelector 활성 시) */} + {showTableSelector && hasMultipleTables && ( + + )} + {/* 필터 입력 필드들 */} {activeFilters.length > 0 && (
-- 2.43.0 From 1d1f04f854b08c22e30b147619e63d4e9118b3db Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 09:55:33 +0900 Subject: [PATCH 11/11] Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node -- 2.43.0
{col.label}