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/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 80a96cb3..4a8be6bd 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -235,6 +235,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; + } + /** * 규칙 목록 조회 (전체) */ @@ -1101,6 +1407,7 @@ class NumberingRuleService { const hasManualPart = rule.parts.some((p: any) => p.generationMethod === "manual"); const skipSequenceLookup = hasManualPart && !manualInputValue; + // prefix_key 기반 순번 조회 + 테이블 내 최대값과 비교 const manualValues = manualInputValue ? [manualInputValue] : undefined; const prefixKey = await this.buildPrefixKey(rule, formData, manualValues); const pool = getPool(); @@ -1108,8 +1415,36 @@ class NumberingRuleService { ? 0 : await this.getSequenceForPrefix(pool, ruleId, companyCode, prefixKey); - logger.info("미리보기: prefix_key 기반 순번 조회", { - ruleId, prefixKey, currentSeq, skipSequenceLookup, + // 대상 테이블에서 실제 최대 시퀀스 조회 + 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, skipSequenceLookup, }); const parts = await Promise.all(rule.parts @@ -1125,7 +1460,7 @@ class NumberingRuleService { case "sequence": { const length = autoConfig.sequenceLength || 3; const startFrom = autoConfig.startFrom || 1; - const nextSequence = currentSeq + startFrom; + const nextSequence = baseSeq + startFrom; return String(nextSequence).padStart(length, "0"); } @@ -1239,20 +1574,15 @@ class NumberingRuleService { const prefixKey = await this.buildPrefixKey(rule, formData, extractedManualValues); const hasSequence = rule.parts.some((p: any) => p.partType === "sequence"); - // 3단계: 순번이 있으면 prefix_key 기반으로 UPSERT하여 다음 순번 획득 + // 3단계: 순번이 있으면 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: prefix_key + 테이블 기반 순번 할당", { ruleId, prefixKey, allocatedSequence, extractedManualValues, }); 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/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 3064e4e5..44051e28 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"; @@ -50,43 +49,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 +130,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 +413,8 @@ export default function TableManagementPage() { setSelectedTable(tableName); setCurrentPage(1); setColumns([]); + setSelectedColumn(null); + setTypeFilter(null); // 선택된 테이블 정보에서 라벨 설정 const tableInfo = tables.find((table) => table.tableName === tableName); @@ -995,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); @@ -1318,700 +1299,342 @@ 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, "컬럼이 없습니다")} -
- ) : ( -
- {/* 컬럼 헤더 (고정) */} -
-
라벨
-
컬럼명
-
입력 타입
-
설명
-
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} + onPkToggle={handlePkToggle} + onIndexToggle={(columnName, checked) => + handleIndexToggle(columnName, "index", checked) + } + /> )} -
- } - 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 && ( @@ -2204,7 +1827,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 new file mode 100644 index 00000000..77f5dedf --- /dev/null +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -0,0 +1,498 @@ +"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]) => { + 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" && ( +
+ + + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + 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} + +
+ )} +
+ )} + + {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..c03c7516 --- /dev/null +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -0,0 +1,281 @@ +"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 }; + onPkToggle?: (columnName: string, checked: boolean) => void; + onIndexToggle?: (columnName: string, checked: boolean) => void; +} + +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, + onColumnChange, + constraints, + typeFilter = null, + getColumnIndexState: externalGetIndexState, + onPkToggle, + onIndexToggle, +}: 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 (클릭 토글) */} +
+ + + + +
+ +
+ +
+
+ ); + })} +
+ ); + }) + )} +
+
+ ); +} 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..329b4049 --- /dev/null +++ b/frontend/components/admin/table-type/types.ts @@ -0,0 +1,77 @@ +/** + * 테이블 타입 관리 페이지 공통 타입 + * 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; + barColor: string; + label: string; + desc: string; + iconChar: string; +} + +/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */ +export const INPUT_TYPE_COLORS: Record = { + 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: "◉" }, +}; + +/** 컬럼 그룹 판별 */ +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"; +} 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/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%, 항상 표시) */} -
- + ((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 9bf0b7d3..24a50cdd 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"; } diff --git a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx index 0d917414..a786cd49 100644 --- a/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/v2-table-search-widget/TableSearchWidget.tsx @@ -798,6 +798,25 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table minHeight: "48px", }} > + {/* 테이블 선택 드롭다운 (여러 테이블 + showTableSelector 활성 시) */} + {showTableSelector && hasMultipleTables && ( + + )} + {/* 필터 입력 필드들 */} {activeFilters.length > 0 && (
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"