통계카드에 시간 표시

This commit is contained in:
dohyeons 2025-11-14 16:55:52 +09:00
parent 5533a134c6
commit 385ecdc46a
5 changed files with 89 additions and 241 deletions

View File

@ -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
};
}

View File

@ -397,6 +397,7 @@ export interface CustomMetricConfig {
unit?: string; // 표시 단위 (원, 건, % 등)
color?: "indigo" | "green" | "blue" | "purple" | "orange" | "gray"; // 카드 색상
decimals?: number; // 소수점 자릿수 (기본: 0)
refreshInterval?: number; // 자동 새로고침 간격 (초, 0이면 비활성)
// 필터 조건
filters?: Array<{

View File

@ -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">

View File

@ -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>
);
}

View File

@ -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>
);
}