Merge pull request '대시보드 뷰어에 반응형 적용' (#116) from feat/dashboard into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/116
This commit is contained in:
hyeonsu 2025-10-21 13:11:34 +09:00
commit 45d00e10e7
1 changed files with 118 additions and 35 deletions

View File

@ -161,13 +161,24 @@ interface DashboardViewerProps {
*/ */
export function DashboardViewer({ export function DashboardViewer({
elements, elements,
dashboardId,
refreshInterval, refreshInterval,
backgroundColor = "#f9fafb", backgroundColor = "#f9fafb",
resolution = "fhd", resolution = "fhd",
}: DashboardViewerProps) { }: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({}); const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set()); const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
// 화면 크기 감지
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
// 캔버스 설정 계산 // 캔버스 설정 계산
const canvasConfig = useMemo(() => { const canvasConfig = useMemo(() => {
@ -269,6 +280,21 @@ export function DashboardViewer({
return () => clearInterval(interval); return () => clearInterval(interval);
}, [refreshInterval, loadAllData]); }, [refreshInterval, loadAllData]);
// 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래)
const sortedElements = useMemo(() => {
if (!isMobile) return elements;
return [...elements].sort((a, b) => {
// Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함)
const yDiff = a.position.y - b.position.y;
if (Math.abs(yDiff) > 50) {
return yDiff;
}
// 같은 행이면 X 좌표로 정렬
return a.position.x - b.position.x;
});
}, [elements, isMobile]);
// 요소가 없는 경우 // 요소가 없는 경우
if (elements.length === 0) { if (elements.length === 0) {
return ( return (
@ -284,30 +310,47 @@ export function DashboardViewer({
return ( return (
<DashboardProvider> <DashboardProvider>
{/* 스크롤 가능한 컨테이너 */} {isMobile ? (
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8"> // 모바일/태블릿: 세로 스택 레이아웃
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */} <div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}>
<div <div className="mx-auto max-w-3xl space-y-4">
className="relative rounded-lg" {sortedElements.map((element) => (
style={{ <ViewerElement
width: `${canvasConfig.width}px`, key={element.id}
minHeight: `${canvasConfig.height}px`, element={element}
height: `${canvasHeight}px`, data={elementData[element.id]}
backgroundColor: backgroundColor, isLoading={loadingElements.has(element.id)}
}} onRefresh={() => loadElementData(element)}
> isMobile={true}
{/* 대시보드 요소들 */} />
{elements.map((element) => ( ))}
<ViewerElement </div>
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
/>
))}
</div> </div>
</div> ) : (
// 데스크톱: 기존 고정 캔버스 레이아웃
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8">
<div
className="relative rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={false}
/>
))}
</div>
</div>
)}
</DashboardProvider> </DashboardProvider>
); );
} }
@ -317,14 +360,61 @@ interface ViewerElementProps {
data?: QueryResult; data?: QueryResult;
isLoading: boolean; isLoading: boolean;
onRefresh: () => void; onRefresh: () => void;
isMobile: boolean;
} }
/** /**
* *
*/ */
function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementProps) { function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
if (isMobile) {
// 모바일/태블릿: 세로 스택 카드 스타일
return (
<div
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{ minHeight: "300px" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
</button>
</div>
)}
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
{element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={undefined} height={250} />
) : (
renderWidget(element)
)}
</div>
{isLoading && (
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
<div className="text-center">
<div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<div className="text-sm text-gray-600"> ...</div>
</div>
</div>
)}
</div>
);
}
// 데스크톱: 기존 absolute positioning
return ( return (
<div <div
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm" className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
@ -337,16 +427,13 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
{/* 헤더 (showHeader가 false가 아닐 때만 표시) */}
{element.showHeader !== false && ( {element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3"> <div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3> <h3 className="text-sm font-semibold text-gray-800">{element.customTitle || element.title}</h3>
{/* 새로고침 버튼 (항상 렌더링하되 opacity로 제어) */}
<button <button
onClick={onRefresh} onClick={onRefresh}
disabled={isLoading} disabled={isLoading}
className={`hover:text-muted-foreground text-gray-400 transition-opacity disabled:opacity-50 ${ className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${
isHovered ? "opacity-100" : "opacity-0" isHovered ? "opacity-100" : "opacity-0"
}`} }`}
title="새로고침" title="새로고침"
@ -359,8 +446,6 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
</button> </button>
</div> </div>
)} )}
{/* 내용 */}
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}> <div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
{element.type === "chart" ? ( {element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} /> <ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
@ -368,13 +453,11 @@ function ViewerElement({ element, data, isLoading, onRefresh }: ViewerElementPro
renderWidget(element) renderWidget(element)
)} )}
</div> </div>
{/* 로딩 오버레이 */}
{isLoading && ( {isLoading && (
<div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white"> <div className="bg-opacity-75 absolute inset-0 flex items-center justify-center bg-white">
<div className="text-center"> <div className="text-center">
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" /> <div className="mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<div className="text-muted-foreground text-sm"> ...</div> <div className="text-sm text-gray-600"> ...</div>
</div> </div>
</div> </div>
)} )}