jskim-node #394

Merged
kjs merged 18 commits from jskim-node into main 2026-02-26 13:48:08 +09:00
2 changed files with 179 additions and 9 deletions
Showing only changes of commit 66c92bb7b1 - Show all commits

View File

@ -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(() => {

View File

@ -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(() => {