Compare commits
9 Commits
39ddf59275
...
720917fcab
| Author | SHA1 | Date |
|---|---|---|
|
|
720917fcab | |
|
|
8b2a195eff | |
|
|
d4949983fb | |
|
|
2d5224c938 | |
|
|
3613f9eef4 | |
|
|
95c685051d | |
|
|
eff3b45dc9 | |
|
|
d709515d6d | |
|
|
8799568cc6 |
|
|
@ -547,4 +547,93 @@ export class DashboardController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||||
|
* POST /api/dashboards/table-schema
|
||||||
|
*/
|
||||||
|
async getTableSchema(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.body;
|
||||||
|
|
||||||
|
if (!tableName || typeof tableName !== "string") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블명이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블명 검증 (SQL 인젝션 방지)
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 테이블명입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgreSQL information_schema에서 컬럼 정보 조회
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
column_name,
|
||||||
|
data_type,
|
||||||
|
udt_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await PostgreSQLService.query(query, [
|
||||||
|
tableName.toLowerCase(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 날짜/시간 타입 컬럼 필터링
|
||||||
|
const dateColumns = result.rows
|
||||||
|
.filter((row: any) => {
|
||||||
|
const dataType = row.data_type?.toLowerCase();
|
||||||
|
const udtName = row.udt_name?.toLowerCase();
|
||||||
|
return (
|
||||||
|
dataType === "timestamp" ||
|
||||||
|
dataType === "timestamp without time zone" ||
|
||||||
|
dataType === "timestamp with time zone" ||
|
||||||
|
dataType === "date" ||
|
||||||
|
dataType === "time" ||
|
||||||
|
dataType === "time without time zone" ||
|
||||||
|
dataType === "time with time zone" ||
|
||||||
|
udtName === "timestamp" ||
|
||||||
|
udtName === "timestamptz" ||
|
||||||
|
udtName === "date" ||
|
||||||
|
udtName === "time" ||
|
||||||
|
udtName === "timetz"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((row: any) => row.column_name);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tableName,
|
||||||
|
columns: result.rows.map((row: any) => ({
|
||||||
|
name: row.column_name,
|
||||||
|
type: row.data_type,
|
||||||
|
udtName: row.udt_name,
|
||||||
|
})),
|
||||||
|
dateColumns,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
|
||||||
|
error:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? (error as Error).message
|
||||||
|
: "스키마 조회 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ router.post(
|
||||||
dashboardController.fetchExternalApi.bind(dashboardController)
|
dashboardController.fetchExternalApi.bind(dashboardController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||||
|
router.post(
|
||||||
|
"/table-schema",
|
||||||
|
dashboardController.getTableSchema.bind(dashboardController)
|
||||||
|
);
|
||||||
|
|
||||||
// 인증이 필요한 라우트들
|
// 인증이 필요한 라우트들
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ services:
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||||
|
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
||||||
|
- ITS_API_KEY=${ITS_API_KEY:-}
|
||||||
|
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -326,13 +326,17 @@ export function CanvasElement({
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
|
// 필터 적용 (날짜 필터 등)
|
||||||
|
const { applyQueryFilters } = await import("./utils/queryHelpers");
|
||||||
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||||
|
|
||||||
// 외부 DB vs 현재 DB 분기
|
// 외부 DB vs 현재 DB 분기
|
||||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||||
// 외부 DB
|
// 외부 DB
|
||||||
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection");
|
||||||
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
const externalResult = await ExternalDbConnectionAPI.executeQuery(
|
||||||
parseInt(element.dataSource.externalConnectionId),
|
parseInt(element.dataSource.externalConnectionId),
|
||||||
element.dataSource.query,
|
filteredQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!externalResult.success) {
|
if (!externalResult.success) {
|
||||||
|
|
@ -348,7 +352,7 @@ export function CanvasElement({
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
result = await dashboardApi.executeQuery(element.dataSource.query);
|
result = await dashboardApi.executeQuery(filteredQuery);
|
||||||
|
|
||||||
setChartData({
|
setChartData({
|
||||||
columns: result.columns || [],
|
columns: result.columns || [],
|
||||||
|
|
@ -367,6 +371,7 @@ export function CanvasElement({
|
||||||
element.dataSource?.query,
|
element.dataSource?.query,
|
||||||
element.dataSource?.connectionType,
|
element.dataSource?.connectionType,
|
||||||
element.dataSource?.externalConnectionId,
|
element.dataSource?.externalConnectionId,
|
||||||
|
element.chartConfig,
|
||||||
element.type,
|
element.type,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
import { ChartConfig, QueryResult } from "./types";
|
import { ChartConfig, QueryResult } from "./types";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
@ -10,7 +10,10 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { TrendingUp, AlertCircle } from "lucide-react";
|
import { AlertCircle } from "lucide-react";
|
||||||
|
import { DateFilterPanel } from "./DateFilterPanel";
|
||||||
|
import { extractTableNameFromQuery } from "./utils/queryHelpers";
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
|
||||||
interface ChartConfigPanelProps {
|
interface ChartConfigPanelProps {
|
||||||
config?: ChartConfig;
|
config?: ChartConfig;
|
||||||
|
|
@ -18,6 +21,7 @@ interface ChartConfigPanelProps {
|
||||||
onConfigChange: (config: ChartConfig) => void;
|
onConfigChange: (config: ChartConfig) => void;
|
||||||
chartType?: string;
|
chartType?: string;
|
||||||
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
dataSourceType?: "database" | "api"; // 데이터 소스 타입
|
||||||
|
query?: string; // SQL 쿼리 (테이블명 추출용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,8 +36,10 @@ export function ChartConfigPanel({
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
chartType,
|
chartType,
|
||||||
dataSourceType,
|
dataSourceType,
|
||||||
|
query,
|
||||||
}: ChartConfigPanelProps) {
|
}: ChartConfigPanelProps) {
|
||||||
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
const [currentConfig, setCurrentConfig] = useState<ChartConfig>(config || {});
|
||||||
|
const [dateColumns, setDateColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
// 원형/도넛 차트 또는 REST API는 Y축이 필수가 아님
|
||||||
const isPieChart = chartType === "pie" || chartType === "donut";
|
const isPieChart = chartType === "pie" || chartType === "donut";
|
||||||
|
|
@ -70,6 +76,34 @@ export function ChartConfigPanel({
|
||||||
return type === "object" || type === "array";
|
return type === "object" || type === "array";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 테이블 스키마에서 실제 날짜 컬럼 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query || !queryResult || dataSourceType === "api") {
|
||||||
|
// API 소스는 스키마 조회 불가
|
||||||
|
setDateColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableName = extractTableNameFromQuery(query);
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
setDateColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dashboardApi
|
||||||
|
.getTableSchema(tableName)
|
||||||
|
.then((schema) => {
|
||||||
|
// 원본 테이블의 모든 날짜 컬럼을 표시
|
||||||
|
// (SELECT에 없어도 WHERE 절에 사용 가능)
|
||||||
|
setDateColumns(schema.dateColumns);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("❌ 테이블 스키마 조회 실패:", error);
|
||||||
|
// 실패 시 빈 배열 (날짜 필터 비활성화)
|
||||||
|
setDateColumns([]);
|
||||||
|
});
|
||||||
|
}, [query, queryResult, dataSourceType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 데이터 필드 매핑 */}
|
{/* 데이터 필드 매핑 */}
|
||||||
|
|
@ -80,7 +114,7 @@ export function ChartConfigPanel({
|
||||||
<Card className="border-blue-200 bg-blue-50 p-4">
|
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||||
<div className="mb-2 flex items-center gap-2">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||||
<h4 className="font-semibold text-blue-900">📋 API 응답 데이터 미리보기</h4>
|
<h4 className="font-semibold text-blue-900">API 응답 데이터 미리보기</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded bg-white p-3 text-xs">
|
<div className="rounded bg-white p-3 text-xs">
|
||||||
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||||
|
|
@ -94,7 +128,7 @@ export function ChartConfigPanel({
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<div className="font-semibold">⚠️ 차트에 사용할 수 없는 컬럼 감지</div>
|
<div className="font-semibold">차트에 사용할 수 없는 컬럼 감지</div>
|
||||||
<div className="mt-1 text-sm">
|
<div className="mt-1 text-sm">
|
||||||
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
다음 컬럼은 객체 또는 배열 타입이라서 차트 축으로 선택할 수 없습니다:
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
|
@ -106,7 +140,7 @@ export function ChartConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-gray-600">
|
<div className="mt-2 text-xs text-gray-600">
|
||||||
💡 <strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
<strong>해결 방법:</strong> JSON Path를 사용하여 중첩된 객체 내부의 값을 직접 추출하세요.
|
||||||
<br />
|
<br />
|
||||||
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
예: <code className="rounded bg-gray-100 px-1">main</code> 또는{" "}
|
||||||
<code className="rounded bg-gray-100 px-1">data.items</code>
|
<code className="rounded bg-gray-100 px-1">data.items</code>
|
||||||
|
|
@ -138,7 +172,7 @@ export function ChartConfigPanel({
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="선택하세요" />
|
<SelectValue placeholder="선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="z-[99999]">
|
||||||
{simpleColumns.map((col) => {
|
{simpleColumns.map((col) => {
|
||||||
const preview = sampleData[col];
|
const preview = sampleData[col];
|
||||||
const previewText =
|
const previewText =
|
||||||
|
|
@ -158,7 +192,7 @@ export function ChartConfigPanel({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{simpleColumns.length === 0 && (
|
{simpleColumns.length === 0 && (
|
||||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -176,17 +210,14 @@ export function ChartConfigPanel({
|
||||||
{/* 숫자 타입 우선 표시 */}
|
{/* 숫자 타입 우선 표시 */}
|
||||||
{numericColumns.length > 0 && (
|
{numericColumns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 text-xs font-medium text-green-700">✅ 숫자 타입 (권장)</div>
|
<div className="mb-2 text-xs font-medium text-green-700">숫자 타입 (권장)</div>
|
||||||
{numericColumns.map((col) => {
|
{numericColumns.map((col) => {
|
||||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||||
? currentConfig.yAxis.includes(col)
|
? currentConfig.yAxis.includes(col)
|
||||||
: currentConfig.yAxis === col;
|
: currentConfig.yAxis === col;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={col} className="flex items-center gap-2 rounded border-green-500 bg-green-50 p-2">
|
||||||
key={col}
|
|
||||||
className="flex items-center gap-2 rounded border-l-2 border-green-500 bg-green-50 p-2"
|
|
||||||
>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -226,7 +257,7 @@ export function ChartConfigPanel({
|
||||||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||||
<>
|
<>
|
||||||
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||||
<div className="mb-2 text-xs font-medium text-gray-600">📝 기타 타입</div>
|
<div className="mb-2 text-xs font-medium text-gray-600">기타 타입</div>
|
||||||
{simpleColumns
|
{simpleColumns
|
||||||
.filter((col) => !numericColumns.includes(col))
|
.filter((col) => !numericColumns.includes(col))
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
|
|
@ -275,7 +306,7 @@ export function ChartConfigPanel({
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
{simpleColumns.length === 0 && (
|
{simpleColumns.length === 0 && (
|
||||||
<p className="text-xs text-red-500">⚠️ 사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||||
|
|
@ -301,7 +332,7 @@ export function ChartConfigPanel({
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="z-[99999]">
|
||||||
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||||
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||||
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||||
|
|
@ -311,7 +342,7 @@ export function ChartConfigPanel({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
💡 그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -328,7 +359,7 @@ export function ChartConfigPanel({
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="없음" />
|
<SelectValue placeholder="없음" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="z-[99999]">
|
||||||
<SelectItem value="__none__">없음</SelectItem>
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
<SelectItem key={col} value={col}>
|
<SelectItem key={col} value={col}>
|
||||||
|
|
@ -387,44 +418,10 @@ export function ChartConfigPanel({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 설정 미리보기 */}
|
{/* 날짜 필터 */}
|
||||||
<Card className="bg-gray-50 p-4">
|
{dateColumns.length > 0 && (
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-700">
|
<DateFilterPanel config={currentConfig} dateColumns={dateColumns} onChange={updateConfig} />
|
||||||
<TrendingUp className="h-4 w-4" />
|
)}
|
||||||
설정 미리보기
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 text-xs text-gray-600">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="font-medium">X축:</span>
|
|
||||||
<span>{currentConfig.xAxis || "미설정"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="font-medium">Y축:</span>
|
|
||||||
<span>
|
|
||||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 0
|
|
||||||
? `${currentConfig.yAxis.length}개 (${currentConfig.yAxis.join(", ")})`
|
|
||||||
: currentConfig.yAxis || "미설정"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="font-medium">집계:</span>
|
|
||||||
<span>{currentConfig.aggregation || "없음"}</span>
|
|
||||||
</div>
|
|
||||||
{currentConfig.groupBy && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="font-medium">그룹핑:</span>
|
|
||||||
<span>{currentConfig.groupBy}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<span className="font-medium">데이터 행 수:</span>
|
|
||||||
<Badge variant="secondary">{queryResult.rows.length}개</Badge>
|
|
||||||
</div>
|
|
||||||
{Array.isArray(currentConfig.yAxis) && currentConfig.yAxis.length > 1 && (
|
|
||||||
<div className="mt-2 text-blue-600">✨ 다중 시리즈 차트가 생성됩니다!</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 필수 필드 확인 */}
|
{/* 필수 필드 확인 */}
|
||||||
{!currentConfig.xAxis && (
|
{!currentConfig.xAxis && (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ChartConfig } from "./types";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Calendar, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { getQuickDateRange } from "./utils/queryHelpers";
|
||||||
|
|
||||||
|
interface DateFilterPanelProps {
|
||||||
|
config: ChartConfig;
|
||||||
|
dateColumns: string[];
|
||||||
|
onChange: (updates: Partial<ChartConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateFilterPanel({ config, dateColumns, onChange }: DateFilterPanelProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
|
const dateFilter = config.dateFilter || {
|
||||||
|
enabled: false,
|
||||||
|
dateColumn: dateColumns[0] || "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickRange = (range: "today" | "week" | "month" | "year") => {
|
||||||
|
const { startDate, endDate } = getQuickDateRange(range);
|
||||||
|
onChange({
|
||||||
|
dateFilter: {
|
||||||
|
...dateFilter,
|
||||||
|
enabled: true,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
quickRange: range,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 컬럼이 없으면 표시하지 않음
|
||||||
|
if (dateColumns.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex cursor-pointer items-center justify-between" onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600" />
|
||||||
|
<Label className="cursor-pointer text-sm font-medium text-gray-700">데이터 필터 (선택)</Label>
|
||||||
|
{dateFilter.enabled && <span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">활성</span>}
|
||||||
|
</div>
|
||||||
|
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{/* 필터 활성화 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enableDateFilter"
|
||||||
|
checked={dateFilter.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onChange({
|
||||||
|
dateFilter: {
|
||||||
|
...dateFilter,
|
||||||
|
enabled: checked as boolean,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="enableDateFilter" className="cursor-pointer text-sm font-normal">
|
||||||
|
날짜 필터 사용
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dateFilter.enabled && (
|
||||||
|
<>
|
||||||
|
{/* 날짜 컬럼 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 text-sm font-medium text-gray-700">날짜 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={dateFilter.dateColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onChange({
|
||||||
|
dateFilter: {
|
||||||
|
...dateFilter,
|
||||||
|
dateColumn: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="날짜 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[99999]">
|
||||||
|
{dateColumns.map((col) => (
|
||||||
|
<SelectItem key={col} value={col}>
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">감지된 날짜 컬럼: {dateColumns.join(", ")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빠른 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 text-sm font-medium text-gray-700">빠른 선택</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={dateFilter.quickRange === "today" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickRange("today")}
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={dateFilter.quickRange === "week" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickRange("week")}
|
||||||
|
>
|
||||||
|
이번 주
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={dateFilter.quickRange === "month" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickRange("month")}
|
||||||
|
>
|
||||||
|
이번 달
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={dateFilter.quickRange === "year" ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleQuickRange("year")}
|
||||||
|
>
|
||||||
|
올해
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 직접 입력 */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 text-sm font-medium text-gray-700">시작일</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter.startDate || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
dateFilter: {
|
||||||
|
...dateFilter,
|
||||||
|
startDate: e.target.value,
|
||||||
|
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 text-sm font-medium text-gray-700">종료일</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter.endDate || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
dateFilter: {
|
||||||
|
...dateFilter,
|
||||||
|
endDate: e.target.value,
|
||||||
|
quickRange: undefined, // 직접 입력 시 빠른 선택 해제
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 정보 */}
|
||||||
|
{dateFilter.startDate && dateFilter.endDate && (
|
||||||
|
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-800">
|
||||||
|
<strong>필터 적용:</strong> {dateFilter.dateColumn} 컬럼에서 {dateFilter.startDate}부터{" "}
|
||||||
|
{dateFilter.endDate}까지 데이터를 가져옵니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,6 @@ import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
||||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
import { ApiConfig } from "./data-sources/ApiConfig";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
import { X, ChevronLeft, ChevronRight, Save } from "lucide-react";
|
||||||
|
|
||||||
interface ElementConfigModalProps {
|
interface ElementConfigModalProps {
|
||||||
|
|
@ -32,11 +31,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
const [chartConfig, setChartConfig] = useState<ChartConfig>(element.chartConfig || {});
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
|
||||||
|
|
||||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
||||||
const isSimpleWidget =
|
const isSimpleWidget =
|
||||||
element.subtype === "vehicle-status" ||
|
element.subtype === "vehicle-status" ||
|
||||||
element.subtype === "vehicle-list" ||
|
element.subtype === "vehicle-list" ||
|
||||||
element.subtype === "status-summary" || // 커스텀 상태 카드
|
element.subtype === "status-summary" || // 커스텀 상태 카드
|
||||||
// element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석)
|
// element.subtype === "list-summary" || // 커스텀 목록 카드 (다른 분 작업 중 - 임시 주석)
|
||||||
element.subtype === "delivery-status" ||
|
element.subtype === "delivery-status" ||
|
||||||
|
|
@ -45,10 +44,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
element.subtype === "cargo-list" ||
|
element.subtype === "cargo-list" ||
|
||||||
element.subtype === "customer-issues" ||
|
element.subtype === "customer-issues" ||
|
||||||
element.subtype === "driver-management";
|
element.subtype === "driver-management";
|
||||||
|
|
||||||
// 지도 위젯 (위도/경도 매핑 필요)
|
// 지도 위젯 (위도/경도 매핑 필요)
|
||||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
||||||
|
|
||||||
// 주석
|
// 주석
|
||||||
// 모달이 열릴 때 초기화
|
// 모달이 열릴 때 초기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -94,6 +93,10 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
// 쿼리 테스트 결과 처리
|
// 쿼리 테스트 결과 처리
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||||
setQueryResult(result);
|
setQueryResult(result);
|
||||||
|
|
||||||
|
// 쿼리가 변경되었으므로 차트 설정 초기화 (X/Y축 리셋)
|
||||||
|
console.log("🔄 쿼리 변경 감지 - 차트 설정 초기화");
|
||||||
|
setChartConfig({});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 다음 단계로 이동
|
// 다음 단계로 이동
|
||||||
|
|
@ -117,6 +120,9 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
dataSource,
|
dataSource,
|
||||||
chartConfig,
|
chartConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(" 저장할 element:", updatedElement);
|
||||||
|
|
||||||
onSave(updatedElement);
|
onSave(updatedElement);
|
||||||
onClose();
|
onClose();
|
||||||
}, [element, dataSource, chartConfig, onSave, onClose]);
|
}, [element, dataSource, chartConfig, onSave, onClose]);
|
||||||
|
|
@ -136,11 +142,14 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
||||||
const isApiSource = dataSource.type === "api";
|
const isApiSource = dataSource.type === "api";
|
||||||
|
|
||||||
|
// Y축 검증 헬퍼
|
||||||
|
const hasYAxis =
|
||||||
|
chartConfig.yAxis &&
|
||||||
|
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
||||||
|
|
||||||
const canSave = isSimpleWidget
|
const canSave = isSimpleWidget
|
||||||
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
|
? // 간단한 위젯: 2단계에서 쿼리 테스트 후 저장 가능
|
||||||
currentStep === 2 &&
|
currentStep === 2 && queryResult && queryResult.rows.length > 0
|
||||||
queryResult &&
|
|
||||||
queryResult.rows.length > 0
|
|
||||||
: isMapWidget
|
: isMapWidget
|
||||||
? // 지도 위젯: 위도/경도 매핑 필요
|
? // 지도 위젯: 위도/경도 매핑 필요
|
||||||
currentStep === 2 &&
|
currentStep === 2 &&
|
||||||
|
|
@ -149,17 +158,17 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
chartConfig.latitudeColumn &&
|
chartConfig.latitudeColumn &&
|
||||||
chartConfig.longitudeColumn
|
chartConfig.longitudeColumn
|
||||||
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
: // 차트: 기존 로직 (2단계에서 차트 설정 필요)
|
||||||
currentStep === 2 &&
|
currentStep === 2 &&
|
||||||
queryResult &&
|
queryResult &&
|
||||||
queryResult.rows.length > 0 &&
|
queryResult.rows.length > 0 &&
|
||||||
chartConfig.xAxis &&
|
chartConfig.xAxis &&
|
||||||
(isPieChart || isApiSource
|
(isPieChart || isApiSource
|
||||||
? // 파이/도넛 차트 또는 REST API: Y축 또는 집계 함수 필요
|
? // 파이/도넛 차트 또는 REST API
|
||||||
chartConfig.yAxis ||
|
chartConfig.aggregation === "count"
|
||||||
(Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0) ||
|
? true // count는 Y축 없어도 됨
|
||||||
chartConfig.aggregation === "count"
|
: hasYAxis // 다른 집계(sum, avg, max, min) 또는 집계 없음 → Y축 필수
|
||||||
: // 일반 차트 (DB): Y축 필수
|
: // 일반 차트 (DB): Y축 필수
|
||||||
chartConfig.yAxis || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
hasYAxis);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
|
@ -188,13 +197,11 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
|
{/* 진행 상황 표시 - 간단한 위젯은 표시 안 함 */}
|
||||||
{!isSimpleWidget && (
|
{!isSimpleWidget && (
|
||||||
<div className="border-b bg-gray-50 px-6 py-4">
|
<div className="border-b bg-gray-50 px-6 py-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm font-medium text-gray-700">
|
<div className="text-sm font-medium text-gray-700">
|
||||||
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
단계 {currentStep} / 2: {currentStep === 1 ? "데이터 소스 선택" : "데이터 설정 및 차트 설정"}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary">{Math.round((currentStep / 2) * 100)}% 완료</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress value={(currentStep / 2) * 100} className="h-2" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -205,7 +212,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className={`grid ${isSimpleWidget ? 'grid-cols-1' : 'grid-cols-2'} gap-6`}>
|
<div className={`grid ${isSimpleWidget ? "grid-cols-1" : "grid-cols-2"} gap-6`}>
|
||||||
{/* 왼쪽: 데이터 설정 */}
|
{/* 왼쪽: 데이터 설정 */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{dataSource.type === "database" ? (
|
{dataSource.type === "database" ? (
|
||||||
|
|
@ -240,23 +247,22 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : // 차트: 차트 설정 패널
|
||||||
|
queryResult && queryResult.rows.length > 0 ? (
|
||||||
|
<ChartConfigPanel
|
||||||
|
config={chartConfig}
|
||||||
|
queryResult={queryResult}
|
||||||
|
onConfigChange={handleChartConfigChange}
|
||||||
|
chartType={element.subtype}
|
||||||
|
dataSourceType={dataSource.type}
|
||||||
|
query={dataSource.query}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
// 차트: 차트 설정 패널
|
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
||||||
queryResult && queryResult.rows.length > 0 ? (
|
<div>
|
||||||
<ChartConfigPanel
|
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-8 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="mt-1 text-xs text-gray-500">데이터를 가져온 후 차트 설정이 표시됩니다</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -266,13 +272,7 @@ export function ElementConfigModal({ element, isOpen, onClose, onSave }: Element
|
||||||
|
|
||||||
{/* 모달 푸터 */}
|
{/* 모달 푸터 */}
|
||||||
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
<div className="flex items-center justify-between border-t bg-gray-50 p-6">
|
||||||
<div>
|
<div>{queryResult && <Badge variant="default">{queryResult.rows.length}개 데이터 로드됨</Badge>}</div>
|
||||||
{queryResult && (
|
|
||||||
<Badge variant="default" className="bg-green-600">
|
|
||||||
📊 {queryResult.rows.length}개 데이터 로드됨
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{!isSimpleWidget && currentStep > 1 && (
|
{!isSimpleWidget && currentStep > 1 && (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { ChartDataSource, QueryResult } from "./types";
|
import { ChartDataSource, QueryResult, ChartConfig } from "./types";
|
||||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Play, Loader2, Database, Code } from "lucide-react";
|
import { Play, Loader2, Database, Code } from "lucide-react";
|
||||||
|
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||||
|
|
||||||
interface QueryEditorProps {
|
interface QueryEditorProps {
|
||||||
dataSource?: ChartDataSource;
|
dataSource?: ChartDataSource;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Chart } from "./Chart";
|
||||||
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
import { transformQueryResultToChartData } from "../utils/chartDataTransform";
|
||||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { applyQueryFilters } from "../utils/queryHelpers";
|
||||||
|
|
||||||
interface ChartRendererProps {
|
interface ChartRendererProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -105,10 +106,11 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
} else if (element.dataSource.query) {
|
} else if (element.dataSource.query) {
|
||||||
// Database (현재 DB 또는 외부 DB)
|
// Database (현재 DB 또는 외부 DB)
|
||||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||||
// 외부 DB
|
// 외부 DB - 필터 적용
|
||||||
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||||
const result = await ExternalDbConnectionAPI.executeQuery(
|
const result = await ExternalDbConnectionAPI.executeQuery(
|
||||||
parseInt(element.dataSource.externalConnectionId),
|
parseInt(element.dataSource.externalConnectionId),
|
||||||
element.dataSource.query,
|
filteredQuery,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -122,14 +124,24 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 현재 DB
|
// 현재 DB - 필터 적용
|
||||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
console.log("📊 ChartRenderer: 현재 DB 쿼리 실행");
|
||||||
|
console.log(" 원본 쿼리:", element.dataSource.query);
|
||||||
|
console.log(" chartConfig:", element.chartConfig);
|
||||||
|
|
||||||
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||||
|
|
||||||
|
console.log(" 필터 적용된 쿼리:", filteredQuery);
|
||||||
|
|
||||||
|
const result = await dashboardApi.executeQuery(filteredQuery);
|
||||||
queryResult = {
|
queryResult = {
|
||||||
columns: result.columns,
|
columns: result.columns,
|
||||||
rows: result.rows,
|
rows: result.rows,
|
||||||
totalRows: result.rowCount,
|
totalRows: result.rowCount,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(" 쿼리 결과:", queryResult);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
throw new Error("데이터 소스가 올바르게 설정되지 않았습니다");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ChartDataSource, QueryResult, ApiResponse } from "../types";
|
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -25,48 +25,72 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
const [testResult, setTestResult] = useState<QueryResult | null>(null);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 헤더를 배열로 정규화 (객체 형식 호환)
|
||||||
|
const normalizeHeaders = (): KeyValuePair[] => {
|
||||||
|
if (!dataSource.headers) return [];
|
||||||
|
if (Array.isArray(dataSource.headers)) return dataSource.headers;
|
||||||
|
// 객체 형식이면 배열로 변환
|
||||||
|
return Object.entries(dataSource.headers as Record<string, string>).map(([key, value]) => ({
|
||||||
|
id: `header_${Date.now()}_${Math.random()}`,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
// 헤더 추가
|
// 헤더 추가
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
const headers = dataSource.headers || {};
|
const headers = normalizeHeaders();
|
||||||
const newKey = `header_${Object.keys(headers).length + 1}`;
|
onChange({
|
||||||
onChange({ headers: { ...headers, [newKey]: "" } });
|
headers: [...headers, { id: `header_${Date.now()}`, key: "", value: "" }],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 헤더 제거
|
// 헤더 제거
|
||||||
const removeHeader = (key: string) => {
|
const removeHeader = (id: string) => {
|
||||||
const headers = { ...dataSource.headers };
|
const headers = normalizeHeaders();
|
||||||
delete headers[key];
|
onChange({ headers: headers.filter((h) => h.id !== id) });
|
||||||
onChange({ headers });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 헤더 업데이트
|
// 헤더 업데이트
|
||||||
const updateHeader = (oldKey: string, newKey: string, value: string) => {
|
const updateHeader = (id: string, updates: Partial<KeyValuePair>) => {
|
||||||
const headers = { ...dataSource.headers };
|
const headers = normalizeHeaders();
|
||||||
delete headers[oldKey];
|
onChange({
|
||||||
headers[newKey] = value;
|
headers: headers.map((h) => (h.id === id ? { ...h, ...updates } : h)),
|
||||||
onChange({ headers });
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쿼리 파라미터를 배열로 정규화 (객체 형식 호환)
|
||||||
|
const normalizeQueryParams = (): KeyValuePair[] => {
|
||||||
|
if (!dataSource.queryParams) return [];
|
||||||
|
if (Array.isArray(dataSource.queryParams)) return dataSource.queryParams;
|
||||||
|
// 객체 형식이면 배열로 변환
|
||||||
|
return Object.entries(dataSource.queryParams as Record<string, string>).map(([key, value]) => ({
|
||||||
|
id: `param_${Date.now()}_${Math.random()}`,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 쿼리 파라미터 추가
|
// 쿼리 파라미터 추가
|
||||||
const addQueryParam = () => {
|
const addQueryParam = () => {
|
||||||
const queryParams = dataSource.queryParams || {};
|
const queryParams = normalizeQueryParams();
|
||||||
const newKey = `param_${Object.keys(queryParams).length + 1}`;
|
onChange({
|
||||||
onChange({ queryParams: { ...queryParams, [newKey]: "" } });
|
queryParams: [...queryParams, { id: `param_${Date.now()}`, key: "", value: "" }],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 쿼리 파라미터 제거
|
// 쿼리 파라미터 제거
|
||||||
const removeQueryParam = (key: string) => {
|
const removeQueryParam = (id: string) => {
|
||||||
const queryParams = { ...dataSource.queryParams };
|
const queryParams = normalizeQueryParams();
|
||||||
delete queryParams[key];
|
onChange({ queryParams: queryParams.filter((p) => p.id !== id) });
|
||||||
onChange({ queryParams });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 쿼리 파라미터 업데이트
|
// 쿼리 파라미터 업데이트
|
||||||
const updateQueryParam = (oldKey: string, newKey: string, value: string) => {
|
const updateQueryParam = (id: string, updates: Partial<KeyValuePair>) => {
|
||||||
const queryParams = { ...dataSource.queryParams };
|
const queryParams = normalizeQueryParams();
|
||||||
delete queryParams[oldKey];
|
onChange({
|
||||||
queryParams[newKey] = value;
|
queryParams: queryParams.map((p) => (p.id === id ? { ...p, ...updates } : p)),
|
||||||
onChange({ queryParams });
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// API 테스트
|
// API 테스트
|
||||||
|
|
@ -82,14 +106,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 쿼리 파라미터 구성
|
// 쿼리 파라미터 구성
|
||||||
const params = new URLSearchParams();
|
const params: Record<string, string> = {};
|
||||||
if (dataSource.queryParams) {
|
const normalizedQueryParams = normalizeQueryParams();
|
||||||
Object.entries(dataSource.queryParams).forEach(([key, value]) => {
|
normalizedQueryParams.forEach(({ key, value }) => {
|
||||||
if (key && value) {
|
if (key && value) {
|
||||||
params.append(key, value);
|
params[key] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// 헤더 구성
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const normalizedHeaders = normalizeHeaders();
|
||||||
|
normalizedHeaders.forEach(({ key, value }) => {
|
||||||
|
if (key && value) {
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
// 백엔드 프록시를 통한 외부 API 호출 (CORS 우회)
|
||||||
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
const response = await fetch("http://localhost:8080/api/dashboards/fetch-external-api", {
|
||||||
|
|
@ -100,8 +132,8 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: dataSource.endpoint,
|
url: dataSource.endpoint,
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: dataSource.headers || {},
|
headers: headers,
|
||||||
queryParams: Object.fromEntries(params),
|
queryParams: params,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -217,31 +249,34 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dataSource.queryParams && Object.keys(dataSource.queryParams).length > 0 ? (
|
{(() => {
|
||||||
<div className="space-y-2">
|
const params = normalizeQueryParams();
|
||||||
{Object.entries(dataSource.queryParams).map(([key, value]) => (
|
return params.length > 0 ? (
|
||||||
<div key={key} className="flex gap-2">
|
<div className="space-y-2">
|
||||||
<Input
|
{params.map((param) => (
|
||||||
placeholder="key"
|
<div key={param.id} className="flex gap-2">
|
||||||
value={key}
|
<Input
|
||||||
onChange={(e) => updateQueryParam(key, e.target.value, value)}
|
placeholder="key"
|
||||||
className="flex-1"
|
value={param.key}
|
||||||
/>
|
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
|
||||||
<Input
|
className="flex-1"
|
||||||
placeholder="value"
|
/>
|
||||||
value={value}
|
<Input
|
||||||
onChange={(e) => updateQueryParam(key, key, e.target.value)}
|
placeholder="value"
|
||||||
className="flex-1"
|
value={param.value}
|
||||||
/>
|
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
|
||||||
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(key)}>
|
className="flex-1"
|
||||||
<X className="h-4 w-4" />
|
/>
|
||||||
</Button>
|
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
|
||||||
</div>
|
<X className="h-4 w-4" />
|
||||||
))}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -262,8 +297,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const headers = normalizeHeaders();
|
||||||
onChange({
|
onChange({
|
||||||
headers: { ...dataSource.headers, Authorization: "Bearer YOUR_TOKEN" },
|
headers: [...headers, { id: `header_${Date.now()}`, key: "Authorization", value: "" }],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -273,8 +309,9 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const headers = normalizeHeaders();
|
||||||
onChange({
|
onChange({
|
||||||
headers: { ...dataSource.headers, "Content-Type": "application/json" },
|
headers: [...headers, { id: `header_${Date.now()}`, key: "Content-Type", value: "application/json" }],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -282,32 +319,35 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dataSource.headers && Object.keys(dataSource.headers).length > 0 ? (
|
{(() => {
|
||||||
<div className="space-y-2">
|
const headers = normalizeHeaders();
|
||||||
{Object.entries(dataSource.headers).map(([key, value]) => (
|
return headers.length > 0 ? (
|
||||||
<div key={key} className="flex gap-2">
|
<div className="space-y-2">
|
||||||
<Input
|
{headers.map((header) => (
|
||||||
placeholder="Header Name"
|
<div key={header.id} className="flex gap-2">
|
||||||
value={key}
|
<Input
|
||||||
onChange={(e) => updateHeader(key, e.target.value, value)}
|
placeholder="Header Name"
|
||||||
className="flex-1"
|
value={header.key}
|
||||||
/>
|
onChange={(e) => updateHeader(header.id, { key: e.target.value })}
|
||||||
<Input
|
className="flex-1"
|
||||||
placeholder="Header Value"
|
/>
|
||||||
value={value}
|
<Input
|
||||||
onChange={(e) => updateHeader(key, key, e.target.value)}
|
placeholder="Header Value"
|
||||||
className="flex-1"
|
value={header.value}
|
||||||
type={key.toLowerCase().includes("auth") ? "password" : "text"}
|
onChange={(e) => updateHeader(header.id, { value: e.target.value })}
|
||||||
/>
|
className="flex-1"
|
||||||
<Button variant="ghost" size="icon" onClick={() => removeHeader(key)}>
|
type={header.key.toLowerCase().includes("auth") ? "password" : "text"}
|
||||||
<X className="h-4 w-4" />
|
/>
|
||||||
</Button>
|
<Button variant="ghost" size="icon" onClick={() => removeHeader(header.id)}>
|
||||||
</div>
|
<X className="h-4 w-4" />
|
||||||
))}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
))}
|
||||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* JSON Path */}
|
{/* JSON Path */}
|
||||||
|
|
@ -358,7 +398,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
{/* 테스트 결과 */}
|
{/* 테스트 결과 */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<Card className="border-green-200 bg-green-50 p-4">
|
<Card className="border-green-200 bg-green-50 p-4">
|
||||||
<div className="mb-2 text-sm font-medium text-green-800">✅ API 호출 성공</div>
|
<div className="mb-2 text-sm font-medium text-green-800">API 호출 성공</div>
|
||||||
<div className="space-y-1 text-xs text-green-700">
|
<div className="space-y-1 text-xs text-green-700">
|
||||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,13 @@ export interface ResizeHandle {
|
||||||
cursor: string;
|
cursor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 키-값 쌍 인터페이스
|
||||||
|
export interface KeyValuePair {
|
||||||
|
id: string; // 고유 ID
|
||||||
|
key: string; // 키
|
||||||
|
value: string; // 값
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChartDataSource {
|
export interface ChartDataSource {
|
||||||
type: "database" | "api"; // 데이터 소스 타입
|
type: "database" | "api"; // 데이터 소스 타입
|
||||||
|
|
||||||
|
|
@ -84,8 +91,8 @@ export interface ChartDataSource {
|
||||||
// API 관련
|
// API 관련
|
||||||
endpoint?: string; // API URL
|
endpoint?: string; // API URL
|
||||||
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
method?: "GET"; // HTTP 메서드 (GET만 지원)
|
||||||
headers?: Record<string, string>; // 커스텀 헤더
|
headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
|
||||||
queryParams?: Record<string, string>; // URL 쿼리 파라미터
|
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
|
||||||
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")
|
||||||
|
|
||||||
// 공통
|
// 공통
|
||||||
|
|
@ -106,6 +113,18 @@ export interface ChartConfig {
|
||||||
sortOrder?: "asc" | "desc"; // 정렬 순서
|
sortOrder?: "asc" | "desc"; // 정렬 순서
|
||||||
limit?: number; // 데이터 개수 제한
|
limit?: number; // 데이터 개수 제한
|
||||||
|
|
||||||
|
// 데이터 필터
|
||||||
|
dateFilter?: {
|
||||||
|
enabled: boolean; // 날짜 필터 활성화
|
||||||
|
dateColumn?: string; // 날짜 컬럼
|
||||||
|
startDate?: string; // 시작일 (YYYY-MM-DD)
|
||||||
|
endDate?: string; // 종료일 (YYYY-MM-DD)
|
||||||
|
quickRange?: "today" | "week" | "month" | "year"; // 빠른 선택
|
||||||
|
};
|
||||||
|
|
||||||
|
// 안전장치
|
||||||
|
autoLimit?: number; // 자동 LIMIT (기본: 1000)
|
||||||
|
|
||||||
// 스타일
|
// 스타일
|
||||||
colors?: string[]; // 차트 색상 팔레트
|
colors?: string[]; // 차트 색상 팔레트
|
||||||
title?: string; // 차트 제목
|
title?: string; // 차트 제목
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { ChartConfig } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리에 안전장치 LIMIT 추가
|
||||||
|
*/
|
||||||
|
export function applySafetyLimit(query: string, limit: number = 1000): string {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
|
||||||
|
// 이미 LIMIT이 있으면 그대로 반환
|
||||||
|
if (/\bLIMIT\b/i.test(trimmedQuery)) {
|
||||||
|
return trimmedQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${trimmedQuery} LIMIT ${limit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 필터를 쿼리에 적용
|
||||||
|
*/
|
||||||
|
export function applyDateFilter(query: string, dateColumn: string, startDate?: string, endDate?: string): string {
|
||||||
|
if (!dateColumn || (!startDate && !endDate)) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
// NULL 값 제외 조건 추가 (필수)
|
||||||
|
conditions.push(`${dateColumn} IS NOT NULL`);
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
conditions.push(`${dateColumn} >= '${startDate}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
// 종료일은 해당 날짜의 23:59:59까지 포함
|
||||||
|
conditions.push(`${dateColumn} <= '${endDate} 23:59:59'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FROM 절 이후의 WHERE, GROUP BY, ORDER BY, LIMIT 위치 파악
|
||||||
|
// 줄바꿈 제거하여 한 줄로 만들기 (정규식 매칭을 위해)
|
||||||
|
let baseQuery = query.trim().replace(/\s+/g, " ");
|
||||||
|
let whereClause = "";
|
||||||
|
let groupByClause = "";
|
||||||
|
let orderByClause = "";
|
||||||
|
let limitClause = "";
|
||||||
|
|
||||||
|
// LIMIT 추출
|
||||||
|
const limitMatch = baseQuery.match(/\s+LIMIT\s+\d+\s*$/i);
|
||||||
|
if (limitMatch) {
|
||||||
|
limitClause = limitMatch[0];
|
||||||
|
baseQuery = baseQuery.substring(0, limitMatch.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY 추출
|
||||||
|
const orderByMatch = baseQuery.match(/\s+ORDER\s+BY\s+.+$/i);
|
||||||
|
if (orderByMatch) {
|
||||||
|
orderByClause = orderByMatch[0];
|
||||||
|
baseQuery = baseQuery.substring(0, orderByMatch.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GROUP BY 추출
|
||||||
|
const groupByMatch = baseQuery.match(/\s+GROUP\s+BY\s+.+$/i);
|
||||||
|
if (groupByMatch) {
|
||||||
|
groupByClause = groupByMatch[0];
|
||||||
|
baseQuery = baseQuery.substring(0, groupByMatch.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHERE 추출 (있으면)
|
||||||
|
const whereMatch = baseQuery.match(/\s+WHERE\s+.+$/i);
|
||||||
|
if (whereMatch) {
|
||||||
|
whereClause = whereMatch[0];
|
||||||
|
baseQuery = baseQuery.substring(0, whereMatch.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터 조건 추가
|
||||||
|
const filterCondition = conditions.join(" AND ");
|
||||||
|
if (whereClause) {
|
||||||
|
// 기존 WHERE 절이 있으면 AND로 연결
|
||||||
|
whereClause = `${whereClause} AND ${filterCondition}`;
|
||||||
|
} else {
|
||||||
|
// WHERE 절이 없으면 새로 생성
|
||||||
|
whereClause = ` WHERE ${filterCondition}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 쿼리 재조립
|
||||||
|
const finalQuery = `${baseQuery}${whereClause}${groupByClause}${orderByClause}${limitClause}`;
|
||||||
|
|
||||||
|
console.log("🔧 날짜 필터 적용:");
|
||||||
|
console.log(" 원본 쿼리:", query);
|
||||||
|
console.log(" 최종 쿼리:", finalQuery);
|
||||||
|
|
||||||
|
return finalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빠른 날짜 범위 계산
|
||||||
|
*/
|
||||||
|
export function getQuickDateRange(range: "today" | "week" | "month" | "year"): {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
} {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case "today":
|
||||||
|
return {
|
||||||
|
startDate: today.toISOString().split("T")[0],
|
||||||
|
endDate: today.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
|
||||||
|
case "week": {
|
||||||
|
const weekStart = new Date(today);
|
||||||
|
weekStart.setDate(today.getDate() - today.getDay()); // 일요일부터
|
||||||
|
return {
|
||||||
|
startDate: weekStart.toISOString().split("T")[0],
|
||||||
|
endDate: today.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "month": {
|
||||||
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
return {
|
||||||
|
startDate: monthStart.toISOString().split("T")[0],
|
||||||
|
endDate: today.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case "year": {
|
||||||
|
const yearStart = new Date(today.getFullYear(), 0, 1);
|
||||||
|
return {
|
||||||
|
startDate: yearStart.toISOString().split("T")[0],
|
||||||
|
endDate: today.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리에서 테이블명 추출
|
||||||
|
*/
|
||||||
|
export function extractTableNameFromQuery(query: string): string | null {
|
||||||
|
const trimmedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
// FROM 절 찾기
|
||||||
|
const fromMatch = trimmedQuery.match(/\bfrom\s+([a-z_][a-z0-9_]*)/i);
|
||||||
|
if (fromMatch) {
|
||||||
|
return fromMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 컬럼 자동 감지 (서버에서 테이블 스키마 조회 필요)
|
||||||
|
* 이 함수는 쿼리 결과가 아닌, 원본 테이블의 실제 컬럼 타입을 확인해야 합니다.
|
||||||
|
*
|
||||||
|
* 현재는 임시로 컬럼명 기반 추측만 수행합니다.
|
||||||
|
*/
|
||||||
|
export function detectDateColumns(columns: string[], rows: Record<string, any>[]): string[] {
|
||||||
|
const dateColumns: string[] = [];
|
||||||
|
|
||||||
|
// 컬럼명 기반 추측 (가장 안전한 방법)
|
||||||
|
columns.forEach((col) => {
|
||||||
|
const lowerCol = col.toLowerCase();
|
||||||
|
if (
|
||||||
|
lowerCol.includes("date") ||
|
||||||
|
lowerCol.includes("time") ||
|
||||||
|
lowerCol.includes("created") ||
|
||||||
|
lowerCol.includes("updated") ||
|
||||||
|
lowerCol.includes("modified") ||
|
||||||
|
lowerCol === "reg_date" ||
|
||||||
|
lowerCol === "regdate" ||
|
||||||
|
lowerCol === "update_date" ||
|
||||||
|
lowerCol === "updatedate" ||
|
||||||
|
lowerCol.endsWith("_at") || // created_at, updated_at
|
||||||
|
lowerCol.endsWith("_date") || // birth_date, start_date
|
||||||
|
lowerCol.endsWith("_time") // start_time, end_time
|
||||||
|
) {
|
||||||
|
dateColumns.push(col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 데이터가 있는 경우, 실제 값도 확인 (추가 검증)
|
||||||
|
if (rows.length > 0 && dateColumns.length > 0) {
|
||||||
|
const firstRow = rows[0];
|
||||||
|
|
||||||
|
// 컬럼명으로 감지된 것들 중에서 실제 날짜 형식인지 재확인
|
||||||
|
const validatedColumns = dateColumns.filter((col) => {
|
||||||
|
const value = firstRow[col];
|
||||||
|
|
||||||
|
// null이면 스킵 (판단 불가)
|
||||||
|
if (value == null) return true;
|
||||||
|
|
||||||
|
// Date 객체면 확실히 날짜
|
||||||
|
if (value instanceof Date) return true;
|
||||||
|
|
||||||
|
// 문자열이고 날짜 형식이면 날짜
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
if (!isNaN(parsed)) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자면 날짜가 아님 (타임스탬프 제외)
|
||||||
|
if (typeof value === "number") {
|
||||||
|
// 타임스탬프인지 확인 (1970년 이후의 밀리초 또는 초)
|
||||||
|
if (value > 946684800000 || (value > 946684800 && value < 2147483647)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return validatedColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateColumns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리에 필터와 안전장치를 모두 적용
|
||||||
|
*/
|
||||||
|
export function applyQueryFilters(query: string, config?: ChartConfig): string {
|
||||||
|
console.log("🔍 applyQueryFilters 호출:");
|
||||||
|
console.log(" config:", config);
|
||||||
|
console.log(" dateFilter:", config?.dateFilter);
|
||||||
|
|
||||||
|
let processedQuery = query;
|
||||||
|
|
||||||
|
// 1. 날짜 필터 적용
|
||||||
|
if (config?.dateFilter?.enabled && config.dateFilter.dateColumn) {
|
||||||
|
console.log("✅ 날짜 필터 적용 중...");
|
||||||
|
processedQuery = applyDateFilter(
|
||||||
|
processedQuery,
|
||||||
|
config.dateFilter.dateColumn,
|
||||||
|
config.dateFilter.startDate,
|
||||||
|
config.dateFilter.endDate,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ 날짜 필터 비활성화 또는 설정 없음");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 안전장치 LIMIT 적용
|
||||||
|
const limit = config?.autoLimit ?? 1000;
|
||||||
|
processedQuery = applySafetyLimit(processedQuery, limit);
|
||||||
|
|
||||||
|
return processedQuery;
|
||||||
|
}
|
||||||
|
|
@ -2,28 +2,28 @@
|
||||||
* 대시보드 API 클라이언트
|
* 대시보드 API 클라이언트
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DashboardElement } from '@/components/admin/dashboard/types';
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||||
|
|
||||||
// API 기본 설정
|
// API 기본 설정
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
||||||
|
|
||||||
// 토큰 가져오기 (실제 인증 시스템에 맞게 수정)
|
// 토큰 가져오기 (실제 인증 시스템에 맞게 수정)
|
||||||
function getAuthToken(): string | null {
|
function getAuthToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === "undefined") return null;
|
||||||
return localStorage.getItem('authToken') || sessionStorage.getItem('authToken');
|
return localStorage.getItem("authToken") || sessionStorage.getItem("authToken");
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 요청 헬퍼
|
// API 요청 헬퍼
|
||||||
async function apiRequest<T>(
|
async function apiRequest<T>(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: RequestInit = {}
|
options: RequestInit = {},
|
||||||
): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> {
|
): Promise<{ success: boolean; data?: T; message?: string; pagination?: any }> {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
|
|
||||||
const config: RequestInit = {
|
const config: RequestInit = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
...options.headers,
|
...options.headers,
|
||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -31,32 +31,32 @@ async function apiRequest<T>(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||||
|
|
||||||
// 응답이 JSON이 아닐 수도 있으므로 안전하게 처리
|
// 응답이 JSON이 아닐 수도 있으므로 안전하게 처리
|
||||||
let result;
|
let result;
|
||||||
try {
|
try {
|
||||||
result = await response.json();
|
result = await response.json();
|
||||||
} catch (jsonError) {
|
} catch (jsonError) {
|
||||||
console.error('JSON Parse Error:', jsonError);
|
console.error("JSON Parse Error:", jsonError);
|
||||||
throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`);
|
throw new Error(`서버 응답을 파싱할 수 없습니다. Status: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error('API Error Response:', {
|
console.error("API Error Response:", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
result
|
result,
|
||||||
});
|
});
|
||||||
throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`);
|
throw new Error(result.message || `HTTP ${response.status}: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('API Request Error:', {
|
console.error("API Request Error:", {
|
||||||
endpoint,
|
endpoint,
|
||||||
error: error?.message || error,
|
error: error?.message || error,
|
||||||
errorObj: error,
|
errorObj: error,
|
||||||
config
|
config,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -99,154 +99,153 @@ export interface DashboardListQuery {
|
||||||
|
|
||||||
// 대시보드 API 함수들
|
// 대시보드 API 함수들
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 생성
|
* 대시보드 생성
|
||||||
*/
|
*/
|
||||||
async createDashboard(data: CreateDashboardRequest): Promise<Dashboard> {
|
async createDashboard(data: CreateDashboardRequest): Promise<Dashboard> {
|
||||||
const result = await apiRequest<Dashboard>('/dashboards', {
|
const result = await apiRequest<Dashboard>("/dashboards", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || '대시보드 생성에 실패했습니다.');
|
throw new Error(result.message || "대시보드 생성에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 목록 조회
|
* 대시보드 목록 조회
|
||||||
*/
|
*/
|
||||||
async getDashboards(query: DashboardListQuery = {}) {
|
async getDashboards(query: DashboardListQuery = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (query.page) params.append('page', query.page.toString());
|
if (query.page) params.append("page", query.page.toString());
|
||||||
if (query.limit) params.append('limit', query.limit.toString());
|
if (query.limit) params.append("limit", query.limit.toString());
|
||||||
if (query.search) params.append('search', query.search);
|
if (query.search) params.append("search", query.search);
|
||||||
if (query.category) params.append('category', query.category);
|
if (query.category) params.append("category", query.category);
|
||||||
if (typeof query.isPublic === 'boolean') params.append('isPublic', query.isPublic.toString());
|
if (typeof query.isPublic === "boolean") params.append("isPublic", query.isPublic.toString());
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const endpoint = `/dashboards${queryString ? `?${queryString}` : ''}`;
|
const endpoint = `/dashboards${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
const result = await apiRequest<Dashboard[]>(endpoint);
|
const result = await apiRequest<Dashboard[]>(endpoint);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || '대시보드 목록 조회에 실패했습니다.');
|
throw new Error(result.message || "대시보드 목록 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dashboards: result.data || [],
|
dashboards: result.data || [],
|
||||||
pagination: result.pagination
|
pagination: result.pagination,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 내 대시보드 목록 조회
|
* 내 대시보드 목록 조회
|
||||||
*/
|
*/
|
||||||
async getMyDashboards(query: DashboardListQuery = {}) {
|
async getMyDashboards(query: DashboardListQuery = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (query.page) params.append('page', query.page.toString());
|
if (query.page) params.append("page", query.page.toString());
|
||||||
if (query.limit) params.append('limit', query.limit.toString());
|
if (query.limit) params.append("limit", query.limit.toString());
|
||||||
if (query.search) params.append('search', query.search);
|
if (query.search) params.append("search", query.search);
|
||||||
if (query.category) params.append('category', query.category);
|
if (query.category) params.append("category", query.category);
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ''}`;
|
const endpoint = `/dashboards/my${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
const result = await apiRequest<Dashboard[]>(endpoint);
|
const result = await apiRequest<Dashboard[]>(endpoint);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || '내 대시보드 목록 조회에 실패했습니다.');
|
throw new Error(result.message || "내 대시보드 목록 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dashboards: result.data || [],
|
dashboards: result.data || [],
|
||||||
pagination: result.pagination
|
pagination: result.pagination,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 상세 조회
|
* 대시보드 상세 조회
|
||||||
*/
|
*/
|
||||||
async getDashboard(id: string): Promise<Dashboard> {
|
async getDashboard(id: string): Promise<Dashboard> {
|
||||||
const result = await apiRequest<Dashboard>(`/dashboards/${id}`);
|
const result = await apiRequest<Dashboard>(`/dashboards/${id}`);
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || '대시보드 조회에 실패했습니다.');
|
throw new Error(result.message || "대시보드 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 대시보드 조회 (인증 불필요)
|
* 공개 대시보드 조회 (인증 불필요)
|
||||||
*/
|
*/
|
||||||
async getPublicDashboard(id: string): Promise<Dashboard> {
|
async getPublicDashboard(id: string): Promise<Dashboard> {
|
||||||
const result = await apiRequest<Dashboard>(`/dashboards/public/${id}`);
|
const result = await apiRequest<Dashboard>(`/dashboards/public/${id}`);
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || '대시보드 조회에 실패했습니다.');
|
throw new Error(result.message || "대시보드 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 수정
|
* 대시보드 수정
|
||||||
*/
|
*/
|
||||||
async updateDashboard(id: string, data: Partial<CreateDashboardRequest>): Promise<Dashboard> {
|
async updateDashboard(id: string, data: Partial<CreateDashboardRequest>): Promise<Dashboard> {
|
||||||
const result = await apiRequest<Dashboard>(`/dashboards/${id}`, {
|
const result = await apiRequest<Dashboard>(`/dashboards/${id}`, {
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || '대시보드 수정에 실패했습니다.');
|
throw new Error(result.message || "대시보드 수정에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 삭제
|
* 대시보드 삭제
|
||||||
*/
|
*/
|
||||||
async deleteDashboard(id: string): Promise<void> {
|
async deleteDashboard(id: string): Promise<void> {
|
||||||
const result = await apiRequest(`/dashboards/${id}`, {
|
const result = await apiRequest(`/dashboards/${id}`, {
|
||||||
method: 'DELETE',
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || '대시보드 삭제에 실패했습니다.');
|
throw new Error(result.message || "대시보드 삭제에 실패했습니다.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 공개 대시보드 목록 조회 (인증 불필요)
|
* 공개 대시보드 목록 조회 (인증 불필요)
|
||||||
*/
|
*/
|
||||||
async getPublicDashboards(query: DashboardListQuery = {}) {
|
async getPublicDashboards(query: DashboardListQuery = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (query.page) params.append('page', query.page.toString());
|
if (query.page) params.append("page", query.page.toString());
|
||||||
if (query.limit) params.append('limit', query.limit.toString());
|
if (query.limit) params.append("limit", query.limit.toString());
|
||||||
if (query.search) params.append('search', query.search);
|
if (query.search) params.append("search", query.search);
|
||||||
if (query.category) params.append('category', query.category);
|
if (query.category) params.append("category", query.category);
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ''}`;
|
const endpoint = `/dashboards/public${queryString ? `?${queryString}` : ""}`;
|
||||||
|
|
||||||
const result = await apiRequest<Dashboard[]>(endpoint);
|
const result = await apiRequest<Dashboard[]>(endpoint);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.message || '공개 대시보드 목록 조회에 실패했습니다.');
|
throw new Error(result.message || "공개 대시보드 목록 조회에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dashboards: result.data || [],
|
dashboards: result.data || [],
|
||||||
pagination: result.pagination
|
pagination: result.pagination,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -254,17 +253,41 @@ export const dashboardApi = {
|
||||||
* 쿼리 실행 (차트 데이터 조회)
|
* 쿼리 실행 (차트 데이터 조회)
|
||||||
*/
|
*/
|
||||||
async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> {
|
async executeQuery(query: string): Promise<{ columns: string[]; rows: any[]; rowCount: number }> {
|
||||||
const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>('/dashboards/execute-query', {
|
const result = await apiRequest<{ columns: string[]; rows: any[]; rowCount: number }>("/dashboards/execute-query", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({ query }),
|
body: JSON.stringify({ query }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
throw new Error(result.message || '쿼리 실행에 실패했습니다.');
|
throw new Error(result.message || "쿼리 실행에 실패했습니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 스키마 조회 (날짜 컬럼 감지용)
|
||||||
|
*/
|
||||||
|
async getTableSchema(tableName: string): Promise<{
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; type: string; udtName: string }>;
|
||||||
|
dateColumns: string[];
|
||||||
|
}> {
|
||||||
|
const result = await apiRequest<{
|
||||||
|
tableName: string;
|
||||||
|
columns: Array<{ name: string; type: string; udtName: string }>;
|
||||||
|
dateColumns: string[];
|
||||||
|
}>("/dashboards/table-schema", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ tableName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error(result.message || "테이블 스키마 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 에러 처리 유틸리티
|
// 에러 처리 유틸리티
|
||||||
|
|
@ -272,10 +295,10 @@ export function handleApiError(error: any): string {
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof error === 'string') {
|
if (typeof error === "string") {
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '알 수 없는 오류가 발생했습니다.';
|
return "알 수 없는 오류가 발생했습니다.";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue