jskim-node #394
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !imgSrc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="이미지"
|
||||
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||
|
||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
}
|
||||
|
|
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
|
||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({}); // 좌측 카테고리 매핑
|
||||
|
|
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return result;
|
||||
}, []);
|
||||
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
|
||||
const formatCellValue = useCallback(
|
||||
(
|
||||
columnName: string,
|
||||
|
|
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 이미지 타입: 썸네일 표시
|
||||
const colInputType = columnInputTypes[columnName];
|
||||
if (colInputType === "image" && value) {
|
||||
return <SplitPanelCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 🆕 날짜 포맷 적용
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
|
|
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||
);
|
||||
|
||||
// 좌측 데이터 로드
|
||||
|
|
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setRightColumnLabels(labels);
|
||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
|
||||
// 우측 테이블 + 추가 탭 테이블의 inputType 로드
|
||||
const tablesToLoad = new Set<string>([rightTableName]);
|
||||
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||
additionalTabs.forEach((tab: any) => {
|
||||
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||
});
|
||||
|
||||
const inputTypes: Record<string, string> = {};
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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 <span className="text-muted-foreground text-xs">-</span>;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
onError={() => setImgSrc(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SplitPanelCellImage.displayName = "SplitPanelCellImage";
|
||||
|
||||
/**
|
||||
* SplitPanelLayout 컴포넌트
|
||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||
|
|
@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
|
||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
|
||||
// 추가 탭 관련 상태
|
||||
|
|
@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
|
||||
// 이미지 타입 컬럼 처리
|
||||
const colInputType = columnInputTypes[columnName];
|
||||
if (colInputType === "image" && value) {
|
||||
return <SplitPanelCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 🆕 날짜 포맷 적용
|
||||
if (format?.type === "date" || format?.dateFormat) {
|
||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||
|
|
@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 일반 값
|
||||
return String(value);
|
||||
},
|
||||
[formatDateValue, formatNumberValue],
|
||||
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||
);
|
||||
|
||||
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||
|
|
@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setRightColumnLabels(labels);
|
||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
|
||||
// 컬럼 inputType 로드 (이미지 등 특수 렌더링을 위해)
|
||||
const tablesToLoad = new Set<string>([rightTableName]);
|
||||
const additionalTabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||
additionalTabs.forEach((tab: any) => {
|
||||
if (tab.tableName) tablesToLoad.add(tab.tableName);
|
||||
});
|
||||
|
||||
const inputTypes: Record<string, string> = {};
|
||||
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(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue