From 4e29f922683de02412fe8a792c3e2e3cc515b089 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 16:39:47 +0900 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EA=B4=80=EB=A6=AC=20ui=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/tableMng/page.tsx | 537 +++++++++--------- .../CategoryValueEditDialog.tsx | 4 +- .../card-display/CardDisplayComponent.tsx | 249 ++++++-- .../card-display/CardDisplayConfigPanel.tsx | 31 + .../registry/components/card-display/types.ts | 4 +- 5 files changed, 491 insertions(+), 334 deletions(-) diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 5dcbb6be..cd0c462d 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -1093,229 +1093,283 @@ export default function TableManagementPage() { {/* 우측 메인 영역: 컬럼 타입 관리 (80%) */}
-
-
- {!selectedTable ? ( -
-
-

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

-
+
+ {!selectedTable ? ( +
+
+

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

- ) : ( - <> - {/* 테이블 라벨 설정 */} -
-
- setTableLabel(e.target.value)} - placeholder="테이블 표시명" - className="h-10 text-sm" - /> -
-
- setTableDescription(e.target.value)} - placeholder="테이블 설명" - className="h-10 text-sm" - /> -
+
+ ) : ( + <> + {/* 테이블 라벨 설정 + 저장 버튼 (고정 영역) */} +
+
+ 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, "컬럼 정보 로딩 중...")} - + {columnsLoading ? ( +
+ + + {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_LOADING_COLUMNS, "컬럼 정보 로딩 중...")} + +
+ ) : columns.length === 0 ? ( +
+ {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} +
+ ) : ( +
+ {/* 컬럼 헤더 (고정) */} +
+
컬럼명
+
라벨
+
입력 타입
+
설명
- ) : columns.length === 0 ? ( -
- {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} -
- ) : ( -
- {/* 컬럼 헤더 */} -
-
컬럼명
-
라벨
-
입력 타입
-
설명
-
- {/* 컬럼 리스트 */} -
{ - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 - if (scrollHeight - scrollTop <= clientHeight + 100) { - loadMoreColumns(); - } - }} - > - {columns.map((column, index) => ( -
-
-
{column.columnName}
-
-
- handleLabelChange(column.columnName, e.target.value)} - placeholder={column.columnName} - className="h-8 text-xs" - /> -
-
-
- {/* 입력 타입 선택 */} + {/* 컬럼 리스트 (스크롤 영역) */} +
{ + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 + if (scrollHeight - scrollTop <= clientHeight + 100) { + loadMoreColumns(); + } + }} + > + {columns.map((column, index) => ( +
+
+
{column.columnName}
+
+
+ handleLabelChange(column.columnName, e.target.value)} + placeholder={column.columnName} + className="h-8 text-xs" + /> +
+
+
+ {/* 입력 타입 선택 */} + + {/* 입력 타입이 'code'인 경우 공통코드 선택 */} + {column.inputType === "code" && ( - {/* 입력 타입이 'code'인 경우 공통코드 선택 */} - {column.inputType === "code" && ( - - )} - {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} - {column.inputType === "category" && ( -
- -
- {secondLevelMenus.length === 0 ? ( -

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

- ) : ( - secondLevelMenus.map((menu) => { - // menuObjid를 숫자로 변환하여 비교 - const menuObjidNum = Number(menu.menuObjid); - const isChecked = (column.categoryMenus || []).includes(menuObjidNum); - - return ( -
- { - const currentMenus = column.categoryMenus || []; - const newMenus = e.target.checked - ? [...currentMenus, menuObjidNum] - : currentMenus.filter((id) => id !== menuObjidNum); - - setColumns((prev) => - prev.map((col) => - col.columnName === column.columnName - ? { ...col, categoryMenus: newMenus } - : col - ) - ); - }} - className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" - /> - -
- ); - }) - )} -
- {column.categoryMenus && column.categoryMenus.length > 0 && ( -

- {column.categoryMenus.length}개 메뉴 선택됨 + )} + {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} + {column.inputType === "category" && ( +

+ +
+ {secondLevelMenus.length === 0 ? ( +

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

+ ) : ( + secondLevelMenus.map((menu) => { + // menuObjid를 숫자로 변환하여 비교 + const menuObjidNum = Number(menu.menuObjid); + const isChecked = (column.categoryMenus || []).includes(menuObjidNum); + + return ( +
+ { + const currentMenus = column.categoryMenus || []; + const newMenus = e.target.checked + ? [...currentMenus, menuObjidNum] + : currentMenus.filter((id) => id !== menuObjidNum); + + setColumns((prev) => + prev.map((col) => + col.columnName === column.columnName + ? { ...col, categoryMenus: newMenus } + : col + ) + ); + }} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + /> + +
+ ); + }) )}
- )} - {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} - {column.inputType === "entity" && ( - <> - {/* 참조 테이블 */} + {column.categoryMenus && column.categoryMenus.length > 0 && ( +

+ {column.categoryMenus.length}개 메뉴 선택됨 +

+ )} +
+ )} + {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} + {column.inputType === "entity" && ( + <> + {/* 참조 테이블 */} +
+ + +
+ + {/* 조인 컬럼 */} + {column.referenceTable && column.referenceTable !== "none" && (
+ )} - {/* 조인 컬럼 */} - {column.referenceTable && column.referenceTable !== "none" && ( + {/* 표시 컬럼 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && (
- handleDetailSettingsChange( - column.columnName, - "entity_display_column", - value, - ) - } - > - - - - - -- 선택 안함 -- - {referenceTableColumns[column.referenceTable]?.map((refCol, index) => ( - - {refCol.columnName} - - ))} - {(!referenceTableColumns[column.referenceTable] || - referenceTableColumns[column.referenceTable].length === 0) && ( - -
-
- 로딩중 -
-
- )} -
- -
- )} - - {/* 설정 완료 표시 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( -
- - 설정 완료 -
- )} - - )} -
-
-
- handleColumnChange(index, "description", e.target.value)} - placeholder="설명" - className="h-8 w-full text-xs" - /> + {/* 설정 완료 표시 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && + column.displayColumn && + column.displayColumn !== "none" && ( +
+ + 설정 완료 +
+ )} + + )}
- ))} -
+
+ handleColumnChange(index, "description", e.target.value)} + placeholder="설명" + className="h-8 w-full text-xs" + /> +
+
+ ))} {/* 로딩 표시 */} {columnsLoading && ( @@ -1428,28 +1435,16 @@ export default function TableManagementPage() { 더 많은 컬럼 로딩 중...
)} - - {/* 페이지 정보 */} -
- {columns.length} / {totalColumns} 컬럼 표시됨 -
- - {/* 전체 저장 버튼 */} -
- -
- )} - - )} -
+ + {/* 페이지 정보 (고정 하단) */} +
+ {columns.length} / {totalColumns} 컬럼 표시됨 +
+
+ )} + + )}
diff --git a/frontend/components/table-category/CategoryValueEditDialog.tsx b/frontend/components/table-category/CategoryValueEditDialog.tsx index 7ad415f0..18880eef 100644 --- a/frontend/components/table-category/CategoryValueEditDialog.tsx +++ b/frontend/components/table-category/CategoryValueEditDialog.tsx @@ -66,8 +66,8 @@ export const CategoryValueEditDialog: React.FC< onUpdate(value.valueId!, { valueLabel: valueLabel.trim(), - description: description.trim(), - color: color, + description: description.trim() || undefined, // 빈 문자열 대신 undefined + color: color === "none" ? null : color, // "none"은 null로 (배지 없음) }); }; diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 1285addf..a84ee354 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -4,11 +4,12 @@ import React, { useEffect, useState, useMemo, useCallback } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; -import { getFullImageUrl } from "@/lib/api/client"; +import { getFullImageUrl, apiClient } from "@/lib/api/client"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { useModalDataStore } from "@/stores/modalDataStore"; @@ -51,6 +52,14 @@ export const CardDisplayComponent: React.FC = ({ const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(false); + + // 카테고리 매핑 상태 (카테고리 코드 -> 라벨/색상) + const [columnMeta, setColumnMeta] = useState< + Record + >({}); + const [categoryMappings, setCategoryMappings] = useState< + Record> + >({}); // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) const [selectedRows, setSelectedRows] = useState>(new Set()); @@ -120,44 +129,78 @@ export const CardDisplayComponent: React.FC = ({ const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정 if (!tableNameToUse) { - // console.log("📋 CardDisplay: 테이블명이 설정되지 않음", { - // tableName, - // componentTableName: component.componentConfig?.tableName, - // }); return; } - // console.log("📋 CardDisplay: 사용할 테이블명", { - // tableName, - // componentTableName: component.componentConfig?.tableName, - // finalTableName: tableNameToUse, - // }); - try { setLoading(true); - // console.log(`📋 CardDisplay: ${tableNameToUse} 테이블 데이터 로딩 시작`); - // 테이블 데이터와 컬럼 정보를 병렬로 로드 - const [dataResponse, columnsResponse] = await Promise.all([ + // 테이블 데이터, 컬럼 정보, 입력 타입 정보를 병렬로 로드 + const [dataResponse, columnsResponse, inputTypesResponse] = await Promise.all([ tableTypeApi.getTableData(tableNameToUse, { page: 1, size: 50, // 카드 표시용으로 적당한 개수 }), tableTypeApi.getColumns(tableNameToUse), + tableTypeApi.getColumnInputTypes(tableNameToUse), ]); - // console.log(`📋 CardDisplay: ${tableNameToUse} 데이터 로딩 완료`, { - // total: dataResponse.total, - // dataLength: dataResponse.data.length, - // columnsLength: columnsResponse.length, - // sampleData: dataResponse.data.slice(0, 2), - // sampleColumns: columnsResponse.slice(0, 3), - // }); - setLoadedTableData(dataResponse.data); setLoadedTableColumns(columnsResponse); + + // 컬럼 메타 정보 설정 (inputType 포함) + const meta: Record = {}; + inputTypesResponse.forEach((item: any) => { + meta[item.columnName || item.column_name] = { + webType: item.webType || item.web_type, + inputType: item.inputType || item.input_type, + codeCategory: item.codeCategory || item.code_category, + }; + }); + console.log("📋 [CardDisplay] 컬럼 메타 정보:", meta); + setColumnMeta(meta); + + // 카테고리 타입 컬럼 찾기 및 매핑 로드 + const categoryColumns = Object.entries(meta) + .filter(([_, m]) => m.inputType === "category") + .map(([columnName]) => columnName); + + console.log("📋 [CardDisplay] 카테고리 컬럼:", categoryColumns); + + if (categoryColumns.length > 0) { + const mappings: Record> = {}; + + for (const columnName of categoryColumns) { + try { + console.log(`📋 [CardDisplay] 카테고리 매핑 로드 시작: ${tableNameToUse}/${columnName}`); + const response = await apiClient.get(`/table-categories/${tableNameToUse}/${columnName}/values`); + + console.log(`📋 [CardDisplay] 카테고리 API 응답 [${columnName}]:`, response.data); + + if (response.data.success && response.data.data) { + const mapping: Record = {}; + response.data.data.forEach((item: any) => { + // API 응답 형식: valueCode, valueLabel (camelCase) + const code = item.valueCode || item.value_code || item.category_code || item.code || item.value; + const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code; + // color가 null/undefined/"none"이면 undefined로 유지 (배지 없음) + const rawColor = item.color ?? item.badge_color; + const color = (rawColor && rawColor !== "none") ? rawColor : undefined; + mapping[code] = { label, color }; + console.log(`📋 [CardDisplay] 매핑 추가: ${code} -> ${label} (color: ${color})`); + }); + mappings[columnName] = mapping; + } + } catch (error) { + console.error(`❌ CardDisplay: 카테고리 매핑 로드 실패 [${columnName}]`, error); + } + } + + console.log("📋 [CardDisplay] 최종 카테고리 매핑:", mappings); + setCategoryMappings(mappings); + } } catch (error) { - console.error(`❌ CardDisplay: ${tableNameToUse} 데이터 로딩 실패`, error); + console.error(`❌ CardDisplay: 데이터 로딩 실패`, error); setLoadedTableData([]); setLoadedTableColumns([]); } finally { @@ -392,10 +435,58 @@ export const CardDisplayComponent: React.FC = ({ return text.substring(0, maxLength) + "..."; }; - // 컬럼 매핑에서 값 가져오기 - const getColumnValue = (data: any, columnName?: string) => { + // 컬럼 값을 문자열로 가져오기 (카테고리 타입인 경우 매핑된 라벨 반환) + const getColumnValueAsString = (data: any, columnName?: string): string => { if (!columnName) return ""; - return data[columnName] || ""; + const value = data[columnName]; + if (value === null || value === undefined || value === "") return ""; + + // 카테고리 타입인 경우 매핑된 라벨 반환 + const meta = columnMeta[columnName]; + if (meta?.inputType === "category") { + const mapping = categoryMappings[columnName]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + return categoryData?.label || valueStr; + } + + return String(value); + }; + + // 컬럼 매핑에서 값 가져오기 (카테고리 타입인 경우 배지로 표시) + const getColumnValue = (data: any, columnName?: string): React.ReactNode => { + if (!columnName) return ""; + const value = data[columnName]; + if (value === null || value === undefined || value === "") return ""; + + // 카테고리 타입인 경우 매핑된 라벨과 배지로 표시 + const meta = columnMeta[columnName]; + if (meta?.inputType === "category") { + const mapping = categoryMappings[columnName]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color; + + // 색상이 없거나(null/undefined), 빈 문자열이거나, "none"이면 일반 텍스트로 표시 (배지 없음) + if (!displayColor || displayColor === "none") { + return displayLabel; + } + + return ( + + {displayLabel} + + ); + } + + return String(value); }; // 컬럼명을 라벨로 변환하는 헬퍼 함수 @@ -516,16 +607,16 @@ export const CardDisplayComponent: React.FC = ({
) : ( displayData.map((data, index) => { - // 타이틀, 서브타이틀, 설명 값 결정 (원래 카드 레이아웃과 동일한 로직) + // 타이틀, 서브타이틀, 설명 값 결정 (문자열로 가져와서 표시) const titleValue = - getColumnValue(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); + getColumnValueAsString(data, componentConfig.columnMapping?.titleColumn) || getAutoFallbackValue(data, "title"); const subtitleValue = - getColumnValue(data, componentConfig.columnMapping?.subtitleColumn) || + getColumnValueAsString(data, componentConfig.columnMapping?.subtitleColumn) || getAutoFallbackValue(data, "subtitle"); const descriptionValue = - getColumnValue(data, componentConfig.columnMapping?.descriptionColumn) || + getColumnValueAsString(data, componentConfig.columnMapping?.descriptionColumn) || getAutoFallbackValue(data, "description"); // 이미지 컬럼 자동 감지 (image_path, photo 등) - 대소문자 무시 @@ -628,27 +719,33 @@ export const CardDisplayComponent: React.FC = ({
)} - {/* 카드 액션 */} -
- - -
+ {/* 카드 액션 - 설정에 따라 표시 */} + {(componentConfig.cardStyle?.showActions ?? true) && ( +
+ {(componentConfig.cardStyle?.showViewButton ?? true) && ( + + )} + {(componentConfig.cardStyle?.showEditButton ?? true) && ( + + )} +
+ )}
); @@ -672,16 +769,48 @@ export const CardDisplayComponent: React.FC = ({
{Object.entries(selectedData) .filter(([key, value]) => value !== null && value !== undefined && value !== '') - .map(([key, value]) => ( -
-
- {key.replace(/_/g, ' ')} + .map(([key, value]) => { + // 카테고리 타입인 경우 배지로 표시 + const meta = columnMeta[key]; + let displayValue: React.ReactNode = String(value); + + if (meta?.inputType === "category") { + const mapping = categoryMappings[key]; + const valueStr = String(value); + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color; + + // 색상이 있고 "none"이 아닌 경우에만 배지로 표시 + if (displayColor && displayColor !== "none") { + displayValue = ( + + {displayLabel} + + ); + } else { + // 배지 없음: 일반 텍스트로 표시 + displayValue = displayLabel; + } + } + + return ( +
+
+ {getColumnLabel(key)} +
+
+ {displayValue} +
-
- {String(value)} -
-
- )) + ); + }) }
diff --git a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx index dc993238..beff4783 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayConfigPanel.tsx @@ -277,6 +277,37 @@ export const CardDisplayConfigPanel: React.FC = ({ 액션 버튼 표시
+ + {/* 개별 버튼 설정 (액션 버튼이 활성화된 경우에만 표시) */} + {(config.cardStyle?.showActions ?? true) && ( +
+
+ handleNestedChange("cardStyle.showViewButton", e.target.checked)} + className="rounded border-gray-300" + /> + +
+ +
+ handleNestedChange("cardStyle.showEditButton", e.target.checked)} + className="rounded border-gray-300" + /> + +
+
+ )}
diff --git a/frontend/lib/registry/components/card-display/types.ts b/frontend/lib/registry/components/card-display/types.ts index c711125a..7154eb72 100644 --- a/frontend/lib/registry/components/card-display/types.ts +++ b/frontend/lib/registry/components/card-display/types.ts @@ -13,7 +13,9 @@ export interface CardStyleConfig { maxDescriptionLength?: number; imagePosition?: "top" | "left" | "right"; imageSize?: "small" | "medium" | "large"; - showActions?: boolean; // 액션 버튼 표시 여부 + showActions?: boolean; // 액션 버튼 표시 여부 (전체) + showViewButton?: boolean; // 상세보기 버튼 표시 여부 + showEditButton?: boolean; // 편집 버튼 표시 여부 } /**