jskim-node #394
|
|
@ -23,7 +23,8 @@ import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -39,6 +40,80 @@ import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-opt
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useSplitPanel } from "./SplitPanelContext";
|
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 {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
}
|
}
|
||||||
|
|
@ -182,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
|
||||||
|
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({}); // 테이블별 컬럼 inputType
|
||||||
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
const [leftCategoryMappings, setLeftCategoryMappings] = useState<
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({}); // 좌측 카테고리 매핑
|
>({}); // 좌측 카테고리 매핑
|
||||||
|
|
@ -619,7 +695,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return result;
|
return result;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷)
|
// 셀 값 포맷팅 함수 (카테고리 타입 처리 + 날짜/숫자 포맷 + 이미지)
|
||||||
const formatCellValue = useCallback(
|
const formatCellValue = useCallback(
|
||||||
(
|
(
|
||||||
columnName: string,
|
columnName: string,
|
||||||
|
|
@ -636,6 +712,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
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) {
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
|
@ -702,7 +784,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 좌측 데이터 로드
|
// 좌측 데이터 로드
|
||||||
|
|
@ -1453,14 +1535,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRightColumnLabels(labels);
|
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) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightTableColumns();
|
loadRightTableColumns();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 카테고리 매핑 로드
|
// 좌측 테이블 카테고리 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -51,6 +52,42 @@ export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
selectedPanelComponentId?: string;
|
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 컴포넌트
|
* SplitPanelLayout 컴포넌트
|
||||||
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
* 마스터-디테일 패턴의 좌우 분할 레이아웃
|
||||||
|
|
@ -210,6 +247,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||||
|
const [columnInputTypes, setColumnInputTypes] = useState<Record<string, string>>({});
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||||
|
|
||||||
// 추가 탭 관련 상태
|
// 추가 탭 관련 상태
|
||||||
|
|
@ -905,6 +943,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
) => {
|
) => {
|
||||||
if (value === null || value === undefined) return "-";
|
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) {
|
if (format?.type === "date" || format?.dateFormat) {
|
||||||
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
return formatDateValue(value, format?.dateFormat || "YYYY-MM-DD");
|
||||||
|
|
@ -971,7 +1015,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 일반 값
|
// 일반 값
|
||||||
return String(value);
|
return String(value);
|
||||||
},
|
},
|
||||||
[formatDateValue, formatNumberValue],
|
[formatDateValue, formatNumberValue, columnInputTypes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
// 🆕 패널 config의 columns에서 additionalJoinColumns 추출하는 헬퍼
|
||||||
|
|
@ -1835,14 +1879,36 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setRightColumnLabels(labels);
|
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) {
|
} catch (error) {
|
||||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadRightTableColumns();
|
loadRightTableColumns();
|
||||||
}, [componentConfig.rightPanel?.tableName, isDesignMode]);
|
}, [componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs, isDesignMode]);
|
||||||
|
|
||||||
// 좌측 테이블 카테고리 매핑 로드
|
// 좌측 테이블 카테고리 매핑 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue