통계카드에 시간 표시
This commit is contained in:
parent
5533a134c6
commit
385ecdc46a
|
|
@ -1,12 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { ChartDataSource, QueryResult, ChartConfig } from "./types";
|
||||
import { ChartDataSource, QueryResult } from "./types";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -14,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
|
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||
|
||||
interface QueryEditorProps {
|
||||
dataSource?: ChartDataSource;
|
||||
|
|
@ -106,7 +104,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
|||
...dataSource,
|
||||
type: "database",
|
||||
query: query.trim(),
|
||||
refreshInterval: dataSource?.refreshInterval ?? 0,
|
||||
lastExecuted: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
@ -168,8 +165,8 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 에디터 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Database className="h-3.5 w-3.5 text-primary" />
|
||||
<h4 className="text-xs font-semibold text-foreground">SQL 쿼리 에디터</h4>
|
||||
<Database className="text-primary h-3.5 w-3.5" />
|
||||
<h4 className="text-foreground text-xs font-semibold">SQL 쿼리 에디터</h4>
|
||||
</div>
|
||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
||||
{isExecuting ? (
|
||||
|
|
@ -188,7 +185,7 @@ ORDER BY 하위부서수 DESC`,
|
|||
|
||||
{/* 샘플 쿼리 아코디언 */}
|
||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-border bg-muted px-2 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted">
|
||||
<CollapsibleTrigger className="border-border bg-muted text-foreground hover:bg-muted flex w-full items-center gap-1.5 rounded border px-2 py-1.5 text-xs font-medium transition-colors">
|
||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
샘플 쿼리
|
||||
</CollapsibleTrigger>
|
||||
|
|
@ -196,33 +193,33 @@ ORDER BY 하위부서수 DESC`,
|
|||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => insertSampleQuery("users")}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서별 사용자
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("dept")}
|
||||
className="flex items-center gap-1 rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted flex items-center gap-1 rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
<Code className="h-3 w-3" />
|
||||
부서 정보
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByDate")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
월별 가입 추이
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("usersByPosition")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
직급별 분포
|
||||
</button>
|
||||
<button
|
||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
||||
className="rounded border border-border bg-background px-2 py-1 text-[11px] transition-colors hover:bg-muted"
|
||||
className="border-border bg-background hover:bg-muted rounded border px-2 py-1 text-[11px] transition-colors"
|
||||
>
|
||||
부서 계층
|
||||
</button>
|
||||
|
|
@ -247,46 +244,6 @@ ORDER BY 하위부서수 DESC`,
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로고침 간격 설정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs">자동 새로고침:</Label>
|
||||
<Select
|
||||
value={String(dataSource?.refreshInterval ?? 0)}
|
||||
onValueChange={(value) =>
|
||||
onDataSourceChange({
|
||||
...dataSource,
|
||||
type: "database",
|
||||
query,
|
||||
refreshInterval: parseInt(value),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[99999]">
|
||||
<SelectItem value="0" className="text-xs">
|
||||
수동
|
||||
</SelectItem>
|
||||
<SelectItem value="10000" className="text-xs">
|
||||
10초
|
||||
</SelectItem>
|
||||
<SelectItem value="30000" className="text-xs">
|
||||
30초
|
||||
</SelectItem>
|
||||
<SelectItem value="60000" className="text-xs">
|
||||
1분
|
||||
</SelectItem>
|
||||
<SelectItem value="300000" className="text-xs">
|
||||
5분
|
||||
</SelectItem>
|
||||
<SelectItem value="600000" className="text-xs">
|
||||
10분
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 오류 메시지 */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
|
|
@ -300,15 +257,15 @@ ORDER BY 하위부서수 DESC`,
|
|||
{/* 쿼리 결과 미리보기 */}
|
||||
{queryResult && (
|
||||
<Card>
|
||||
<div className="border-b border-border bg-muted px-2 py-1.5">
|
||||
<div className="border-border bg-muted border-b px-2 py-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-foreground">쿼리 결과</span>
|
||||
<span className="text-foreground text-xs font-medium">쿼리 결과</span>
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{queryResult.rows.length}행
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">실행 시간: {queryResult.executionTime}ms</span>
|
||||
<span className="text-muted-foreground text-[10px]">실행 시간: {queryResult.executionTime}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -339,13 +296,13 @@ ORDER BY 하위부서수 DESC`,
|
|||
</Table>
|
||||
|
||||
{queryResult.rows.length > 10 && (
|
||||
<div className="mt-2 text-center text-[10px] text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-2 text-center text-[10px]">
|
||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">결과가 없습니다.</div>
|
||||
<div className="text-muted-foreground py-6 text-center text-xs">결과가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -353,169 +310,3 @@ ORDER BY 하위부서수 DESC`,
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 쿼리 결과 생성 함수
|
||||
*/
|
||||
function generateSampleQueryResult(query: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// 디버깅용 로그
|
||||
// console.log('generateSampleQueryResult called with query:', query.substring(0, 100));
|
||||
|
||||
// 가장 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
const isComparison =
|
||||
queryLower.includes("galaxy") ||
|
||||
queryLower.includes("갤럭시") ||
|
||||
queryLower.includes("아이폰") ||
|
||||
queryLower.includes("iphone");
|
||||
const isRegional = queryLower.includes("region") || queryLower.includes("지역");
|
||||
const isMonthly = queryLower.includes("month");
|
||||
const isSales = queryLower.includes("sales") || queryLower.includes("매출");
|
||||
const isUsers = queryLower.includes("users") || queryLower.includes("사용자");
|
||||
const isProducts = queryLower.includes("product") || queryLower.includes("상품");
|
||||
const isWeekly = queryLower.includes("week");
|
||||
|
||||
// console.log('Sample data type detection:', {
|
||||
// isComparison,
|
||||
// isRegional,
|
||||
// isWeekly,
|
||||
// isProducts,
|
||||
// isMonthly,
|
||||
// isSales,
|
||||
// isUsers,
|
||||
// querySnippet: query.substring(0, 200)
|
||||
// });
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
// 더 구체적인 조건부터 먼저 체크 (순서 중요!)
|
||||
if (isComparison) {
|
||||
// console.log('✅ Using COMPARISON data');
|
||||
// 제품 비교 데이터 (다중 시리즈)
|
||||
columns = ["month", "galaxy_sales", "iphone_sales", "other_sales"];
|
||||
rows = [
|
||||
{ month: "2024-01", galaxy_sales: 450000, iphone_sales: 620000, other_sales: 130000 },
|
||||
{ month: "2024-02", galaxy_sales: 520000, iphone_sales: 680000, other_sales: 150000 },
|
||||
{ month: "2024-03", galaxy_sales: 480000, iphone_sales: 590000, other_sales: 110000 },
|
||||
{ month: "2024-04", galaxy_sales: 610000, iphone_sales: 650000, other_sales: 160000 },
|
||||
{ month: "2024-05", galaxy_sales: 720000, iphone_sales: 780000, other_sales: 180000 },
|
||||
{ month: "2024-06", galaxy_sales: 680000, iphone_sales: 690000, other_sales: 170000 },
|
||||
{ month: "2024-07", galaxy_sales: 750000, iphone_sales: 800000, other_sales: 170000 },
|
||||
{ month: "2024-08", galaxy_sales: 690000, iphone_sales: 720000, other_sales: 170000 },
|
||||
{ month: "2024-09", galaxy_sales: 730000, iphone_sales: 750000, other_sales: 170000 },
|
||||
{ month: "2024-10", galaxy_sales: 800000, iphone_sales: 810000, other_sales: 170000 },
|
||||
{ month: "2024-11", galaxy_sales: 870000, iphone_sales: 880000, other_sales: 170000 },
|
||||
{ month: "2024-12", galaxy_sales: 950000, iphone_sales: 990000, other_sales: 160000 },
|
||||
];
|
||||
// COMPARISON 데이터를 반환하고 함수 종료
|
||||
// console.log('COMPARISON data generated:', {
|
||||
// columns,
|
||||
// rowCount: rows.length,
|
||||
// sampleRow: rows[0],
|
||||
// allRows: rows,
|
||||
// fieldTypes: {
|
||||
// month: typeof rows[0].month,
|
||||
// galaxy_sales: typeof rows[0].galaxy_sales,
|
||||
// iphone_sales: typeof rows[0].iphone_sales,
|
||||
// other_sales: typeof rows[0].other_sales
|
||||
// },
|
||||
// firstFewRows: rows.slice(0, 3),
|
||||
// lastFewRows: rows.slice(-3)
|
||||
// });
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: Math.floor(Math.random() * 200) + 100,
|
||||
};
|
||||
} else if (isRegional) {
|
||||
// console.log('✅ Using REGIONAL data');
|
||||
// 지역별 분기별 매출
|
||||
columns = ["지역", "Q1", "Q2", "Q3", "Q4"];
|
||||
rows = [
|
||||
{ 지역: "서울", Q1: 1200000, Q2: 1350000, Q3: 1420000, Q4: 1580000 },
|
||||
{ 지역: "경기", Q1: 980000, Q2: 1120000, Q3: 1180000, Q4: 1290000 },
|
||||
{ 지역: "부산", Q1: 650000, Q2: 720000, Q3: 780000, Q4: 850000 },
|
||||
{ 지역: "대구", Q1: 450000, Q2: 490000, Q3: 520000, Q4: 580000 },
|
||||
{ 지역: "인천", Q1: 520000, Q2: 580000, Q3: 620000, Q4: 690000 },
|
||||
{ 지역: "광주", Q1: 380000, Q2: 420000, Q3: 450000, Q4: 490000 },
|
||||
{ 지역: "대전", Q1: 410000, Q2: 460000, Q3: 490000, Q4: 530000 },
|
||||
];
|
||||
} else if (isWeekly && isUsers) {
|
||||
// console.log('✅ Using USERS data');
|
||||
// 사용자 가입 추이
|
||||
columns = ["week", "new_users"];
|
||||
rows = [
|
||||
{ week: "2024-W10", new_users: 23 },
|
||||
{ week: "2024-W11", new_users: 31 },
|
||||
{ week: "2024-W12", new_users: 28 },
|
||||
{ week: "2024-W13", new_users: 35 },
|
||||
{ week: "2024-W14", new_users: 42 },
|
||||
{ week: "2024-W15", new_users: 38 },
|
||||
{ week: "2024-W16", new_users: 45 },
|
||||
{ week: "2024-W17", new_users: 52 },
|
||||
{ week: "2024-W18", new_users: 48 },
|
||||
{ week: "2024-W19", new_users: 55 },
|
||||
{ week: "2024-W20", new_users: 61 },
|
||||
{ week: "2024-W21", new_users: 58 },
|
||||
];
|
||||
} else if (isProducts && !isComparison) {
|
||||
// console.log('✅ Using PRODUCTS data');
|
||||
// 상품별 판매량
|
||||
columns = ["product_name", "total_sold", "revenue"];
|
||||
rows = [
|
||||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||||
{ product_name: "키보드", total_sold: 78, revenue: 15600000 },
|
||||
{ product_name: "마우스", total_sold: 145, revenue: 8700000 },
|
||||
{ product_name: "모니터", total_sold: 67, revenue: 134000000 },
|
||||
{ product_name: "프린터", total_sold: 34, revenue: 17000000 },
|
||||
{ product_name: "웹캠", total_sold: 89, revenue: 8900000 },
|
||||
];
|
||||
} else if (isMonthly && isSales && !isComparison) {
|
||||
// console.log('✅ Using MONTHLY SALES data');
|
||||
// 월별 매출 데이터
|
||||
columns = ["month", "sales", "order_count"];
|
||||
rows = [
|
||||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||||
{ month: "2024-07", sales: 1720000, order_count: 71 },
|
||||
{ month: "2024-08", sales: 1580000, order_count: 63 },
|
||||
{ month: "2024-09", sales: 1650000, order_count: 68 },
|
||||
{ month: "2024-10", sales: 1780000, order_count: 75 },
|
||||
{ month: "2024-11", sales: 1920000, order_count: 82 },
|
||||
{ month: "2024-12", sales: 2100000, order_count: 89 },
|
||||
];
|
||||
} else {
|
||||
// console.log('⚠️ Using DEFAULT data');
|
||||
// 기본 샘플 데이터
|
||||
columns = ["category", "value", "count"];
|
||||
rows = [
|
||||
{ category: "A", value: 100, count: 10 },
|
||||
{ category: "B", value: 150, count: 15 },
|
||||
{ category: "C", value: 120, count: 12 },
|
||||
{ category: "D", value: 180, count: 18 },
|
||||
{ category: "E", value: 90, count: 9 },
|
||||
{ category: "F", value: 200, count: 20 },
|
||||
{ category: "G", value: 110, count: 11 },
|
||||
{ category: "H", value: 160, count: 16 },
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
columns,
|
||||
rows,
|
||||
totalRows: rows.length,
|
||||
executionTime: Math.floor(Math.random() * 200) + 100, // 100-300ms
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -397,6 +397,7 @@ export interface CustomMetricConfig {
|
|||
unit?: string; // 표시 단위 (원, 건, % 등)
|
||||
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
|
||||
decimals?: number; // 소수점 자릿수 (기본: 0)
|
||||
refreshInterval?: number; // 자동 새로고침 간격 (초, 0이면 비활성)
|
||||
|
||||
// 필터 조건
|
||||
filters?: Array<{
|
||||
|
|
|
|||
|
|
@ -231,6 +231,29 @@ export function CustomMetricSection({ queryResult, config, onConfigChange }: Cus
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* 6. 자동 새로고침 간격 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">자동 새로고침</Label>
|
||||
<Select
|
||||
value={(config.refreshInterval ?? 30).toString()}
|
||||
onValueChange={(value) => onConfigChange({ refreshInterval: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue placeholder="간격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0" className="text-xs">없음</SelectItem>
|
||||
<SelectItem value="10" className="text-xs">10초</SelectItem>
|
||||
<SelectItem value="30" className="text-xs">30초</SelectItem>
|
||||
<SelectItem value="60" className="text-xs">1분</SelectItem>
|
||||
<SelectItem value="300" className="text-xs">5분</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
통계 데이터를 자동으로 갱신하는 주기
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{config.valueColumn && config.aggregation && (
|
||||
<div className="bg-muted/50 space-y-1 rounded-md border p-3">
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
const [value, setValue] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
|
||||
const config = element?.customMetricConfig;
|
||||
|
||||
|
|
@ -82,11 +83,15 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element]);
|
||||
}, [element, config?.refreshInterval]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
|
@ -132,6 +137,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
|
|
@ -192,6 +198,7 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
if (config?.valueColumn && config?.aggregation) {
|
||||
const calculatedValue = calculateMetric(rows, config.valueColumn, config.aggregation);
|
||||
setValue(calculatedValue);
|
||||
setLastUpdateTime(new Date()); // 업데이트 시간 기록
|
||||
} else {
|
||||
setValue(0);
|
||||
}
|
||||
|
|
@ -283,6 +290,16 @@ export default function CustomMetricTestWidget({ element }: CustomMetricTestWidg
|
|||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 시간 */}
|
||||
{lastUpdateTime && (
|
||||
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,17 +70,22 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
const [value, setValue] = useState<number>(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null);
|
||||
|
||||
const config = element?.customMetricConfig;
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
|
||||
// 자동 새로고침 (30초마다)
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// 자동 새로고침 (설정된 간격마다, 0이면 비활성)
|
||||
const refreshInterval = config?.refreshInterval ?? 30; // 기본값: 30초
|
||||
|
||||
if (refreshInterval > 0) {
|
||||
const interval = setInterval(loadData, refreshInterval * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [element]);
|
||||
}, [element, config?.refreshInterval]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
|
|
@ -198,15 +203,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
setError(err instanceof Error ? err.message : "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<div className="bg-background flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -214,12 +220,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-destructive">⚠️ {error}</p>
|
||||
<p className="text-destructive text-sm">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-2 rounded bg-destructive/10 px-3 py-1 text-xs text-destructive hover:bg-destructive/20"
|
||||
className="bg-destructive/10 text-destructive hover:bg-destructive/20 mt-2 rounded px-3 py-1 text-xs"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
|
|
@ -238,10 +244,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
// 설정이 없으면 안내 화면
|
||||
if (!hasDataSource || !hasConfig) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background p-4">
|
||||
<div className="bg-background flex h-full items-center justify-center p-4">
|
||||
<div className="max-w-xs space-y-2 text-center">
|
||||
<h3 className="text-sm font-bold text-foreground">통계 카드</h3>
|
||||
<div className="space-y-1.5 text-xs text-foreground">
|
||||
<h3 className="text-foreground text-sm font-bold">통계 카드</h3>
|
||||
<div className="text-foreground space-y-1.5 text-xs">
|
||||
<p className="font-medium">📊 단일 통계 위젯</p>
|
||||
<ul className="space-y-0.5 text-left">
|
||||
<li>• 데이터 소스에서 쿼리를 실행합니다</li>
|
||||
|
|
@ -250,7 +256,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
<li>• COUNT, SUM, AVG, MIN, MAX 지원</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-2 rounded-lg bg-primary/10 p-2 text-[10px] text-primary">
|
||||
<div className="bg-primary/10 text-primary mt-2 rounded-lg p-2 text-[10px]">
|
||||
<p className="font-medium">⚙️ 설정 방법</p>
|
||||
<p>1. 데이터 탭에서 쿼리 실행</p>
|
||||
<p>2. 필터 조건 추가 (선택사항)</p>
|
||||
|
|
@ -268,7 +274,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
|
||||
// 통계 카드 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center bg-card p-6 text-center">
|
||||
<div className="bg-card flex h-full w-full flex-col items-center justify-center p-6 text-center">
|
||||
{/* 제목 */}
|
||||
<div className="text-muted-foreground mb-2 text-sm font-medium">{config?.title || "통계"}</div>
|
||||
|
||||
|
|
@ -277,6 +283,16 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
|
|||
<span className="text-primary text-4xl font-bold">{formattedValue}</span>
|
||||
{config?.unit && <span className="text-muted-foreground text-lg">{config.unit}</span>}
|
||||
</div>
|
||||
|
||||
{/* 마지막 업데이트 시간 */}
|
||||
{lastUpdateTime && (
|
||||
<div className="text-muted-foreground mt-3 text-[10px]">
|
||||
{lastUpdateTime.toLocaleTimeString("ko-KR")}
|
||||
{config?.refreshInterval && config.refreshInterval > 0 && (
|
||||
<span className="ml-1">• {config.refreshInterval}초마다 갱신</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue