From e305e78155bc308ad43dfaff75177c284569fdc9 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 11:43:26 +0900 Subject: [PATCH 1/7] 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"; } From dfd26e19335539596ac31f0e2e9a3640df0da4c2 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 16:28:42 +0900 Subject: [PATCH 2/7] 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, }); From 825f164bdeddcbfeeedbd28f2c172416b77cfc27 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:15:12 +0900 Subject: [PATCH 3/7] 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" From a391918e5805e3717c6fa08ce9ab1493a5332d33 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:28:34 +0900 Subject: [PATCH 4/7] [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"; +} From 43aafb36c186c6b53f00e0c8f6a83046f79d6bb6 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 17:58:37 +0900 Subject: [PATCH 5/7] 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: "◉" }, }; /** 컬럼 그룹 판별 */ From 6a50e1e924a2f545123a70c29325ce62528bcc6f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 16 Mar 2026 18:59:45 +0900 Subject: [PATCH 6/7] 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 토글" > From 4ba931dc70b7085ff4ee26db8bb263d80be2b8bb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 09:54:44 +0900 Subject: [PATCH 7/7] =?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 && (