From 66c92bb7b13604aeac1e6c1d0516ce94eda349d5 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 25 Feb 2026 15:29:04 +0900 Subject: [PATCH] feat: Enhance image rendering in SplitPanelLayoutComponent - Introduced SplitPanelCellImage component for rendering image thumbnails in table cells, supporting both object IDs and file paths. - Updated formatCellValue function to display images for columns with input type "image". - Improved loading logic for column input types to accommodate special rendering for images in both SplitPanelLayoutComponent and V2SplitPanelLayoutComponent. - Enhanced error handling for image loading failures, ensuring a better user experience when images cannot be displayed. --- .../SplitPanelLayoutComponent.tsx | 114 +++++++++++++++++- .../SplitPanelLayoutComponent.tsx | 74 +++++++++++- 2 files changed, 179 insertions(+), 9 deletions(-) diff --git a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx index b7b4191d..bc4ba2ba 100644 --- a/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/split-panel-layout/SplitPanelLayoutComponent.tsx @@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; import { Dialog, DialogContent, @@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; +// 테이블 셀 이미지 썸네일 컴포넌트 +const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { + const [imgSrc, setImgSrc] = React.useState(null); + const [error, setError] = React.useState(false); + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + let mounted = true; + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; + const isObjid = /^\d+$/.test(strValue); + + if (isObjid) { + const loadImage = async () => { + try { + const response = await apiClient.get(`/files/preview/${strValue}`, { responseType: "blob" }); + if (mounted) { + const blob = new Blob([response.data]); + setImgSrc(window.URL.createObjectURL(blob)); + setLoading(false); + } + } catch { + if (mounted) { setError(true); setLoading(false); } + } + }; + loadImage(); + } else { + setImgSrc(getFullImageUrl(strValue)); + setLoading(false); + } + + return () => { mounted = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !imgSrc) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ 이미지 { + e.stopPropagation(); + const rawValue = String(value); + const strValue = rawValue.includes(",") ? rawValue.split(",")[0].trim() : rawValue; + const isObjid = /^\d+$/.test(strValue); + window.open(isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue), "_blank"); + }} + onError={() => setError(true)} + /> +
+ ); +}); +SplitPanelCellImage.displayName = "SplitPanelCellImage"; + export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 props } @@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 const [leftColumnLabels, setLeftColumnLabels] = useState>({}); // 좌측 컬럼 라벨 const [rightColumnLabels, setRightColumnLabels] = useState>({}); // 우측 컬럼 라벨 + const [columnInputTypes, setColumnInputTypes] = useState>({}); // 테이블별 컬럼 inputType const [leftCategoryMappings, setLeftCategoryMappings] = useState< Record> >({}); // 좌측 카테고리 매핑 @@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC return result; }, []); - // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷) + // 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지) const formatCellValue = useCallback( ( columnName: string, @@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC ) => { if (value === null || value === undefined) return "-"; + // 이미지 타입: 썸네일 표시 + const colInputType = columnInputTypes[columnName]; + if (colInputType === "image" && value) { + return ; + } + // 🆕 날짜 포맷 적용 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); @@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC // 일반 값 return String(value); }, - [formatDateValue, formatNumberValue], + [formatDateValue, formatNumberValue, columnInputTypes], ); // 좌측 데이터 로드 @@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC } }); setRightColumnLabels(labels); - console.log("✅ 우측 컬럼 라벨 로드:", labels); + + // 우측 테이블 + 추가 탭 테이블의 inputType 로드 + const tablesToLoad = new Set([rightTableName]); + const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; + additionalTabs.forEach((tab: any) => { + if (tab.tableName) tablesToLoad.add(tab.tableName); + }); + + const inputTypes: Record = {}; + for (const tbl of tablesToLoad) { + try { + const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl); + inputTypesResponse.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (colName) { + inputTypes[colName] = col.inputType || "text"; + } + }); + } catch { + // inputType 로드 실패 시 무시 + } + } + setColumnInputTypes(inputTypes); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; loadRightTableColumns(); - }, [componentConfig.rightPanel?.tableName, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]); // 좌측 테이블 카테고리 매핑 로드 useEffect(() => { diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 3458c1df..5e8bee69 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -25,7 +25,8 @@ import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { useToast } from "@/hooks/use-toast"; import { tableTypeApi } from "@/lib/api/screen"; -import { apiClient } from "@/lib/api/client"; +import { apiClient, getFullImageUrl } from "@/lib/api/client"; +import { getFilePreviewUrl } from "@/lib/api/file"; import { Dialog, DialogContent, @@ -51,6 +52,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { selectedPanelComponentId?: string; } +// 이미지 셀 렌더링 컴포넌트 (objid 또는 파일 경로 지원) +const SplitPanelCellImage: React.FC<{ value: string }> = React.memo(({ value }) => { + const [imgSrc, setImgSrc] = React.useState(null); + + React.useEffect(() => { + if (!value) return; + const strVal = String(value).trim(); + if (!strVal || strVal === "-") return; + + if (strVal.startsWith("http") || strVal.startsWith("/uploads/") || strVal.startsWith("/api/")) { + setImgSrc(getFullImageUrl(strVal)); + } else { + const previewUrl = getFilePreviewUrl(strVal); + fetch(previewUrl, { credentials: "include" }) + .then((res) => { + if (!res.ok) throw new Error("fetch failed"); + return res.blob(); + }) + .then((blob) => setImgSrc(URL.createObjectURL(blob))) + .catch(() => setImgSrc(null)); + } + }, [value]); + + if (!imgSrc) return -; + + return ( + setImgSrc(null)} + /> + ); +}); +SplitPanelCellImage.displayName = "SplitPanelCellImage"; + /** * SplitPanelLayout 컴포넌트 * 마스터-디테일 패턴의 좌우 분할 레이아웃 @@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingRight, setIsLoadingRight] = useState(false); const [rightTableColumns, setRightTableColumns] = useState([]); // 우측 테이블 컬럼 정보 + const [columnInputTypes, setColumnInputTypes] = useState>({}); const [expandedItems, setExpandedItems] = useState>(new Set()); // 펼쳐진 항목들 // 추가 탭 관련 상태 @@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC ) => { if (value === null || value === undefined) return "-"; + // 이미지 타입 컬럼 처리 + const colInputType = columnInputTypes[columnName]; + if (colInputType === "image" && value) { + return ; + } + // 🆕 날짜 포맷 적용 if (format?.type === "date" || format?.dateFormat) { return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD"); @@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC // 일반 값 return String(value); }, - [formatDateValue, formatNumberValue], + [formatDateValue, formatNumberValue, columnInputTypes], ); // 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼 @@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC } }); setRightColumnLabels(labels); - console.log("✅ 우측 컬럼 라벨 로드:", labels); + + // 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해) + const tablesToLoad = new Set([rightTableName]); + const additionalTabs = componentConfig.rightPanel?.additionalTabs || []; + additionalTabs.forEach((tab: any) => { + if (tab.tableName) tablesToLoad.add(tab.tableName); + }); + + const inputTypes: Record = {}; + for (const tbl of tablesToLoad) { + try { + const inputTypesResponse = await tableTypeApi.getColumnInputTypes(tbl); + inputTypesResponse.forEach((col: any) => { + const colName = col.columnName || col.column_name; + if (colName) { + inputTypes[colName] = col.inputType || "text"; + } + }); + } catch { + // ignore + } + } + setColumnInputTypes(inputTypes); } catch (error) { console.error("우측 테이블 컬럼 정보 로드 실패:", error); } }; loadRightTableColumns(); - }, [componentConfig.rightPanel?.tableName, isDesignMode]); + }, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]); // 좌측 테이블 카테고리 매핑 로드 useEffect(() => {