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:
commit
45d00e10e7
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue