Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
@@ -189,18 +189,6 @@ export function DashboardViewer({
|
||||
}: DashboardViewerProps) {
|
||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||
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(() => {
|
||||
@@ -302,10 +290,8 @@ export function DashboardViewer({
|
||||
return () => clearInterval(interval);
|
||||
}, [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;
|
||||
@@ -315,7 +301,7 @@ export function DashboardViewer({
|
||||
// 같은 행이면 X 좌표로 정렬
|
||||
return a.position.x - b.position.x;
|
||||
});
|
||||
}, [elements, isMobile]);
|
||||
}, [elements]);
|
||||
|
||||
// 요소가 없는 경우
|
||||
if (elements.length === 0) {
|
||||
@@ -332,10 +318,18 @@ export function DashboardViewer({
|
||||
|
||||
return (
|
||||
<DashboardProvider>
|
||||
{isMobile ? (
|
||||
// 모바일/태블릿: 세로 스택 레이아웃
|
||||
<div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
|
||||
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
|
||||
<div
|
||||
className="relative rounded-lg"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
height: `${canvasHeight}px`,
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
>
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
@@ -343,38 +337,29 @@ export function DashboardViewer({
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
isMobile={false}
|
||||
canvasWidth={canvasConfig.width}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 데스크톱: 기존 고정 캔버스 레이아웃
|
||||
<div className="min-h-screen bg-gray-100 py-8">
|
||||
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
@@ -385,22 +370,28 @@ interface ViewerElementProps {
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
isMobile: boolean;
|
||||
canvasWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 뷰어 요소 컴포넌트
|
||||
* - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정)
|
||||
* - 태블릿 이하: 세로 스택 카드 레이아웃
|
||||
*/
|
||||
function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
// 마운트 확인 (Leaflet 지도 초기화 문제 해결)
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
@@ -408,19 +399,31 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||
className="text-gray-400 transition-colors 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" />
|
||||
) : (
|
||||
"🔄"
|
||||
)}
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "p-4" : "p-4"} style={{ minHeight: "250px" }}>
|
||||
{element.type === "chart" ? (
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={undefined} height={250} />
|
||||
) : (
|
||||
renderWidget(element)
|
||||
@@ -438,18 +441,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||
);
|
||||
}
|
||||
|
||||
// 데스크톱: 기존 absolute positioning
|
||||
// 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
|
||||
// 단, 너비는 화면 크기에 따라 비율로 조정
|
||||
const widthPercentage = (element.size.width / canvasWidth) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
|
||||
style={{
|
||||
left: element.position.x,
|
||||
left: `${(element.position.x / canvasWidth) * 100}%`,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
width: `${widthPercentage}%`,
|
||||
height: element.size.height,
|
||||
}}
|
||||
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">
|
||||
@@ -457,22 +461,37 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
className="text-gray-400 transition-colors 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" />
|
||||
) : (
|
||||
"🔄"
|
||||
)}
|
||||
<svg
|
||||
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
|
||||
{element.type === "chart" ? (
|
||||
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
|
||||
<div className={element.showHeader !== false ? "h-[calc(100%-50px)] w-full" : "h-full w-full"}>
|
||||
{!isMounted ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : element.type === "chart" ? (
|
||||
<ChartRenderer
|
||||
element={element}
|
||||
data={data}
|
||||
width={undefined}
|
||||
height={element.showHeader !== false ? element.size.height - 50 : element.size.height}
|
||||
/>
|
||||
) : (
|
||||
renderWidget(element)
|
||||
)}
|
||||
|
||||
@@ -374,45 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-white p-4">
|
||||
{/* 스크롤 가능한 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid w-full gap-4" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}
|
||||
>
|
||||
<div className="text-sm text-gray-600">{card.label}</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-white p-2">
|
||||
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */}
|
||||
<div className="grid h-full w-full gap-2" style={{ gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))" }}>
|
||||
{/* 그룹별 카드 (활성화 시) */}
|
||||
{isGroupByMode &&
|
||||
groupedCards.map((card, index) => {
|
||||
// 색상 순환 (6가지 색상)
|
||||
const colorKeys = Object.keys(colorMap) as Array<keyof typeof colorMap>;
|
||||
const colorKey = colorKeys[index % colorKeys.length];
|
||||
const colors = colorMap[colorKey];
|
||||
|
||||
return (
|
||||
<div key={metric.id} className={`rounded-lg border ${colors.bg} ${colors.border} p-4 text-center`}>
|
||||
<div className="text-sm text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
<span className="ml-1 text-lg">{metric.unit}</span>
|
||||
</div>
|
||||
<div
|
||||
key={`group-${index}`}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{card.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>{card.value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 일반 지표 카드 (항상 표시) */}
|
||||
{metrics.map((metric) => {
|
||||
const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray;
|
||||
const formattedValue = metric.calculatedValue.toFixed(metric.decimals);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={`flex flex-col items-center justify-center rounded-lg border ${colors.bg} ${colors.border} p-2`}
|
||||
>
|
||||
<div className="text-[10px] text-gray-600">{metric.label}</div>
|
||||
<div className={`mt-0.5 text-xl font-bold ${colors.text}`}>
|
||||
{formattedValue}
|
||||
<span className="ml-0.5 text-sm">{metric.unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user